zfin/docs/explanation/projections-model.md
Emil Lerch d078bc5a62
All checks were successful
Generic zig build / build (push) Successful in 4m34s
Generic zig build / publish-macos (push) Successful in 10s
Generic zig build / deploy (push) Successful in 2m12s
add fund expense ratio for projections/description of deltas vis-a-vis FireCALC
2026-06-24 09:02:57 -07:00

13 KiB

The retirement projection model

zfin projections 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 and Plan for retirement.

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 (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.

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 50 years) that sustains your spending, and renders the grid of answers. One cell is promoted to the headline (see promotion rules). If no length within the cap works, the cell is infeasible -- shown honestly rather than fudged.

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 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 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 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.
  • 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); 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