279 lines
14 KiB
Markdown
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.
|