implementation of spending smile
This commit is contained in:
parent
3aff2e61b4
commit
120c51bce4
16 changed files with 1075 additions and 24 deletions
2
TODO.md
2
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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
9
examples/post-retirement-smile/accounts.srf
Normal file
9
examples/post-retirement-smile/accounts.srf
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
441
examples/post-retirement-smile/history/imported_values.srf
Normal file
441
examples/post-retirement-smile/history/imported_values.srf
Normal 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
|
||||
8
examples/post-retirement-smile/metadata.srf
Normal file
8
examples/post-retirement-smile/metadata.srf
Normal 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
|
||||
35
examples/post-retirement-smile/portfolio.srf
Normal file
35
examples/post-retirement-smile/portfolio.srf
Normal 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
|
||||
51
examples/post-retirement-smile/projections.srf
Normal file
51
examples/post-retirement-smile/projections.srf
Normal 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
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue