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
|
- id: test
|
||||||
name: Run zig build test
|
name: Run zig build test
|
||||||
entry: zig
|
entry: zig
|
||||||
args: ["build", "coverage", "-Dcoverage-threshold=79"]
|
args: ["build", "coverage", "-Dcoverage-threshold=80"]
|
||||||
language: system
|
language: system
|
||||||
types: [file]
|
types: [file]
|
||||||
pass_filenames: false
|
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 run -- <args> # build and run CLI
|
||||||
zig build docs # generate library documentation
|
zig build docs # generate library documentation
|
||||||
zig build coverage # run tests with kcov coverage (Linux only). See "Coverage" section.
|
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`):
|
**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
|
(`HIGH` / `MEDIUM` / `LOW`) mark items that deserve explicit
|
||||||
ranking; unlabeled items are "someday, if the mood strikes."
|
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.**
|
Contribution-attribution overlay. Today's actuals line includes
|
||||||
Today the W1 ("set spending, find date") workflow reports the
|
contributions implicitly; the bands assume modeled contributions
|
||||||
earliest retirement at each user-configured `(horizon, confidence)`
|
that may or may not match reality. A "decompose actuals into
|
||||||
cell. The philosophically correct version asks "when have I
|
market return vs contributions" annotation would clarify how
|
||||||
accumulated enough wealth that the projection shows a 95%
|
much of the trajectory was the model being right vs new money
|
||||||
probability of success withdrawing X per year from retirement
|
arriving on schedule. The contributions pipeline
|
||||||
until age-of-death?" - i.e. goal-seek across both `accumulation_years`
|
(`transaction_log.srf` + lot-diff) already has the flow data, so
|
||||||
AND `distribution_years` simultaneously, anchored to a configured
|
this is the one overlay follow-up with real analytical payoff.
|
||||||
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.
|
|
||||||
|
|
||||||
## Investigate: detailed 401(k) contributions data source
|
## 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
|
length comes from your retirement-date input. With no input, it's
|
||||||
zero years (an already-retired view).
|
zero years (an already-retired view).
|
||||||
2. **Distribution** -- annual spending withdrawn (CPI-adjusted by
|
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 is flat in real terms unless you set
|
||||||
[`spending_change`](../reference/config/projections-srf.md#declining-spending-the-smile)
|
[`spending_change`](../reference/config/projections-srf.md#declining-spending-the-smile)
|
||||||
to taper or grow it year over year (the Blanchett "spending 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
|
question: for each (horizon x confidence) cell it searches for the
|
||||||
**earliest** accumulation length (up to `max_accumulation_years`, 50
|
**earliest** accumulation length (up to `max_accumulation_years`, 50
|
||||||
years by default) that sustains your spending, and renders the grid of
|
years by default) that sustains your spending, and renders the grid of
|
||||||
answers. One cell is promoted to the
|
answers. One cell is promoted to the headline (see
|
||||||
headline (see
|
|
||||||
[promotion rules](../reference/config/projections-srf.md#the-two-retirement-planning-inputs)).
|
[promotion rules](../reference/config/projections-srf.md#the-two-retirement-planning-inputs)).
|
||||||
If no length within the cap works, the cell is **infeasible** -- shown
|
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
|
honestly rather than fudged. A young saver with a runway longer than 50
|
||||||
years can raise the cap via
|
years can raise the cap via
|
||||||
[`max_accumulation_years`](../reference/config/projections-srf.md#config-fields).
|
[`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
|
## The caveat that matters most
|
||||||
|
|
||||||
zfin states this loudly by design, and so does this page:
|
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 |
|
| Field | Type | Description |
|
||||||
|--------------------------------------|------|--------------------------------------------------------------------------------------------------------------------------------|
|
|--------------------------------------|------|--------------------------------------------------------------------------------------------------------------------------------|
|
||||||
| `target_stock_pct` | num | Asset-allocation target (0-100). Sets the simulation's stock/bond blend. |
|
| `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. |
|
| `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). |
|
| `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` | 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; resolves to `target_age - oldest_current_age`. Repeatable. |
|
| `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_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. |
|
| `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. |
|
| `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` | num | Desired retirement spending, in today's dollars. |
|
||||||
| `target_spending_inflation_adjusted` | bool | If `true` (default), target spending grows with CPI during distribution. |
|
| `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). |
|
| `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`. |
|
| `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`. |
|
| `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.
|
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
|
### Capping outlier returns
|
||||||
|
|
||||||
The **Projected return** shown by `zfin projections` (and the "Projected
|
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,
|
When `target_spending` is set, the **earliest-retirement grid** shows,
|
||||||
for each (horizon x confidence) pair, the earliest year that sustains
|
for each (horizon x confidence) pair, the earliest year that sustains
|
||||||
the spending. The default promotion rule picks the headline cell by
|
the spending. The default promotion rule picks the headline cell at 99%
|
||||||
walking horizons longest-to-shortest at 99% confidence, preferring the
|
confidence: it prefers an age-of-death-anchored (`horizon_age`) column
|
||||||
longest horizon that keeps the oldest person under age 100. Override it
|
(the latest death, if several), since that's the "plan to the last
|
||||||
with a `retirement_target` annotation on one horizon line:
|
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
|
```srf
|
||||||
# use the 35yr x 95% cell as the headline
|
# 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
|
- The **Accumulation phase** block is populated by **promoting one
|
||||||
cell** from the grid into the headline retirement line, plus the
|
cell** from the grid into the headline retirement line, plus the
|
||||||
median portfolio at retirement and p10-p90 range. The default
|
median portfolio at retirement and p10-p90 range. The default
|
||||||
promotion rule walks horizons longest -> shortest and picks the
|
promotion rule prefers an age-of-death (`horizon_age`) column - the
|
||||||
longest one whose end year keeps the oldest configured person
|
"plan to the last survivor" answer - at 99% confidence (most
|
||||||
under age 100, at 99% confidence (most conservative). If even
|
conservative); with only numeric horizons it walks them longest ->
|
||||||
the shortest horizon overshoots, it's used anyway.
|
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 grid stays rendered for transparency - the user can see how
|
||||||
the headline cell compares to the rest of the matrix.
|
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.
|
# flat real spending, the default.
|
||||||
type::config,spending_change:num:-2
|
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:20
|
||||||
type::config,horizon:num:30
|
type::config,horizon:num:30
|
||||||
type::config,horizon_age:num:95
|
type::config,horizon_age:num:95
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,8 @@
|
||||||
# Allocation target shifts more conservative in retirement
|
# Allocation target shifts more conservative in retirement
|
||||||
type::config,target_stock_pct:num:60
|
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:20
|
||||||
type::config,horizon:num:30
|
type::config,horizon:num:30
|
||||||
type::config,horizon_age:num:95
|
type::config,horizon_age:num:95
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,8 @@ type::config,target_stock_pct:num:80
|
||||||
# Distribution-phase horizons to simulate
|
# Distribution-phase horizons to simulate
|
||||||
type::config,horizon:num:25
|
type::config,horizon:num:25
|
||||||
type::config,horizon:num:35
|
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
|
type::config,horizon_age:num:95
|
||||||
|
|
||||||
# Target retirement date: oldest partner (Pat) reaches 65 in 2046
|
# 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
|
# Distribution-phase horizons to simulate
|
||||||
type::config,horizon:num:25
|
type::config,horizon:num:25
|
||||||
type::config,horizon:num:35
|
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
|
type::config,horizon_age:num:95
|
||||||
|
|
||||||
# Target retirement date: oldest partner (Pat) reaches 65 in 2046
|
# Target retirement date: oldest partner (Pat) reaches 65 in 2046
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,11 @@
|
||||||
# earliest accumulation length that sustains that spending at each
|
# earliest accumulation length that sustains that spending at each
|
||||||
# configured (horizon × confidence) pair, renders the resulting
|
# configured (horizon × confidence) pair, renders the resulting
|
||||||
# grid, and promotes one cell into the Accumulation phase block as
|
# grid, and promotes one cell into the Accumulation phase block as
|
||||||
# the headline. The default promotion rule is "longest configured
|
# the headline. The default promotion rule prefers an age-of-death
|
||||||
# horizon at 99% confidence, where the oldest person stays under
|
# (`horizon_age`) column at 99% confidence - the "plan to the last
|
||||||
# age 100." See `pre-retirement-spending-target/` for the explicit-
|
# survivor" answer - falling back to the longest numeric horizon that
|
||||||
# override variant.
|
# 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)
|
# Asset allocation target (80% stocks / 20% bonds - typical pre-retirement)
|
||||||
type::config,target_stock_pct:num:80
|
type::config,target_stock_pct:num:80
|
||||||
|
|
@ -22,7 +23,8 @@ type::config,target_stock_pct:num:80
|
||||||
# Distribution-phase horizons to simulate
|
# Distribution-phase horizons to simulate
|
||||||
type::config,horizon:num:25
|
type::config,horizon:num:25
|
||||||
type::config,horizon:num:35
|
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
|
type::config,horizon_age:num:95
|
||||||
|
|
||||||
# Annual household contribution to retirement accounts
|
# 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 out.print("\n", .{});
|
||||||
try cli.printBold(out, color, "Terminal Portfolio Value (nominal, at 99% withdrawal rate)\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_labels = [_][]const u8{ "Pessimistic (p10)", "Median (p50)", "Optimistic (p90)" };
|
||||||
const p_styles = [_]view.StyleIntent{ .muted, .normal, .muted };
|
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", .{});
|
try cli.printBold(out, color, "Safe Withdrawal (FIRECalc historical simulation)\n", .{});
|
||||||
|
|
||||||
// Header row
|
// 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
|
// Withdrawal rows. When an accumulation phase is active the
|
||||||
// per-row % rate is suppressed (it would divide today's-dollars
|
// 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;
|
var hdr: std.ArrayListUnmanaged(u8) = .empty;
|
||||||
try hdr.appendNTimes(va, ' ', label_width);
|
try hdr.appendNTimes(va, ' ', label_width);
|
||||||
for (horizons) |h| {
|
for (horizons, 0..) |h, hi| {
|
||||||
var hbuf: [16]u8 = undefined;
|
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.appendNTimes(va, ' ', cell_width -| hlabel.len);
|
||||||
try hdr.appendSlice(va, hlabel);
|
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 renderCompareRowPct(&w, false, "X", 0.1, 0.2);
|
||||||
try testing.expect(std.mem.indexOf(u8, w.buffered(), "\x1b[") == null);
|
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 = "", .style = th.contentStyle() });
|
||||||
|
|
||||||
try lines.append(arena, .{
|
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(),
|
.style = th.headerStyle(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1262,7 +1262,7 @@ fn appendSwrTable(
|
||||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
|
|
||||||
try lines.append(arena, .{
|
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(),
|
.style = th.headerStyle(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1417,9 +1417,9 @@ fn appendAccumulationBlocks(
|
||||||
{
|
{
|
||||||
var hdr: std.ArrayListUnmanaged(u8) = .empty;
|
var hdr: std.ArrayListUnmanaged(u8) = .empty;
|
||||||
try hdr.appendNTimes(arena, ' ', label_width);
|
try hdr.appendNTimes(arena, ' ', label_width);
|
||||||
for (horizons) |h| {
|
for (horizons, 0..) |h, hi| {
|
||||||
var hbuf: [16]u8 = undefined;
|
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.appendNTimes(arena, ' ', cell_width -| hlabel.len);
|
||||||
try hdr.appendSlice(arena, hlabel);
|
try hdr.appendSlice(arena, hlabel);
|
||||||
}
|
}
|
||||||
|
|
@ -2095,7 +2095,7 @@ fn buildLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const Style
|
||||||
|
|
||||||
// Column header
|
// Column header
|
||||||
try lines.append(arena, .{
|
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(),
|
.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, 0), counts.rates);
|
||||||
try testing.expectEqual(@as(usize, 1), counts.footnotes);
|
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 "??";
|
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 ─────────────────────────────────────────
|
// ── Allocation summary ─────────────────────────────────────────
|
||||||
|
|
||||||
/// Result of formatting the allocation note.
|
/// Result of formatting the allocation note.
|
||||||
|
|
@ -329,8 +341,6 @@ pub const ProjectionInputs = enum {
|
||||||
|
|
||||||
pub const ProjectionData = projections.ProjectionData;
|
pub const ProjectionData = projections.ProjectionData;
|
||||||
|
|
||||||
pub const runProjectionGrid = projections.runProjectionGrid;
|
|
||||||
|
|
||||||
pub fn buildProjectionContext(
|
pub fn buildProjectionContext(
|
||||||
alloc: std.mem.Allocator,
|
alloc: std.mem.Allocator,
|
||||||
config: projections.UserConfig,
|
config: projections.UserConfig,
|
||||||
|
|
@ -352,13 +362,43 @@ pub fn buildProjectionContext(
|
||||||
var retirement = config.resolveRetirement(as_of);
|
var retirement = config.resolveRetirement(as_of);
|
||||||
const accumulation_years: u16 = retirement.accumulation_years;
|
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,
|
alloc,
|
||||||
config.getHorizons(),
|
columns,
|
||||||
config.getConfidenceLevels(),
|
config.getConfidenceLevels(),
|
||||||
total_value,
|
total_value,
|
||||||
sim_stock_pct,
|
sim_stock_pct,
|
||||||
events,
|
|
||||||
accumulation_years,
|
accumulation_years,
|
||||||
config.annual_contribution,
|
config.annual_contribution,
|
||||||
config.contribution_inflation_adjusted,
|
config.contribution_inflation_adjusted,
|
||||||
|
|
@ -371,7 +411,6 @@ pub fn buildProjectionContext(
|
||||||
// available; same boundary year for all horizons).
|
// available; same boundary year for all horizons).
|
||||||
var accumulation_stats: ?AccumulationStats = null;
|
var accumulation_stats: ?AccumulationStats = null;
|
||||||
if (accumulation_years > 0) {
|
if (accumulation_years > 0) {
|
||||||
const horizons = config.getHorizons();
|
|
||||||
if (horizons.len > 0) {
|
if (horizons.len > 0) {
|
||||||
const last_band = data.bands[horizons.len - 1];
|
const last_band = data.bands[horizons.len - 1];
|
||||||
if (last_band) |b| {
|
if (last_band) |b| {
|
||||||
|
|
@ -389,31 +428,57 @@ pub fn buildProjectionContext(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Earliest retirement grid: when `target_spending` is set,
|
// Earliest retirement grid: when `target_spending` is set, search
|
||||||
// search for the earliest retirement year per (horizon ×
|
// for the earliest retirement year per (horizon x confidence)
|
||||||
// confidence) pair.
|
// 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;
|
var earliest: ?[]projections.EarliestRetirement = null;
|
||||||
if (config.target_spending) |target| {
|
if (config.target_spending) |target| {
|
||||||
const horizons = config.getHorizons();
|
|
||||||
const confs = config.getConfidenceLevels();
|
const confs = config.getConfidenceLevels();
|
||||||
const cells = try alloc.alloc(projections.EarliestRetirement, horizons.len * confs.len);
|
const cells = try alloc.alloc(projections.EarliestRetirement, horizons.len * confs.len);
|
||||||
for (confs, 0..) |conf, ci| {
|
for (confs, 0..) |conf, ci| {
|
||||||
for (horizons, 0..) |h, hi| {
|
for (horizons, 0..) |h, hi| {
|
||||||
cells[ci * horizons.len + hi] = try projections.findEarliestRetirement(
|
if (death_ages[hi] == 0) {
|
||||||
alloc,
|
cells[ci * horizons.len + hi] = try projections.findEarliestRetirement(
|
||||||
total_value,
|
alloc,
|
||||||
sim_stock_pct,
|
total_value,
|
||||||
config.annual_contribution,
|
sim_stock_pct,
|
||||||
config.contribution_inflation_adjusted,
|
config.annual_contribution,
|
||||||
target,
|
config.contribution_inflation_adjusted,
|
||||||
config.target_spending_inflation_adjusted,
|
target,
|
||||||
h,
|
config.target_spending_inflation_adjusted,
|
||||||
conf,
|
h,
|
||||||
events,
|
conf,
|
||||||
config.max_accumulation_years,
|
events,
|
||||||
sim_expense_ratio,
|
config.max_accumulation_years,
|
||||||
config.spending_real_change orelse 0,
|
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;
|
earliest = cells;
|
||||||
|
|
@ -437,7 +502,6 @@ pub fn buildProjectionContext(
|
||||||
// target-spending answer below it.
|
// target-spending answer below it.
|
||||||
if (inputs == .target_spending) {
|
if (inputs == .target_spending) {
|
||||||
if (earliest) |grid| {
|
if (earliest) |grid| {
|
||||||
const horizons = config.getHorizons();
|
|
||||||
const confs = config.getConfidenceLevels();
|
const confs = config.getConfidenceLevels();
|
||||||
if (projections.pickPromotedCell(&config, as_of, confs)) |pc| {
|
if (projections.pickPromotedCell(&config, as_of, confs)) |pc| {
|
||||||
const cell = grid[pc.confidence_index * horizons.len + pc.horizon_index];
|
const cell = grid[pc.confidence_index * horizons.len + pc.horizon_index];
|
||||||
|
|
@ -1071,13 +1135,17 @@ pub const TableRow = struct {
|
||||||
style: StyleIntent,
|
style: StyleIntent,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Build a column header row for a given set of horizons and column width.
|
/// Build a column header row for a given set of horizons and column
|
||||||
pub fn buildHeaderRow(arena: std.mem.Allocator, horizons: []const u16, col_width: usize) ![]const u8 {
|
/// 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;
|
var row: std.ArrayListUnmanaged(u8) = .empty;
|
||||||
try row.appendNTimes(arena, ' ', withdrawal_label_width);
|
try row.appendNTimes(arena, ' ', withdrawal_label_width);
|
||||||
for (horizons) |h| {
|
for (horizons, 0..) |h, hi| {
|
||||||
var hbuf: [16]u8 = undefined;
|
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.appendNTimes(arena, ' ', col_width -| hlabel.len);
|
||||||
try row.appendSlice(arena, hlabel);
|
try row.appendSlice(arena, hlabel);
|
||||||
}
|
}
|
||||||
|
|
@ -1682,11 +1750,26 @@ test "buildHeaderRow formats horizons" {
|
||||||
const a = arena.allocator();
|
const a = arena.allocator();
|
||||||
|
|
||||||
const horizons = [_]u16{ 30, 45 };
|
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, "30 Year") != null);
|
||||||
try std.testing.expect(std.mem.indexOf(u8, result, "45 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" {
|
test "buildHeaderRow uses terminal column width" {
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
var arena = std.heap.ArenaAllocator.init(allocator);
|
var arena = std.heap.ArenaAllocator.init(allocator);
|
||||||
|
|
@ -1694,8 +1777,8 @@ test "buildHeaderRow uses terminal column width" {
|
||||||
const a = arena.allocator();
|
const a = arena.allocator();
|
||||||
|
|
||||||
const horizons = [_]u16{20};
|
const horizons = [_]u16{20};
|
||||||
const narrow = try buildHeaderRow(a, &horizons, withdrawal_col_width);
|
const narrow = try buildHeaderRow(a, &horizons, &.{}, withdrawal_col_width);
|
||||||
const wide = try buildHeaderRow(a, &horizons, terminal_col_width);
|
const wide = try buildHeaderRow(a, &horizons, &.{}, terminal_col_width);
|
||||||
try std.testing.expect(wide.len > narrow.len);
|
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);
|
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" {
|
test "swrRateNote: null without accumulation, present with accumulation" {
|
||||||
// Distribution-only (already retired): the rate is a correct
|
// Distribution-only (already retired): the rate is a correct
|
||||||
// withdrawal rate against the current portfolio, so render it.
|
// withdrawal rate against the current portfolio, so render it.
|
||||||
|
|
@ -1786,6 +1920,14 @@ test "fmtHorizonLabel" {
|
||||||
try std.testing.expectEqualStrings("30 Year", label);
|
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 ────────
|
// ── Accumulation phase / earliest retirement view tests ────────
|
||||||
|
|
||||||
test "fmtRetirementLine: none" {
|
test "fmtRetirementLine: none" {
|
||||||
|
|
@ -2125,6 +2267,58 @@ test "buildProjectionContext: both_targets inputs when both fields configured" {
|
||||||
try std.testing.expect(ctx.earliest != null);
|
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 ─────────────────────────────────────
|
// ── Overlay-actuals tests ─────────────────────────────────────
|
||||||
|
|
||||||
/// Build a TimelinePoint with just the date and liquid value
|
/// Build a TimelinePoint with just the date and liquid value
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue