Compare commits

..

No commits in common. "0cd01dd452338ddabdededb7acf4f34662d81434" and "1d89b68da4513d7db39ed9dca6ca8ea9954f133e" have entirely different histories.

18 changed files with 57 additions and 1297 deletions

100
TODO.md
View file

@ -31,6 +31,8 @@ ranking; unlabeled items are "someday, if the mood strikes."
earlier than the other would benefit from per-person earlier than the other would benefit from per-person
`retirement_age` fields on each `type::birthdate` record, with `retirement_age` fields on each `type::birthdate` record, with
contributions stopped per-person. 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 - **Historical projection overlay follow-ups.** The base
`--overlay-actuals` overlay shipped (CLI tip + TUI primary surface). `--overlay-actuals` overlay shipped (CLI tip + TUI primary surface).
Open enhancements: Open enhancements:
@ -207,93 +209,41 @@ populate. This could be solved on the server by spawning a thread to fetch the
data, then returning 202 Accepted, which could then be polled client side. Maybe data, then returning 202 Accepted, which could then be polled client side. Maybe
this is a better long term approach? this is a better long term approach?
## Support Tiingo paid plan - priority LOW ## Configurable live-quote provider (Tiingo IEX) - priority LOW
zfin hardwires Tiingo to free-tier assumptions: the provider is
constructed with `RateLimiter.perHour(io, 50)` in `Tiingo.init`
(`providers/tiingo.zig`), and the only Tiingo surface is end-of-day
candles plus the corporate actions that ride along in the same
response. A user who pays for a Tiingo plan ($30/mo Power tier and
up) gets nothing for it today - the same 50/hour throttle, the same
EOD-only data. "Support the paid plan" is the umbrella for unlocking
what that subscription actually buys: higher rate limits and
real-time IEX quotes. The two are coupled (real-time polling only
makes sense once the limit is raised), which is why they belong in
one entry rather than two.
### Tier-aware rate limiting
The 50/hour cap is hardcoded in `Tiingo.init`
(`RateLimiter.perHour(io, 50)`), and the module docstring bakes in
"Free tier: 50 requests/hour and 1,000 requests/day." Paid tiers
raise both ceilings substantially, so a paying subscriber is being
throttled far below their entitlement. Today only the hourly bucket
is wired; the daily ceiling isn't enforced at all (the docstring
notes it's "far from binding" for bursty EOD usage - real-time
polling changes that calculus).
Work:
- Make the Tiingo limits configurable instead of hardcoded. Options:
explicit `ZFIN_TIINGO_RATE_PER_HOUR` (and per-day) numeric env
knobs, or a coarser `ZFIN_TIINGO_PLAN` = `free` (default) |
`power` | ... that maps to known limits. Lean toward explicit
numeric overrides so we aren't chasing Tiingo's published per-tier
numbers as they drift.
- `RateLimiter` already supports arbitrary `init(io, max, window_ns)`
plus `perDay`/`perHour` convenience ctors, so the limiter side is
cheap. Decide whether a paid plan needs both an hourly and a daily
bucket enforced, or whether hourly alone stays sufficient.
- Caveat from `RateLimiter`'s own docs: the bucket is in-memory and
per-process - it caps a single run's burst, not usage across
separate launches in the same window. Sustained real-time polling
(below) makes cross-process usage likelier, so revisit whether
per-process accounting is still good enough.
### Real-time IEX quotes (was: configurable live-quote provider)
The TUI refresh key (`r`) values the portfolio with live intraday The TUI refresh key (`r`) values the portfolio with live intraday
quotes via `DataService.loadLiveQuotes` (`service.zig`), which is quotes via `DataService.loadLiveQuotes`, which is Yahoo-only: Yahoo is
Yahoo-only: Yahoo is keyless, consolidated, and stays off every keyless, consolidated, and stays off every rate-limit budget, so bursty
rate-limit budget, so bursty refresh traffic costs nothing. The refresh traffic costs nothing. The tradeoffs are that Yahoo's
tradeoffs are that Yahoo's unofficial feed is ~15-minute delayed and unofficial feed is ~15-minute delayed and "can break without notice."
"can break without notice."
Tiingo's IEX endpoint (`/iex/?tickers=A,B,C`) is a strong opt-in Tiingo's IEX endpoint (`/iex/?tickers=A,B,C`) is a strong opt-in
alternative for a paid subscriber: it's genuinely real-time (IEX alternative: it's genuinely real-time (IEX last-sale, no 15-min delay),
last-sale, no 15-min delay), official/keyed, and bills per HTTP official/keyed, and bills per HTTP request - one call returns the whole
request - one call returns the whole portfolio (confirmed portfolio (confirmed empirically: a 2-ticker batch decrements the daily
empirically: a 2-ticker batch decrements the daily quota by 1, not quota by 1, not 2). Fields map cleanly: `tngoLast` to price, `prevClose`
2). Fields map cleanly: `tngoLast` to price, `prevClose` to to day-change. Caveats: IEX is a single venue (~2-3% of volume), so
day-change. Caveats: IEX is a single venue (~2-3% of volume), so
`tngoLast` can sit stale between prints on illiquid names, and IEX `tngoLast` can sit stale between prints on illiquid names, and IEX
doesn't trade mutual funds, so those fall back to the candle close. doesn't trade mutual funds, so those fall back to the candle close.
Proposal: a config knob (env var, e.g. `ZFIN_LIVE_QUOTE_PROVIDER` = Proposal: a config knob (env var, e.g. `ZFIN_LIVE_QUOTE_PROVIDER` =
`yahoo` (default) | `tiingo`) that switches `loadLiveQuotes` to a new `yahoo` (default) | `tiingo`) that switches `loadLiveQuotes` to a new
`Tiingo.fetchQuotes(tickers)` batched call. A paid subscriber who `Tiingo.fetchQuotes(tickers)` batched call. Someone on Tiingo's Power
wants real-time and mashes `r` a lot (or once we add streaming) tier ($30/mo, higher limits) who wants real-time and mashes `r` a lot
reuses their existing `TIINGO_API_KEY` and gets real-time coverage; (or once we add streaming) reuses their existing `TIINGO_API_KEY` and
everyone else keeps the keyless Yahoo default. gets real-time coverage; everyone else keeps the keyless Yahoo default.
Implementation notes: Implementation notes:
- `Tiingo.fetchQuotes` returns an array whose order is NOT guaranteed - `Tiingo.fetchQuotes` returns an array whose order is NOT guaranteed to
to match the request order, so key results by the returned match the request order, so key results by the returned `ticker`
`ticker` field, not by position. field, not by position.
- Live quotes share Tiingo's token bucket, so this is the concrete - Tiingo-sourced live quotes would share Tiingo's 50/hour token bucket
reason the tier-aware rate-limiting work above has to land first (`RateLimiter.perHour`, wired into the provider). A batched quote
(or alongside): a batched quote call is only 1 request, but heavy call is 1 request, but heavy `r` use plus candle refreshes draw from
`r` use plus candle refreshes draining the free 50/hour bucket is the same hourly budget, so watch for contention.
exactly the contention that raising the paid-tier limit relieves. - Tiingo websocket streaming would be the natural follow-on for true
push-based real-time, replacing poll-on-`r` entirely.
### Websocket streaming (follow-on)
Tiingo's IEX websocket would be the natural follow-on for true
push-based real-time, replacing poll-on-`r` entirely. Materially
bigger than the REST quote path (persistent connection, reconnect
handling, a background task feeding the TUI) and squarely a
paid-plan feature. Sequence it after the REST quote path proves out.
## Analysis: dividend equity / income-shaped equity - think about it ## Analysis: dividend equity / income-shaped equity - think about it

