zfin/docs/reference/config/projections-srf.md

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

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:

  1. 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_age requires at least one birthdate.

  2. The horizon is coupled to the retirement date. Distribution length = age_of_death - retirement_age. With target_spending set, the earliest-retirement search shrinks the distribution as it pushes the retirement date later (the death date is fixed), so the column header reads to age 95 rather than a fixed year count.

  3. Per-person income and expenses end at death. Each person's Social Security, pension, wages, and their own late-life expenses (entered as type::event with that person) 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_contribution to 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:-20000
    

    During 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_pct and 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


Documentation home