# `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)