implementation of spending smile

This commit is contained in:
Emil Lerch 2026-06-26 14:52:45 -07:00
parent 3aff2e61b4
commit 120c51bce4
Signed by: lobo
GPG key ID: A7B62D657EF764F8
16 changed files with 1075 additions and 24 deletions

View file

@ -31,8 +31,6 @@ ranking; unlabeled items are "someday, if the mood strikes."
earlier than the other would benefit from per-person
`retirement_age` fields on each `type::birthdate` record, with
contributions stopped per-person.
- Multiple spending models: flat (current), decreasing (1-2% real annual decrease,
Blanchett "spending smile"). Late-life healthcare better modeled as a life event.
- **Historical projection overlay follow-ups.** The base
`--overlay-actuals` overlay shipped (CLI tip + TUI primary surface).
Open enhancements:

View file

@ -26,6 +26,9 @@ Every projection runs the same two phases in order:
zero years (an already-retired view).
2. **Distribution** -- annual spending withdrawn (CPI-adjusted by
default), no contributions. Its length is the configured `horizon`.
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").
[Life events](../reference/config/projections-srf.md#event-fields)
(Social Security, pensions, tuition, healthcare) adjust the cash flow

View file

@ -45,6 +45,7 @@ type::event,name::Social Security,start_age:num:70,amount:num:38400
| `contribution_inflation_adjusted` | bool | If `true` (default), contributions grow with CPI year over year. |
| `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). |
| `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`. |
@ -80,6 +81,43 @@ individual stocks, bonds, and cash contribute ~0. Set the result once:
[Parity with FIRECalc](../../explanation/projections-model.md#parity-with-firecalc)
for how the fee interacts with the rest of the model.
### Declining spending (the smile)
By default the simulation holds spending flat in real terms - the same
inflation-adjusted dollars every year. Real retirees don't behave that
way: spending tends to taper as people age (David Blanchett's
"spending smile"). Set `spending_change` to model that drift:
```
type::config,spending_change:num:-2
```
is a 2%/yr real decline. The value is a **whole percent**, and the
**sign is the direction**: negative declines, positive rises. Absent
(the default) means flat. The magnitude is clamped to 10%/yr - a
larger value is almost always a units typo (entering a fraction like
`0.02` where a percent was meant).
How it interacts with the rest of the model:
- The safe-withdrawal numbers become the **first** distribution
year's spend; each later year is scaled by the drift. Declining
spending therefore *raises* the safe first-year withdrawal (you
spend less later, so you can afford more now); rising spending
lowers it.
- The drift is a straight line. The Blanchett smile's late-life
*upturn* (healthcare) is not baked in - model it separately as a
`type::event` expense (see below). Composing a declining
`spending_change` with a late-life healthcare expense reproduces
the full U-shaped smile.
- When a drift is configured, the Safe Withdrawal table gains a
**lowest-spending callout** in today's dollars - e.g.
`Lowest spending: $121,235 in year 12 (2037)`. Because it accounts
for expense events, the bottom of the U can land mid-retirement
rather than at the final year.
See the `post-retirement-smile/` example for a worked configuration.
### Capping outlier returns
The **Projected return** shown by `zfin projections` (and the "Projected
@ -183,7 +221,7 @@ anchor.
## The example configurations
The five bundled examples are fully-configured walkthroughs of each
The six bundled examples are fully-configured walkthroughs of each
combination:
| `examples/...` | Inputs |
@ -193,6 +231,7 @@ combination:
| `pre-retirement-spending-target` | target spending + an explicit (infeasible) anchor |
| `pre-retirement-both` | target date + target spending |
| `post-retirement` | neither (distribution-only) |
| `post-retirement-smile` | distribution-only + declining `spending_change` |
```bash
ZFIN_HOME=examples/pre-retirement-both zfin projections

View file

@ -11,6 +11,7 @@ ZFIN_HOME=examples/pre-retirement-age zfin projections
ZFIN_HOME=examples/pre-retirement-spending zfin projections
ZFIN_HOME=examples/pre-retirement-spending-target zfin projections
ZFIN_HOME=examples/post-retirement zfin projections
ZFIN_HOME=examples/post-retirement-smile zfin projections
ZFIN_HOME=examples/pre-retirement-both zfin --tui
```
@ -19,10 +20,10 @@ these examples are fictional. Do not interpret them as advice.
## Available examples
The five scenarios share the same fictional couple and balance sheet
The six scenarios share the same fictional couple and balance sheet
(~$1.3M, age ~45, contributing $80k/yr) for the four pre-retirement
variants, and a separate retired couple for the distribution example.
Only the `projections.srf` configuration differs across the
variants, and a separate retired couple for the two distribution
examples. Only the `projections.srf` configuration differs across the
pre-retirement variants - making it easy to see how each
retirement-planning input shapes the output.
@ -131,6 +132,32 @@ starting at age 80, plus Social Security already in pay status.
Asset allocation target: 60/40 (more bond-heavy than the
pre-retirement examples).
### `post-retirement-smile/`
The same retired couple as `post-retirement/`, but demonstrating the
**declining spending model** (`spending_change`). Real spending is no
longer held flat - it drifts down 2%/yr (`spending_change:num:-2`),
modeling the Blanchett "spending smile": retirees spend more in the
early "go-go" years and taper through the "slow-go" years.
The smile's late-life upturn is NOT baked into the model - it is
composed from the existing life-event mechanism. The age-80 healthcare
expense (sized as a realistic long-term-care figure) supplies the
rising "no-go" limb on top of the declining base. Because that expense
outweighs the base decline once it starts, the lowest-spending year
lands in mid-retirement (the year just before age 80), not the final
year. The Safe Withdrawal table gains a callout:
```
Lowest spending: $121,235 in year 12 (2037), today's dollars
```
reporting the bottom of the U in today's dollars. Note the first-year
safe withdrawal is *higher* than in the flat `post-retirement/`
example: spending less in later years frees up a larger draw early.
(Exact figures move with the live benchmark data; the shape is the
point.)
## Configuration file map
Every example contains:

View file

@ -0,0 +1,9 @@
#!srfv1
# Account tax type classification for the post-retirement example.
account::Robin Trad IRA,tax_type::traditional,institution::fidelity,account_number::RTRA
account::Robin Roth,tax_type::roth,institution::fidelity,account_number::RROT
account::Jamie Trad IRA,tax_type::traditional,institution::vanguard,account_number::JTRA
account::Jamie Roth,tax_type::roth,institution::vanguard,account_number::JROT
account::Joint taxable,tax_type::taxable,institution::schwab,account_number::JT01
account::Family HSA,tax_type::hsa,institution::fidelity,account_number::HSA01

View file

@ -0,0 +1,37 @@
#!srfv1
# Synthetic snapshot for the projection-overlay demo. Real users land
# real snapshot files here via `zfin snapshot`; this fixture is shaped
# the same way but the totals are made up so the example renders with
# meaningful numbers.
kind::meta,snapshot_version:num:1,as_of_date::2024-04-01,captured_at:num:1711929600,zfin_version::example,stale_count:num:0
kind::total,scope::net_worth,value:num:2350000.00
kind::total,scope::liquid,value:num:2350000.00
kind::total,scope::illiquid,value:num:0
kind::tax_type,label::Traditional (Pre-Tax),value:num:1450000.00
kind::tax_type,label::Roth (Post-Tax),value:num:480000.00
kind::tax_type,label::Taxable,value:num:380000.00
kind::tax_type,label::HSA (Triple Tax-Free),value:num:40000.00
kind::account,name::Robin Trad IRA,value:num:980000.00
kind::account,name::Jamie Trad IRA,value:num:470000.00
kind::account,name::Robin Roth,value:num:280000.00
kind::account,name::Jamie Roth,value:num:200000.00
kind::account,name::Joint taxable,value:num:380000.00
kind::account,name::Family HSA,value:num:40000.00
kind::lot,symbol::VTI,lot_symbol::VTI,account::Robin Trad IRA,security_type::Stock,shares:num:1800,open_price:num:60.20,cost_basis:num:108360.00,value:num:455400.00,price:num:253.00,quote_date::2024-04-01
kind::lot,symbol::AGG,lot_symbol::AGG,account::Robin Trad IRA,security_type::Stock,shares:num:1400,open_price:num:107.40,cost_basis:num:150360.00,value:num:138600.00,price:num:99.00,quote_date::2024-04-01
kind::lot,symbol::SCHD,lot_symbol::SCHD,account::Robin Trad IRA,security_type::Stock,shares:num:600,open_price:num:53.10,cost_basis:num:31860.00,value:num:46500.00,price:num:77.50,quote_date::2024-04-01
kind::lot,symbol::cash,lot_symbol::cash,account::Robin Trad IRA,security_type::Cash,shares:num:18500,open_price:num:1.00,cost_basis:num:18500.00,value:num:18500.00,price:num:1.00,quote_date::2024-04-01
kind::lot,symbol::VTI,lot_symbol::VTI,account::Robin Roth,security_type::Stock,shares:num:380,open_price:num:71.50,cost_basis:num:27170.00,value:num:96140.00,price:num:253.00,quote_date::2024-04-01
kind::lot,symbol::QQQ,lot_symbol::QQQ,account::Robin Roth,security_type::Stock,shares:num:140,open_price:num:97.30,cost_basis:num:13622.00,value:num:62300.00,price:num:445.00,quote_date::2024-04-01
kind::lot,symbol::cash,lot_symbol::cash,account::Robin Roth,security_type::Cash,shares:num:1240,open_price:num:1.00,cost_basis:num:1240.00,value:num:1240.00,price:num:1.00,quote_date::2024-04-01
kind::lot,symbol::VTI,lot_symbol::VTI,account::Jamie Trad IRA,security_type::Stock,shares:num:920,open_price:num:64.80,cost_basis:num:59616.00,value:num:232760.00,price:num:253.00,quote_date::2024-04-01
kind::lot,symbol::AGG,lot_symbol::AGG,account::Jamie Trad IRA,security_type::Stock,shares:num:850,open_price:num:108.10,cost_basis:num:91885.00,value:num:84150.00,price:num:99.00,quote_date::2024-04-01
kind::lot,symbol::cash,lot_symbol::cash,account::Jamie Trad IRA,security_type::Cash,shares:num:9800,open_price:num:1.00,cost_basis:num:9800.00,value:num:9800.00,price:num:1.00,quote_date::2024-04-01
kind::lot,symbol::SPY,lot_symbol::SPY,account::Jamie Roth,security_type::Stock,shares:num:200,open_price:num:152.20,cost_basis:num:30440.00,value:num:104000.00,price:num:520.00,quote_date::2024-04-01
kind::lot,symbol::SCHD,lot_symbol::SCHD,account::Jamie Roth,security_type::Stock,shares:num:280,open_price:num:55.40,cost_basis:num:15512.00,value:num:21700.00,price:num:77.50,quote_date::2024-04-01
kind::lot,symbol::cash,lot_symbol::cash,account::Jamie Roth,security_type::Cash,shares:num:715,open_price:num:1.00,cost_basis:num:715.00,value:num:715.00,price:num:1.00,quote_date::2024-04-01
kind::lot,symbol::SPY,lot_symbol::SPY,account::Joint taxable,security_type::Stock,shares:num:240,open_price:num:198.40,cost_basis:num:47616.00,value:num:124800.00,price:num:520.00,quote_date::2024-04-01
kind::lot,symbol::AGG,lot_symbol::AGG,account::Joint taxable,security_type::Stock,shares:num:600,open_price:num:106.90,cost_basis:num:64140.00,value:num:59400.00,price:num:99.00,quote_date::2024-04-01
kind::lot,symbol::cash,lot_symbol::cash,account::Joint taxable,security_type::Cash,shares:num:62000,open_price:num:1.00,cost_basis:num:62000.00,value:num:62000.00,price:num:1.00,quote_date::2024-04-01
kind::lot,symbol::VTI,lot_symbol::VTI,account::Family HSA,security_type::Stock,shares:num:140,open_price:num:108.30,cost_basis:num:15162.00,value:num:35420.00,price:num:253.00,quote_date::2024-04-01
kind::lot,symbol::cash,lot_symbol::cash,account::Family HSA,security_type::Cash,shares:num:4200,open_price:num:1.00,cost_basis:num:4200.00,value:num:4200.00,price:num:1.00,quote_date::2024-04-01

View file

@ -0,0 +1,16 @@
#!srfv1
# Synthetic snapshot - see 2024-04-01-portfolio.srf for the framing.
kind::meta,snapshot_version:num:1,as_of_date::2024-10-01,captured_at:num:1727740800,zfin_version::example,stale_count:num:0
kind::total,scope::net_worth,value:num:2470000.00
kind::total,scope::liquid,value:num:2470000.00
kind::total,scope::illiquid,value:num:0
kind::tax_type,label::Traditional (Pre-Tax),value:num:1520000.00
kind::tax_type,label::Roth (Post-Tax),value:num:505000.00
kind::tax_type,label::Taxable,value:num:402000.00
kind::tax_type,label::HSA (Triple Tax-Free),value:num:43000.00
kind::account,name::Robin Trad IRA,value:num:1030000.00
kind::account,name::Jamie Trad IRA,value:num:490000.00
kind::account,name::Robin Roth,value:num:295000.00
kind::account,name::Jamie Roth,value:num:210000.00
kind::account,name::Joint taxable,value:num:402000.00
kind::account,name::Family HSA,value:num:43000.00

View file

@ -0,0 +1,16 @@
#!srfv1
# Synthetic snapshot - see 2024-04-01-portfolio.srf for the framing.
kind::meta,snapshot_version:num:1,as_of_date::2025-04-01,captured_at:num:1743465600,zfin_version::example,stale_count:num:0
kind::total,scope::net_worth,value:num:2580000.00
kind::total,scope::liquid,value:num:2580000.00
kind::total,scope::illiquid,value:num:0
kind::tax_type,label::Traditional (Pre-Tax),value:num:1590000.00
kind::tax_type,label::Roth (Post-Tax),value:num:528000.00
kind::tax_type,label::Taxable,value:num:418000.00
kind::tax_type,label::HSA (Triple Tax-Free),value:num:44000.00
kind::account,name::Robin Trad IRA,value:num:1075000.00
kind::account,name::Jamie Trad IRA,value:num:515000.00
kind::account,name::Robin Roth,value:num:308000.00
kind::account,name::Jamie Roth,value:num:220000.00
kind::account,name::Joint taxable,value:num:418000.00
kind::account,name::Family HSA,value:num:44000.00

View file

@ -0,0 +1,441 @@
#!srfv1
# Synthetic imported_values for the projection-overlay demo.
# One row per week, 2016-01-03 through 2024-03-31, geometric
# growth from $1.5M to $2.35M with light noise. The post-
# retirement household had a longer accumulation history before
# the household-wide retirement on 2023-12-31; the overlay shows
# how the trajectory looked relative to projections from any
# past as-of date.
#
# All numbers fictional.
date::2016-01-03,liquid:num:1506274.21
date::2016-01-10,liquid:num:1480170.08
date::2016-01-17,liquid:num:1492990.64
date::2016-01-24,liquid:num:1492211.09
date::2016-01-31,liquid:num:1516963.26
date::2016-02-07,liquid:num:1515844.10
date::2016-02-14,liquid:num:1527185.11
date::2016-02-21,liquid:num:1492278.80
date::2016-02-28,liquid:num:1509038.33
date::2016-03-06,liquid:num:1492802.49
date::2016-03-13,liquid:num:1502948.92
date::2016-03-20,liquid:num:1517570.24
date::2016-03-27,liquid:num:1497336.99
date::2016-04-03,liquid:num:1506760.66
date::2016-04-10,liquid:num:1528930.61
date::2016-04-17,liquid:num:1525730.79
date::2016-04-24,liquid:num:1512476.06
date::2016-05-01,liquid:num:1530950.36
date::2016-05-08,liquid:num:1542644.98
date::2016-05-15,liquid:num:1507400.61
date::2016-05-22,liquid:num:1545703.68
date::2016-05-29,liquid:num:1542365.33
date::2016-06-05,liquid:num:1527497.31
date::2016-06-12,liquid:num:1520576.18
date::2016-06-19,liquid:num:1559158.06
date::2016-06-26,liquid:num:1532120.38
date::2016-07-03,liquid:num:1522445.71
date::2016-07-10,liquid:num:1524219.86
date::2016-07-17,liquid:num:1560599.35
date::2016-07-24,liquid:num:1550922.78
date::2016-07-31,liquid:num:1561987.23
date::2016-08-07,liquid:num:1560021.49
date::2016-08-14,liquid:num:1552647.61
date::2016-08-21,liquid:num:1574618.65
date::2016-08-28,liquid:num:1548540.50
date::2016-09-04,liquid:num:1558256.50
date::2016-09-11,liquid:num:1572843.72
date::2016-09-18,liquid:num:1564623.15
date::2016-09-25,liquid:num:1577643.90
date::2016-10-02,liquid:num:1565964.18
date::2016-10-09,liquid:num:1573569.05
date::2016-10-16,liquid:num:1544272.68
date::2016-10-23,liquid:num:1554446.46
date::2016-10-30,liquid:num:1558964.35
date::2016-11-06,liquid:num:1550717.65
date::2016-11-13,liquid:num:1559553.71
date::2016-11-20,liquid:num:1554960.54
date::2016-11-27,liquid:num:1564949.16
date::2016-12-04,liquid:num:1583508.18
date::2016-12-11,liquid:num:1572334.21
date::2016-12-18,liquid:num:1574230.29
date::2016-12-25,liquid:num:1568249.00
date::2017-01-01,liquid:num:1572617.69
date::2017-01-08,liquid:num:1606110.49
date::2017-01-15,liquid:num:1594047.11
date::2017-01-22,liquid:num:1593858.10
date::2017-01-29,liquid:num:1574626.68
date::2017-02-05,liquid:num:1602920.70
date::2017-02-12,liquid:num:1577548.29
date::2017-02-19,liquid:num:1589536.34
date::2017-02-26,liquid:num:1620424.63
date::2017-03-05,liquid:num:1605354.48
date::2017-03-12,liquid:num:1603044.29
date::2017-03-19,liquid:num:1610854.34
date::2017-03-26,liquid:num:1620149.82
date::2017-04-02,liquid:num:1618622.67
date::2017-04-09,liquid:num:1593944.83
date::2017-04-16,liquid:num:1586105.08
date::2017-04-23,liquid:num:1601451.00
date::2017-04-30,liquid:num:1600816.47
date::2017-05-07,liquid:num:1599740.94
date::2017-05-14,liquid:num:1636883.10
date::2017-05-21,liquid:num:1635364.85
date::2017-05-28,liquid:num:1609795.36
date::2017-06-04,liquid:num:1628042.92
date::2017-06-11,liquid:num:1617100.00
date::2017-06-18,liquid:num:1644068.86
date::2017-06-25,liquid:num:1623563.32
date::2017-07-02,liquid:num:1615790.00
date::2017-07-09,liquid:num:1616585.89
date::2017-07-16,liquid:num:1633671.74
date::2017-07-23,liquid:num:1620754.20
date::2017-07-30,liquid:num:1638224.83
date::2017-08-06,liquid:num:1655307.80
date::2017-08-13,liquid:num:1632552.05
date::2017-08-20,liquid:num:1625401.81
date::2017-08-27,liquid:num:1665409.40
date::2017-09-03,liquid:num:1643100.44
date::2017-09-10,liquid:num:1624166.31
date::2017-09-17,liquid:num:1623700.35
date::2017-09-24,liquid:num:1628487.71
date::2017-10-01,liquid:num:1655812.12
date::2017-10-08,liquid:num:1665697.22
date::2017-10-15,liquid:num:1649093.45
date::2017-10-22,liquid:num:1633013.47
date::2017-10-29,liquid:num:1650526.03
date::2017-11-05,liquid:num:1682818.09
date::2017-11-12,liquid:num:1661320.84
date::2017-11-19,liquid:num:1685087.37
date::2017-11-26,liquid:num:1681343.71
date::2017-12-03,liquid:num:1640675.61
date::2017-12-10,liquid:num:1677854.74
date::2017-12-17,liquid:num:1677654.66
date::2017-12-24,liquid:num:1672154.38
date::2017-12-31,liquid:num:1660350.28
date::2018-01-07,liquid:num:1680871.53
date::2018-01-14,liquid:num:1656015.95
date::2018-01-21,liquid:num:1674009.54
date::2018-01-28,liquid:num:1676713.19
date::2018-02-04,liquid:num:1703681.34
date::2018-02-11,liquid:num:1701525.72
date::2018-02-18,liquid:num:1672355.76
date::2018-02-25,liquid:num:1686100.66
date::2018-03-04,liquid:num:1671560.86
date::2018-03-11,liquid:num:1710510.67
date::2018-03-18,liquid:num:1710160.84
date::2018-03-25,liquid:num:1682889.49
date::2018-04-01,liquid:num:1701961.06
date::2018-04-08,liquid:num:1702213.01
date::2018-04-15,liquid:num:1680749.85
date::2018-04-22,liquid:num:1713602.74
date::2018-04-29,liquid:num:1703999.76
date::2018-05-06,liquid:num:1718008.44
date::2018-05-13,liquid:num:1707099.87
date::2018-05-20,liquid:num:1681747.79
date::2018-05-27,liquid:num:1700095.81
date::2018-06-03,liquid:num:1686233.51
date::2018-06-10,liquid:num:1734731.84
date::2018-06-17,liquid:num:1733952.88
date::2018-06-24,liquid:num:1733341.36
date::2018-07-01,liquid:num:1708136.36
date::2018-07-08,liquid:num:1697043.03
date::2018-07-15,liquid:num:1741172.70
date::2018-07-22,liquid:num:1746555.99
date::2018-07-29,liquid:num:1703802.00
date::2018-08-05,liquid:num:1726323.88
date::2018-08-12,liquid:num:1706510.78
date::2018-08-19,liquid:num:1744190.23
date::2018-08-26,liquid:num:1746284.18
date::2018-09-02,liquid:num:1714943.29
date::2018-09-09,liquid:num:1734801.75
date::2018-09-16,liquid:num:1740499.27
date::2018-09-23,liquid:num:1727456.04
date::2018-09-30,liquid:num:1760993.60
date::2018-10-07,liquid:num:1739334.74
date::2018-10-14,liquid:num:1730086.92
date::2018-10-21,liquid:num:1749058.35
date::2018-10-28,liquid:num:1760887.05
date::2018-11-04,liquid:num:1734955.23
date::2018-11-11,liquid:num:1742580.49
date::2018-11-18,liquid:num:1780369.42
date::2018-11-25,liquid:num:1764038.85
date::2018-12-02,liquid:num:1754712.57
date::2018-12-09,liquid:num:1760741.45
date::2018-12-16,liquid:num:1741622.11
date::2018-12-23,liquid:num:1748927.30
date::2018-12-30,liquid:num:1756759.30
date::2019-01-06,liquid:num:1771860.10
date::2019-01-13,liquid:num:1754701.40
date::2019-01-20,liquid:num:1756008.58
date::2019-01-27,liquid:num:1749906.94
date::2019-02-03,liquid:num:1781553.61
date::2019-02-10,liquid:num:1761982.34
date::2019-02-17,liquid:num:1799912.03
date::2019-02-24,liquid:num:1799347.16
date::2019-03-03,liquid:num:1759058.69
date::2019-03-10,liquid:num:1769841.27
date::2019-03-17,liquid:num:1794778.06
date::2019-03-24,liquid:num:1772266.19
date::2019-03-31,liquid:num:1769719.49
date::2019-04-07,liquid:num:1814732.12
date::2019-04-14,liquid:num:1797020.74
date::2019-04-21,liquid:num:1793600.39
date::2019-04-28,liquid:num:1812290.66
date::2019-05-05,liquid:num:1815418.39
date::2019-05-12,liquid:num:1783979.07
date::2019-05-19,liquid:num:1780787.53
date::2019-05-26,liquid:num:1800735.05
date::2019-06-02,liquid:num:1802211.18
date::2019-06-09,liquid:num:1806450.62
date::2019-06-16,liquid:num:1822568.06
date::2019-06-23,liquid:num:1821443.44
date::2019-06-30,liquid:num:1840259.10
date::2019-07-07,liquid:num:1793931.08
date::2019-07-14,liquid:num:1812393.60
date::2019-07-21,liquid:num:1810830.41
date::2019-07-28,liquid:num:1841266.99
date::2019-08-04,liquid:num:1809657.08
date::2019-08-11,liquid:num:1808346.93
date::2019-08-18,liquid:num:1824400.81
date::2019-08-25,liquid:num:1824839.72
date::2019-09-01,liquid:num:1818872.35
date::2019-09-08,liquid:num:1819192.07
date::2019-09-15,liquid:num:1858163.62
date::2019-09-22,liquid:num:1833647.60
date::2019-09-29,liquid:num:1858632.40
date::2019-10-06,liquid:num:1843399.65
date::2019-10-13,liquid:num:1817701.68
date::2019-10-20,liquid:num:1872095.57
date::2019-10-27,liquid:num:1865008.20
date::2019-11-03,liquid:num:1874329.49
date::2019-11-10,liquid:num:1873921.19
date::2019-11-17,liquid:num:1871562.87
date::2019-11-24,liquid:num:1835561.20
date::2019-12-01,liquid:num:1855259.51
date::2019-12-08,liquid:num:1842042.20
date::2019-12-15,liquid:num:1854417.03
date::2019-12-22,liquid:num:1837228.66
date::2019-12-29,liquid:num:1857059.48
date::2020-01-05,liquid:num:1892937.95
date::2020-01-12,liquid:num:1854566.61
date::2020-01-19,liquid:num:1885607.31
date::2020-01-26,liquid:num:1869100.59
date::2020-02-02,liquid:num:1869254.39
date::2020-02-09,liquid:num:1901270.63
date::2020-02-16,liquid:num:1905402.99
date::2020-02-23,liquid:num:1882604.03
date::2020-03-01,liquid:num:1893750.46
date::2020-03-08,liquid:num:1863883.73
date::2020-03-15,liquid:num:1873857.35
date::2020-03-22,liquid:num:1913863.37
date::2020-03-29,liquid:num:1893784.55
date::2020-04-05,liquid:num:1893664.36
date::2020-04-12,liquid:num:1907330.29
date::2020-04-19,liquid:num:1870045.52
date::2020-04-26,liquid:num:1901994.49
date::2020-05-03,liquid:num:1899347.68
date::2020-05-10,liquid:num:1921286.60
date::2020-05-17,liquid:num:1883596.42
date::2020-05-24,liquid:num:1931478.74
date::2020-05-31,liquid:num:1883109.89
date::2020-06-07,liquid:num:1891131.62
date::2020-06-14,liquid:num:1916568.62
date::2020-06-21,liquid:num:1923172.36
date::2020-06-28,liquid:num:1899901.32
date::2020-07-05,liquid:num:1895253.69
date::2020-07-12,liquid:num:1941588.20
date::2020-07-19,liquid:num:1906496.17
date::2020-07-26,liquid:num:1928582.73
date::2020-08-02,liquid:num:1932033.26
date::2020-08-09,liquid:num:1922479.54
date::2020-08-16,liquid:num:1934005.17
date::2020-08-23,liquid:num:1932497.80
date::2020-08-30,liquid:num:1958406.38
date::2020-09-06,liquid:num:1918045.00
date::2020-09-13,liquid:num:1949800.65
date::2020-09-20,liquid:num:1924057.21
date::2020-09-27,liquid:num:1935216.38
date::2020-10-04,liquid:num:1953323.00
date::2020-10-11,liquid:num:1933671.34
date::2020-10-18,liquid:num:1936636.55
date::2020-10-25,liquid:num:1964139.59
date::2020-11-01,liquid:num:1926421.58
date::2020-11-08,liquid:num:1951040.21
date::2020-11-15,liquid:num:1984767.72
date::2020-11-22,liquid:num:1986702.56
date::2020-11-29,liquid:num:1934525.87
date::2020-12-06,liquid:num:1944779.41
date::2020-12-13,liquid:num:1949877.06
date::2020-12-20,liquid:num:1991311.26
date::2020-12-27,liquid:num:1990298.29
date::2021-01-03,liquid:num:1992283.19
date::2021-01-10,liquid:num:1964209.07
date::2021-01-17,liquid:num:1953719.36
date::2021-01-24,liquid:num:1995834.42
date::2021-01-31,liquid:num:1990192.49
date::2021-02-07,liquid:num:1986814.37
date::2021-02-14,liquid:num:2011223.14
date::2021-02-21,liquid:num:1993485.46
date::2021-02-28,liquid:num:1957062.48
date::2021-03-07,liquid:num:2007383.72
date::2021-03-14,liquid:num:1978564.00
date::2021-03-21,liquid:num:2002390.89
date::2021-03-28,liquid:num:2020971.33
date::2021-04-04,liquid:num:1974881.64
date::2021-04-11,liquid:num:1975813.53
date::2021-04-18,liquid:num:1977373.69
date::2021-04-25,liquid:num:2006251.41
date::2021-05-02,liquid:num:1991451.29
date::2021-05-09,liquid:num:2013552.70
date::2021-05-16,liquid:num:2022454.62
date::2021-05-23,liquid:num:1993550.07
date::2021-05-30,liquid:num:2021645.89
date::2021-06-06,liquid:num:2001368.76
date::2021-06-13,liquid:num:2017051.85
date::2021-06-20,liquid:num:2044415.42
date::2021-06-27,liquid:num:2042958.06
date::2021-07-04,liquid:num:1999319.39
date::2021-07-11,liquid:num:2021544.84
date::2021-07-18,liquid:num:2014718.10
date::2021-07-25,liquid:num:2000185.32
date::2021-08-01,liquid:num:2049078.62
date::2021-08-08,liquid:num:2043039.39
date::2021-08-15,liquid:num:2022249.97
date::2021-08-22,liquid:num:2053678.63
date::2021-08-29,liquid:num:2044217.45
date::2021-09-05,liquid:num:2038752.62
date::2021-09-12,liquid:num:2015232.97
date::2021-09-19,liquid:num:2021365.92
date::2021-09-26,liquid:num:2073151.20
date::2021-10-03,liquid:num:2076598.49
date::2021-10-10,liquid:num:2056688.24
date::2021-10-17,liquid:num:2076662.71
date::2021-10-24,liquid:num:2063266.96
date::2021-10-31,liquid:num:2038571.19
date::2021-11-07,liquid:num:2039423.11
date::2021-11-14,liquid:num:2052752.87
date::2021-11-21,liquid:num:2091524.16
date::2021-11-28,liquid:num:2087324.69
date::2021-12-05,liquid:num:2093517.72
date::2021-12-12,liquid:num:2098081.98
date::2021-12-19,liquid:num:2057383.88
date::2021-12-26,liquid:num:2061992.10
date::2022-01-02,liquid:num:2054990.75
date::2022-01-09,liquid:num:2099441.91
date::2022-01-16,liquid:num:2108138.63
date::2022-01-23,liquid:num:2080438.49
date::2022-01-30,liquid:num:2096037.58
date::2022-02-06,liquid:num:2068992.93
date::2022-02-13,liquid:num:2119833.41
date::2022-02-20,liquid:num:2117945.21
date::2022-02-27,liquid:num:2127179.16
date::2022-03-06,liquid:num:2118981.81
date::2022-03-13,liquid:num:2125649.33
date::2022-03-20,liquid:num:2073804.56
date::2022-03-27,liquid:num:2120940.92
date::2022-04-03,liquid:num:2097581.19
date::2022-04-10,liquid:num:2137672.79
date::2022-04-17,liquid:num:2131756.64
date::2022-04-24,liquid:num:2137906.20
date::2022-05-01,liquid:num:2136753.44
date::2022-05-08,liquid:num:2104403.29
date::2022-05-15,liquid:num:2139732.30
date::2022-05-22,liquid:num:2098690.79
date::2022-05-29,liquid:num:2149612.76
date::2022-06-05,liquid:num:2150991.71
date::2022-06-12,liquid:num:2112581.95
date::2022-06-19,liquid:num:2152800.54
date::2022-06-26,liquid:num:2132231.85
date::2022-07-03,liquid:num:2124514.94
date::2022-07-10,liquid:num:2158190.94
date::2022-07-17,liquid:num:2123970.82
date::2022-07-24,liquid:num:2113074.49
date::2022-07-31,liquid:num:2126191.75
date::2022-08-07,liquid:num:2137121.49
date::2022-08-14,liquid:num:2173938.80
date::2022-08-21,liquid:num:2182831.55
date::2022-08-28,liquid:num:2140649.30
date::2022-09-04,liquid:num:2166335.51
date::2022-09-11,liquid:num:2152933.75
date::2022-09-18,liquid:num:2192891.54
date::2022-09-25,liquid:num:2166297.87
date::2022-10-02,liquid:num:2194751.65
date::2022-10-09,liquid:num:2143446.55
date::2022-10-16,liquid:num:2201368.82
date::2022-10-23,liquid:num:2152048.75
date::2022-10-30,liquid:num:2205457.05
date::2022-11-06,liquid:num:2162223.99
date::2022-11-13,liquid:num:2154211.56
date::2022-11-20,liquid:num:2177813.35
date::2022-11-27,liquid:num:2199353.30
date::2022-12-04,liquid:num:2174435.56
date::2022-12-11,liquid:num:2195917.05
date::2022-12-18,liquid:num:2191980.02
date::2022-12-25,liquid:num:2185963.32
date::2023-01-01,liquid:num:2200854.66
date::2023-01-08,liquid:num:2181928.89
date::2023-01-15,liquid:num:2214181.76
date::2023-01-22,liquid:num:2169769.29
date::2023-01-29,liquid:num:2233150.75
date::2023-02-05,liquid:num:2209848.60
date::2023-02-12,liquid:num:2224153.76
date::2023-02-19,liquid:num:2227971.52
date::2023-02-26,liquid:num:2225561.23
date::2023-03-05,liquid:num:2207511.18
date::2023-03-12,liquid:num:2190230.39
date::2023-03-19,liquid:num:2232117.23
date::2023-03-26,liquid:num:2212167.02
date::2023-04-02,liquid:num:2213390.50
date::2023-04-09,liquid:num:2251404.12
date::2023-04-16,liquid:num:2245173.51
date::2023-04-23,liquid:num:2219423.64
date::2023-04-30,liquid:num:2222343.04
date::2023-05-07,liquid:num:2231317.08
date::2023-05-14,liquid:num:2233245.28
date::2023-05-21,liquid:num:2228398.03
date::2023-05-28,liquid:num:2219388.90
date::2023-06-04,liquid:num:2241467.63
date::2023-06-11,liquid:num:2278890.69
date::2023-06-18,liquid:num:2263503.62
date::2023-06-25,liquid:num:2281114.76
date::2023-07-02,liquid:num:2264051.81
date::2023-07-09,liquid:num:2245102.67
date::2023-07-16,liquid:num:2264200.71
date::2023-07-23,liquid:num:2229388.93
date::2023-07-30,liquid:num:2251191.79
date::2023-08-06,liquid:num:2263271.58
date::2023-08-13,liquid:num:2275859.26
date::2023-08-20,liquid:num:2283331.39
date::2023-08-27,liquid:num:2272767.48
date::2023-09-03,liquid:num:2273581.88
date::2023-09-10,liquid:num:2260330.94
date::2023-09-17,liquid:num:2280458.73
date::2023-09-24,liquid:num:2312175.85
date::2023-10-01,liquid:num:2307376.22
date::2023-10-08,liquid:num:2266767.69
date::2023-10-15,liquid:num:2263298.54
date::2023-10-22,liquid:num:2295303.70
date::2023-10-29,liquid:num:2305796.28
date::2023-11-05,liquid:num:2287668.64
date::2023-11-12,liquid:num:2323422.44
date::2023-11-19,liquid:num:2321199.08
date::2023-11-26,liquid:num:2318203.53
date::2023-12-03,liquid:num:2289586.07
date::2023-12-10,liquid:num:2290209.09
date::2023-12-17,liquid:num:2280476.20
date::2023-12-24,liquid:num:2298172.34
date::2023-12-31,liquid:num:2316589.88
date::2024-01-07,liquid:num:2345090.41
date::2024-01-14,liquid:num:2293393.46
date::2024-01-21,liquid:num:2319622.73
date::2024-01-28,liquid:num:2337084.24
date::2024-02-04,liquid:num:2309090.09
date::2024-02-11,liquid:num:2346629.82
date::2024-02-18,liquid:num:2334930.69
date::2024-02-25,liquid:num:2319809.02
date::2024-03-03,liquid:num:2351162.44
date::2024-03-10,liquid:num:2307900.75
date::2024-03-17,liquid:num:2362754.08
date::2024-03-24,liquid:num:2366566.10
date::2024-03-31,liquid:num:2322264.40

View file

@ -0,0 +1,8 @@
#!srfv1
# Symbol classification metadata for the post-retirement example.
symbol::VTI,sector::Diversified,geo::US,asset_class::US Large Cap
symbol::SPY,sector::Diversified,geo::US,asset_class::US Large Cap
symbol::QQQ,sector::Technology,geo::US,asset_class::US Large Cap
symbol::SCHD,sector::Diversified,geo::US,asset_class::US Large Cap
symbol::AGG,sector::Bonds,geo::US,asset_class::Bonds

View file

@ -0,0 +1,35 @@
#!srfv1
# Example portfolio: post-retirement household, ~age 68, ~$2.5M total.
# All names, share counts, and prices are fictional. The household is
# already retired and drawing down - see projections.srf for the
# distribution-only configuration (no accumulation).
# Robin's Traditional IRA - primary drawdown source
symbol::VTI,shares:num:1800,open_date::2010-08-15,open_price:num:60.20,account::Robin Trad IRA
symbol::AGG,shares:num:1400,open_date::2015-03-22,open_price:num:107.40,account::Robin Trad IRA
symbol::SCHD,shares:num:600,open_date::2018-04-30,open_price:num:53.10,account::Robin Trad IRA
security_type::cash,shares:num:18500.00,open_date::2026-04-30,open_price:num:1.00,account::Robin Trad IRA
# Robin's Roth IRA - preserved for late-life / heirs
symbol::VTI,shares:num:380,open_date::2012-11-08,open_price:num:71.50,account::Robin Roth
symbol::QQQ,shares:num:140,open_date::2014-06-12,open_price:num:97.30,account::Robin Roth
security_type::cash,shares:num:1240.00,open_date::2026-04-30,open_price:num:1.00,account::Robin Roth
# Jamie's Traditional IRA
symbol::VTI,shares:num:920,open_date::2011-05-18,open_price:num:64.80,account::Jamie Trad IRA
symbol::AGG,shares:num:850,open_date::2016-09-04,open_price:num:108.10,account::Jamie Trad IRA
security_type::cash,shares:num:9800.00,open_date::2026-04-30,open_price:num:1.00,account::Jamie Trad IRA
# Jamie's Roth IRA
symbol::SPY,shares:num:200,open_date::2013-02-14,open_price:num:152.20,account::Jamie Roth
symbol::SCHD,shares:num:280,open_date::2020-08-25,open_price:num:55.40,account::Jamie Roth
security_type::cash,shares:num:715.00,open_date::2026-04-30,open_price:num:1.00,account::Jamie Roth
# Joint taxable - bridge income, RMD overflow
symbol::SPY,shares:num:240,open_date::2014-09-30,open_price:num:198.40,account::Joint taxable
symbol::AGG,shares:num:600,open_date::2017-11-15,open_price:num:106.90,account::Joint taxable
security_type::cash,shares:num:62000.00,open_date::2026-04-30,open_price:num:1.00,account::Joint taxable
# Family HSA - still tax-advantaged, used for late-life medical
symbol::VTI,shares:num:140,open_date::2016-06-22,open_price:num:108.30,account::Family HSA
security_type::cash,shares:num:4200.00,open_date::2026-04-30,open_price:num:1.00,account::Family HSA

View file

@ -0,0 +1,51 @@
#!srfv1
# Post-retirement projection with a declining ("spending smile") model.
#
# Same retired couple as the post-retirement/ example - Robin (born
# 1958) and Jamie (born 1961) - but this variant demonstrates the
# spending_change feature: real spending is not held flat, it drifts.
#
# The Blanchett "spending smile": retirees spend more in the early
# "go-go" years, taper through the "slow-go" years, then spending
# rises again late as healthcare costs dominate the "no-go" years.
# zfin models the two limbs separately:
#
# 1. The declining limb is `spending_change` below (-2%/yr real).
# 2. The late-life rise is the Healthcare life event (age 80),
# already an expense record - no special-casing needed.
#
# Composing those two produces the U-shaped smile, and the
# "Lowest spending: ... in year N" callout under the Safe Withdrawal
# table reports the bottom of the U in today's dollars.
# Allocation target shifts more conservative in retirement
type::config,target_stock_pct:num:60
# Real spending declines 2%/yr through retirement (negative = decline,
# positive would model a rising real spend). Whole percent; absent =
# flat real spending, the default.
type::config,spending_change:num:-2
# Distribution horizons - through age 90 (older partner first)
type::config,horizon:num:20
type::config,horizon:num:30
type::config,horizon_age:num:95
# Birthdates
type::birthdate,date::1958-02-19
type::birthdate,date::1961-07-04,person:num:2
# Social Security - both already collecting. Income reduces the
# portfolio withdrawal but is NOT counted as spending, so it does not
# move the spending-trough callout.
type::event,name::Social Security (Robin),start_age:num:67,person:num:1,amount:num:34800
type::event,name::Social Security (Jamie),start_age:num:65,person:num:2,amount:num:28200
# Late-life healthcare bump - the rising limb of the smile. Modeled as
# a recurring expense starting at age 80 for the older partner (a
# realistic long-term-care figure). It is large enough to outweigh the
# base decline once it starts, so the spending trough lands in
# mid-retirement - the year just before age 80 - rather than the final
# year. That mid-trajectory minimum is exactly what the trough callout
# is for.
type::event,name::Healthcare (late-life),start_age:num:80,person:num:1,amount:num:-55000

View file

@ -242,6 +242,14 @@ pub const UserConfig = struct {
/// If true, the target spending grows with CPI during the
/// distribution phase (matches the existing SWR model).
target_spending_inflation_adjusted: bool = true,
/// Signed annual *real* change in spending across the
/// distribution phase, as a fraction (e.g. -0.02 = declines
/// 2%/yr, +0.01 = rises 1%/yr). `null` -> flat real spending,
/// the historical default. Set via
/// `type::config,spending_change:num:N` where N is a whole
/// percent (negative = decline). Feeds `SimParams.
/// spending_real_change` for every horizon/confidence cell.
spending_real_change: ?f64 = null,
/// Ceiling on the accumulation years the earliest-retirement
/// search (`findEarliestRetirement`) will consider when
/// `target_spending` is set. Defaults to
@ -480,6 +488,10 @@ const SrfConfig = struct {
contribution_inflation_adjusted: ?bool = null,
target_spending: ?f64 = null,
target_spending_inflation_adjusted: ?bool = null,
/// Signed annual real spending change, in whole percent
/// (negative = decline). Parsed/clamped into
/// `UserConfig.spending_real_change` as a fraction.
spending_change: ?f64 = null,
max_accumulation_years: ?u16 = null,
benchmark_stock: ?[]const u8 = null,
benchmark_bond: ?[]const u8 = null,
@ -508,6 +520,15 @@ const SrfProjection = union(enum) {
event: SrfEvent,
};
/// Clamp on the magnitude of `spending_change` (10%/yr real, in
/// either direction). A larger drift is almost certainly a units
/// typo - someone entering a fraction (0.02) where a whole percent
/// (2) was expected reads as 0.02%/yr (negligible), but the reverse
/// (entering 20 meaning 0.20) would otherwise crater spending to
/// zero within a decade. The clamp keeps a fat-fingered value from
/// silently producing nonsense.
pub const max_abs_spending_real_change: f64 = 0.10;
/// Parse a projections.srf file into a UserConfig.
/// Returns default config if data is null or unparseable.
///
@ -525,6 +546,7 @@ const SrfProjection = union(enum) {
/// Format (union-tagged SRF records):
/// type::config,target_stock_pct:num:80
/// type::config,horizon:num:30
/// type::config,spending_change:num:-2
/// type::birthdate,date::1975-03-15
/// type::event,name::Social Security,start_age:num:67,amount:num:38400
pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig {
@ -632,6 +654,23 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig {
if (c.target_spending_inflation_adjusted) |b| {
config.target_spending_inflation_adjusted = b;
}
if (c.spending_change) |pct| {
// Entered as a whole percent (negative = decline,
// positive = rising real spending); stored as a
// fraction. Clamp the magnitude so a units typo
// can't drive spending to zero or absurd growth.
const frac = pct / 100.0;
const cap = max_abs_spending_real_change;
if (frac > cap) {
warnUser("projections: spending_change capped at +{d:.0}%/yr (got {d}%)", .{ cap * 100.0, pct });
config.spending_real_change = cap;
} else if (frac < -cap) {
warnUser("projections: spending_change capped at -{d:.0}%/yr (got {d}%)", .{ cap * 100.0, pct });
config.spending_real_change = -cap;
} else {
config.spending_real_change = frac;
}
}
if (c.max_accumulation_years) |n| {
if (n == 0) {
// A zero-year search ceiling is degenerate (it
@ -760,6 +799,19 @@ pub const SimParams = struct {
stock_pct: f64,
annual_spending: f64,
spending_inflation_adjusted: bool = true,
/// Signed annual *real* change in spending, applied across the
/// distribution phase (the "spending smile" / Blanchett model).
/// A fraction: -0.02 = spending declines 2%/yr in real terms
/// ("slow-go" years), +0.01 = rises 1%/yr. `0` (the default) is
/// flat real spending - the historical behavior, byte-identical.
///
/// `annual_spending` is the *first* distribution year's spend;
/// year `d` of distribution spends `annual_spending * (1 +
/// spending_real_change)^d` in real terms, then the usual CPI
/// factor converts to nominal. Localized late-life cost humps
/// (healthcare) are modeled separately as `events`, so this is a
/// monotonic drift, not the full U-curve.
spending_real_change: f64 = 0,
/// Distribution-phase length (the "horizon" in the existing API).
distribution_years: u16,
accumulation_years: u16 = 0,
@ -833,6 +885,13 @@ fn simulateTwoPhase(
if (buf) |b| b[0] = portfolio;
var cumulative_inflation: f64 = 1.0;
// Real-spending multiplier for the current distribution year.
// Pinned at 1.0 through accumulation and the first distribution
// year (d=0), then compounded by `(1 + spending_real_change)`
// each subsequent distribution year. `spending_real_change == 0`
// leaves it at 1.0 forever -> flat real spending, byte-identical
// to the pre-smile behavior.
var spend_factor: f64 = 1.0;
var failed = false;
var y: usize = 0;
@ -865,10 +924,11 @@ fn simulateTwoPhase(
params.annual_contribution;
portfolio += contribution + event_net;
} else {
const real_spending = params.annual_spending * spend_factor;
const spending = if (params.spending_inflation_adjusted)
params.annual_spending * cumulative_inflation
real_spending * cumulative_inflation
else
params.annual_spending;
real_spending;
portfolio -= spending - event_net;
if (portfolio <= 0 and !failed) {
// Survival-only callers exit immediately - there's
@ -876,6 +936,9 @@ fn simulateTwoPhase(
if (buf == null) return false;
failed = true;
}
// Compound the real-spending drift for next year. No-op
// when `spending_real_change == 0` (factor stays 1.0).
spend_factor *= (1.0 + params.spending_real_change);
}
// Market return on the post-cashflow balance, net of the
@ -999,11 +1062,13 @@ pub fn findSafeWithdrawalWithAccumulation(
annual_contribution: f64,
contribution_inflation_adjusted: bool,
expense_ratio: f64,
spending_real_change: f64,
) WithdrawalResult {
return searchSafeWithdrawal(.{
.initial_value = initial_value,
.stock_pct = stock_pct,
.annual_spending = 0, // overwritten by the search loop
.spending_real_change = spending_real_change,
.distribution_years = horizon,
.accumulation_years = accumulation_years,
.annual_contribution = annual_contribution,
@ -1139,6 +1204,7 @@ pub fn findEarliestRetirement(
events: []const ResolvedEvent,
max_years: u16,
expense_ratio: f64,
spending_real_change: f64,
) !EarliestRetirement {
const data = shiller.annual_returns;
@ -1149,6 +1215,7 @@ pub fn findEarliestRetirement(
.stock_pct = stock_pct,
.annual_spending = target_spending,
.spending_inflation_adjusted = target_spending_inflation_adjusted,
.spending_real_change = spending_real_change,
.distribution_years = distribution_years,
.accumulation_years = n,
.annual_contribution = annual_contribution,
@ -1482,6 +1549,7 @@ pub fn runProjectionGrid(
annual_contribution: f64,
contribution_inflation_adjusted: bool,
expense_ratio: f64,
spending_real_change: f64,
) !ProjectionData {
const num_results = horizons.len * confidence_levels.len;
const withdrawals = try alloc.alloc(WithdrawalResult, num_results);
@ -1497,6 +1565,7 @@ pub fn runProjectionGrid(
annual_contribution,
contribution_inflation_adjusted,
expense_ratio,
spending_real_change,
);
}
}
@ -1508,6 +1577,7 @@ pub fn runProjectionGrid(
.initial_value = total_value,
.stock_pct = stock_pct,
.annual_spending = wr.annual_amount,
.spending_real_change = spending_real_change,
.distribution_years = h,
.accumulation_years = accumulation_years,
.annual_contribution = annual_contribution,
@ -1519,7 +1589,83 @@ pub fn runProjectionGrid(
return .{ .withdrawals = withdrawals, .bands = bands, .ci_99 = ci_99 };
}
// Test-only convenience wrappers
// Spending trough (the "how low does it get" callout)
/// The lowest-spending year of a projection's distribution phase,
/// in today's dollars. Surfaced next to the first-year safe
/// withdrawal so a user running a declining ("slow-go") spending
/// model can see how little they spend at the bottom.
pub const SpendingTrough = struct {
/// Minimum total real spending (today's dollars) reached.
amount: f64,
/// Distribution-year offset (0-based) where the minimum occurs.
year_offset: u16,
/// Years from `as_of` to that year, 1-based (1 = first
/// retirement year). Equals `accumulation_years + year_offset + 1`.
years_from_now: u16,
/// Calendar date of the trough year (`as_of` advanced by
/// `accumulation_years + year_offset`).
date: Date,
};
/// Find the lowest-spending distribution year, in today's dollars.
///
/// Base spending follows the real-change drift: distribution year
/// `d` spends `first_year_spend * (1 + spending_real_change)^d` in
/// real terms. EXPENSE life events (negative `annual_amount`, e.g.
/// late-life healthcare) add to spending - that is what produces a
/// mid-retirement trough rather than a monotonic slide to the final
/// year. Income events (Social Security, positive amounts) are
/// funding rather than spending and are excluded.
///
/// Today's-dollar (real) terms throughout: each active expense event
/// contributes its configured magnitude. This is a deterministic
/// display approximation - it does not erode non-inflation-adjusted
/// events across time the way the per-cycle simulation does - but it
/// gives a single, stable number for the callout. `as_of` anchors
/// the calendar year.
///
/// Returns `null` only for a zero-length distribution phase.
pub fn spendingTrough(
first_year_spend: f64,
spending_real_change: f64,
events: []const ResolvedEvent,
accumulation_years: u16,
distribution_years: u16,
as_of: Date,
) ?SpendingTrough {
if (distribution_years == 0) return null;
var min_amount: f64 = std.math.floatMax(f64);
var min_d: u16 = 0;
var factor: f64 = 1.0;
var d: u16 = 0;
while (d < distribution_years) : (d += 1) {
const sim_year = accumulation_years + d;
var spend = first_year_spend * factor;
for (events) |*ev| {
// Only expense events count as spending; income (SS etc.)
// funds withdrawals but is not consumption.
if (ev.annual_amount < 0 and ev.isActive(sim_year)) {
spend += -ev.annual_amount;
}
}
if (spend < min_amount) {
min_amount = spend;
min_d = d;
}
factor *= (1.0 + spending_real_change);
}
return .{
.amount = min_amount,
.year_offset = min_d,
.years_from_now = accumulation_years + min_d + 1,
.date = as_of.addYears(accumulation_years + min_d),
};
}
//
// Thin, distribution-only, zero-fee wrappers over the production
// `*Params` entry points (`searchSafeWithdrawal`, `successRateParams`,
@ -1818,8 +1964,8 @@ test "FIRECalc parity: expense ratio matches FIRECalc's default fee" {
// Safe withdrawal with the fee on. FIRECalc fee=0.18 refs:
// 75/25 30y 95% -> $40,381; 75/25 45y 99% $7.7M -> $258,747.
const w_30 = findSafeWithdrawalWithAccumulation(30, 1_000_000, 0.75, 0.95, &.{}, 0, 0, true, 0.0018);
const w_45 = findSafeWithdrawalWithAccumulation(45, 7_700_000, 0.75, 0.99, &.{}, 0, 0, true, 0.0018);
const w_30 = findSafeWithdrawalWithAccumulation(30, 1_000_000, 0.75, 0.95, &.{}, 0, 0, true, 0.0018, 0);
const w_45 = findSafeWithdrawalWithAccumulation(45, 7_700_000, 0.75, 0.99, &.{}, 0, 0, true, 0.0018, 0);
try std.testing.expect(w_30.annual_amount >= 40_381 * 0.97);
try std.testing.expect(w_30.annual_amount <= 40_381 * 1.15);
try std.testing.expect(w_45.annual_amount >= 258_747 * 0.97);
@ -2473,7 +2619,7 @@ test "regression: zero accumulation matches direct findSafeWithdrawal" {
// because the two paths execute the same code with the same
// inputs - any drift here means the unification broke.
const direct = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &.{});
const via_accum = findSafeWithdrawalWithAccumulation(30, 1_000_000, 0.75, 0.95, &.{}, 0, 0, true, 0);
const via_accum = findSafeWithdrawalWithAccumulation(30, 1_000_000, 0.75, 0.95, &.{}, 0, 0, true, 0, 0);
try std.testing.expectEqual(direct.annual_amount, via_accum.annual_amount);
try std.testing.expectEqual(direct.confidence, via_accum.confidence);
try std.testing.expectEqual(direct.withdrawal_rate, via_accum.withdrawal_rate);
@ -2558,7 +2704,7 @@ test "two-phase: SWR with accumulation exceeds same-portfolio direct SWR" {
// a higher safe withdrawal than $1M alone over a 30-year
// distribution at the same confidence.
const direct = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &.{});
const with_accum = findSafeWithdrawalWithAccumulation(30, 1_000_000, 0.75, 0.95, &.{}, 10, 100_000, true, 0);
const with_accum = findSafeWithdrawalWithAccumulation(30, 1_000_000, 0.75, 0.95, &.{}, 10, 100_000, true, 0, 0);
try std.testing.expect(with_accum.annual_amount > direct.annual_amount);
}
@ -2642,6 +2788,7 @@ test "findEarliestRetirement: feasible at N=0 returns 0" {
&.{},
50, // max_years
0, // expense_ratio
0, // spending_real_change
);
try std.testing.expectEqual(@as(?u16, 0), r.accumulation_years);
}
@ -2663,6 +2810,7 @@ test "findEarliestRetirement: unreachable returns null" {
&.{},
50,
0, // expense_ratio
0, // spending_real_change
);
try std.testing.expectEqual(@as(?u16, null), r.accumulation_years);
}
@ -2683,6 +2831,7 @@ test "findEarliestRetirement: longer distribution shifts retirement later or unc
&.{},
50,
0, // expense_ratio
0, // spending_real_change
);
const long = try findEarliestRetirement(
allocator,
@ -2697,6 +2846,7 @@ test "findEarliestRetirement: longer distribution shifts retirement later or unc
&.{},
50,
0, // expense_ratio
0, // spending_real_change
);
if (short.accumulation_years != null and long.accumulation_years != null) {
try std.testing.expect(long.accumulation_years.? >= short.accumulation_years.?);
@ -2718,6 +2868,7 @@ test "findEarliestRetirement: result includes portfolio statistics" {
&.{},
50,
0, // expense_ratio
0, // spending_real_change
);
if (r.accumulation_years) |n| {
if (n > 0) {
@ -2996,7 +3147,7 @@ test "runProjectionGrid: structure and indexing" {
const horizons = [_]u16{ 20, 30 };
const conf = [_]f64{ 0.95, 0.99 };
const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0);
const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0, 0);
defer freeProjectionData(allocator, data);
// 2 horizons × 2 confidence levels = 4 withdrawal results.
@ -3014,7 +3165,7 @@ test "runProjectionGrid: withdrawal monotonicity along confidence axis" {
const horizons = [_]u16{30};
const conf = [_]f64{ 0.90, 0.95, 0.99 };
const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0);
const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0, 0);
defer freeProjectionData(allocator, data);
const w_90 = data.withdrawals[0 * horizons.len + 0].annual_amount;
@ -3030,7 +3181,7 @@ test "runProjectionGrid: withdrawal monotonicity along horizon axis" {
const horizons = [_]u16{ 20, 30, 45 };
const conf = [_]f64{0.95};
const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0);
const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0, 0);
defer freeProjectionData(allocator, data);
const w_20 = data.withdrawals[0 * horizons.len + 0].annual_amount;
@ -3045,7 +3196,7 @@ test "runProjectionGrid: distribution-only band length is horizon + 1" {
const horizons = [_]u16{ 20, 30 };
const conf = [_]f64{ 0.95, 0.99 };
const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0);
const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0, 0);
defer freeProjectionData(allocator, data);
// band[0] covers horizons[0] = 20 -> 21 entries; band[1] covers
@ -3060,7 +3211,7 @@ test "runProjectionGrid: with-accumulation band length includes accumulation_yea
const conf = [_]f64{0.95};
// 10 years of accumulation + 30 years distribution -> 41 entries.
const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 10, 50_000, true, 0);
const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 10, 50_000, true, 0, 0);
defer freeProjectionData(allocator, data);
try std.testing.expectEqual(@as(usize, 41), data.bands[0].?.len);
@ -3071,7 +3222,7 @@ test "runProjectionGrid: bands are p10 ≤ p25 ≤ p50 ≤ p75 ≤ p90 at every
const horizons = [_]u16{30};
const conf = [_]f64{0.95};
const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0);
const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0, 0);
defer freeProjectionData(allocator, data);
for (data.bands[0].?) |b| {
@ -3088,7 +3239,7 @@ test "runProjectionGrid: year 0 in every band equals total_value" {
const conf = [_]f64{0.95};
const total_value: f64 = 2_000_000;
const data = try runProjectionGrid(allocator, &horizons, &conf, total_value, 0.75, &.{}, 0, 0, true, 0);
const data = try runProjectionGrid(allocator, &horizons, &conf, total_value, 0.75, &.{}, 0, 0, true, 0, 0);
defer freeProjectionData(allocator, data);
for (data.bands) |b_opt| {
@ -3112,7 +3263,7 @@ test "runProjectionGrid: bands are computed at the highest-confidence withdrawal
const horizons = [_]u16{30};
const conf = [_]f64{ 0.90, 0.95, 0.99 };
const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0);
const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0, 0);
defer freeProjectionData(allocator, data);
const wr_99 = data.withdrawals[data.ci_99 * horizons.len + 0];
@ -3144,10 +3295,10 @@ test "runProjectionGrid: accumulation passes through to both withdrawals and ban
const horizons = [_]u16{30};
const conf = [_]f64{0.95};
const dist_only = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0);
const dist_only = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0, 0);
defer freeProjectionData(allocator, dist_only);
const with_accum = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 10, 50_000, true, 0);
const with_accum = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 10, 50_000, true, 0, 0);
defer freeProjectionData(allocator, with_accum);
// SWR with 10y of contributions on top should exceed SWR
@ -3163,9 +3314,173 @@ test "runProjectionGrid: zero horizons produces empty results without crashing"
const horizons = [_]u16{};
const conf = [_]f64{ 0.95, 0.99 };
const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0);
const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0, 0);
defer freeProjectionData(allocator, data);
try std.testing.expectEqual(@as(usize, 0), data.withdrawals.len);
try std.testing.expectEqual(@as(usize, 0), data.bands.len);
}
// Spending-drift (the "smile") tests
test "spending_real_change: declining spending raises safe withdrawal, rising lowers it" {
// Same portfolio, horizon, and confidence - only the spending
// trajectory differs. Spending less in the slow-go years frees up
// a higher first-year draw; spending more requires a lower one.
const flat = findSafeWithdrawalWithAccumulation(30, 1_000_000, 0.75, 0.95, &.{}, 0, 0, true, 0, 0);
const declining = findSafeWithdrawalWithAccumulation(30, 1_000_000, 0.75, 0.95, &.{}, 0, 0, true, 0, -0.02);
const rising = findSafeWithdrawalWithAccumulation(30, 1_000_000, 0.75, 0.95, &.{}, 0, 0, true, 0, 0.02);
try std.testing.expect(declining.annual_amount > flat.annual_amount);
try std.testing.expect(rising.annual_amount < flat.annual_amount);
}
test "spending_real_change: zero drift is identical to the flat model" {
// The default (rate 0) must reproduce the pre-smile behavior
// exactly - the regression pin for every existing projection.
const flat = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &.{});
const zero_drift = findSafeWithdrawalWithAccumulation(30, 1_000_000, 0.75, 0.95, &.{}, 0, 0, true, 0, 0);
try std.testing.expectEqual(flat.annual_amount, zero_drift.annual_amount);
}
test "spendingTrough: monotonic decline bottoms out in the final year" {
const as_of = Date.fromYmd(2026, 1, 1);
const t = spendingTrough(60_000, -0.02, &.{}, 0, 30, as_of).?;
// No events -> spending falls every year -> trough is the last
// distribution year (d = 29).
try std.testing.expectEqual(@as(u16, 29), t.year_offset);
try std.testing.expectEqual(@as(u16, 30), t.years_from_now);
const expected = 60_000.0 * std.math.pow(f64, 0.98, 29);
try std.testing.expectApproxEqAbs(expected, t.amount, 1.0);
try std.testing.expectEqual(@as(i16, 2055), t.date.year());
}
test "spendingTrough: a late healthcare expense pulls the trough to mid-retirement" {
const as_of = Date.fromYmd(2026, 1, 1);
// Base spending declines 2%/yr; a permanent +$40k/yr healthcare
// expense begins at distribution year 20. Spending slides until
// then, then jumps - so the trough is the year just before the
// hump (d = 19), not the final year. This is the whole reason the
// trough is computed rather than read off the last year.
const healthcare = [_]ResolvedEvent{.{
.start_year = 20,
.duration = 0,
.annual_amount = -40_000,
.inflation_adjusted = true,
}};
const t = spendingTrough(60_000, -0.02, &healthcare, 0, 30, as_of).?;
try std.testing.expectEqual(@as(u16, 19), t.year_offset);
}
test "spendingTrough: rising spending bottoms out in the first year" {
const as_of = Date.fromYmd(2026, 1, 1);
const t = spendingTrough(50_000, 0.01, &.{}, 0, 30, as_of).?;
try std.testing.expectEqual(@as(u16, 0), t.year_offset);
try std.testing.expectEqual(@as(u16, 1), t.years_from_now);
try std.testing.expectApproxEqAbs(@as(f64, 50_000), t.amount, 0.01);
}
test "spendingTrough: income events do not count as spending" {
const as_of = Date.fromYmd(2026, 1, 1);
// A Social Security income event funds withdrawals but is not
// consumption, so it must not lower the reported spending trough.
const ss = [_]ResolvedEvent{.{
.start_year = 5,
.duration = 0,
.annual_amount = 30_000, // positive = income
.inflation_adjusted = true,
}};
const with_income = spendingTrough(60_000, -0.02, &ss, 0, 30, as_of).?;
const without = spendingTrough(60_000, -0.02, &.{}, 0, 30, as_of).?;
try std.testing.expectEqual(without.amount, with_income.amount);
try std.testing.expectEqual(without.year_offset, with_income.year_offset);
}
test "spendingTrough: accumulation phase offsets the trough year and date" {
const as_of = Date.fromYmd(2026, 1, 1);
// 10 accumulation years, then 20 distribution years declining
// 1%/yr. Trough at the last distribution year (d = 19);
// years_from_now = 10 + 19 + 1 = 30; calendar 2026 + 29 = 2055.
const t = spendingTrough(50_000, -0.01, &.{}, 10, 20, as_of).?;
try std.testing.expectEqual(@as(u16, 19), t.year_offset);
try std.testing.expectEqual(@as(u16, 30), t.years_from_now);
try std.testing.expectEqual(@as(i16, 2055), t.date.year());
}
test "spendingTrough: zero distribution years returns null" {
const as_of = Date.fromYmd(2026, 1, 1);
try std.testing.expectEqual(
@as(?SpendingTrough, null),
spendingTrough(60_000, -0.02, &.{}, 0, 0, as_of),
);
}
test "parseProjectionsConfig spending_change negative is a decline" {
const config = parseProjectionsConfig("#!srfv1\ntype::config,spending_change:num:-2\n");
try std.testing.expectApproxEqAbs(@as(f64, -0.02), config.spending_real_change.?, 1e-9);
}
test "parseProjectionsConfig spending_change positive is a rise" {
const config = parseProjectionsConfig("#!srfv1\ntype::config,spending_change:num:1\n");
try std.testing.expectApproxEqAbs(@as(f64, 0.01), config.spending_real_change.?, 1e-9);
}
test "parseProjectionsConfig spending_change absent stays null (flat)" {
const config = parseProjectionsConfig("#!srfv1\ntype::config,horizon:num:30\n");
try std.testing.expectEqual(@as(?f64, null), config.spending_real_change);
}
test "parseProjectionsConfig spending_change magnitude is clamped both directions" {
const hi = parseProjectionsConfig("#!srfv1\ntype::config,spending_change:num:50\n");
try std.testing.expectApproxEqAbs(max_abs_spending_real_change, hi.spending_real_change.?, 1e-9);
const lo = parseProjectionsConfig("#!srfv1\ntype::config,spending_change:num:-50\n");
try std.testing.expectApproxEqAbs(-max_abs_spending_real_change, lo.spending_real_change.?, 1e-9);
}
test "integration: declining model + late healthcare troughs mid-retirement" {
// Mirrors the shipped `examples/post-retirement-smile` config
// (keep the two in sync). Exercises the full path - parse the
// signed-percent drift, resolve the life events, and compute the
// trough. Composing the 2%/yr decline with the age-80 healthcare
// expense must put the spending trough in mid-retirement (the
// year just before the expense begins), not at the final
// distribution year - the whole reason the trough is searched for
// rather than read off the last year. (The shipped example file
// itself is validated by running the binary against it; @embedFile
// can't reach outside src/.)
const cfg =
\\#!srfv1
\\type::config,target_stock_pct:num:60
\\type::config,spending_change:num:-2
\\type::config,horizon:num:30
\\type::birthdate,date::1958-02-19
\\type::birthdate,date::1961-07-04,person:num:2
\\type::event,name::Social Security (Robin),start_age:num:67,person:num:1,amount:num:34800
\\type::event,name::Healthcare (late-life),start_age:num:80,person:num:1,amount:num:-55000
;
const config = parseProjectionsConfig(cfg);
// -2 whole percent -> -0.02 fraction.
try std.testing.expectApproxEqAbs(@as(f64, -0.02), config.spending_real_change.?, 1e-9);
// Resolve the life events against a fixed reference date (not
// "today" - tests must be deterministic).
const as_of = Date.fromYmd(2026, 6, 26);
const resolved = config.resolveEvents(as_of);
const events = resolved[0..config.event_count];
// Find the resolved start year of the lone expense event (the
// late-life healthcare bump).
var hc_start: ?u16 = null;
for (events) |ev| {
if (ev.annual_amount < 0) hc_start = ev.start_year;
}
try std.testing.expect(hc_start != null);
const dist_years: u16 = 30;
const t = spendingTrough(150_000, config.spending_real_change.?, events, 0, dist_years, as_of).?;
// Trough is the year just before healthcare starts...
try std.testing.expectEqual(hc_start.? - 1, t.year_offset);
// ...which is strictly before the final distribution year.
try std.testing.expect(t.year_offset < dist_years - 1);
}

