projection modeling enhancements based on youngest in a couple + documentation
This commit is contained in:
parent
e22e987121
commit
4ed15c7cf4
15 changed files with 1568 additions and 301 deletions
|
|
@ -46,7 +46,7 @@ repos:
|
|||
- id: test
|
||||
name: Run zig build test
|
||||
entry: zig
|
||||
args: ["build", "coverage", "-Dcoverage-threshold=79"]
|
||||
args: ["build", "coverage", "-Dcoverage-threshold=80"]
|
||||
language: system
|
||||
types: [file]
|
||||
pass_filenames: false
|
||||
|
|
|
|||
|
|
@ -399,7 +399,7 @@ zig build test # run all tests (single binary, discovers all tests
|
|||
zig build run -- <args> # build and run CLI
|
||||
zig build docs # generate library documentation
|
||||
zig build coverage # run tests with kcov coverage (Linux only). See "Coverage" section.
|
||||
zig build coverage -Dcoverage-threshold=72 # fail build if coverage < N% (see .pre-commit-config.yaml for current floor)
|
||||
zig build coverage -Dcoverage-threshold=80 # fail build if coverage < N% (see .pre-commit-config.yaml for current floor)
|
||||
```
|
||||
|
||||
**Tooling** (managed via `.mise.toml`):
|
||||
|
|
|
|||
69
TODO.md
69
TODO.md
|
|
@ -5,67 +5,16 @@ ordered roughly by priority within each section. Priority labels
|
|||
(`HIGH` / `MEDIUM` / `LOW`) mark items that deserve explicit
|
||||
ranking; unlabeled items are "someday, if the mood strikes."
|
||||
|
||||
## Projections: future enhancements
|
||||
## Projections: Contribution-attribution overlay
|
||||
|
||||
- **Goal-seek over distribution horizon for W1 - priority LOW.**
|
||||
Today the W1 ("set spending, find date") workflow reports the
|
||||
earliest retirement at each user-configured `(horizon, confidence)`
|
||||
cell. The philosophically correct version asks "when have I
|
||||
accumulated enough wealth that the projection shows a 95%
|
||||
probability of success withdrawing X per year from retirement
|
||||
until age-of-death?" - i.e. goal-seek across both `accumulation_years`
|
||||
AND `distribution_years` simultaneously, anchored to a configured
|
||||
age-of-death. NP-shaped search; not worth optimizing until
|
||||
someone wants it.
|
||||
- **Per-person retirement_age - priority LOW.**
|
||||
V1 of the accumulation-phase spec chose Option A: a single
|
||||
household retirement boundary derived from the oldest configured
|
||||
birthdate. Households where one earner retires significantly
|
||||
earlier than the other would benefit from per-person
|
||||
`retirement_age` fields on each `type::birthdate` record, with
|
||||
contributions stopped per-person.
|
||||
- **Historical projection overlay follow-ups.** The base
|
||||
`--overlay-actuals` overlay shipped (CLI tip + TUI primary surface).
|
||||
Open enhancements:
|
||||
- Historical `metadata.srf` / `projections.srf` for back-dated
|
||||
runs. Today the overlay re-runs against current classifications
|
||||
and assumptions; for historically faithful what-the-model-said-then
|
||||
output we'd check out the git-tracked versions of those files
|
||||
at the as-of commit and load those instead. Edge case until
|
||||
classifications materially drift.
|
||||
- Contribution-attribution overlay. Today's actuals line includes
|
||||
contributions implicitly; the bands assume modeled contributions
|
||||
that may or may not match reality. A "decompose actuals into
|
||||
market return vs contributions" annotation would clarify how
|
||||
much of the trajectory was the model being right vs new money
|
||||
arriving on schedule.
|
||||
- **Better composition basis for imported-only as-of.** Today
|
||||
the imported-only path uses today's allocations scaled by
|
||||
`imported_liquid / today_total_liquid`. That's the simplest
|
||||
thing that could work, but it's "today's mix back-dated" -
|
||||
it ignores everything we know about the historical context.
|
||||
Specifically: `imported_values.srf` already carries an
|
||||
`expected_return` field per row that the user captured at
|
||||
that date in their source spreadsheet. We could:
|
||||
- Use the imported `expected_return` as a sanity check
|
||||
against the simulation's per-position weighted return
|
||||
(warn or clamp if they diverge wildly - the spreadsheet's
|
||||
number reflects what the user actually saw at the time).
|
||||
- Use the imported `expected_return` to bias the
|
||||
stock/bond split inference: a higher expected return
|
||||
implies a higher historical equity weighting than today's
|
||||
mix probably reflects.
|
||||
- Reach further: derive a synthetic stock/bond split from
|
||||
the imported `expected_return` directly, treating it as
|
||||
a weighted average of SPY and AGG returns at that date
|
||||
and solving for the weights. That gives a per-imported-
|
||||
row composition that's locally faithful instead of
|
||||
one-mix-fits-all.
|
||||
None of these are urgent - the current "today's mix scaled"
|
||||
approximation is documented as such and the bands still
|
||||
render meaningfully - but each would tighten the historical
|
||||
faithfulness one notch. Pick whichever has the highest
|
||||
payoff vs. complexity when this gets revisited.
|
||||
Contribution-attribution overlay. Today's actuals line includes
|
||||
contributions implicitly; the bands assume modeled contributions
|
||||
that may or may not match reality. A "decompose actuals into
|
||||
market return vs contributions" annotation would clarify how
|
||||
much of the trajectory was the model being right vs new money
|
||||
arriving on schedule. The contributions pipeline
|
||||
(`transaction_log.srf` + lot-diff) already has the flow data, so
|
||||
this is the one overlay follow-up with real analytical payoff.
|
||||
|
||||
## Investigate: detailed 401(k) contributions data source
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,10 @@ Every projection runs the same two phases in order:
|
|||
length comes from your retirement-date input. With no input, it's
|
||||
zero years (an already-retired view).
|
||||
2. **Distribution** -- annual spending withdrawn (CPI-adjusted by
|
||||
default), no contributions. Its length is the configured `horizon`.
|
||||
default), no contributions. Its length is the configured `horizon`,
|
||||
or - when you set a `horizon_age` - the years until the *last
|
||||
surviving* household member reaches that age of death (see
|
||||
[Mortality](#mortality-the-surviving-spouse)).
|
||||
Spending is flat in real terms unless you set
|
||||
[`spending_change`](../reference/config/projections-srf.md#declining-spending-the-smile)
|
||||
to taper or grow it year over year (the Blanchett "spending smile").
|
||||
|
|
@ -103,14 +106,61 @@ When you set a `target_spending` instead of a date, zfin inverts the
|
|||
question: for each (horizon x confidence) cell it searches for the
|
||||
**earliest** accumulation length (up to `max_accumulation_years`, 50
|
||||
years by default) that sustains your spending, and renders the grid of
|
||||
answers. One cell is promoted to the
|
||||
headline (see
|
||||
answers. One cell is promoted to the headline (see
|
||||
[promotion rules](../reference/config/projections-srf.md#the-two-retirement-planning-inputs)).
|
||||
If no length within the cap works, the cell is **infeasible** -- shown
|
||||
honestly rather than fudged. A young saver with a runway longer than 50
|
||||
years can raise the cap via
|
||||
[`max_accumulation_years`](../reference/config/projections-srf.md#config-fields).
|
||||
|
||||
For a plain `horizon` the distribution length is fixed, so a later
|
||||
retirement means the money has to last from a later start to a fixed
|
||||
number of years out. For an age-anchored `horizon_age` column the
|
||||
distribution instead *shrinks* as retirement slides later, because the
|
||||
end (the last survivor's death) is pinned: the total span from today is
|
||||
constant and the column header reads `to age 95` rather than a year
|
||||
count.
|
||||
|
||||
## Mortality: the surviving spouse
|
||||
|
||||
A `horizon_age` turns on a mortality model that a plain numeric horizon
|
||||
doesn't have. It rests on the financial-planning standard for couples:
|
||||
**the money must last until the last surviving member dies**, because
|
||||
the household needs income for as long as *either* spouse is alive
|
||||
(Blanchett, "How to Estimate 'The End' of Retirement," *Journal of
|
||||
Financial Planning*, 2021). So the horizon is anchored on the
|
||||
**youngest** member (who reaches the age of death latest), not the
|
||||
oldest.
|
||||
|
||||
Two adjustments fire at the **first** death (the oldest member reaching
|
||||
the age of death):
|
||||
|
||||
- **Spending steps down.** A surviving spouse needs less than the
|
||||
couple but far more than half - shared costs (housing, utilities,
|
||||
insurance) don't fall when one person dies. zfin scales base spending
|
||||
by [`survivor_spending_pct`](../reference/config/projections-srf.md#survivor-spending-survivor_spending_pct)
|
||||
(default 75%, a 25% cut). That default is the conservative edge of the
|
||||
standard equivalence-scale range (the OECD-modified scale implies
|
||||
~67%, the square-root scale ~71%); financial-planning software is
|
||||
gentler at ~80%. The income side falls harder than the need side -
|
||||
the Chicago Fed found household income drops ~37% at widowhood but the
|
||||
standard-of-living-adjusted decline is only ~11% - which is why the
|
||||
knob is about *need*, not income.
|
||||
- **The deceased's income stops.** Each person's Social Security,
|
||||
pension, and wages (and their own late-life expenses), entered as
|
||||
`type::event`, terminate the year that person dies. Survivor benefits
|
||||
that partly continue (a pension's survivor percentage, Social
|
||||
Security's keep-the-higher rule) are modeled as a separate event on
|
||||
the surviving spouse - see the
|
||||
[config reference](../reference/config/projections-srf.md#modeling-survivor-benefits).
|
||||
|
||||
These two effects pull in opposite directions on the headline number:
|
||||
the last-survivor horizon lengthens the plan (more conservative), while
|
||||
the survivor spending cut and the (correct) retention of only the
|
||||
survivor's own benefits shorten the funding need. Modeling both is more
|
||||
faithful than either the old "stop at the first death" truncation or a
|
||||
naive "fund the longer life at full couple spending."
|
||||
|
||||
## The caveat that matters most
|
||||
|
||||
zfin states this loudly by design, and so does this page:
|
||||
|
|
|
|||
|
|
@ -35,10 +35,10 @@ type::event,name::Social Security,start_age:num:70,amount:num:38400
|
|||
| Field | Type | Description |
|
||||
|--------------------------------------|------|--------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `target_stock_pct` | num | Asset-allocation target (0-100). Sets the simulation's stock/bond blend. |
|
||||
| `expense_ratio` | num | Annual fund expense ratio as a percent (e.g. `0.18` = 0.18%), subtracted from the blended return each year. Default `0.18` (FIRECalc's default; realistic for a fund portfolio). Override down (`0.04`) for low-cost index funds, up for active funds, or `0` for all individual stocks. |
|
||||
| `return_cap` | num | Optional ceiling, as a percent (e.g. `30` = 30%), on each position's conservative trailing return before it is weighted into the displayed **Projected return**. Default: none. See [Capping outlier returns](#capping-outlier-returns). |
|
||||
| `horizon` | num | Distribution-phase length in years. Repeat the line for multiple horizons. |
|
||||
| `horizon_age` | num | Horizon expressed as an age; resolves to `target_age - oldest_current_age`. Repeatable. |
|
||||
| `expense_ratio` | num | Annual fund expense ratio as a percent (e.g. `0.18` = 0.18%), subtracted from the blended return each year. Default `0.18` (FIRECalc's default; realistic for a fund portfolio). Override down (`0.04`) for low-cost index funds, up for active funds, or `0` for all individual stocks. |
|
||||
| `return_cap` | num | Optional ceiling, as a percent (e.g. `30` = 30%), on each position's conservative trailing return before it is weighted into the displayed **Projected return**. Default: none. See [Capping outlier returns](#capping-outlier-returns). |
|
||||
| `horizon` | num | Distribution-phase length in years (a fixed horizon, no mortality modeling). Repeat the line for multiple horizons. |
|
||||
| `horizon_age` | num | Horizon expressed as an **age of death**. The distribution runs until the *last surviving* member reaches this age; the column also models the survivor spending step-down and per-person income/expense termination at death. See [Planning to an age of death](#planning-to-an-age-of-death). Repeatable. |
|
||||
| `retirement_age` | num | Age the **oldest** configured person must reach to retire. |
|
||||
| `retirement_at` | date | Absolute retirement date (`YYYY-MM-DD`). Wins over `retirement_age` if both set. |
|
||||
| `annual_contribution` | num | Yearly accumulation-phase contribution, in today's dollars. |
|
||||
|
|
@ -46,6 +46,7 @@ type::event,name::Social Security,start_age:num:70,amount:num:38400
|
|||
| `target_spending` | num | Desired retirement spending, in today's dollars. |
|
||||
| `target_spending_inflation_adjusted` | bool | If `true` (default), target spending grows with CPI during distribution. |
|
||||
| `spending_change` | num | Signed annual *real* change in spending across the distribution phase, as a whole percent. Negative = declining (e.g. `-2` = -2%/yr, the "spending smile"); positive = rising. Default: absent = flat real spending. Magnitude clamped to 10%/yr. See [Declining spending](#declining-spending-the-smile). |
|
||||
| `survivor_spending_pct` | num | Percent of the couple's joint spending the surviving spouse needs after the first death, for `horizon_age` columns. Default `75` (a 25% reduction). Only applies to a multi-person household with an age gap. See [Planning to an age of death](#planning-to-an-age-of-death). |
|
||||
| `max_accumulation_years` | num | Ceiling (in years) the earliest-retirement search scans when `target_spending` is set. Default `50`, capped at `100`. |
|
||||
| `retirement_target` | num | Annotation on a `horizon`/`horizon_age` line that overrides the earliest-retirement promotion rule. Allowed: `90`, `95`, `99`. |
|
||||
|
||||
|
|
@ -118,6 +119,122 @@ How it interacts with the rest of the model:
|
|||
|
||||
See the `post-retirement-smile/` example for a worked configuration.
|
||||
|
||||
### Planning to an age of death
|
||||
|
||||
A plain `horizon` is a fixed number of distribution years. A
|
||||
`horizon_age` instead anchors the horizon to an **age of death**, and
|
||||
turns on the mortality model:
|
||||
|
||||
```
|
||||
type::config,horizon_age:num:95
|
||||
type::birthdate,date::1962-03-01
|
||||
type::birthdate,date::1967-08-15,person:num:2
|
||||
```
|
||||
|
||||
This says "plan until we reach age 95." Three things follow:
|
||||
|
||||
1. **Last-survivor horizon.** The money must last until the *youngest*
|
||||
member reaches the age of death (they reach it latest in calendar
|
||||
time), not the oldest. For a couple this is the standard
|
||||
financial-planning treatment: a household's assets must fund the
|
||||
period for as long as *either* spouse is alive. (Anchoring on the
|
||||
oldest would truncate the plan at the first death and silently
|
||||
under-fund the survivor's remaining years.) A `horizon_age` requires
|
||||
at least one `birthdate`.
|
||||
|
||||
2. **The horizon is coupled to the retirement date.** Distribution
|
||||
length = `age_of_death - retirement_age`. With `target_spending` set,
|
||||
the earliest-retirement search shrinks the distribution as it pushes
|
||||
the retirement date later (the death date is fixed), so the column
|
||||
header reads `to age 95` rather than a fixed year count.
|
||||
|
||||
3. **Per-person income and expenses end at death.** Each person's
|
||||
Social Security, pension, wages, and their own late-life expenses
|
||||
(entered as `type::event` with that `person`) stop the year that
|
||||
person reaches the age of death - a deceased spouse no longer
|
||||
collects their own benefit.
|
||||
|
||||
#### Survivor spending (`survivor_spending_pct`)
|
||||
|
||||
In a couple with an age gap, the first death (the *oldest* reaching the
|
||||
age of death) steps household spending down to a surviving-spouse level
|
||||
for the remaining years. `survivor_spending_pct` is the percent of the
|
||||
couple's joint spending the survivor needs:
|
||||
|
||||
```
|
||||
type::config,survivor_spending_pct:num:75
|
||||
```
|
||||
|
||||
The **default is 75** (a 25% reduction). A surviving spouse needs *less*
|
||||
than the couple (one fewer person) but far *more* than half, because
|
||||
most costs - housing, property tax, utilities, insurance - are shared
|
||||
and don't fall when one person dies. The default sits at the
|
||||
conservative edge of the standard range:
|
||||
|
||||
- **OECD equivalence scales**, the standard economic adjustment for
|
||||
household size: the OECD-modified scale (1.0 for the first adult,
|
||||
0.5 for the second) implies a survivor needs ~67% (a 33% cut); the
|
||||
square-root scale (needs proportional to sqrt of household size)
|
||||
implies ~71% (a 29% cut). See Eurostat,
|
||||
"[Equivalised disposable income](https://ec.europa.eu/eurostat/statistics-explained/index.php?title=Glossary:Equivalised_disposable_income)".
|
||||
- **Financial-planning convention** is gentler, ~80% (a 20% cut);
|
||||
e.g. Kiplinger, ["Five Financial Changes That Happen When Your
|
||||
Spouse Dies"](https://www.kiplinger.com/retirement/financial-changes-that-happen-when-your-spouse-dies)
|
||||
(2024), which also warns that the right number is individual - a
|
||||
survivor's spending can even rise.
|
||||
- The income side falls much harder than the need side, which is why
|
||||
this is about *need*, not income: the Chicago Fed (Fadlon, Ramnath &
|
||||
Tong, *Chicago Fed Letter* No. 438, 2020) found household income
|
||||
drops ~37% at widowhood but, after adjusting for the smaller
|
||||
household, the standard-of-living decline was only ~11%.
|
||||
|
||||
The value is configurable because it is highly individual; 75% is a
|
||||
defensible, slightly-conservative starting point. Any value `>= 0` is
|
||||
honored (including above 100, for a survivor whose spending rises).
|
||||
|
||||
#### Modeling survivor benefits
|
||||
|
||||
Capping a deceased person's events at their death sets them to zero,
|
||||
which is correct for benefits that die with the holder but not for
|
||||
those that partly continue. Model the continuing portion as an event
|
||||
tied to the **surviving** person:
|
||||
|
||||
- A pension with a 50% survivor benefit: enter the survivor's 50% as a
|
||||
separate event on the surviving spouse.
|
||||
- Social Security's "keep the higher" rule (the survivor keeps the
|
||||
larger of the two benefits): if the higher earner dies first, add a
|
||||
survivor-benefit event on the survivor starting at the first-death
|
||||
age. Otherwise the default reads slightly conservative for that
|
||||
household.
|
||||
|
||||
### Staggered retirement (one spouse retires earlier)
|
||||
|
||||
zfin models a single household retirement boundary: the accumulation
|
||||
phase ends and the distribution phase begins at one date. To model one
|
||||
spouse retiring before the other:
|
||||
|
||||
- If the early retirement has effectively already happened (one spouse
|
||||
is no longer contributing), just set `annual_contribution` to the
|
||||
remaining (solo) saver's amount. It applies across the whole
|
||||
accumulation phase, which is exactly what you want.
|
||||
- For a future step-down (both still working, retiring at different
|
||||
dates), add a negative-contribution event at the early retiree's
|
||||
date so the yearly inflow drops then:
|
||||
|
||||
```srf
|
||||
type::event,name::Stop saving (A),start_age:num:62,person:num:1,amount:num:-20000
|
||||
```
|
||||
|
||||
During accumulation a life event adjusts the portfolio's yearly
|
||||
inflow, so a negative amount models the reduced saving directly.
|
||||
|
||||
Caveat: during accumulation, life-event *income* (a positive amount)
|
||||
is **added to the portfolio** - modeled as saved, not consumed. So
|
||||
model an early retiree whose Social Security or pension is *spent* by
|
||||
lowering `annual_contribution`, not by adding a positive income event
|
||||
(which would inflate savings). Reserve positive accumulation-phase
|
||||
events for income that genuinely gets invested.
|
||||
|
||||
### Capping outlier returns
|
||||
|
||||
The **Projected return** shown by `zfin projections` (and the "Projected
|
||||
|
|
@ -202,10 +319,13 @@ about the file:
|
|||
|
||||
When `target_spending` is set, the **earliest-retirement grid** shows,
|
||||
for each (horizon x confidence) pair, the earliest year that sustains
|
||||
the spending. The default promotion rule picks the headline cell by
|
||||
walking horizons longest-to-shortest at 99% confidence, preferring the
|
||||
longest horizon that keeps the oldest person under age 100. Override it
|
||||
with a `retirement_target` annotation on one horizon line:
|
||||
the spending. The default promotion rule picks the headline cell at 99%
|
||||
confidence: it prefers an age-of-death-anchored (`horizon_age`) column
|
||||
(the latest death, if several), since that's the "plan to the last
|
||||
survivor" answer; with only plain numeric horizons it walks them
|
||||
longest-to-shortest, preferring the longest that keeps the oldest
|
||||
person under age 100. Override it with a `retirement_target` annotation
|
||||
on one horizon line:
|
||||
|
||||
```srf
|
||||
# use the 35yr x 95% cell as the headline
|
||||
|
|
|
|||
|
|
@ -73,10 +73,11 @@ set, `target_spending` is not. Output renders:
|
|||
- The **Accumulation phase** block is populated by **promoting one
|
||||
cell** from the grid into the headline retirement line, plus the
|
||||
median portfolio at retirement and p10-p90 range. The default
|
||||
promotion rule walks horizons longest -> shortest and picks the
|
||||
longest one whose end year keeps the oldest configured person
|
||||
under age 100, at 99% confidence (most conservative). If even
|
||||
the shortest horizon overshoots, it's used anyway.
|
||||
promotion rule prefers an age-of-death (`horizon_age`) column - the
|
||||
"plan to the last survivor" answer - at 99% confidence (most
|
||||
conservative); with only numeric horizons it walks them longest ->
|
||||
shortest and picks the longest whose end year keeps the oldest
|
||||
configured person under age 100.
|
||||
- The grid stays rendered for transparency - the user can see how
|
||||
the headline cell compares to the rest of the matrix.
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,8 @@ type::config,target_stock_pct:num:60
|
|||
# flat real spending, the default.
|
||||
type::config,spending_change:num:-2
|
||||
|
||||
# Distribution horizons - through age 90 (older partner first)
|
||||
# Distribution horizons - plus a plan-to-age-95 column anchored on the
|
||||
# youngest partner (the last survivor)
|
||||
type::config,horizon:num:20
|
||||
type::config,horizon:num:30
|
||||
type::config,horizon_age:num:95
|
||||
|
|
|
|||
|
|
@ -13,7 +13,8 @@
|
|||
# Allocation target shifts more conservative in retirement
|
||||
type::config,target_stock_pct:num:60
|
||||
|
||||
# Distribution horizons - through age 90 (older partner first)
|
||||
# Distribution horizons - plus a plan-to-age-95 column anchored on the
|
||||
# youngest partner (the last survivor)
|
||||
type::config,horizon:num:20
|
||||
type::config,horizon:num:30
|
||||
type::config,horizon_age:num:95
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@ type::config,target_stock_pct:num:80
|
|||
# Distribution-phase horizons to simulate
|
||||
type::config,horizon:num:25
|
||||
type::config,horizon:num:35
|
||||
# Plan through age 95 - the older partner's first-to-hit-95 sets the floor
|
||||
# Plan through age 95 - anchored on the youngest partner (the last
|
||||
# survivor), so the money lasts until they reach 95.
|
||||
type::config,horizon_age:num:95
|
||||
|
||||
# Target retirement date: oldest partner (Pat) reaches 65 in 2046
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@ type::config,target_stock_pct:num:80
|
|||
# Distribution-phase horizons to simulate
|
||||
type::config,horizon:num:25
|
||||
type::config,horizon:num:35
|
||||
# Plan through age 95 - the older partner's first-to-hit-95 sets the floor
|
||||
# Plan through age 95 - anchored on the youngest partner (the last
|
||||
# survivor), so the money lasts until they reach 95.
|
||||
type::config,horizon_age:num:95
|
||||
|
||||
# Target retirement date: oldest partner (Pat) reaches 65 in 2046
|
||||
|
|
|
|||
|
|
@ -11,10 +11,11 @@
|
|||
# earliest accumulation length that sustains that spending at each
|
||||
# configured (horizon × confidence) pair, renders the resulting
|
||||
# grid, and promotes one cell into the Accumulation phase block as
|
||||
# the headline. The default promotion rule is "longest configured
|
||||
# horizon at 99% confidence, where the oldest person stays under
|
||||
# age 100." See `pre-retirement-spending-target/` for the explicit-
|
||||
# override variant.
|
||||
# the headline. The default promotion rule prefers an age-of-death
|
||||
# (`horizon_age`) column at 99% confidence - the "plan to the last
|
||||
# survivor" answer - falling back to the longest numeric horizon that
|
||||
# keeps the oldest person under age 100. See
|
||||
# `pre-retirement-spending-target/` for the explicit-override variant.
|
||||
|
||||
# Asset allocation target (80% stocks / 20% bonds - typical pre-retirement)
|
||||
type::config,target_stock_pct:num:80
|
||||
|
|
@ -22,7 +23,8 @@ type::config,target_stock_pct:num:80
|
|||
# Distribution-phase horizons to simulate
|
||||
type::config,horizon:num:25
|
||||
type::config,horizon:num:35
|
||||
# Plan through age 95 - the older partner's first-to-hit-95 sets the floor
|
||||
# Plan through age 95 - anchored on the youngest partner (the last
|
||||
# survivor), so the money lasts until they reach 95.
|
||||
type::config,horizon_age:num:95
|
||||
|
||||
# Annual household contribution to retirement accounts
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -966,7 +966,7 @@ pub fn runBands(
|
|||
try out.print("\n", .{});
|
||||
try cli.printBold(out, color, "Terminal Portfolio Value (nominal, at 99% withdrawal rate)\n", .{});
|
||||
|
||||
try out.print("{s}\n", .{try view.buildHeaderRow(va, horizons, view.terminal_col_width)});
|
||||
try out.print("{s}\n", .{try view.buildHeaderRow(va, horizons, ctx.config.horizon_death_age[0..horizons.len], view.terminal_col_width)});
|
||||
|
||||
const p_labels = [_][]const u8{ "Pessimistic (p10)", "Median (p50)", "Optimistic (p90)" };
|
||||
const p_styles = [_]view.StyleIntent{ .muted, .normal, .muted };
|
||||
|
|
@ -980,7 +980,7 @@ pub fn runBands(
|
|||
try cli.printBold(out, color, "Safe Withdrawal (FIRECalc historical simulation)\n", .{});
|
||||
|
||||
// Header row
|
||||
try out.print("{s}\n", .{try view.buildHeaderRow(va, horizons, view.withdrawal_col_width)});
|
||||
try out.print("{s}\n", .{try view.buildHeaderRow(va, horizons, ctx.config.horizon_death_age[0..horizons.len], view.withdrawal_col_width)});
|
||||
|
||||
// Withdrawal rows. When an accumulation phase is active the
|
||||
// per-row % rate is suppressed (it would divide today's-dollars
|
||||
|
|
@ -1818,9 +1818,9 @@ fn renderEarliestBlock(out: *std.Io.Writer, color: bool, va: std.mem.Allocator,
|
|||
{
|
||||
var hdr: std.ArrayListUnmanaged(u8) = .empty;
|
||||
try hdr.appendNTimes(va, ' ', label_width);
|
||||
for (horizons) |h| {
|
||||
for (horizons, 0..) |h, hi| {
|
||||
var hbuf: [16]u8 = undefined;
|
||||
const hlabel = view.fmtHorizonLabel(&hbuf, h);
|
||||
const hlabel = view.fmtHorizonLabelAge(&hbuf, h, ctx.config.horizon_death_age[hi]);
|
||||
try hdr.appendNTimes(va, ' ', cell_width -| hlabel.len);
|
||||
try hdr.appendSlice(va, hlabel);
|
||||
}
|
||||
|
|
@ -2508,3 +2508,91 @@ test "renderCompareRowPct: no ANSI when color=false" {
|
|||
try renderCompareRowPct(&w, false, "X", 0.1, 0.2);
|
||||
try testing.expect(std.mem.indexOf(u8, w.buffered(), "\x1b[") == null);
|
||||
}
|
||||
|
||||
// ── Render-block tests (synthetic context, in-memory writer) ───
|
||||
|
||||
/// Build a ProjectionContext from a config for render tests, using a
|
||||
/// synthetic benchmark comparison (no network / DataService). Mirrors
|
||||
/// the setup the view-model integration tests use.
|
||||
fn buildCtxForTest(arena: std.mem.Allocator, config: projections.UserConfig, as_of: Date) !view.ProjectionContext {
|
||||
const benchmark = @import("../analytics/benchmark.zig");
|
||||
const comparison: benchmark.BenchmarkComparison = .{
|
||||
.stock_returns = .{},
|
||||
.bond_returns = .{},
|
||||
.benchmark_returns = .{},
|
||||
.portfolio_returns = .{},
|
||||
.conservative_return = 0.07,
|
||||
.stock_pct = 0.8,
|
||||
.bond_pct = 0.2,
|
||||
};
|
||||
return view.buildProjectionContext(arena, config, comparison, 0.8, 0.2, 3_000_000, &.{}, as_of);
|
||||
}
|
||||
|
||||
test "renderEarliestBlock: age-anchored column renders 'to age N' header" {
|
||||
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||
defer arena.deinit();
|
||||
const a = arena.allocator();
|
||||
var config = projections.parseProjectionsConfig(
|
||||
\\#!srfv1
|
||||
\\type::config,horizon_age:num:95
|
||||
\\type::config,target_spending:num:120000
|
||||
\\type::config,survivor_spending_pct:num:75
|
||||
\\type::birthdate,date::1965-03-01
|
||||
\\type::birthdate,date::1968-08-15,person:num:2
|
||||
);
|
||||
const as_of = Date.fromYmd(2026, 6, 15);
|
||||
try config.resolveHorizonAges(as_of);
|
||||
const ctx = try buildCtxForTest(a, config, as_of);
|
||||
|
||||
var buf: [8192]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
try renderEarliestBlock(&w, false, a, ctx, as_of);
|
||||
const out = w.buffered();
|
||||
|
||||
try testing.expect(std.mem.indexOf(u8, out, "Earliest retirement") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, out, "to age 95") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, out, "% confidence") != null);
|
||||
// No ANSI when color is off.
|
||||
try testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
|
||||
}
|
||||
|
||||
test "renderEarliestBlock: numeric horizon renders 'N Year' header" {
|
||||
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||
defer arena.deinit();
|
||||
const a = arena.allocator();
|
||||
const config = projections.parseProjectionsConfig(
|
||||
\\#!srfv1
|
||||
\\type::config,horizon:num:30
|
||||
\\type::config,target_spending:num:40000
|
||||
);
|
||||
const as_of = Date.fromYmd(2026, 6, 15);
|
||||
const ctx = try buildCtxForTest(a, config, as_of);
|
||||
|
||||
var buf: [8192]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
try renderEarliestBlock(&w, false, a, ctx, as_of);
|
||||
const out = w.buffered();
|
||||
|
||||
try testing.expect(std.mem.indexOf(u8, out, "30 Year") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, out, "to age") == null);
|
||||
}
|
||||
|
||||
test "renderAccumulationBlock: target-date config prints accumulation stats" {
|
||||
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||
defer arena.deinit();
|
||||
const a = arena.allocator();
|
||||
var config = projections.UserConfig{};
|
||||
config.retirement_at = Date.fromYmd(2040, 7, 1);
|
||||
config.annual_contribution = 60_000;
|
||||
const as_of = Date.fromYmd(2026, 7, 1);
|
||||
const ctx = try buildCtxForTest(a, config, as_of);
|
||||
|
||||
var buf: [8192]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
try renderAccumulationBlock(&w, false, a, ctx);
|
||||
const out = w.buffered();
|
||||
|
||||
try testing.expect(std.mem.indexOf(u8, out, "Accumulation phase:") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, out, "Years until possible retirement") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, out, "Median portfolio at retirement") != null);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1214,7 +1214,7 @@ fn buildFooterSection(app: *App, arena: std.mem.Allocator, lines: *std.ArrayList
|
|||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||
|
||||
try lines.append(arena, .{
|
||||
.text = try std.fmt.allocPrint(arena, " {s}", .{try view.buildHeaderRow(arena, horizons, view.terminal_col_width)}),
|
||||
.text = try std.fmt.allocPrint(arena, " {s}", .{try view.buildHeaderRow(arena, horizons, config.horizon_death_age[0..horizons.len], view.terminal_col_width)}),
|
||||
.style = th.headerStyle(),
|
||||
});
|
||||
|
||||
|
|
@ -1262,7 +1262,7 @@ fn appendSwrTable(
|
|||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||
|
||||
try lines.append(arena, .{
|
||||
.text = try std.fmt.allocPrint(arena, " {s}", .{try view.buildHeaderRow(arena, horizons, view.withdrawal_col_width)}),
|
||||
.text = try std.fmt.allocPrint(arena, " {s}", .{try view.buildHeaderRow(arena, horizons, pctx.config.horizon_death_age[0..horizons.len], view.withdrawal_col_width)}),
|
||||
.style = th.headerStyle(),
|
||||
});
|
||||
|
||||
|
|
@ -1417,9 +1417,9 @@ fn appendAccumulationBlocks(
|
|||
{
|
||||
var hdr: std.ArrayListUnmanaged(u8) = .empty;
|
||||
try hdr.appendNTimes(arena, ' ', label_width);
|
||||
for (horizons) |h| {
|
||||
for (horizons, 0..) |h, hi| {
|
||||
var hbuf: [16]u8 = undefined;
|
||||
const hlabel = view.fmtHorizonLabel(&hbuf, h);
|
||||
const hlabel = view.fmtHorizonLabelAge(&hbuf, h, pctx.config.horizon_death_age[hi]);
|
||||
try hdr.appendNTimes(arena, ' ', cell_width -| hlabel.len);
|
||||
try hdr.appendSlice(arena, hlabel);
|
||||
}
|
||||
|
|
@ -2095,7 +2095,7 @@ fn buildLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const Style
|
|||
|
||||
// Column header
|
||||
try lines.append(arena, .{
|
||||
.text = try std.fmt.allocPrint(arena, " {s}", .{try view.buildHeaderRow(arena, horizons, view.terminal_col_width)}),
|
||||
.text = try std.fmt.allocPrint(arena, " {s}", .{try view.buildHeaderRow(arena, horizons, config.horizon_death_age[0..horizons.len], view.terminal_col_width)}),
|
||||
.style = th.headerStyle(),
|
||||
});
|
||||
|
||||
|
|
@ -2325,3 +2325,105 @@ test "appendSwrTable: accumulation suppresses rate rows and adds one footnote" {
|
|||
try testing.expectEqual(@as(usize, 0), counts.rates);
|
||||
try testing.expectEqual(@as(usize, 1), counts.footnotes);
|
||||
}
|
||||
|
||||
/// Build an age-of-death-anchored, target-spending context for the
|
||||
/// TUI render tests (couple + `horizon_age` + survivor cut). Synthetic
|
||||
/// benchmark comparison, no DataService.
|
||||
fn buildAgeAnchoredTestCtx(arena: std.mem.Allocator, as_of: zfin.Date) !view.ProjectionContext {
|
||||
const benchmark = @import("../analytics/benchmark.zig");
|
||||
const projections = @import("../analytics/projections.zig");
|
||||
var config = projections.parseProjectionsConfig(
|
||||
\\#!srfv1
|
||||
\\type::config,horizon_age:num:95
|
||||
\\type::config,target_spending:num:120000
|
||||
\\type::config,survivor_spending_pct:num:75
|
||||
\\type::birthdate,date::1965-03-01
|
||||
\\type::birthdate,date::1968-08-15,person:num:2
|
||||
\\type::event,name::Social Security (A),start_age:num:70,person:num:1,amount:num:38400
|
||||
\\type::event,name::College Tuition,start_age:num:62,person:num:1,duration:num:4,amount:num:-55000
|
||||
\\type::event,name::Pension,start_age:num:65,person:num:2,amount:num:24000,inflation_adjusted:bool:false
|
||||
);
|
||||
try config.resolveHorizonAges(as_of);
|
||||
const comparison: benchmark.BenchmarkComparison = .{
|
||||
.stock_returns = .{},
|
||||
.bond_returns = .{},
|
||||
.benchmark_returns = .{},
|
||||
.portfolio_returns = .{},
|
||||
.conservative_return = 0.07,
|
||||
.stock_pct = 0.8,
|
||||
.bond_pct = 0.2,
|
||||
};
|
||||
return view.buildProjectionContext(arena, config, comparison, 0.8, 0.2, 3_000_000, &.{}, as_of);
|
||||
}
|
||||
|
||||
test "appendAccumulationBlocks: age-anchored target spending renders grid with 'to age' header" {
|
||||
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||
defer arena.deinit();
|
||||
const a = arena.allocator();
|
||||
const as_of = zfin.Date.fromYmd(2026, 6, 15);
|
||||
const ctx = try buildAgeAnchoredTestCtx(a, as_of);
|
||||
|
||||
var lines: std.ArrayListUnmanaged(StyledLine) = .empty;
|
||||
try appendAccumulationBlocks(&lines, a, theme.default_theme, ctx, as_of);
|
||||
|
||||
var found_accum = false;
|
||||
var found_earliest = false;
|
||||
var found_age = false;
|
||||
for (lines.items) |line| {
|
||||
if (std.mem.indexOf(u8, line.text, "Accumulation phase") != null) found_accum = true;
|
||||
if (std.mem.indexOf(u8, line.text, "Earliest retirement") != null) found_earliest = true;
|
||||
if (std.mem.indexOf(u8, line.text, "to age 95") != null) found_age = true;
|
||||
}
|
||||
try testing.expect(found_accum);
|
||||
try testing.expect(found_earliest);
|
||||
// The "to age N" header is a plain text line (the per-confidence
|
||||
// date rows are grapheme-rendered, so they're not searched here).
|
||||
try testing.expect(found_age);
|
||||
}
|
||||
|
||||
test "appendAccumulationBlocks: numeric target-date config renders accumulation stats, no grid" {
|
||||
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||
defer arena.deinit();
|
||||
const a = arena.allocator();
|
||||
const as_of = zfin.Date.fromYmd(2026, 7, 1);
|
||||
const ctx = try buildSwrTestCtx(a, zfin.Date.fromYmd(2040, 7, 1), 60_000, as_of);
|
||||
|
||||
var lines: std.ArrayListUnmanaged(StyledLine) = .empty;
|
||||
try appendAccumulationBlocks(&lines, a, theme.default_theme, ctx, as_of);
|
||||
|
||||
var found_accum = false;
|
||||
var found_earliest = false;
|
||||
for (lines.items) |line| {
|
||||
if (std.mem.indexOf(u8, line.text, "Accumulation phase") != null) found_accum = true;
|
||||
if (std.mem.indexOf(u8, line.text, "Earliest retirement") != null) found_earliest = true;
|
||||
}
|
||||
try testing.expect(found_accum);
|
||||
// Target-date input has no earliest-retirement grid.
|
||||
try testing.expect(!found_earliest);
|
||||
}
|
||||
|
||||
test "appendEventSummary: renders a Life Events line per configured event" {
|
||||
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||
defer arena.deinit();
|
||||
const a = arena.allocator();
|
||||
const as_of = zfin.Date.fromYmd(2026, 6, 15);
|
||||
const ctx = try buildAgeAnchoredTestCtx(a, as_of);
|
||||
|
||||
var lines: std.ArrayListUnmanaged(StyledLine) = .empty;
|
||||
try appendEventSummary(&lines, as_of, a, theme.default_theme, ctx);
|
||||
|
||||
var found_header = false;
|
||||
var found_ss = false;
|
||||
var found_tuition = false;
|
||||
var found_nominal = false;
|
||||
for (lines.items) |line| {
|
||||
if (std.mem.indexOf(u8, line.text, "Life Events") != null) found_header = true;
|
||||
if (std.mem.indexOf(u8, line.text, "Social Security") != null) found_ss = true;
|
||||
if (std.mem.indexOf(u8, line.text, "College Tuition") != null) found_tuition = true;
|
||||
if (std.mem.indexOf(u8, line.text, "nominal") != null) found_nominal = true;
|
||||
}
|
||||
try testing.expect(found_header);
|
||||
try testing.expect(found_ss);
|
||||
try testing.expect(found_tuition); // expense event (negative amount)
|
||||
try testing.expect(found_nominal); // the non-inflation-adjusted pension
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,6 +100,18 @@ pub fn fmtHorizonLabel(buf: []u8, horizon: u16) []const u8 {
|
|||
return std.fmt.bufPrint(buf, "{d} Year", .{horizon}) catch "??";
|
||||
}
|
||||
|
||||
/// Format a horizon column header, age-aware. When `death_age` is
|
||||
/// non-zero the column is age-of-death-anchored and renders "to age
|
||||
/// N" (the horizon shrinks as retirement slides, so the fixed-year
|
||||
/// label would be misleading). Otherwise falls back to the numeric
|
||||
/// "N Year" label.
|
||||
pub fn fmtHorizonLabelAge(buf: []u8, horizon: u16, death_age: u16) []const u8 {
|
||||
if (death_age != 0) {
|
||||
return std.fmt.bufPrint(buf, "to age {d}", .{death_age}) catch "??";
|
||||
}
|
||||
return fmtHorizonLabel(buf, horizon);
|
||||
}
|
||||
|
||||
// ── Allocation summary ─────────────────────────────────────────
|
||||
|
||||
/// Result of formatting the allocation note.
|
||||
|
|
@ -329,8 +341,6 @@ pub const ProjectionInputs = enum {
|
|||
|
||||
pub const ProjectionData = projections.ProjectionData;
|
||||
|
||||
pub const runProjectionGrid = projections.runProjectionGrid;
|
||||
|
||||
pub fn buildProjectionContext(
|
||||
alloc: std.mem.Allocator,
|
||||
config: projections.UserConfig,
|
||||
|
|
@ -352,13 +362,43 @@ pub fn buildProjectionContext(
|
|||
var retirement = config.resolveRetirement(as_of);
|
||||
const accumulation_years: u16 = retirement.accumulation_years;
|
||||
|
||||
const data = try runProjectionGrid(
|
||||
const horizons = config.getHorizons();
|
||||
// Parallel age-of-death provenance: 0 = plain numeric horizon
|
||||
// (no mortality), non-zero = age-anchored column.
|
||||
const death_ages = config.horizon_death_age[0..horizons.len];
|
||||
|
||||
// Build per-column grid specs. Numeric columns share the uncapped
|
||||
// `events` and carry no mortality (today's behavior). Age-anchored
|
||||
// columns derive their distribution from the retirement boundary,
|
||||
// terminate each person's events at their own death, and carry the
|
||||
// survivor spending step-down at the first death.
|
||||
const columns = try alloc.alloc(projections.GridColumn, horizons.len);
|
||||
defer alloc.free(columns);
|
||||
// Backing storage for age columns' capped events; only age slots
|
||||
// are populated and read (numeric columns point at `events`).
|
||||
const col_events = try alloc.alloc([projections.UserConfig.max_events]projections.ResolvedEvent, horizons.len);
|
||||
defer alloc.free(col_events);
|
||||
for (horizons, 0..) |h, i| {
|
||||
if (death_ages[i] == 0) {
|
||||
columns[i] = .{ .distribution_years = h, .events = events };
|
||||
} else {
|
||||
const m = projections.columnMortality(&config, as_of, death_ages[i], accumulation_years);
|
||||
col_events[i] = config.resolveEventsToAge(as_of, death_ages[i]);
|
||||
columns[i] = .{
|
||||
.distribution_years = m.distribution_years,
|
||||
.events = col_events[i][0..config.event_count],
|
||||
.survivor_factor = m.survivor_factor,
|
||||
.first_death_year = m.first_death_year,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const data = try projections.runProjectionGridColumns(
|
||||
alloc,
|
||||
config.getHorizons(),
|
||||
columns,
|
||||
config.getConfidenceLevels(),
|
||||
total_value,
|
||||
sim_stock_pct,
|
||||
events,
|
||||
accumulation_years,
|
||||
config.annual_contribution,
|
||||
config.contribution_inflation_adjusted,
|
||||
|
|
@ -371,7 +411,6 @@ pub fn buildProjectionContext(
|
|||
// available; same boundary year for all horizons).
|
||||
var accumulation_stats: ?AccumulationStats = null;
|
||||
if (accumulation_years > 0) {
|
||||
const horizons = config.getHorizons();
|
||||
if (horizons.len > 0) {
|
||||
const last_band = data.bands[horizons.len - 1];
|
||||
if (last_band) |b| {
|
||||
|
|
@ -389,31 +428,57 @@ pub fn buildProjectionContext(
|
|||
}
|
||||
}
|
||||
|
||||
// Earliest retirement grid: when `target_spending` is set,
|
||||
// search for the earliest retirement year per (horizon ×
|
||||
// confidence) pair.
|
||||
// Earliest retirement grid: when `target_spending` is set, search
|
||||
// for the earliest retirement year per (horizon x confidence)
|
||||
// pair. Numeric columns use the fixed-horizon search; age-anchored
|
||||
// columns use the to-age search (distribution derived from the
|
||||
// retirement date, mortality threaded through).
|
||||
var earliest: ?[]projections.EarliestRetirement = null;
|
||||
if (config.target_spending) |target| {
|
||||
const horizons = config.getHorizons();
|
||||
const confs = config.getConfidenceLevels();
|
||||
const cells = try alloc.alloc(projections.EarliestRetirement, horizons.len * confs.len);
|
||||
for (confs, 0..) |conf, ci| {
|
||||
for (horizons, 0..) |h, hi| {
|
||||
cells[ci * horizons.len + hi] = try projections.findEarliestRetirement(
|
||||
alloc,
|
||||
total_value,
|
||||
sim_stock_pct,
|
||||
config.annual_contribution,
|
||||
config.contribution_inflation_adjusted,
|
||||
target,
|
||||
config.target_spending_inflation_adjusted,
|
||||
h,
|
||||
conf,
|
||||
events,
|
||||
config.max_accumulation_years,
|
||||
sim_expense_ratio,
|
||||
config.spending_real_change orelse 0,
|
||||
);
|
||||
if (death_ages[hi] == 0) {
|
||||
cells[ci * horizons.len + hi] = try projections.findEarliestRetirement(
|
||||
alloc,
|
||||
total_value,
|
||||
sim_stock_pct,
|
||||
config.annual_contribution,
|
||||
config.contribution_inflation_adjusted,
|
||||
target,
|
||||
config.target_spending_inflation_adjusted,
|
||||
h,
|
||||
conf,
|
||||
events,
|
||||
config.max_accumulation_years,
|
||||
sim_expense_ratio,
|
||||
config.spending_real_change orelse 0,
|
||||
);
|
||||
} else {
|
||||
// The earliest search varies the retirement date,
|
||||
// so the distribution is derived per-N from
|
||||
// `total_span` (pass accumulation 0 here).
|
||||
const m = projections.columnMortality(&config, as_of, death_ages[hi], 0);
|
||||
cells[ci * horizons.len + hi] = try projections.findEarliestRetirementToAge(
|
||||
alloc,
|
||||
total_value,
|
||||
sim_stock_pct,
|
||||
config.annual_contribution,
|
||||
config.contribution_inflation_adjusted,
|
||||
target,
|
||||
config.target_spending_inflation_adjusted,
|
||||
m.total_span,
|
||||
m.first_death_year,
|
||||
m.survivor_factor,
|
||||
death_ages[hi],
|
||||
conf,
|
||||
col_events[hi][0..config.event_count],
|
||||
config.max_accumulation_years,
|
||||
sim_expense_ratio,
|
||||
config.spending_real_change orelse 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
earliest = cells;
|
||||
|
|
@ -437,7 +502,6 @@ pub fn buildProjectionContext(
|
|||
// target-spending answer below it.
|
||||
if (inputs == .target_spending) {
|
||||
if (earliest) |grid| {
|
||||
const horizons = config.getHorizons();
|
||||
const confs = config.getConfidenceLevels();
|
||||
if (projections.pickPromotedCell(&config, as_of, confs)) |pc| {
|
||||
const cell = grid[pc.confidence_index * horizons.len + pc.horizon_index];
|
||||
|
|
@ -1071,13 +1135,17 @@ pub const TableRow = struct {
|
|||
style: StyleIntent,
|
||||
};
|
||||
|
||||
/// Build a column header row for a given set of horizons and column width.
|
||||
pub fn buildHeaderRow(arena: std.mem.Allocator, horizons: []const u16, col_width: usize) ![]const u8 {
|
||||
/// Build a column header row for a given set of horizons and column
|
||||
/// width. `death_ages` is parallel to `horizons` (0 = numeric column,
|
||||
/// non-zero = age-anchored, rendered "to age N"); pass an
|
||||
/// all-zero/empty-equivalent slice for a purely numeric grid.
|
||||
pub fn buildHeaderRow(arena: std.mem.Allocator, horizons: []const u16, death_ages: []const u16, col_width: usize) ![]const u8 {
|
||||
var row: std.ArrayListUnmanaged(u8) = .empty;
|
||||
try row.appendNTimes(arena, ' ', withdrawal_label_width);
|
||||
for (horizons) |h| {
|
||||
for (horizons, 0..) |h, hi| {
|
||||
var hbuf: [16]u8 = undefined;
|
||||
const hlabel = fmtHorizonLabel(&hbuf, h);
|
||||
const da: u16 = if (hi < death_ages.len) death_ages[hi] else 0;
|
||||
const hlabel = fmtHorizonLabelAge(&hbuf, h, da);
|
||||
try row.appendNTimes(arena, ' ', col_width -| hlabel.len);
|
||||
try row.appendSlice(arena, hlabel);
|
||||
}
|
||||
|
|
@ -1682,11 +1750,26 @@ test "buildHeaderRow formats horizons" {
|
|||
const a = arena.allocator();
|
||||
|
||||
const horizons = [_]u16{ 30, 45 };
|
||||
const result = try buildHeaderRow(a, &horizons, withdrawal_col_width);
|
||||
const result = try buildHeaderRow(a, &horizons, &.{}, withdrawal_col_width);
|
||||
try std.testing.expect(std.mem.indexOf(u8, result, "30 Year") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, result, "45 Year") != null);
|
||||
}
|
||||
|
||||
test "buildHeaderRow renders age-anchored columns as 'to age N'" {
|
||||
const allocator = std.testing.allocator;
|
||||
var arena = std.heap.ArenaAllocator.init(allocator);
|
||||
defer arena.deinit();
|
||||
const a = arena.allocator();
|
||||
|
||||
const horizons = [_]u16{ 30, 33 };
|
||||
const death_ages = [_]u16{ 0, 95 }; // col 0 numeric, col 1 age-anchored
|
||||
const result = try buildHeaderRow(a, &horizons, &death_ages, withdrawal_col_width);
|
||||
try std.testing.expect(std.mem.indexOf(u8, result, "30 Year") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, result, "to age 95") != null);
|
||||
// The age column does NOT show its raw year count.
|
||||
try std.testing.expect(std.mem.indexOf(u8, result, "33 Year") == null);
|
||||
}
|
||||
|
||||
test "buildHeaderRow uses terminal column width" {
|
||||
const allocator = std.testing.allocator;
|
||||
var arena = std.heap.ArenaAllocator.init(allocator);
|
||||
|
|
@ -1694,8 +1777,8 @@ test "buildHeaderRow uses terminal column width" {
|
|||
const a = arena.allocator();
|
||||
|
||||
const horizons = [_]u16{20};
|
||||
const narrow = try buildHeaderRow(a, &horizons, withdrawal_col_width);
|
||||
const wide = try buildHeaderRow(a, &horizons, terminal_col_width);
|
||||
const narrow = try buildHeaderRow(a, &horizons, &.{}, withdrawal_col_width);
|
||||
const wide = try buildHeaderRow(a, &horizons, &.{}, terminal_col_width);
|
||||
try std.testing.expect(wide.len > narrow.len);
|
||||
}
|
||||
|
||||
|
|
@ -1722,6 +1805,57 @@ test "buildWithdrawalRows produces amount and rate" {
|
|||
try std.testing.expect(rows.rate.style == .muted);
|
||||
}
|
||||
|
||||
test "buildPercentileRow renders '--' for null and empty bands" {
|
||||
const allocator = std.testing.allocator;
|
||||
var arena = std.heap.ArenaAllocator.init(allocator);
|
||||
defer arena.deinit();
|
||||
const a = arena.allocator();
|
||||
|
||||
const filled = [_]projections.YearPercentiles{
|
||||
.{ .year = 0, .p10 = 1, .p25 = 2, .p50 = 3, .p75 = 4, .p90 = 5 },
|
||||
.{ .year = 1, .p10 = 100, .p25 = 200, .p50 = 300, .p75 = 400, .p90 = 500 },
|
||||
};
|
||||
const empty: []const projections.YearPercentiles = &.{};
|
||||
const all_bands = [_]?[]const projections.YearPercentiles{ &filled, null, empty };
|
||||
|
||||
const row = try buildPercentileRow(a, "Median (p50)", 1, &all_bands, .normal);
|
||||
// First column has data -> the p50 of the last year ($300).
|
||||
try std.testing.expect(std.mem.indexOf(u8, row.text, "$300") != null);
|
||||
// The null and empty columns each render the "--" sentinel.
|
||||
try std.testing.expect(std.mem.indexOf(u8, row.text, "--") != null);
|
||||
}
|
||||
|
||||
test "fmtEventLine: income/expense, timing, duration, and nominal branches" {
|
||||
const allocator = std.testing.allocator;
|
||||
var arena = std.heap.ArenaAllocator.init(allocator);
|
||||
defer arena.deinit();
|
||||
const a = arena.allocator();
|
||||
const ages = [_]u16{60};
|
||||
|
||||
// Future income (start in the future) - positive style, "(in Nyr)".
|
||||
var ss = projections.LifeEvent{ .start_age = 70, .person = 0, .annual_amount = 38_400 };
|
||||
ss.name_len = @intCast((std.fmt.bufPrint(&ss.name, "Social Security", .{}) catch unreachable).len);
|
||||
const ss_line = try fmtEventLine(a, &ss, &ages);
|
||||
try std.testing.expect(ss_line.style == .positive);
|
||||
try std.testing.expect(std.mem.indexOf(u8, ss_line.text, "Social Security") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, ss_line.text, "in 10yr") != null);
|
||||
|
||||
// Current expense with duration and nominal flag - negative style,
|
||||
// "(now)", ", Nyr", ", nominal".
|
||||
var exp = projections.LifeEvent{ .start_age = 60, .person = 0, .duration = 4, .annual_amount = -55_000, .inflation_adjusted = false };
|
||||
exp.name_len = @intCast((std.fmt.bufPrint(&exp.name, "Tuition", .{}) catch unreachable).len);
|
||||
const exp_line = try fmtEventLine(a, &exp, &ages);
|
||||
try std.testing.expect(exp_line.style == .negative);
|
||||
try std.testing.expect(std.mem.indexOf(u8, exp_line.text, "now") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, exp_line.text, "4yr") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, exp_line.text, "nominal") != null);
|
||||
|
||||
// Out-of-range person -> startYear null -> "age N" fallback (no timing).
|
||||
const orphan = projections.LifeEvent{ .start_age = 67, .person = 3, .annual_amount = 1000 };
|
||||
const orphan_line = try fmtEventLine(a, &orphan, &ages);
|
||||
try std.testing.expect(std.mem.indexOf(u8, orphan_line.text, "age 67") != null);
|
||||
}
|
||||
|
||||
test "swrRateNote: null without accumulation, present with accumulation" {
|
||||
// Distribution-only (already retired): the rate is a correct
|
||||
// withdrawal rate against the current portfolio, so render it.
|
||||
|
|
@ -1786,6 +1920,14 @@ test "fmtHorizonLabel" {
|
|||
try std.testing.expectEqualStrings("30 Year", label);
|
||||
}
|
||||
|
||||
test "fmtHorizonLabelAge" {
|
||||
var buf: [16]u8 = undefined;
|
||||
// death_age 0 -> numeric "N Year" label.
|
||||
try std.testing.expectEqualStrings("30 Year", fmtHorizonLabelAge(&buf, 30, 0));
|
||||
// death_age non-zero -> "to age N", ignoring the (variable) year count.
|
||||
try std.testing.expectEqualStrings("to age 95", fmtHorizonLabelAge(&buf, 33, 95));
|
||||
}
|
||||
|
||||
// ── Accumulation phase / earliest retirement view tests ────────
|
||||
|
||||
test "fmtRetirementLine: none" {
|
||||
|
|
@ -2125,6 +2267,58 @@ test "buildProjectionContext: both_targets inputs when both fields configured" {
|
|||
try std.testing.expect(ctx.earliest != null);
|
||||
}
|
||||
|
||||
test "buildProjectionContext: age-anchored horizon flows through to the earliest grid" {
|
||||
const allocator = std.testing.allocator;
|
||||
var arena = std.heap.ArenaAllocator.init(allocator);
|
||||
defer arena.deinit();
|
||||
|
||||
// Couple: born 1962 (~63) and 1967 (~58) as of mid-2026; plan to
|
||||
// age 95 (horizon_age), targeting $50k/yr with a survivor cut.
|
||||
var config = projections.parseProjectionsConfig(
|
||||
\\#!srfv1
|
||||
\\type::config,horizon_age:num:95
|
||||
\\type::config,target_spending:num:50000
|
||||
\\type::config,survivor_spending_pct:num:70
|
||||
\\type::birthdate,date::1962-03-01
|
||||
\\type::birthdate,date::1967-08-15,person:num:2
|
||||
);
|
||||
const as_of = Date.fromYmd(2026, 6, 15);
|
||||
try config.resolveHorizonAges(as_of);
|
||||
|
||||
const comparison: benchmark.BenchmarkComparison = .{
|
||||
.stock_returns = .{},
|
||||
.bond_returns = .{},
|
||||
.benchmark_returns = .{},
|
||||
.portfolio_returns = .{},
|
||||
.conservative_return = 0.07,
|
||||
.stock_pct = 0.75,
|
||||
.bond_pct = 0.25,
|
||||
};
|
||||
|
||||
const ctx = try buildProjectionContext(
|
||||
arena.allocator(),
|
||||
config,
|
||||
comparison,
|
||||
0.75,
|
||||
0.25,
|
||||
3_000_000,
|
||||
&.{},
|
||||
as_of,
|
||||
);
|
||||
|
||||
try std.testing.expectEqual(ProjectionInputs.target_spending, ctx.inputs);
|
||||
try std.testing.expect(ctx.earliest != null);
|
||||
// One age horizon x 3 confidences = 3 cells, all flagged age-anchored
|
||||
// at 95 (the youngest reaches 95 last, setting the horizon).
|
||||
try std.testing.expectEqual(@as(usize, 3), ctx.earliest.?.len);
|
||||
for (ctx.earliest.?) |cell| {
|
||||
try std.testing.expectEqual(@as(u16, 95), cell.death_age);
|
||||
}
|
||||
// The promoted headline comes from the age column.
|
||||
try std.testing.expect(ctx.retirement.source == .promoted or
|
||||
ctx.retirement.source == .promoted_infeasible);
|
||||
}
|
||||
|
||||
// ── Overlay-actuals tests ─────────────────────────────────────
|
||||
|
||||
/// Build a TimelinePoint with just the date and liquid value
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue