20 KiB
projections.srf reference
projections.srf configures zfin projections:
the Monte-Carlo-style retirement simulation run over the Shiller
dataset (1871-present). zfin loads it from the same directory as the
portfolio. It is optional -- without it, the command runs with
sensible defaults (20/30/45-year horizons, 90/95/99% confidence,
no accumulation phase).
This page is the field reference. For how the simulation actually works see The retirement projection model; for a guided setup see Plan for retirement.
Record types
Every line starts with a type:: discriminator:
type:: |
Purpose |
|---|---|
config |
A single configuration field (allocation, horizon, retirement target, contributions, spending). |
birthdate |
A household member's birthdate (drives ages, horizon_age, retirement_age, life-event timing). |
event |
A life event: recurring income or expense (Social Security, pension, tuition, healthcare). |
#!srfv1
type::config,target_stock_pct:num:80
type::config,horizon:num:25
type::config,horizon_age:num:95
type::birthdate,date::1981-04-12
type::event,name::Social Security,start_age:num:70,amount:num:38400
config fields
| Field | Type | Description |
|---|---|---|
target_stock_pct |
num | Asset-allocation target (0-100). Sets the simulation's stock/bond blend. |
expense_ratio |
num | Annual fund expense ratio as a percent (e.g. 0.18 = 0.18%), subtracted from the blended return each year. Default 0.18 (FIRECalc's default; realistic for a fund portfolio). Override down (0.04) for low-cost index funds, up for active funds, or 0 for all individual stocks. |
return_cap |
num | Optional ceiling, as a percent (e.g. 30 = 30%), on each position's conservative trailing return before it is weighted into the displayed Projected return. Default: none. See Capping outlier returns. |
horizon |
num | Distribution-phase length in years (a fixed horizon, no mortality modeling). Repeat the line for multiple horizons. |
horizon_age |
num | Horizon expressed as an age of death. The distribution runs until the last surviving member reaches this age; the column also models the survivor spending step-down and per-person income/expense termination at death. See Planning to an age of death. Repeatable. |
retirement_age |
num | Age the oldest configured person must reach to retire. |
retirement_at |
date | Absolute retirement date (YYYY-MM-DD). Wins over retirement_age if both set. |
annual_contribution |
num | Yearly accumulation-phase contribution, in today's dollars. |
contribution_inflation_adjusted |
bool | If true (default), contributions grow with CPI year over year. |
target_spending |
num | Desired retirement spending, in today's dollars. |
target_spending_inflation_adjusted |
bool | If true (default), target spending grows with CPI during distribution. |
spending_change |
num | Signed annual real change in spending across the distribution phase, as a whole percent. Negative = declining (e.g. -2 = -2%/yr, the "spending smile"); positive = rising. Default: absent = flat real spending. Magnitude clamped to 10%/yr. See Declining spending. |
survivor_spending_pct |
num | Percent of the couple's joint spending the surviving spouse needs after the first death, for horizon_age columns. Default 75 (a 25% reduction). Only applies to a multi-person household with an age gap. See Planning to an age of death. |
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. |
Choosing an expense_ratio
The expense ratio is the annual fund-fee drag on the portfolio. zfin
defaults to 0.18% -- the same figure FIRECalc uses, and a
realistic (mildly conservative) assumption for a portfolio that holds
funds. Modeling no fee is less accurate and makes the projection too
optimistic, so 0 is not the default; it's an explicit choice for an
all-individual-stock portfolio.
The right number is portfolio-specific, so override it with yours. zfin can't infer it automatically: the fund-profile provider doesn't return expense ratios, and the only free source that does (Yahoo) is accurate for ETFs but wrong for mutual funds -- so the default is a sensible constant rather than a derived value.
Reference points for picking yours:
| Portfolio style | Typical blended ER |
|---|---|
| Low-cost index (VTI/VOO/BND, 3-fund) | ~0.03-0.06% |
| Target-date index funds | ~0.08-0.15% |
| FIRECalc's default | 0.18% |
| Mix with active / specialty funds | ~0.20-0.50% |
| Heavy active management | 0.50-1.00%+ |
To estimate your own, take each fund's published ER (the fund company's
page, or Yahoo Finance for ETFs), weight by market value, and sum;
individual stocks, bonds, and cash contribute ~0. Set the result once:
type::config,expense_ratio:num:0.18. See
Parity with FIRECalc
for how the fee interacts with the rest of the model.
Declining spending (the smile)
By default the simulation holds spending flat in real terms - the same
inflation-adjusted dollars every year. Real retirees don't behave that
way: spending tends to taper as people age (David Blanchett's
"spending smile"). Set spending_change to model that drift:
type::config,spending_change:num:-2
is a 2%/yr real decline. The value is a whole percent, and the
sign is the direction: negative declines, positive rises. Absent
(the default) means flat. The magnitude is clamped to 10%/yr - a
larger value is almost always a units typo (entering a fraction like
0.02 where a percent was meant).
How it interacts with the rest of the model:
- The safe-withdrawal numbers become the first distribution year's spend; each later year is scaled by the drift. Declining spending therefore raises the safe first-year withdrawal (you spend less later, so you can afford more now); rising spending lowers it.
- The drift is a straight line. The Blanchett smile's late-life
upturn (healthcare) is not baked in - model it separately as a
type::eventexpense (see below). Composing a decliningspending_changewith 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.
Planning to an age of death
A plain horizon is a fixed number of distribution years. A
horizon_age instead anchors the horizon to an age of death, and
turns on the mortality model:
type::config,horizon_age:num:95
type::birthdate,date::1962-03-01
type::birthdate,date::1967-08-15,person:num:2
This says "plan until we reach age 95." Three things follow:
-
Last-survivor horizon. The money must last until the youngest member reaches the age of death (they reach it latest in calendar time), not the oldest. For a couple this is the standard financial-planning treatment: a household's assets must fund the period for as long as either spouse is alive. (Anchoring on the oldest would truncate the plan at the first death and silently under-fund the survivor's remaining years.) A
horizon_agerequires at least onebirthdate. -
The horizon is coupled to the retirement date. Distribution length =
age_of_death - retirement_age. Withtarget_spendingset, the earliest-retirement search shrinks the distribution as it pushes the retirement date later (the death date is fixed), so the column header readsto age 95rather than a fixed year count. -
Per-person income and expenses end at death. Each person's Social Security, pension, wages, and their own late-life expenses (entered as
type::eventwith thatperson) stop the year that person reaches the age of death - a deceased spouse no longer collects their own benefit.
Survivor spending (survivor_spending_pct)
In a couple with an age gap, the first death (the oldest reaching the
age of death) steps household spending down to a surviving-spouse level
for the remaining years. survivor_spending_pct is the percent of the
couple's joint spending the survivor needs:
type::config,survivor_spending_pct:num:75
The default is 75 (a 25% reduction). A surviving spouse needs less than the couple (one fewer person) but far more than half, because most costs - housing, property tax, utilities, insurance - are shared and don't fall when one person dies. The default sits at the conservative edge of the standard range:
- OECD equivalence scales, the standard economic adjustment for household size: the OECD-modified scale (1.0 for the first adult, 0.5 for the second) implies a survivor needs ~67% (a 33% cut); the square-root scale (needs proportional to sqrt of household size) implies ~71% (a 29% cut). See Eurostat, "Equivalised disposable income".
- Financial-planning convention is gentler, ~80% (a 20% cut); e.g. Kiplinger, "Five Financial Changes That Happen When Your Spouse Dies" (2024), which also warns that the right number is individual - a survivor's spending can even rise.
- The income side falls much harder than the need side, which is why this is about need, not income: the Chicago Fed (Fadlon, Ramnath & Tong, Chicago Fed Letter No. 438, 2020) found household income drops ~37% at widowhood but, after adjusting for the smaller household, the standard-of-living decline was only ~11%.
The value is configurable because it is highly individual; 75% is a
defensible, slightly-conservative starting point. Any value >= 0 is
honored (including above 100, for a survivor whose spending rises).
Modeling survivor benefits
Capping a deceased person's events at their death sets them to zero, which is correct for benefits that die with the holder but not for those that partly continue. Model the continuing portion as an event tied to the surviving person:
- A pension with a 50% survivor benefit: enter the survivor's 50% as a separate event on the surviving spouse.
- Social Security's "keep the higher" rule (the survivor keeps the larger of the two benefits): if the higher earner dies first, add a survivor-benefit event on the survivor starting at the first-death age. Otherwise the default reads slightly conservative for that household.
Staggered retirement (one spouse retires earlier)
zfin models a single household retirement boundary: the accumulation phase ends and the distribution phase begins at one date. To model one spouse retiring before the other:
-
If the early retirement has effectively already happened (one spouse is no longer contributing), just set
annual_contributionto the remaining (solo) saver's amount. It applies across the whole accumulation phase, which is exactly what you want. -
For a future step-down (both still working, retiring at different dates), add a negative-contribution event at the early retiree's date so the yearly inflow drops then:
type::event,name::Stop saving (A),start_age:num:62,person:num:1,amount:num:-20000During accumulation a life event adjusts the portfolio's yearly inflow, so a negative amount models the reduced saving directly.
Caveat: during accumulation, life-event income (a positive amount)
is added to the portfolio - modeled as saved, not consumed. So
model an early retiree whose Social Security or pension is spent by
lowering annual_contribution, not by adding a positive income event
(which would inflate savings). Reserve positive accumulation-phase
events for income that genuinely gets invested.
Capping outlier returns
The Projected return shown by zfin projections (and the "Projected
return:" row in zfin compare) is a conservative, market-value-weighted
blend of each position's MIN(3Y, 5Y, 10Y) annualized trailing return.
A single position that has run hot recently -- NVDA is the canonical
example -- can carry a multi-hundred-percent trailing return that drags
the whole estimate up to a level no one would forecast forward.
return_cap clamps each position's contribution to a ceiling you pick,
in percent:
# No single position contributes more than 30%/yr to the estimate
type::config,return_cap:num:30
With this set, NVDA's 69% trailing MIN is treated as 30% before weighting; positions already under 30% are untouched. The default is no cap, so the estimate uses the raw trailing returns.
Two things to note:
- It is a single global ceiling applied per position, not a per-symbol value. Set it to the highest forward return you find credible for any holding; every outlier above it clamps down.
- It only affects the displayed conservative Projected return. It
does not change the Monte Carlo percentile bands or the
safe-withdrawal grid -- those blend Shiller S&P/bond history by your
aggregate
target_stock_pctand never look at individual positions.
birthdate fields
| Field | Type | Description |
|---|---|---|
date |
date | Birthdate (YYYY-MM-DD). |
person |
num | Household member (1 default, 2 for a partner). |
type::birthdate,date::1981-04-12
type::birthdate,date::1983-09-08,person:num:2
event fields
Life events modify annual cash flow in both phases. Positive amounts are income (offset withdrawals); negative amounts are expenses (added to withdrawals).
| Field | Type | Description |
|---|---|---|
name |
string | Label shown in the Life Events table. |
amount |
num | Annual amount. Positive = income, negative = expense. |
start_age |
num | Age (of person) at which the event begins. |
duration |
num | Optional length in years. Omit for a permanent event. |
person |
num | Whose age start_age refers to (1 default, 2 for a partner). |
inflation_adjusted |
bool | If true (default), the amount grows with CPI. Set false for a fixed nominal amount. |
# Permanent income starting at age 70
type::event,name::Social Security,start_age:num:70,amount:num:38400
# 4-year expense starting at age 50
type::event,name::College Tuition,start_age:num:50,duration:num:4,amount:num:-55000
# A partner's pension
type::event,name::Pension,start_age:num:65,person:num:2,amount:num:24000
The two retirement-planning inputs
projections.srf answers a different question depending on which
inputs you set. This is the single most important thing to understand
about the file:
| You set... | zfin answers... | Display |
|---|---|---|
A target date (retirement_age / retirement_at) |
"Given my date, what can I spend?" | Accumulation-phase block with a dated headline. |
A target spending (target_spending) |
"Given my spending, when can I retire?" | Earliest-retirement grid; one cell is promoted to the headline. |
| Both | Both, back to back | Configured date wins the headline; grid renders below for comparison. |
| Neither | Already-retired view | "Years until possible retirement: none". |
When target_spending is set, the earliest-retirement grid shows,
for each (horizon x confidence) pair, the earliest year that sustains
the spending. The default promotion rule picks the headline cell at 99%
confidence: it prefers an age-of-death-anchored (horizon_age) column
(the latest death, if several), since that's the "plan to the last
survivor" answer; with only plain numeric horizons it walks them
longest-to-shortest, preferring the longest that keeps the oldest
person under age 100. Override it with a retirement_target annotation
on one horizon line:
# use the 35yr x 95% cell as the headline
type::config,horizon:num:35,retirement_target:num:95
At most one horizon may carry the annotation; configuring more than one
drops them all and falls back to the default rule. If the promoted cell
is infeasible (no accumulation length within max_accumulation_years
-- 50 years by default -- sustains the spending), the headline reads
"not feasible" and the grid still renders so you can pick a workable
anchor.
The example configurations
The six bundled examples are fully-configured walkthroughs of each combination:
examples/... |
Inputs |
|---|---|
pre-retirement-age |
target date only |
pre-retirement-spending |
target spending only |
pre-retirement-spending-target |
target spending + an explicit (infeasible) anchor |
pre-retirement-both |
target date + target spending |
post-retirement |
neither (distribution-only) |
post-retirement-smile |
distribution-only + declining spending_change |
ZFIN_HOME=examples/pre-retirement-both zfin projections
See also
- Plan for retirement -- guided setup.
- The retirement projection model -- how the simulation works.
zfin projections-- command flags (--as-of,--overlay-actuals,--convergence,--return-backtest).