zfin/docs/explanation/projections-model.md

279 lines
14 KiB
Markdown

# The retirement projection model
[`zfin projections`](../reference/cli/projections.md) simulates your
retirement portfolio against real market history. This page explains
the model so you can trust -- and correctly distrust -- its output. For
how to configure it, see the
[`projections.srf` reference](../reference/config/projections-srf.md)
and [Plan for retirement](../guides/plan-retirement.md).
## Historical simulation, not a formula
Rather than assume a single average return, zfin replays your portfolio
through actual historical sequences drawn from the **Shiller dataset**
(US equity total returns and CPI back to 1871). Each simulated run uses
a real historical path of returns and inflation, so the spread of
outcomes reflects real sequences -- including bad-timing sequences like
retiring into 1929, 1973, or 2000. This is the same family of method
as FIRECalc.
## Two phases
Every projection runs the same two phases in order:
1. **Accumulation** -- contributions added each year, no spending. Its
length comes from your retirement-date input. With no input, it's
zero years (an already-retired view).
2. **Distribution** -- annual spending withdrawn (CPI-adjusted by
default), no contributions. Its length is the configured `horizon`.
[Life events](../reference/config/projections-srf.md#event-fields)
(Social Security, pensions, tuition, healthcare) adjust the cash flow
in both phases.
## How inflation is handled
Inflation isn't a fixed assumption. Each historical cycle uses that
start year's **actual CPI sequence** alongside its actual returns (the
Shiller dataset carries both), so a cycle beginning in 1966 replays
1966's stagflation while one beginning in 2009 replays low-inflation
years.
The simulation runs in **nominal dollars**, which means the output
mixes two units -- and knowing which is which is the difference between
a sensible plan and a badly misread one:
- **Flows are entered in today's dollars and inflated forward.** Your
`annual_contribution`, `target_spending`, and inflation-adjusted life
events are amounts in *today's* dollars; each simulated year the model
multiplies them by that cycle's cumulative CPI, holding their
purchasing power constant. Set `contribution_inflation_adjusted`,
`target_spending_inflation_adjusted`, or an event's
`inflation_adjusted` to `false` to pin a flow at a flat nominal amount
instead (e.g. a fixed pension with no COLA).
- **Safe-withdrawal figures are in today's dollars.** "You could spend
~$264k/yr at 99%" means ~$264k of *today's* purchasing power, with the
actual dollar amount rising each retirement year to keep pace with
inflation.
- **Portfolio and terminal values are nominal (future dollars).** The
"Median portfolio at retirement" and the
`Terminal Portfolio Value (nominal, ...)` percentiles are **not**
inflation-adjusted. A ~$244M median balance 50 years out is heavily
inflated dollars, not $244M of today's purchasing power -- judge it
against the inflated spending it has to support, never against today's
prices.
This split is deliberate and matches FIRECalc: you plan spending in real
(today's) terms while the balance compounds in nominal terms.
## Percentile bands
Across all the historical runs, zfin reports the distribution of
outcomes rather than a single number:
- **p10 (pessimistic)** -- only 10% of histories did worse.
- **p50 (median)** -- the middle outcome.
- **p90 (optimistic)** -- only 10% did better.
```
Terminal Portfolio Value (nominal, at 99% withdrawal rate)
25 Year 35 Year
Pessimistic (p10) $6,739,560.02 $11,597,557.94
Median (p50) $30,023,255.68 $66,794,741.87
Optimistic (p90) $103,184,321.05 $279,372,182.75
```
The wide spread is the point: it shows sequence-of-returns risk
honestly instead of hiding it behind an average.
## Confidence and safe withdrawal
The **Safe Withdrawal** table answers "how much could I spend and still
not run out?" at chosen confidence levels (90/95/99%). A 99% safe
withdrawal is the spending level that survived 99% of historical
sequences over that horizon -- the most conservative. Higher confidence
and longer horizons both lower the safe number.
## The earliest-retirement search
When you set a `target_spending` instead of a date, zfin inverts the
question: for each (horizon x confidence) cell it searches for the
**earliest** accumulation length (up to `max_accumulation_years`, 50
years by default) that sustains your spending, and renders the grid of
answers. One cell is promoted to the
headline (see
[promotion rules](../reference/config/projections-srf.md#the-two-retirement-planning-inputs)).
If no length within the cap works, the cell is **infeasible** -- shown
honestly rather than fudged. A young saver with a runway longer than 50
years can raise the cap via
[`max_accumulation_years`](../reference/config/projections-srf.md#config-fields).
## The caveat that matters most
zfin states this loudly by design, and so does this page:
> The actuals overlay and evaluation views
> (`--overlay-actuals`, `--convergence`, `--return-backtest`) tell you
> whether the model was **directionally honest** -- did your real
> trajectory fall within the bands it would have drawn. They do **not**
> tell you whether a safe-withdrawal claim is **accurate**. An SWR
> claim is a 30-year claim; there is at most ~12 years of weekly
> history and a year or two of native snapshots to check it against.
> No one will have data to validate a full-retirement SWR within our
> lifetimes.
Treat the projection as a disciplined way to compare scenarios and
visualize sequence risk -- not as a promise about your specific future.
## Parity with FIRECalc
Because zfin re-implements the FIRECalc method over the same Shiller
dataset, its numbers should -- and do -- **track
[FIRECalc.com](https://firecalc.com/) closely, while running
systematically a little more optimistic**. This section records the
evidence behind that claim from a June 2026 audit, so "tracks closely"
isn't just an assertion. The cross-checks are pinned as a regression
suite (`FIRECalc parity: ...` tests in `analytics/projections.zig`).
### Method
FIRECalc 3.0 was driven directly through its web form (the same
1871-2025 Shiller span zfin embeds, "data thru 1/1/2026"). For an
apples-to-apples comparison, FIRECalc's **expense ratio was set to 0%**
(matching zfin with `expense_ratio:num:0`) and its fixed-income model
left at the default "Long Interest" (10-year Treasury). Zeroing the fee
on both sides removes it as a variable so the references below isolate
the one structural difference, the equity return series. zfin uses the
Treasury *yield* for bonds rather than a bond-price series, which is why
the comparisons hold the allocation at familiar blends.
### What matches, and by how much
Safe-withdrawal dollars (today's dollars, FIRECalc fee=0), the headline
"how much can I spend" number:
| Scenario (portfolio / alloc / horizon / confidence) | FIRECalc | zfin | Δ |
|-----------------------------------------------------|---------:|---------:|------:|
| $1M / 100% / 30y / 95% | $39,697 | $42,717 | +7.6% |
| $1M / 75-25 / 30y / 95% | $41,221 | $44,036 | +6.8% |
| $1M / 100% / 45y / 95% | $35,835 | $37,906 | +5.8% |
| $1M / 100% / 20y / 95% | $45,879 | $49,660 | +8.2% |
| $1M / 100% / 30y / 90% | $43,804 | $47,138 | +7.6% |
| $1M / 100% / 30y / 99% | $35,864 | $38,098 | +6.2% |
| $7.7M / 100% / 45y / 99% | $254,461 | $275,724 | +8.4% |
| $7.7M / 82% / 45y / 99% | $262,770 | $286,314 | +9.0% |
Success rate ($1M, $40k/yr, 30yr, fee=0): FIRECalc 94.4% vs zfin 97.6%
(100% stock); FIRECalc 96.8% vs zfin 99.2% (75/25) -- zfin ~+2-3pp.
Terminal portfolio value (same scenario, **nominal** dollars): median
FIRECalc $5.12M vs zfin $5.71M (+11%); p90 $12.76M vs $14.75M (+16%).
One contributions (accumulation-phase) cross-check -- $500k start,
$30k/yr added for 10 years, then 30-year drawdown, 95% -- lands at
FIRECalc $55,154 vs zfin $53,468 (-3.1%), the one case where zfin came
out *lower* (more conservative). That flip is a real modeling
difference in how the two tools treat the accumulation phase, not
noise -- see "Accumulation phase" below.
### Why zfin runs a little hot: methodology, not a bug
The divergence was isolated with a **$0-spending, 100%-stock** run,
which removes withdrawals, withdrawal timing, fees, and bonds from the
picture entirely. For the 1966 cohort, zfin's year-30 *nominal* balance
is **$20.24M vs FIRECalc's $18.60M** -- a ratio of 0.919 over 30 years,
i.e. FIRECalc's equity returns compound about **0.2-0.3%/yr lower** than
zfin's. That is the entire discrepancy: the gap is in the **equity
total-return series**, not the withdrawal logic.
The reason zfin is higher is that **zfin uses the gold-standard
construction and FIRECalc uses a coarser one**:
- **zfin** reconstructs each year's nominal total return directly from
Shiller's **Real Total Return Price** index -- the canonical
academic S&P total-return series, in which dividends are reinvested
**monthly** -- times that year's CPI change (see
`build/gen_shiller.zig`). The reconstruction recovers Shiller's
published nominal total return exactly.
- **FIRECalc** computes "market growth + dividends" in the lineage of
the 1998 Trinity Study and John Greaney's *Retire Early* spreadsheet
(FIRECalc's own
[methodology page](https://www.firecalc.com/intro.php) describes
this). That construction reinvests dividends more coarsely (annually,
in effect), which **systematically understates compounding** by
roughly a quarter-percent a year versus the monthly-reinvested index.
So zfin's equity returns are slightly higher **because they are more
accurate** -- monthly dividend reinvestment is what actually happened.
Over 30-45 year horizons that ~0.25%/yr compounds into the +6-9%
safe-withdrawal gap, and the worst cohorts (which set the
safe-withdrawal floor) diverge most because small per-year differences
explode near the failure boundary. FIRECalc's own FAQ concedes the
point -- it notes that implementations differ on exactly these details
and "all of the studies converge on the same basic results."
**Honest caveat (cuts the other way):** a more accurate *historical*
return series does not make the *forecast* more accurate -- nobody can
predict your future returns. It only means zfin replays history with
better-constructed inputs. If you specifically want to reproduce
FIRECalc's output, expect zfin to read a few percent higher for this
reason, by design.
### Other differences
- **Terminal values: nominal vs real.** FIRECalc's *on-screen* ending
balances are **real** (start-of-retirement dollars); zfin's terminal
bands are **nominal**. (FIRECalc's spreadsheet *export* is nominal,
which is what the terminal-value table above compares against.) Don't
compare zfin's nominal terminal bands to FIRECalc's on-screen ending
range without deflating one of them first.
- **Accumulation phase: zfin models it through history, FIRECalc
doesn't.** For runs with a pre-retirement contribution phase, the
two tools differ by design. FIRECalc always reports "N possible
*<distribution>* year periods" -- e.g. 126 thirty-year periods --
*regardless of accumulation length* (verified at 0, 10, and 25
years of accumulation, all 126). Since a 1871 distribution start
with 25 years of accumulation would need 1846-1870 data that
predates the dataset, FIRECalc cannot be replaying history during
accumulation: it grows the pre-retirement portfolio
**deterministically** and only Monte-Carlos the distribution. zfin
instead runs the **full accumulation + distribution span through one
continuous historical sequence** (116 cohorts for a 10+30yr run), so
it also captures **accumulation-phase sequence-of-returns risk** -- a
bad market in the years just before retirement. That extra realism
is why zfin reads slightly *lower* (more conservative) on
contribution scenarios. It's a deliberate fidelity gain, not a
discrepancy to reconcile.
### The bottom line
Treat zfin's safe-withdrawal numbers as **tracking FIRECalc within
roughly +6-9%, in the optimistic direction** -- a pure equity-engine
gap that holds with fees matched (both tools default to a 0.18% fee).
If you want a FIRECalc-conservative read, mentally haircut zfin's
safe-spending figure by ~5-10%. The parity
suite asserts zfin stays within -3% / +15% of these references, so a
future engine change that drifts materially further -- in either
direction -- trips a test.
## Assumptions to keep in mind
- **Allocation** is a single stock/bond blend (`target_stock_pct`), not
your exact holdings.
- **Inflation** comes from each historical cycle's own CPI; flows are
real (today's-dollar) and balances are nominal. See
[How inflation is handled](#how-inflation-is-handled).
- **Taxes** are not modeled. Withdrawal figures are pre-tax.
- **Fees** are modeled as a flat annual expense-ratio drag, defaulting
to 0.18% (configurable via
[`expense_ratio`](../reference/config/projections-srf.md)); set it to
your portfolio's blended ratio.
- **Imported-value overlays** scale today's allocation to a historical
total when lot-level history isn't available, because a `liquid::`
row can't reconstruct past composition.
## See also
- [Plan for retirement](../guides/plan-retirement.md) -- the guided walkthrough.
- [`projections.srf` reference](../reference/config/projections-srf.md) -- every input.
- [`zfin projections`](../reference/cli/projections.md) -- flags and evaluation views.