View file

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

View file

@ -45,7 +45,6 @@ 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. | | `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` | num | Desired retirement spending, in today's dollars. |
| `target_spending_inflation_adjusted` | bool | If `true` (default), target spending grows with CPI during distribution. | | `target_spending_inflation_adjusted` | bool | If `true` (default), target spending grows with CPI during distribution. |
| `spending_change` | num | Signed annual *real* change in spending across the distribution phase, as a whole percent. Negative = declining (e.g. `-2` = -2%/yr, the "spending smile"); positive = rising. Default: absent = flat real spending. Magnitude clamped to 10%/yr. See [Declining spending](#declining-spending-the-smile). |
| `max_accumulation_years` | num | Ceiling (in years) the earliest-retirement search scans when `target_spending` is set. Default `50`, capped at `100`. | | `max_accumulation_years` | num | Ceiling (in years) the earliest-retirement search scans when `target_spending` is set. Default `50`, capped at `100`. |
| `retirement_target` | num | Annotation on a `horizon`/`horizon_age` line that overrides the earliest-retirement promotion rule. Allowed: `90`, `95`, `99`. | | `retirement_target` | num | Annotation on a `horizon`/`horizon_age` line that overrides the earliest-retirement promotion rule. Allowed: `90`, `95`, `99`. |
@ -81,43 +80,6 @@ individual stocks, bonds, and cash contribute ~0. Set the result once:
[Parity with FIRECalc](../../explanation/projections-model.md#parity-with-firecalc) [Parity with FIRECalc](../../explanation/projections-model.md#parity-with-firecalc)
for how the fee interacts with the rest of the model. 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 ### Capping outlier returns
The **Projected return** shown by `zfin projections` (and the "Projected The **Projected return** shown by `zfin projections` (and the "Projected
@ -221,7 +183,7 @@ anchor.
## The example configurations ## The example configurations
The six bundled examples are fully-configured walkthroughs of each The five bundled examples are fully-configured walkthroughs of each
combination: combination:
| `examples/...` | Inputs | | `examples/...` | Inputs |
@ -231,7 +193,6 @@ combination:
| `pre-retirement-spending-target` | target spending + an explicit (infeasible) anchor | | `pre-retirement-spending-target` | target spending + an explicit (infeasible) anchor |
| `pre-retirement-both` | target date + target spending | | `pre-retirement-both` | target date + target spending |
| `post-retirement` | neither (distribution-only) | | `post-retirement` | neither (distribution-only) |
| `post-retirement-smile` | distribution-only + declining `spending_change` |
```bash ```bash
ZFIN_HOME=examples/pre-retirement-both zfin projections ZFIN_HOME=examples/pre-retirement-both zfin projections

View file

@ -11,7 +11,6 @@ ZFIN_HOME=examples/pre-retirement-age zfin projections
ZFIN_HOME=examples/pre-retirement-spending zfin projections ZFIN_HOME=examples/pre-retirement-spending zfin projections
ZFIN_HOME=examples/pre-retirement-spending-target zfin projections ZFIN_HOME=examples/pre-retirement-spending-target zfin projections
ZFIN_HOME=examples/post-retirement 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 ZFIN_HOME=examples/pre-retirement-both zfin --tui
``` ```
@ -20,10 +19,10 @@ these examples are fictional. Do not interpret them as advice.
## Available examples ## Available examples
The six scenarios share the same fictional couple and balance sheet The five scenarios share the same fictional couple and balance sheet
(~$1.3M, age ~45, contributing $80k/yr) for the four pre-retirement (~$1.3M, age ~45, contributing $80k/yr) for the four pre-retirement
variants, and a separate retired couple for the two distribution variants, and a separate retired couple for the distribution example.
examples. Only the `projections.srf` configuration differs across the Only the `projections.srf` configuration differs across the
pre-retirement variants - making it easy to see how each pre-retirement variants - making it easy to see how each
retirement-planning input shapes the output. retirement-planning input shapes the output.
@ -132,32 +131,6 @@ starting at age 80, plus Social Security already in pay status.
Asset allocation target: 60/40 (more bond-heavy than the Asset allocation target: 60/40 (more bond-heavy than the
pre-retirement examples). 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 ## Configuration file map
Every example contains: Every example contains:

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -242,14 +242,6 @@ pub const UserConfig = struct {
/// If true, the target spending grows with CPI during the /// If true, the target spending grows with CPI during the
/// distribution phase (matches the existing SWR model). /// distribution phase (matches the existing SWR model).
target_spending_inflation_adjusted: bool = true, 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 /// Ceiling on the accumulation years the earliest-retirement
/// search (`findEarliestRetirement`) will consider when /// search (`findEarliestRetirement`) will consider when
/// `target_spending` is set. Defaults to /// `target_spending` is set. Defaults to
@ -488,10 +480,6 @@ const SrfConfig = struct {
contribution_inflation_adjusted: ?bool = null, contribution_inflation_adjusted: ?bool = null,
target_spending: ?f64 = null, target_spending: ?f64 = null,
target_spending_inflation_adjusted: ?bool = 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, max_accumulation_years: ?u16 = null,
benchmark_stock: ?[]const u8 = null, benchmark_stock: ?[]const u8 = null,
benchmark_bond: ?[]const u8 = null, benchmark_bond: ?[]const u8 = null,
@ -520,15 +508,6 @@ const SrfProjection = union(enum) {
event: SrfEvent, 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. /// Parse a projections.srf file into a UserConfig.
/// Returns default config if data is null or unparseable. /// Returns default config if data is null or unparseable.
/// ///
@ -546,7 +525,6 @@ pub const max_abs_spending_real_change: f64 = 0.10;
/// Format (union-tagged SRF records): /// Format (union-tagged SRF records):
/// type::config,target_stock_pct:num:80 /// type::config,target_stock_pct:num:80
/// type::config,horizon:num:30 /// type::config,horizon:num:30
/// type::config,spending_change:num:-2
/// type::birthdate,date::1975-03-15 /// type::birthdate,date::1975-03-15
/// type::event,name::Social Security,start_age:num:67,amount:num:38400 /// type::event,name::Social Security,start_age:num:67,amount:num:38400
pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig { pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig {
@ -654,23 +632,6 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig {
if (c.target_spending_inflation_adjusted) |b| { if (c.target_spending_inflation_adjusted) |b| {
config.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 (c.max_accumulation_years) |n| {
if (n == 0) { if (n == 0) {
// A zero-year search ceiling is degenerate (it // A zero-year search ceiling is degenerate (it
@ -799,19 +760,6 @@ pub const SimParams = struct {
stock_pct: f64, stock_pct: f64,
annual_spending: f64, annual_spending: f64,
spending_inflation_adjusted: bool = true, 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-phase length (the "horizon" in the existing API).
distribution_years: u16, distribution_years: u16,
accumulation_years: u16 = 0, accumulation_years: u16 = 0,
@ -885,13 +833,6 @@ fn simulateTwoPhase(
if (buf) |b| b[0] = portfolio; if (buf) |b| b[0] = portfolio;
var cumulative_inflation: f64 = 1.0; 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 failed = false;
var y: usize = 0; var y: usize = 0;
@ -924,11 +865,10 @@ fn simulateTwoPhase(
params.annual_contribution; params.annual_contribution;
portfolio += contribution + event_net; portfolio += contribution + event_net;
} else { } else {
const real_spending = params.annual_spending * spend_factor;
const spending = if (params.spending_inflation_adjusted) const spending = if (params.spending_inflation_adjusted)
real_spending * cumulative_inflation params.annual_spending * cumulative_inflation
else else
real_spending; params.annual_spending;
portfolio -= spending - event_net; portfolio -= spending - event_net;
if (portfolio <= 0 and !failed) { if (portfolio <= 0 and !failed) {
// Survival-only callers exit immediately - there's // Survival-only callers exit immediately - there's
@ -936,9 +876,6 @@ fn simulateTwoPhase(
if (buf == null) return false; if (buf == null) return false;
failed = true; 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 // Market return on the post-cashflow balance, net of the
@ -1062,13 +999,11 @@ pub fn findSafeWithdrawalWithAccumulation(
annual_contribution: f64, annual_contribution: f64,
contribution_inflation_adjusted: bool, contribution_inflation_adjusted: bool,
expense_ratio: f64, expense_ratio: f64,
spending_real_change: f64,
) WithdrawalResult { ) WithdrawalResult {
return searchSafeWithdrawal(.{ return searchSafeWithdrawal(.{
.initial_value = initial_value, .initial_value = initial_value,
.stock_pct = stock_pct, .stock_pct = stock_pct,
.annual_spending = 0, // overwritten by the search loop .annual_spending = 0, // overwritten by the search loop
.spending_real_change = spending_real_change,
.distribution_years = horizon, .distribution_years = horizon,
.accumulation_years = accumulation_years, .accumulation_years = accumulation_years,
.annual_contribution = annual_contribution, .annual_contribution = annual_contribution,
@ -1204,7 +1139,6 @@ pub fn findEarliestRetirement(
events: []const ResolvedEvent, events: []const ResolvedEvent,
max_years: u16, max_years: u16,
expense_ratio: f64, expense_ratio: f64,
spending_real_change: f64,
) !EarliestRetirement { ) !EarliestRetirement {
const data = shiller.annual_returns; const data = shiller.annual_returns;
@ -1215,7 +1149,6 @@ pub fn findEarliestRetirement(
.stock_pct = stock_pct, .stock_pct = stock_pct,
.annual_spending = target_spending, .annual_spending = target_spending,
.spending_inflation_adjusted = target_spending_inflation_adjusted, .spending_inflation_adjusted = target_spending_inflation_adjusted,
.spending_real_change = spending_real_change,
.distribution_years = distribution_years, .distribution_years = distribution_years,
.accumulation_years = n, .accumulation_years = n,
.annual_contribution = annual_contribution, .annual_contribution = annual_contribution,
@ -1549,7 +1482,6 @@ pub fn runProjectionGrid(
annual_contribution: f64, annual_contribution: f64,
contribution_inflation_adjusted: bool, contribution_inflation_adjusted: bool,
expense_ratio: f64, expense_ratio: f64,
spending_real_change: f64,
) !ProjectionData { ) !ProjectionData {
const num_results = horizons.len * confidence_levels.len; const num_results = horizons.len * confidence_levels.len;
const withdrawals = try alloc.alloc(WithdrawalResult, num_results); const withdrawals = try alloc.alloc(WithdrawalResult, num_results);
@ -1565,7 +1497,6 @@ pub fn runProjectionGrid(
annual_contribution, annual_contribution,
contribution_inflation_adjusted, contribution_inflation_adjusted,
expense_ratio, expense_ratio,
spending_real_change,
); );
} }
} }
@ -1577,7 +1508,6 @@ pub fn runProjectionGrid(
.initial_value = total_value, .initial_value = total_value,
.stock_pct = stock_pct, .stock_pct = stock_pct,
.annual_spending = wr.annual_amount, .annual_spending = wr.annual_amount,
.spending_real_change = spending_real_change,
.distribution_years = h, .distribution_years = h,
.accumulation_years = accumulation_years, .accumulation_years = accumulation_years,
.annual_contribution = annual_contribution, .annual_contribution = annual_contribution,
@ -1589,83 +1519,7 @@ pub fn runProjectionGrid(
return .{ .withdrawals = withdrawals, .bands = bands, .ci_99 = ci_99 }; return .{ .withdrawals = withdrawals, .bands = bands, .ci_99 = ci_99 };
} }
// Spending trough (the "how low does it get" callout) // Test-only convenience wrappers
/// 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 // Thin, distribution-only, zero-fee wrappers over the production
// `*Params` entry points (`searchSafeWithdrawal`, `successRateParams`, // `*Params` entry points (`searchSafeWithdrawal`, `successRateParams`,
@ -1964,8 +1818,8 @@ test "FIRECalc parity: expense ratio matches FIRECalc's default fee" {
// Safe withdrawal with the fee on. FIRECalc fee=0.18 refs: // 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. // 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, 0); 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, 0); const w_45 = findSafeWithdrawalWithAccumulation(45, 7_700_000, 0.75, 0.99, &.{}, 0, 0, true, 0.0018);
try std.testing.expect(w_30.annual_amount >= 40_381 * 0.97); 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_30.annual_amount <= 40_381 * 1.15);
try std.testing.expect(w_45.annual_amount >= 258_747 * 0.97); try std.testing.expect(w_45.annual_amount >= 258_747 * 0.97);
@ -2619,7 +2473,7 @@ test "regression: zero accumulation matches direct findSafeWithdrawal" {
// because the two paths execute the same code with the same // because the two paths execute the same code with the same
// inputs - any drift here means the unification broke. // inputs - any drift here means the unification broke.
const direct = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &.{}); 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, 0); const via_accum = findSafeWithdrawalWithAccumulation(30, 1_000_000, 0.75, 0.95, &.{}, 0, 0, true, 0);
try std.testing.expectEqual(direct.annual_amount, via_accum.annual_amount); 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.confidence, via_accum.confidence);
try std.testing.expectEqual(direct.withdrawal_rate, via_accum.withdrawal_rate); try std.testing.expectEqual(direct.withdrawal_rate, via_accum.withdrawal_rate);
@ -2704,7 +2558,7 @@ test "two-phase: SWR with accumulation exceeds same-portfolio direct SWR" {
// a higher safe withdrawal than $1M alone over a 30-year // a higher safe withdrawal than $1M alone over a 30-year
// distribution at the same confidence. // distribution at the same confidence.
const direct = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &.{}); 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, 0); const with_accum = findSafeWithdrawalWithAccumulation(30, 1_000_000, 0.75, 0.95, &.{}, 10, 100_000, true, 0);
try std.testing.expect(with_accum.annual_amount > direct.annual_amount); try std.testing.expect(with_accum.annual_amount > direct.annual_amount);
} }
@ -2788,7 +2642,6 @@ test "findEarliestRetirement: feasible at N=0 returns 0" {
&.{}, &.{},
50, // max_years 50, // max_years
0, // expense_ratio 0, // expense_ratio
0, // spending_real_change
); );
try std.testing.expectEqual(@as(?u16, 0), r.accumulation_years); try std.testing.expectEqual(@as(?u16, 0), r.accumulation_years);
} }
@ -2810,7 +2663,6 @@ test "findEarliestRetirement: unreachable returns null" {
&.{}, &.{},
50, 50,
0, // expense_ratio 0, // expense_ratio
0, // spending_real_change
); );
try std.testing.expectEqual(@as(?u16, null), r.accumulation_years); try std.testing.expectEqual(@as(?u16, null), r.accumulation_years);
} }
@ -2831,7 +2683,6 @@ test "findEarliestRetirement: longer distribution shifts retirement later or unc
&.{}, &.{},
50, 50,
0, // expense_ratio 0, // expense_ratio
0, // spending_real_change
); );
const long = try findEarliestRetirement( const long = try findEarliestRetirement(
allocator, allocator,
@ -2846,7 +2697,6 @@ test "findEarliestRetirement: longer distribution shifts retirement later or unc
&.{}, &.{},
50, 50,
0, // expense_ratio 0, // expense_ratio
0, // spending_real_change
); );
if (short.accumulation_years != null and long.accumulation_years != null) { if (short.accumulation_years != null and long.accumulation_years != null) {
try std.testing.expect(long.accumulation_years.? >= short.accumulation_years.?); try std.testing.expect(long.accumulation_years.? >= short.accumulation_years.?);
@ -2868,7 +2718,6 @@ test "findEarliestRetirement: result includes portfolio statistics" {
&.{}, &.{},
50, 50,
0, // expense_ratio 0, // expense_ratio
0, // spending_real_change
); );
if (r.accumulation_years) |n| { if (r.accumulation_years) |n| {
if (n > 0) { if (n > 0) {
@ -3147,7 +2996,7 @@ test "runProjectionGrid: structure and indexing" {
const horizons = [_]u16{ 20, 30 }; const horizons = [_]u16{ 20, 30 };
const conf = [_]f64{ 0.95, 0.99 }; const conf = [_]f64{ 0.95, 0.99 };
const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0, 0); const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0);
defer freeProjectionData(allocator, data); defer freeProjectionData(allocator, data);
// 2 horizons × 2 confidence levels = 4 withdrawal results. // 2 horizons × 2 confidence levels = 4 withdrawal results.
@ -3165,7 +3014,7 @@ test "runProjectionGrid: withdrawal monotonicity along confidence axis" {
const horizons = [_]u16{30}; const horizons = [_]u16{30};
const conf = [_]f64{ 0.90, 0.95, 0.99 }; 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, 0); const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0);
defer freeProjectionData(allocator, data); defer freeProjectionData(allocator, data);
const w_90 = data.withdrawals[0 * horizons.len + 0].annual_amount; const w_90 = data.withdrawals[0 * horizons.len + 0].annual_amount;
@ -3181,7 +3030,7 @@ test "runProjectionGrid: withdrawal monotonicity along horizon axis" {
const horizons = [_]u16{ 20, 30, 45 }; const horizons = [_]u16{ 20, 30, 45 };
const conf = [_]f64{0.95}; const conf = [_]f64{0.95};
const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0, 0); const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0);
defer freeProjectionData(allocator, data); defer freeProjectionData(allocator, data);
const w_20 = data.withdrawals[0 * horizons.len + 0].annual_amount; const w_20 = data.withdrawals[0 * horizons.len + 0].annual_amount;
@ -3196,7 +3045,7 @@ test "runProjectionGrid: distribution-only band length is horizon + 1" {
const horizons = [_]u16{ 20, 30 }; const horizons = [_]u16{ 20, 30 };
const conf = [_]f64{ 0.95, 0.99 }; const conf = [_]f64{ 0.95, 0.99 };
const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0, 0); const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0);
defer freeProjectionData(allocator, data); defer freeProjectionData(allocator, data);
// band[0] covers horizons[0] = 20 -> 21 entries; band[1] covers // band[0] covers horizons[0] = 20 -> 21 entries; band[1] covers
@ -3211,7 +3060,7 @@ test "runProjectionGrid: with-accumulation band length includes accumulation_yea
const conf = [_]f64{0.95}; const conf = [_]f64{0.95};
// 10 years of accumulation + 30 years distribution -> 41 entries. // 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, 0); const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 10, 50_000, true, 0);
defer freeProjectionData(allocator, data); defer freeProjectionData(allocator, data);
try std.testing.expectEqual(@as(usize, 41), data.bands[0].?.len); try std.testing.expectEqual(@as(usize, 41), data.bands[0].?.len);
@ -3222,7 +3071,7 @@ test "runProjectionGrid: bands are p10 ≤ p25 ≤ p50 ≤ p75 ≤ p90 at every
const horizons = [_]u16{30}; const horizons = [_]u16{30};
const conf = [_]f64{0.95}; const conf = [_]f64{0.95};
const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0, 0); const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0);
defer freeProjectionData(allocator, data); defer freeProjectionData(allocator, data);
for (data.bands[0].?) |b| { for (data.bands[0].?) |b| {
@ -3239,7 +3088,7 @@ test "runProjectionGrid: year 0 in every band equals total_value" {
const conf = [_]f64{0.95}; const conf = [_]f64{0.95};
const total_value: f64 = 2_000_000; const total_value: f64 = 2_000_000;
const data = try runProjectionGrid(allocator, &horizons, &conf, total_value, 0.75, &.{}, 0, 0, true, 0, 0); const data = try runProjectionGrid(allocator, &horizons, &conf, total_value, 0.75, &.{}, 0, 0, true, 0);
defer freeProjectionData(allocator, data); defer freeProjectionData(allocator, data);
for (data.bands) |b_opt| { for (data.bands) |b_opt| {
@ -3263,7 +3112,7 @@ test "runProjectionGrid: bands are computed at the highest-confidence withdrawal
const horizons = [_]u16{30}; const horizons = [_]u16{30};
const conf = [_]f64{ 0.90, 0.95, 0.99 }; 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, 0); const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0);
defer freeProjectionData(allocator, data); defer freeProjectionData(allocator, data);
const wr_99 = data.withdrawals[data.ci_99 * horizons.len + 0]; const wr_99 = data.withdrawals[data.ci_99 * horizons.len + 0];
@ -3295,10 +3144,10 @@ test "runProjectionGrid: accumulation passes through to both withdrawals and ban
const horizons = [_]u16{30}; const horizons = [_]u16{30};
const conf = [_]f64{0.95}; const conf = [_]f64{0.95};
const dist_only = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0, 0); const dist_only = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0);
defer freeProjectionData(allocator, dist_only); defer freeProjectionData(allocator, dist_only);
const with_accum = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 10, 50_000, true, 0, 0); const with_accum = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 10, 50_000, true, 0);
defer freeProjectionData(allocator, with_accum); defer freeProjectionData(allocator, with_accum);
// SWR with 10y of contributions on top should exceed SWR // SWR with 10y of contributions on top should exceed SWR
@ -3314,173 +3163,9 @@ test "runProjectionGrid: zero horizons produces empty results without crashing"
const horizons = [_]u16{}; const horizons = [_]u16{};
const conf = [_]f64{ 0.95, 0.99 }; const conf = [_]f64{ 0.95, 0.99 };
const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0, 0); const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0);
defer freeProjectionData(allocator, data); defer freeProjectionData(allocator, data);
try std.testing.expectEqual(@as(usize, 0), data.withdrawals.len); try std.testing.expectEqual(@as(usize, 0), data.withdrawals.len);
try std.testing.expectEqual(@as(usize, 0), data.bands.len); try std.testing.expectEqual(@as(usize, 0), data.bands.len);
} }
// Spending-drift (the "smile") tests
test "spending_real_change: declining spending raises safe withdrawal, rising lowers it" {
// Same portfolio, horizon, and confidence - only the spending
// trajectory differs. Spending less in the slow-go years frees up
// a higher first-year draw; spending more requires a lower one.
const flat = findSafeWithdrawalWithAccumulation(30, 1_000_000, 0.75, 0.95, &.{}, 0, 0, true, 0, 0);
const declining = findSafeWithdrawalWithAccumulation(30, 1_000_000, 0.75, 0.95, &.{}, 0, 0, true, 0, -0.02);
const rising = findSafeWithdrawalWithAccumulation(30, 1_000_000, 0.75, 0.95, &.{}, 0, 0, true, 0, 0.02);
try std.testing.expect(declining.annual_amount > flat.annual_amount);
try std.testing.expect(rising.annual_amount < flat.annual_amount);
}
test "spending_real_change: zero drift is identical to the flat model" {
// The default (rate 0) must reproduce the pre-smile behavior
// exactly - the regression pin for every existing projection.
const flat = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &.{});
const zero_drift = findSafeWithdrawalWithAccumulation(30, 1_000_000, 0.75, 0.95, &.{}, 0, 0, true, 0, 0);
try std.testing.expectEqual(flat.annual_amount, zero_drift.annual_amount);
}
test "spendingTrough: monotonic decline bottoms out in the final year" {
const as_of = Date.fromYmd(2026, 1, 1);
const t = spendingTrough(60_000, -0.02, &.{}, 0, 30, as_of).?;
// No events -> spending falls every year -> trough is the last
// distribution year (d = 29).
try std.testing.expectEqual(@as(u16, 29), t.year_offset);
try std.testing.expectEqual(@as(u16, 30), t.years_from_now);
const expected = 60_000.0 * std.math.pow(f64, 0.98, 29);
try std.testing.expectApproxEqAbs(expected, t.amount, 1.0);
try std.testing.expectEqual(@as(i16, 2055), t.date.year());
}
test "spendingTrough: a late healthcare expense pulls the trough to mid-retirement" {
const as_of = Date.fromYmd(2026, 1, 1);
// Base spending declines 2%/yr; a permanent +$40k/yr healthcare
// expense begins at distribution year 20. Spending slides until
// then, then jumps - so the trough is the year just before the
// hump (d = 19), not the final year. This is the whole reason the
// trough is computed rather than read off the last year.
const healthcare = [_]ResolvedEvent{.{
.start_year = 20,
.duration = 0,
.annual_amount = -40_000,
.inflation_adjusted = true,
}};
const t = spendingTrough(60_000, -0.02, &healthcare, 0, 30, as_of).?;
try std.testing.expectEqual(@as(u16, 19), t.year_offset);
}
test "spendingTrough: rising spending bottoms out in the first year" {
const as_of = Date.fromYmd(2026, 1, 1);
const t = spendingTrough(50_000, 0.01, &.{}, 0, 30, as_of).?;
try std.testing.expectEqual(@as(u16, 0), t.year_offset);
try std.testing.expectEqual(@as(u16, 1), t.years_from_now);
try std.testing.expectApproxEqAbs(@as(f64, 50_000), t.amount, 0.01);
}
test "spendingTrough: income events do not count as spending" {
const as_of = Date.fromYmd(2026, 1, 1);
// A Social Security income event funds withdrawals but is not
// consumption, so it must not lower the reported spending trough.
const ss = [_]ResolvedEvent{.{
.start_year = 5,
.duration = 0,
.annual_amount = 30_000, // positive = income
.inflation_adjusted = true,
}};
const with_income = spendingTrough(60_000, -0.02, &ss, 0, 30, as_of).?;
const without = spendingTrough(60_000, -0.02, &.{}, 0, 30, as_of).?;
try std.testing.expectEqual(without.amount, with_income.amount);
try std.testing.expectEqual(without.year_offset, with_income.year_offset);
}
test "spendingTrough: accumulation phase offsets the trough year and date" {
const as_of = Date.fromYmd(2026, 1, 1);
// 10 accumulation years, then 20 distribution years declining
// 1%/yr. Trough at the last distribution year (d = 19);
// years_from_now = 10 + 19 + 1 = 30; calendar 2026 + 29 = 2055.
const t = spendingTrough(50_000, -0.01, &.{}, 10, 20, as_of).?;
try std.testing.expectEqual(@as(u16, 19), t.year_offset);
try std.testing.expectEqual(@as(u16, 30), t.years_from_now);
try std.testing.expectEqual(@as(i16, 2055), t.date.year());
}
test "spendingTrough: zero distribution years returns null" {
const as_of = Date.fromYmd(2026, 1, 1);
try std.testing.expectEqual(
@as(?SpendingTrough, null),
spendingTrough(60_000, -0.02, &.{}, 0, 0, as_of),
);
}
test "parseProjectionsConfig spending_change negative is a decline" {
const config = parseProjectionsConfig("#!srfv1\ntype::config,spending_change:num:-2\n");
try std.testing.expectApproxEqAbs(@as(f64, -0.02), config.spending_real_change.?, 1e-9);
}
test "parseProjectionsConfig spending_change positive is a rise" {
const config = parseProjectionsConfig("#!srfv1\ntype::config,spending_change:num:1\n");
try std.testing.expectApproxEqAbs(@as(f64, 0.01), config.spending_real_change.?, 1e-9);
}
test "parseProjectionsConfig spending_change absent stays null (flat)" {
const config = parseProjectionsConfig("#!srfv1\ntype::config,horizon:num:30\n");
try std.testing.expectEqual(@as(?f64, null), config.spending_real_change);
}
test "parseProjectionsConfig spending_change magnitude is clamped both directions" {
const hi = parseProjectionsConfig("#!srfv1\ntype::config,spending_change:num:50\n");
try std.testing.expectApproxEqAbs(max_abs_spending_real_change, hi.spending_real_change.?, 1e-9);
const lo = parseProjectionsConfig("#!srfv1\ntype::config,spending_change:num:-50\n");
try std.testing.expectApproxEqAbs(-max_abs_spending_real_change, lo.spending_real_change.?, 1e-9);
}
test "integration: declining model + late healthcare troughs mid-retirement" {
// Mirrors the shipped `examples/post-retirement-smile` config
// (keep the two in sync). Exercises the full path - parse the
// signed-percent drift, resolve the life events, and compute the
// trough. Composing the 2%/yr decline with the age-80 healthcare
// expense must put the spending trough in mid-retirement (the
// year just before the expense begins), not at the final
// distribution year - the whole reason the trough is searched for
// rather than read off the last year. (The shipped example file
// itself is validated by running the binary against it; @embedFile
// can't reach outside src/.)
const cfg =
\\#!srfv1
\\type::config,target_stock_pct:num:60
\\type::config,spending_change:num:-2
\\type::config,horizon:num:30
\\type::birthdate,date::1958-02-19
\\type::birthdate,date::1961-07-04,person:num:2
\\type::event,name::Social Security (Robin),start_age:num:67,person:num:1,amount:num:34800
\\type::event,name::Healthcare (late-life),start_age:num:80,person:num:1,amount:num:-55000
;
const config = parseProjectionsConfig(cfg);
// -2 whole percent -> -0.02 fraction.
try std.testing.expectApproxEqAbs(@as(f64, -0.02), config.spending_real_change.?, 1e-9);
// Resolve the life events against a fixed reference date (not
// "today" - tests must be deterministic).
const as_of = Date.fromYmd(2026, 6, 26);
const resolved = config.resolveEvents(as_of);
const events = resolved[0..config.event_count];
// Find the resolved start year of the lone expense event (the
// late-life healthcare bump).
var hc_start: ?u16 = null;
for (events) |ev| {
if (ev.annual_amount < 0) hc_start = ev.start_year;
}
try std.testing.expect(hc_start != null);
const dist_years: u16 = 30;
const t = spendingTrough(150_000, config.spending_real_change.?, events, 0, dist_years, as_of).?;
// Trough is the year just before healthcare starts...
try std.testing.expectEqual(hc_start.? - 1, t.year_offset);
// ...which is strictly before the final distribution year.
try std.testing.expect(t.year_offset < dist_years - 1);
}

View file

@ -957,19 +957,6 @@ pub fn runBands(
try cli.printFg(out, color, cli.CLR_MUTED, " {s}\n", .{note}); 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 // Life events summary - both as-of and live modes resolve ages
// against the reference date (`resolution.actual` if a snapshot // against the reference date (`resolution.actual` if a snapshot
// was loaded, otherwise `as_of` directly). // was loaded, otherwise `as_of` directly).

View file

@ -315,26 +315,6 @@ pub fn candleFreshness(now_s: i64, kind: InstrumentKind, last_cached: Date) Cand
return .overdue; return .overdue;
} }
/// Whether a candle fetch is warranted for `kind` as of `now_s`, given
/// `last_cached` (the newest bar already in the cache). True when a
/// newer bar is due (`candleFreshness` is `.lagging` or `.overdue`);
/// false when the cache already holds the latest *available* bar - a
/// weekend/holiday/pre-close gap, or genuinely caught up - so the caller
/// should just bump the TTL to the next boundary without hitting the
/// network.
///
/// This is the market-aware gate for getCandles' "do I need to fetch?"
/// decision. It deliberately shares `candleFreshness`'s availability
/// math so the fetch decision and the lag report cannot disagree. A
/// naive `last_cached + 1 >= today` calendar check would skip the fetch
/// for a just-closed session whose bar is due (last_cached == yesterday)
/// while `candleFreshness` simultaneously flagged it `.lagging`, freezing
/// the cache on the stale bar until the next boundary. Pure given
/// `now_s`, so it is fully deterministic for tests.
pub fn shouldRefresh(now_s: i64, kind: InstrumentKind, last_cached: Date) bool {
return candleFreshness(now_s, kind, last_cached) != .current;
}
/// Expiry to stamp on candle meta after an *incremental* fetch on a /// Expiry to stamp on candle meta after an *incremental* fetch on a
/// stale entry returned zero new bars. Maps `candleFreshness` to a /// stale entry returned zero new bars. Maps `candleFreshness` to a
/// boundary: a `.lagging` bar retries soon (`short_retry_s`); `.current` /// boundary: a `.lagging` bar retries soon (`short_retry_s`); `.current`
@ -757,41 +737,6 @@ test "candleFreshness mutual_fund: late NAV is lagging" {
try testing.expectEqual(CandleFreshness.current, candleFreshness(now, .mutual_fund, Date.fromYmd(2025, 6, 16))); try testing.expectEqual(CandleFreshness.current, candleFreshness(now, .mutual_fund, Date.fromYmd(2025, 6, 16)));
} }
test "shouldRefresh equity: just-closed session with only yesterday's bar -> refresh" {
// Fri 2025-06-13, 17:00 ET: Friday's bar is due (past the 16:55
// boundary) but the cache only holds Thursday 06-12. The old naive
// `last_cached + 1 >= today` check skipped this fetch and froze the
// cache until Monday; shouldRefresh must say yes.
const now = etLocalToUtc(Date.fromYmd(2025, 6, 13), 17 * std.time.s_per_hour);
try testing.expect(shouldRefresh(now, .equity, Date.fromYmd(2025, 6, 12)));
}
test "shouldRefresh equity: already holding the just-closed bar -> no refresh" {
const now = etLocalToUtc(Date.fromYmd(2025, 6, 13), 17 * std.time.s_per_hour);
try testing.expect(!shouldRefresh(now, .equity, Date.fromYmd(2025, 6, 13)));
}
test "shouldRefresh equity: pre-close, yesterday's bar is still the latest -> no refresh" {
// Fri 2025-06-13, 10:00 ET: before the 16:55 boundary, Thursday's bar
// is still the latest available. (The case the old check got right.)
const now = etLocalToUtc(Date.fromYmd(2025, 6, 13), 10 * std.time.s_per_hour);
try testing.expect(!shouldRefresh(now, .equity, Date.fromYmd(2025, 6, 12)));
}
test "shouldRefresh equity: weekend gap holding Friday's bar -> no refresh" {
// Sun 2025-06-15: nothing newer than Friday is due over the weekend,
// so no wasteful fetch (the old calendar check would have fetched).
const now = etLocalToUtc(Date.fromYmd(2025, 6, 15), 12 * std.time.s_per_hour);
try testing.expect(!shouldRefresh(now, .equity, Date.fromYmd(2025, 6, 13)));
}
test "shouldRefresh mutual_fund: weekend morning holding latest NAV -> no refresh" {
// Sat 2025-06-14, 05:00 ET: Friday's NAV (data_date 06-13) posted this
// morning; holding it means nothing newer is due.
const now = etLocalToUtc(Date.fromYmd(2025, 6, 14), 5 * std.time.s_per_hour);
try testing.expect(!shouldRefresh(now, .mutual_fund, Date.fromYmd(2025, 6, 13)));
}
test "fmtClockET: 12-hour rendering with EST/EDT and AM/PM" { test "fmtClockET: 12-hour rendering with EST/EDT and AM/PM" {
var buf: [16]u8 = undefined; var buf: [16]u8 = undefined;
// Afternoon (EST, winter): 14:34 ET. // Afternoon (EST, winter): 14:34 ET.

View file

@ -809,18 +809,8 @@ pub const DataService = struct {
// this stale path (next post-close / NAV-availability time). // this stale path (next post-close / NAV-availability time).
const expires = market.nextCandleExpiry(now_s, kind); const expires = market.nextCandleExpiry(now_s, kind);
// Only skip the fetch when we already hold the latest // If last cached date is today or later, just refresh the TTL (meta only)
// *available* bar (weekend/holiday/pre-close gap, or if (!fetch_from.lessThan(today)) {
// caught up): just bump the TTL. Gating on the
// market-aware `shouldRefresh` -- not a naive
// `last_cached + 1 >= today` calendar check -- keeps this
// decision consistent with the `candleFreshness` lag
// report. The old calendar check skipped the fetch when
// `last_date` was merely yesterday, so a just-closed
// session's due bar got cached as "fresh" until the next
// boundary while the lag check reported it lagging (the
// Friday-17:00 deadlock that exited 75 every retry).
if (!market.shouldRefresh(now_s, kind, m.last_date)) {
s.updateCandleMeta(symbol, m.last_close, m.last_date, m.provider, m.fail_count, expires); s.updateCandleMeta(symbol, m.last_close, m.last_date, m.provider, m.fail_count, expires);
if (s.read(self.allocator, Candle, symbol, null, .any)) |r| if (s.read(self.allocator, Candle, symbol, null, .any)) |r|
return .{ .data = r.data, .source = .cached, .timestamp = std.Io.Timestamp.now(self.io, .real).toSeconds(), .allocator = self.allocator }; return .{ .data = r.data, .source = .cached, .timestamp = std.Io.Timestamp.now(self.io, .real).toSeconds(), .allocator = self.allocator };
@ -944,38 +934,10 @@ pub const DataService = struct {
return self.fetchCached(OptionsChain, symbol, null, opts); return self.fetchCached(OptionsChain, symbol, null, opts);
} }
/// Days after an earnings report date during which a still-missing
/// `actual` is worth chasing with a re-fetch. Past this window the
/// gap is treated as permanent (FMP won't backfill it; earnings has
/// no secondary source), so the cache is honored until its TTL.
const earnings_actual_chase_days: i32 = 14;
/// Whether a fresh-in-cache earnings set warrants a re-fetch: true
/// when an event whose report date has arrived (date <= today) is
/// still missing its `actual` AND the report is recent enough
/// (within `window_days`) that the actual could still post.
///
/// Earnings has no cross-provider backfill (unlike dividends/splits,
/// which Tiingo supplements via the candle fetch), so a missing
/// actual only arrives through a later FMP fetch. Without the recency
/// bound a permanently-incomplete past row -- e.g. SPY's 2005-2006
/// estimate-only rows FMP never backfills -- forces a re-fetch every
/// run. The 30-day TTL backstops any actual slower than the window.
fn earningsNeedsRefresh(events: []const EarningsEvent, today: Date, window_days: i32) bool {
for (events) |ev| {
if (ev.actual == null and !today.lessThan(ev.date) and today.days - ev.date.days <= window_days) {
return true;
}
}
return false;
}
/// Fetch earnings history for a symbol. /// Fetch earnings history for a symbol.
/// Checks cache first; fetches from FMP if stale/missing. /// Checks cache first; fetches from FMP if stale/missing.
/// Smart refresh: even if cache is fresh, re-fetches when a *recent* /// Smart refresh: even if cache is fresh, re-fetches when a past earnings
/// past earnings date (within `earnings_actual_chase_days`) still has /// date has no actual results yet (i.e. results just came out).
/// no actual yet (results just came out). Older gaps are treated as
/// permanent and honored until TTL -- see `earningsNeedsRefresh`.
/// ///
/// `opts.skip_network = true` -> returns cached data even if stale, /// `opts.skip_network = true` -> returns cached data even if stale,
/// returns FetchFailed on cache miss without touching the network. /// returns FetchFailed on cache miss without touching the network.
@ -991,12 +953,12 @@ pub const DataService = struct {
if (!opts.force_refresh) { if (!opts.force_refresh) {
if (s.read(self.allocator, EarningsEvent, symbol, earningsPostProcess, .fresh_only)) |cached| { if (s.read(self.allocator, EarningsEvent, symbol, earningsPostProcess, .fresh_only)) |cached| {
// Re-fetch only when a recent report (within the chase // Check if any past/today earnings event is still missing actual results.
// window) is still missing its actual; older gaps never // If so, the announcement likely just happened - force a refresh.
// backfill, so honor the cache. Suppressed under // (Suppressed when opts.skip_network - offline mode never refetches.)
// skip_network (offline mode never refetches). const needs_refresh = if (opts.skip_network) false else for (cached.data) |ev| {
const needs_refresh = !opts.skip_network and if (ev.actual == null and !today.lessThan(ev.date)) break true;
earningsNeedsRefresh(cached.data, today, earnings_actual_chase_days); } else false;
if (!needs_refresh) { if (!needs_refresh) {
log.debug("{s}: earnings fresh in local cache", .{symbol}); log.debug("{s}: earnings fresh in local cache", .{symbol});
@ -4410,47 +4372,3 @@ test "getEtfProfile: carries holding CUSIP through the model boundary" {
try std.testing.expectEqualStrings("999999999", holdings[0].cusip orelse return error.NoCusip); try std.testing.expectEqualStrings("999999999", holdings[0].cusip orelse return error.NoCusip);
try std.testing.expect(holdings[0].symbol == null); // filing had no ticker try std.testing.expect(holdings[0].symbol == null); // filing had no ticker
} }
test "earningsNeedsRefresh: recent missing actual triggers a re-fetch" {
const today = Date.fromYmd(2026, 6, 26);
const events = [_]EarningsEvent{
.{ .date = Date.fromYmd(2026, 6, 20), .estimate = 1.0 }, // 6 days ago, actual not posted yet
};
try std.testing.expect(DataService.earningsNeedsRefresh(&events, today, 14));
}
test "earningsNeedsRefresh: stale missing actual is NOT chased (the SPY case)" {
const today = Date.fromYmd(2026, 6, 26);
const events = [_]EarningsEvent{
.{ .date = Date.fromYmd(2006, 5, 15), .estimate = 2.11 }, // ~20y old, FMP never backfills
.{ .date = Date.fromYmd(2005, 2, 15), .actual = 1.81 },
};
try std.testing.expect(!DataService.earningsNeedsRefresh(&events, today, 14));
}
test "earningsNeedsRefresh: all actuals present -> no re-fetch" {
const today = Date.fromYmd(2026, 6, 26);
const events = [_]EarningsEvent{
.{ .date = Date.fromYmd(2026, 5, 20), .actual = 1.5 },
.{ .date = Date.fromYmd(2026, 2, 20), .actual = 1.2 },
};
try std.testing.expect(!DataService.earningsNeedsRefresh(&events, today, 14));
}
test "earningsNeedsRefresh: upcoming event without actual does not trigger" {
const today = Date.fromYmd(2026, 6, 26);
const events = [_]EarningsEvent{
.{ .date = Date.fromYmd(2026, 8, 26), .estimate = 2.0 }, // future report, no actual yet
};
try std.testing.expect(!DataService.earningsNeedsRefresh(&events, today, 14));
}
test "earningsNeedsRefresh: chase window is inclusive at the boundary" {
const today = Date.fromYmd(2026, 6, 26);
// Exactly 14 days ago -> still chased.
const at_window = [_]EarningsEvent{.{ .date = Date.fromYmd(2026, 6, 12), .estimate = 1.0 }};
try std.testing.expect(DataService.earningsNeedsRefresh(&at_window, today, 14));
// 15 days ago -> past the window, left alone.
const past_window = [_]EarningsEvent{.{ .date = Date.fromYmd(2026, 6, 11), .estimate = 1.0 }};
try std.testing.expect(!DataService.earningsNeedsRefresh(&past_window, today, 14));
}

View file

@ -1279,18 +1279,6 @@ fn appendSwrTable(
.style = th.mutedStyle(), .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 { fn appendEventSummary(lines: *std.ArrayListUnmanaged(StyledLine), as_of: zfin.Date, arena: std.mem.Allocator, th: theme.Theme, pctx: view.ProjectionContext) !void {

View file

@ -174,12 +174,6 @@ pub const ProjectionContext = struct {
/// Drives the header note ("As-of: ... (snapshot)" vs "(imported)") /// Drives the header note ("As-of: ... (snapshot)" vs "(imported)")
/// so users know how literal the bands are. /// so users know how literal the bands are.
as_of_source: AsOfSource = .live, 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 }; pub const AsOfSource = enum { live, snapshot, imported };
@ -341,7 +335,6 @@ pub fn buildProjectionContext(
config.annual_contribution, config.annual_contribution,
config.contribution_inflation_adjusted, config.contribution_inflation_adjusted,
sim_expense_ratio, sim_expense_ratio,
config.spending_real_change orelse 0,
); );
// Accumulation-phase stats: extract portfolio value at the // Accumulation-phase stats: extract portfolio value at the
@ -390,7 +383,6 @@ pub fn buildProjectionContext(
events, events,
config.max_accumulation_years, config.max_accumulation_years,
sim_expense_ratio, sim_expense_ratio,
config.spending_real_change orelse 0,
); );
} }
} }
@ -461,28 +453,6 @@ 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 .{ return .{
.comparison = comparison, .comparison = comparison,
.config = config, .config = config,
@ -494,7 +464,6 @@ pub fn buildProjectionContext(
.accumulation = accumulation_stats, .accumulation = accumulation_stats,
.earliest = earliest, .earliest = earliest,
.inputs = inputs, .inputs = inputs,
.spending_trough = spending_trough,
}; };
} }