View file

@ -957,6 +957,19 @@ pub fn runBands(
try cli.printFg(out, color, cli.CLR_MUTED, " {s}\n", .{note});
}
// Spending trough: when a declining/rising spending model is
// active, surface the lowest-spending year in today's dollars.
// It accounts for expense life events (e.g. a late-life
// healthcare hump), so it can land mid-retirement rather than at
// the final year.
if (ctx.spending_trough) |trough| {
try cli.printFg(out, color, cli.CLR_MUTED, " Lowest spending: {f} in year {d} ({d}), today's dollars\n", .{
Money.from(trough.amount).whole(),
trough.years_from_now,
trough.date.year(),
});
}
// Life events summary - both as-of and live modes resolve ages
// against the reference date (`resolution.actual` if a snapshot
// was loaded, otherwise `as_of` directly).

View file

@ -1279,6 +1279,18 @@ fn appendSwrTable(
.style = th.mutedStyle(),
});
}
// Spending trough callout (declining/rising spending models only).
if (pctx.spending_trough) |trough| {
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " Lowest spending: {f} in year {d} ({d}), today's dollars", .{
Money.from(trough.amount).whole(),
trough.years_from_now,
trough.date.year(),
}),
.style = th.mutedStyle(),
});
}
}
fn appendEventSummary(lines: *std.ArrayListUnmanaged(StyledLine), as_of: zfin.Date, arena: std.mem.Allocator, th: theme.Theme, pctx: view.ProjectionContext) !void {

View file

@ -174,6 +174,12 @@ pub const ProjectionContext = struct {
/// Drives the header note ("As-of: ... (snapshot)" vs "(imported)")
/// so users know how literal the bands are.
as_of_source: AsOfSource = .live,
/// Lowest-spending year of the headline cell's distribution phase
/// (longest horizon at the highest confidence), in today's
/// dollars. Populated only when a `spending_change` is configured
/// - the callout is meaningless for flat spending. `null`
/// otherwise (and in the degenerate zero-horizon case).
spending_trough: ?projections.SpendingTrough = null,
};
pub const AsOfSource = enum { live, snapshot, imported };
@ -335,6 +341,7 @@ pub fn buildProjectionContext(
config.annual_contribution,
config.contribution_inflation_adjusted,
sim_expense_ratio,
config.spending_real_change orelse 0,
);
// Accumulation-phase stats: extract portfolio value at the
@ -383,6 +390,7 @@ pub fn buildProjectionContext(
events,
config.max_accumulation_years,
sim_expense_ratio,
config.spending_real_change orelse 0,
);
}
}
@ -453,6 +461,28 @@ pub fn buildProjectionContext(
}
}
// Spending trough: lowest-spending year of the headline cell
// (longest horizon, highest confidence). Surfaced only when a
// spending drift is configured. Anchored to the same
// `accumulation_years` the grid was computed with, so it lines up
// with the "retire now, 1st-year withdrawal" headline.
var spending_trough: ?projections.SpendingTrough = null;
if (config.spending_real_change) |rate| {
const trough_horizons = config.getHorizons();
if (trough_horizons.len > 0) {
const longest = trough_horizons.len - 1;
const swr = data.withdrawals[data.ci_99 * trough_horizons.len + longest];
spending_trough = projections.spendingTrough(
swr.annual_amount,
rate,
events,
accumulation_years,
trough_horizons[longest],
as_of,
);
}
}
return .{
.comparison = comparison,
.config = config,
@ -464,6 +494,7 @@ pub fn buildProjectionContext(
.accumulation = accumulation_stats,
.earliest = earliest,
.inputs = inputs,
.spending_trough = spending_trough,
};
}