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

368 lines
20 KiB
Markdown

# `projections.srf` reference
`projections.srf` configures [`zfin projections`](../cli/projections.md):
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](../../explanation/projections-model.md);
for a guided setup see [Plan for retirement](../../guides/plan-retirement.md).
## 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). |
```srf
#!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](#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](#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](#declining-spending-the-smile). |
| `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](#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](../../explanation/projections-model.md#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](https://ec.europa.eu/eurostat/statistics-explained/index.php?title=Glossary:Equivalised_disposable_income)".
- **Financial-planning convention** is gentler, ~80% (a 20% cut);
e.g. Kiplinger, ["Five Financial Changes That Happen When Your
Spouse Dies"](https://www.kiplinger.com/retirement/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:
```srf
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:
```srf
# 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). |
```srf
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. |
```srf
# 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:
```srf
# 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` |
```bash
ZFIN_HOME=examples/pre-retirement-both zfin projections
```
## See also
- [Plan for retirement](../../guides/plan-retirement.md) -- guided setup.
- [The retirement projection model](../../explanation/projections-model.md) -- how the simulation works.
- [`zfin projections`](../cli/projections.md) -- command flags (`--as-of`, `--overlay-actuals`, `--convergence`, `--return-backtest`).
---
[Documentation home](../../README.md#reference)