From 120c51bce4978893bc5a724c908e5947d8237c2b Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Fri, 26 Jun 2026 14:52:45 -0700 Subject: [PATCH] implementation of spending smile --- TODO.md | 2 - docs/explanation/projections-model.md | 3 + docs/reference/config/projections-srf.md | 41 +- examples/README.md | 33 +- examples/post-retirement-smile/accounts.srf | 9 + .../history/2024-04-01-portfolio.srf | 37 ++ .../history/2024-10-01-portfolio.srf | 16 + .../history/2025-04-01-portfolio.srf | 16 + .../history/imported_values.srf | 441 ++++++++++++++++++ examples/post-retirement-smile/metadata.srf | 8 + examples/post-retirement-smile/portfolio.srf | 35 ++ .../post-retirement-smile/projections.srf | 51 ++ src/analytics/projections.zig | 351 +++++++++++++- src/commands/projections.zig | 13 + src/tui/projections_tab.zig | 12 + src/views/projections.zig | 31 ++ 16 files changed, 1075 insertions(+), 24 deletions(-) create mode 100644 examples/post-retirement-smile/accounts.srf create mode 100644 examples/post-retirement-smile/history/2024-04-01-portfolio.srf create mode 100644 examples/post-retirement-smile/history/2024-10-01-portfolio.srf create mode 100644 examples/post-retirement-smile/history/2025-04-01-portfolio.srf create mode 100644 examples/post-retirement-smile/history/imported_values.srf create mode 100644 examples/post-retirement-smile/metadata.srf create mode 100644 examples/post-retirement-smile/portfolio.srf create mode 100644 examples/post-retirement-smile/projections.srf diff --git a/TODO.md b/TODO.md index b6e14b1..4f6b54c 100644 --- a/TODO.md +++ b/TODO.md @@ -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: diff --git a/docs/explanation/projections-model.md b/docs/explanation/projections-model.md index b1fdb40..e0d6251 100644 --- a/docs/explanation/projections-model.md +++ b/docs/explanation/projections-model.md @@ -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 diff --git a/docs/reference/config/projections-srf.md b/docs/reference/config/projections-srf.md index 2e21fed..8c2dac6 100644 --- a/docs/reference/config/projections-srf.md +++ b/docs/reference/config/projections-srf.md @@ -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 diff --git a/examples/README.md b/examples/README.md index 1d83c4c..eaea2c9 100644 --- a/examples/README.md +++ b/examples/README.md @@ -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: diff --git a/examples/post-retirement-smile/accounts.srf b/examples/post-retirement-smile/accounts.srf new file mode 100644 index 0000000..adde32d --- /dev/null +++ b/examples/post-retirement-smile/accounts.srf @@ -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 diff --git a/examples/post-retirement-smile/history/2024-04-01-portfolio.srf b/examples/post-retirement-smile/history/2024-04-01-portfolio.srf new file mode 100644 index 0000000..dfe8837 --- /dev/null +++ b/examples/post-retirement-smile/history/2024-04-01-portfolio.srf @@ -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 diff --git a/examples/post-retirement-smile/history/2024-10-01-portfolio.srf b/examples/post-retirement-smile/history/2024-10-01-portfolio.srf new file mode 100644 index 0000000..142bcd6 --- /dev/null +++ b/examples/post-retirement-smile/history/2024-10-01-portfolio.srf @@ -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 diff --git a/examples/post-retirement-smile/history/2025-04-01-portfolio.srf b/examples/post-retirement-smile/history/2025-04-01-portfolio.srf new file mode 100644 index 0000000..995f0a4 --- /dev/null +++ b/examples/post-retirement-smile/history/2025-04-01-portfolio.srf @@ -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 diff --git a/examples/post-retirement-smile/history/imported_values.srf b/examples/post-retirement-smile/history/imported_values.srf new file mode 100644 index 0000000..b543837 --- /dev/null +++ b/examples/post-retirement-smile/history/imported_values.srf @@ -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 diff --git a/examples/post-retirement-smile/metadata.srf b/examples/post-retirement-smile/metadata.srf new file mode 100644 index 0000000..c748ce6 --- /dev/null +++ b/examples/post-retirement-smile/metadata.srf @@ -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 diff --git a/examples/post-retirement-smile/portfolio.srf b/examples/post-retirement-smile/portfolio.srf new file mode 100644 index 0000000..2ec85fb --- /dev/null +++ b/examples/post-retirement-smile/portfolio.srf @@ -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 diff --git a/examples/post-retirement-smile/projections.srf b/examples/post-retirement-smile/projections.srf new file mode 100644 index 0000000..0d39f77 --- /dev/null +++ b/examples/post-retirement-smile/projections.srf @@ -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 diff --git a/src/analytics/projections.zig b/src/analytics/projections.zig index 97c5500..6adfadc 100644 --- a/src/analytics/projections.zig +++ b/src/analytics/projections.zig @@ -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); +} diff --git a/src/commands/projections.zig b/src/commands/projections.zig index f3ff38f..3eac249 100644 --- a/src/commands/projections.zig +++ b/src/commands/projections.zig @@ -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). diff --git a/src/tui/projections_tab.zig b/src/tui/projections_tab.zig index 1d0854d..05cd2b7 100644 --- a/src/tui/projections_tab.zig +++ b/src/tui/projections_tab.zig @@ -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 { diff --git a/src/views/projections.zig b/src/views/projections.zig index e260a9d..19d70e8 100644 --- a/src/views/projections.zig +++ b/src/views/projections.zig @@ -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, }; }