zfin/examples/README.md

8.3 KiB
Raw Blame History

zfin examples

Realistic-but-fictional portfolio configurations for spot-checking zfin features and showing users how the configuration files fit together. Run any zfin command against an example by setting ZFIN_HOME to its directory:

ZFIN_HOME=examples/pre-retirement-both              zfin projections
ZFIN_HOME=examples/pre-retirement-age               zfin projections
ZFIN_HOME=examples/pre-retirement-spending          zfin projections
ZFIN_HOME=examples/pre-retirement-spending-target   zfin projections
ZFIN_HOME=examples/post-retirement                  zfin projections
ZFIN_HOME=examples/post-retirement-smile            zfin projections
ZFIN_HOME=examples/pre-retirement-both              zfin --tui

All names, share counts, account numbers, prices, and life events in these examples are fictional. Do not interpret them as advice.

Available examples

The six scenarios share the same fictional couple and balance sheet (~$1.3M, age ~45, contributing $80k/yr) for the four pre-retirement variants, and a separate retired couple for the two distribution examples. Only the projections.srf configuration differs across the pre-retirement variants - making it easy to see how each retirement-planning input shapes the output.

Background: how the projection accepts input

The simulation always runs the same two-phase model: an accumulation phase (contributions in, no spending) followed by a distribution phase (spending out, no contributions). What the user configures in projections.srf decides which questions the display answers:

  • Target retirement date (retirement_age or retirement_at) - "Given my retirement date, what can I spend?" Produces the Accumulation phase block: median portfolio at retirement, p10-p90 range, and the dated headline retirement line.
  • Target spending (target_spending) - "Given my desired spending, when can I retire?" Produces the Earliest retirement grid (one cell per horizon × confidence) and promotes one cell into the Accumulation phase block as the headline.
  • Both - both blocks render back-to-back. The configured retirement date wins for the headline; the grid is the side-by-side comparison.
  • Neither - distribution-only mode. The Accumulation phase block reduces to a soft "Years until possible retirement: none" line.

pre-retirement-age/

Input: target retirement date only. retirement_age:num:65 is set, target_spending is not. Output renders:

  • Accumulation phase block - median portfolio at the configured retirement date, p10-p90 range, and the Years until possible retirement: 19 (2046-04-12, ages 65/62) line showing both partners' ages at retirement.
  • Standard Safe Withdrawal table for the configured horizons.
  • No "Earliest retirement" grid (no spending target to search against).

pre-retirement-spending/

Input: target spending only. target_spending:num:80000 is set, retirement_age/retirement_at are not. Output renders:

  • Earliest retirement grid - one cell per (horizon × confidence) showing the earliest year the household can retire and sustain $80k/yr at that confidence over that distribution horizon.
  • The Accumulation phase block is populated by promoting one cell from the grid into the headline retirement line, plus the median portfolio at retirement and p10-p90 range. The default promotion rule walks horizons longest -> shortest and picks the longest one whose end year keeps the oldest configured person under age 100, at 99% confidence (most conservative). If even the shortest horizon overshoots, it's used anyway.
  • The grid stays rendered for transparency - the user can see how the headline cell compares to the rest of the matrix.

pre-retirement-spending-target/

Same household and balance sheet as pre-retirement-spending/, but with a much higher target_spending ($2.4M/yr) AND an explicit retirement_target:num:99 annotation on the horizon_age:num:95 record. That combination demonstrates two things at once:

  • The explicit override mechanism: instead of the default promotion rule (longest horizon at 99% confidence, capped at age 100), the user explicitly anchors the headline to the resolved horizon_age:95 × 99% cell. The override survives age-resolution
    • it rides on horizon_age records too, not just horizon.
  • The "not feasible" rendering path: the annotated cell turns out to be infeasible at this spending level (no value of accumulation_years ≤ 50 sustains $2.4M/yr at 99% over a 50-year retirement). The headline line renders "Years until possible retirement: not feasible" instead of a date, and the contribution / median lines below it are suppressed. The full Earliest retirement grid still renders so the user can see which (horizon × confidence) cells DO work and pick a different anchor.

Use this variant when the user has a preferred planning anchor that's different from "longest feasible horizon at maximum conservatism." Allowed retirement_target values are 90, 95, 99. At most one horizon may carry the annotation; configuring two or more drops them all (warning logged) and falls back to the default rule.

pre-retirement-both/

Inputs: both target retirement date AND target spending. Both retirement_age and target_spending are set. Output renders both blocks back-to-back so the user can compare "what I planned" (target retirement date) against "what's earliest feasible" (target spending). The configured retirement date wins for the headline line; the Earliest retirement grid is rendered below for comparison.

post-retirement/

A retired couple, ~age 68, ~$2M total. Distribution-only mode (no target_spending, no retirement_age/retirement_at). Demonstrates the legacy projection behavior, including the soft "Years until possible retirement: none" line that confirms the model isn't projecting any pre-retirement growth.

Includes a late-life healthcare expense modeled as a life event starting at age 80, plus Social Security already in pay status. Asset allocation target: 60/40 (more bond-heavy than the pre-retirement examples).

post-retirement-smile/

The same retired couple as post-retirement/, but demonstrating the declining spending model (spending_change). Real spending is no longer held flat - it drifts down 2%/yr (spending_change:num:-2), modeling the Blanchett "spending smile": retirees spend more in the early "go-go" years and taper through the "slow-go" years.

The smile's late-life upturn is NOT baked into the model - it is composed from the existing life-event mechanism. The age-80 healthcare expense (sized as a realistic long-term-care figure) supplies the rising "no-go" limb on top of the declining base. Because that expense outweighs the base decline once it starts, the lowest-spending year lands in mid-retirement (the year just before age 80), not the final year. The Safe Withdrawal table gains a callout:

  Lowest spending: $121,235 in year 12 (2037), today's dollars

reporting the bottom of the U in today's dollars. Note the first-year safe withdrawal is higher than in the flat post-retirement/ example: spending less in later years frees up a larger draw early. (Exact figures move with the live benchmark data; the shape is the point.)

Configuration file map

Every example contains:

  • portfolio.srf - open lots, one per line. The source of truth for shares, cost basis, and account assignment.
  • accounts.srf - tax type and institution metadata for each account name referenced by portfolio.srf.
  • metadata.srf - sector / geography / asset-class classifications for each symbol.
  • projections.srf - retirement projection configuration: birthdates, target allocation, horizons, life events, and (in the pre-retirement variants) the accumulation-phase / earliest-retirement fields. This is the only file that differs across the four pre-retirement variants.

There's no watchlist.srf in either example. Add one if you're spot-checking watchlist features.

Symbol selection

The examples use symbols whose candle data is commonly cached and kept fresh by ongoing cron jobs:

  • Equity ETFs: SPY, VTI, QQQ, SCHD
  • Bond ETF: AGG

If you add a symbol not in this list, run zfin quote <SYM> once inside the example directory to populate the cache before running analytics commands.