14 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. Repeat the line for multiple horizons. |
horizon_age |
num | Horizon expressed as an age; resolves to target_age - oldest_current_age. 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. |
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.
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 by
walking horizons longest-to-shortest at 99% confidence, preferring the
longest horizon 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).