add fund expense ratio for projections/description of deltas vis-a-vis FireCALC
All checks were successful
Generic zig build / build (push) Successful in 4m34s
Generic zig build / publish-macos (push) Successful in 10s
Generic zig build / deploy (push) Successful in 2m12s

This commit is contained in:
Emil Lerch 2026-06-24 09:02:57 -07:00
parent c34e97aadd
commit d078bc5a62
Signed by: lobo
GPG key ID: A7B62D657EF764F8
7 changed files with 422 additions and 60 deletions

1
.gitignore vendored
View file

@ -10,3 +10,4 @@ coverage/
!examples/**/*.srf
scripts/
.tmp/
.gstack/

26
TODO.md
View file

@ -99,32 +99,6 @@ ranking; unlabeled items are "someday, if the mood strikes."
faithfulness one notch. Pick whichever has the highest
payoff vs. complexity when this gets revisited.
## FIRECalc parity audit (priority LOW)
`analytics/projections.zig` re-implements the FIRECalc algorithm over
the Shiller dataset (`data/shiller.zig`, 1871-present). In practice the
outputs land close to FIRECalc.com, but there is no formal cross-check,
and the user docs only claim results "track FIRECalc closely"
(`docs/guides/plan-retirement.md`). Stand up a parity audit so that
claim is backed by evidence.
Do it:
- Pick a handful of representative inputs (portfolio value, allocation,
horizon, spending, with and without contributions) and run each
through both FIRECalc.com and `zfin projections`.
- Compare success rate, safe-withdrawal dollars, and terminal-value
percentiles; record the deltas and an acceptable tolerance.
- Where they diverge, pin down why. Usual suspects: withdrawal timing
(start- vs end-of-year), inflation / CPI handling, rebalancing
assumptions, fees, and how a partial final year is treated.
There is already a single FIRECalc reference assertion in the tests
(~$305K at 99% / 45yr on $7.7M, around `projections.zig:1696`). Extend
that into a small documented parity suite rather than a lone magic
number, and note any known, accepted differences in
`docs/explanation/projections-model.md`.
## `--export-chart` follow-ups — priority LOW
V1 of `--export-chart <PATH>` shipped for `quote` and `projections`

View file

@ -122,6 +122,137 @@ zfin states this loudly by design, and so does this page:
Treat the projection as a disciplined way to compare scenarios and
visualize sequence risk -- not as a promise about your specific future.
## Parity with FIRECalc
Because zfin re-implements the FIRECalc method over the same Shiller
dataset, its numbers should -- and do -- **track
[FIRECalc.com](https://firecalc.com/) closely, while running
systematically a little more optimistic**. This section records the
evidence behind that claim from a June 2026 audit, so "tracks closely"
isn't just an assertion. The cross-checks are pinned as a regression
suite (`FIRECalc parity: ...` tests in `analytics/projections.zig`).
### Method
FIRECalc 3.0 was driven directly through its web form (the same
1871-2025 Shiller span zfin embeds, "data thru 1/1/2026"). For an
apples-to-apples comparison, FIRECalc's **expense ratio was set to 0%**
(matching zfin with `expense_ratio:num:0`) and its fixed-income model
left at the default "Long Interest" (10-year Treasury). Zeroing the fee
on both sides removes it as a variable so the references below isolate
the one structural difference, the equity return series. zfin uses the
Treasury *yield* for bonds rather than a bond-price series, which is why
the comparisons hold the allocation at familiar blends.
### What matches, and by how much
Safe-withdrawal dollars (today's dollars, FIRECalc fee=0), the headline
"how much can I spend" number:
| Scenario (portfolio / alloc / horizon / confidence) | FIRECalc | zfin | Δ |
|-----------------------------------------------------|---------:|---------:|------:|
| $1M / 100% / 30y / 95% | $39,697 | $42,717 | +7.6% |
| $1M / 75-25 / 30y / 95% | $41,221 | $44,036 | +6.8% |
| $1M / 100% / 45y / 95% | $35,835 | $37,906 | +5.8% |
| $1M / 100% / 20y / 95% | $45,879 | $49,660 | +8.2% |
| $1M / 100% / 30y / 90% | $43,804 | $47,138 | +7.6% |
| $1M / 100% / 30y / 99% | $35,864 | $38,098 | +6.2% |
| $7.7M / 100% / 45y / 99% | $254,461 | $275,724 | +8.4% |
| $7.7M / 82% / 45y / 99% | $262,770 | $286,314 | +9.0% |
Success rate ($1M, $40k/yr, 30yr, fee=0): FIRECalc 94.4% vs zfin 97.6%
(100% stock); FIRECalc 96.8% vs zfin 99.2% (75/25) -- zfin ~+2-3pp.
Terminal portfolio value (same scenario, **nominal** dollars): median
FIRECalc $5.12M vs zfin $5.71M (+11%); p90 $12.76M vs $14.75M (+16%).
One contributions (accumulation-phase) cross-check -- $500k start,
$30k/yr added for 10 years, then 30-year drawdown, 95% -- lands at
FIRECalc $55,154 vs zfin $53,468 (-3.1%), the one case where zfin came
out *lower* (more conservative). That flip is a real modeling
difference in how the two tools treat the accumulation phase, not
noise -- see "Accumulation phase" below.
### Why zfin runs a little hot: methodology, not a bug
The divergence was isolated with a **$0-spending, 100%-stock** run,
which removes withdrawals, withdrawal timing, fees, and bonds from the
picture entirely. For the 1966 cohort, zfin's year-30 *nominal* balance
is **$20.24M vs FIRECalc's $18.60M** -- a ratio of 0.919 over 30 years,
i.e. FIRECalc's equity returns compound about **0.2-0.3%/yr lower** than
zfin's. That is the entire discrepancy: the gap is in the **equity
total-return series**, not the withdrawal logic.
The reason zfin is higher is that **zfin uses the gold-standard
construction and FIRECalc uses a coarser one**:
- **zfin** reconstructs each year's nominal total return directly from
Shiller's **Real Total Return Price** index -- the canonical
academic S&P total-return series, in which dividends are reinvested
**monthly** -- times that year's CPI change (see
`build/gen_shiller.zig`). The reconstruction recovers Shiller's
published nominal total return exactly.
- **FIRECalc** computes "market growth + dividends" in the lineage of
the 1998 Trinity Study and John Greaney's *Retire Early* spreadsheet
(FIRECalc's own
[methodology page](https://www.firecalc.com/intro.php) describes
this). That construction reinvests dividends more coarsely (annually,
in effect), which **systematically understates compounding** by
roughly a quarter-percent a year versus the monthly-reinvested index.
So zfin's equity returns are slightly higher **because they are more
accurate** -- monthly dividend reinvestment is what actually happened.
Over 30-45 year horizons that ~0.25%/yr compounds into the +6-9%
safe-withdrawal gap, and the worst cohorts (which set the
safe-withdrawal floor) diverge most because small per-year differences
explode near the failure boundary. FIRECalc's own FAQ concedes the
point -- it notes that implementations differ on exactly these details
and "all of the studies converge on the same basic results."
**Honest caveat (cuts the other way):** a more accurate *historical*
return series does not make the *forecast* more accurate -- nobody can
predict your future returns. It only means zfin replays history with
better-constructed inputs. If you specifically want to reproduce
FIRECalc's output, expect zfin to read a few percent higher for this
reason, by design.
### Other differences
- **Terminal values: nominal vs real.** FIRECalc's *on-screen* ending
balances are **real** (start-of-retirement dollars); zfin's terminal
bands are **nominal**. (FIRECalc's spreadsheet *export* is nominal,
which is what the terminal-value table above compares against.) Don't
compare zfin's nominal terminal bands to FIRECalc's on-screen ending
range without deflating one of them first.
- **Accumulation phase: zfin models it through history, FIRECalc
doesn't.** For runs with a pre-retirement contribution phase, the
two tools differ by design. FIRECalc always reports "N possible
*<distribution>* year periods" -- e.g. 126 thirty-year periods --
*regardless of accumulation length* (verified at 0, 10, and 25
years of accumulation, all 126). Since a 1871 distribution start
with 25 years of accumulation would need 1846-1870 data that
predates the dataset, FIRECalc cannot be replaying history during
accumulation: it grows the pre-retirement portfolio
**deterministically** and only Monte-Carlos the distribution. zfin
instead runs the **full accumulation + distribution span through one
continuous historical sequence** (116 cohorts for a 10+30yr run), so
it also captures **accumulation-phase sequence-of-returns risk** -- a
bad market in the years just before retirement. That extra realism
is why zfin reads slightly *lower* (more conservative) on
contribution scenarios. It's a deliberate fidelity gain, not a
discrepancy to reconcile.
### The bottom line
Treat zfin's safe-withdrawal numbers as **tracking FIRECalc within
roughly +6-9%, in the optimistic direction** -- a pure equity-engine
gap that holds with fees matched (both tools default to a 0.18% fee).
If you want a FIRECalc-conservative read, mentally haircut zfin's
safe-spending figure by ~5-10%. The parity
suite asserts zfin stays within -3% / +15% of these references, so a
future engine change that drifts materially further -- in either
direction -- trips a test.
## Assumptions to keep in mind
- **Allocation** is a single stock/bond blend (`target_stock_pct`), not
@ -130,6 +261,10 @@ visualize sequence risk -- not as a promise about your specific future.
real (today's-dollar) and balances are nominal. See
[How inflation is handled](#how-inflation-is-handled).
- **Taxes** are not modeled. Withdrawal figures are pre-tax.
- **Fees** are modeled as a flat annual expense-ratio drag, defaulting
to 0.18% (configurable via
[`expense_ratio`](../reference/config/projections-srf.md)); set it to
your portfolio's blended ratio.
- **Imported-value overlays** scale today's allocation to a historical
total when lot-level history isn't available, because a `liquid::`
row can't reconstruct past composition.

View file

@ -28,9 +28,12 @@ including the cohorts who retired into 1929, 1966, or 2000. The spread
of those real outcomes is exactly what becomes the percentile bands and
safe-withdrawal numbers below.
Because it's the same method over the same dataset, results should track
[FIRECalc.com](https://firecalc.com/) closely. For the full method and
its assumptions, see
Because it's the same method over the same dataset, results track
[FIRECalc.com](https://firecalc.com/) closely -- within roughly +6-9% on
safe-withdrawal dollars, in the slightly-optimistic direction (a June
2026 audit pinned the gap to zfin's equity-return series and made it a
regression suite). For the full method, its assumptions, and the parity
evidence, see
[The retirement projection model](../explanation/projections-model.md).
## Keeping the Shiller data current
@ -91,8 +94,8 @@ projected portfolio at retirement:
Accumulation phase:
Years until possible retirement: 19 (2046-04-12, ages 65/62)
Annual contributions: $80,000 (CPI-adjusted)
Median portfolio at retirement: $7,871,732.10
Range (10th90th percentile): $5,807,693.45 to $18,240,675.15
Median portfolio at retirement: $7,599,829.01
Range (10th90th percentile): $5,576,011.69 to $17,552,083.29
```
Below it, the **Safe Withdrawal** table shows the sustainable annual
@ -102,12 +105,12 @@ simulation):
```
Safe Withdrawal (FIRECalc historical simulation)
25 Year 35 Year 50 Year
90% safe withdrawal $347,601 $311,857 $308,728
99% safe withdrawal $314,920 $293,374 $264,002
90% safe withdrawal $331,658 $298,238 $291,267
99% safe withdrawal $301,038 $280,477 $251,851
```
Read it as: "retiring in 2046 with this portfolio, I could withdraw
~$264k/yr and be 99% confident it lasts 50 years (historically)."
~$252k/yr and be 99% confident it lasts 50 years (historically)."
## Question 2: "When can I retire?" (target spending)

View file

@ -35,6 +35,7 @@ type::event,name::Social Security,start_age:num:70,amount:num:38400
| 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. |
| `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. |
@ -45,6 +46,38 @@ type::event,name::Social Security,start_age:num:70,amount:num:38400
| `target_spending_inflation_adjusted` | bool | If `true` (default), target spending grows with CPI during distribution. |
| `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.
## `birthdate` fields
| Field | Type | Description |

View file

@ -157,6 +157,18 @@ pub const ResolvedRetirement = struct {
pub const UserConfig = struct {
/// Target stock allocation percentage (0-100). Used for simulation blending.
target_stock_pct: ?f64 = null,
/// Annual fund expense ratio as a percentage (e.g. 0.18 = 0.18%),
/// applied as a drag on the blended return each simulated year.
/// **Defaults to 0.18%** -- FIRECalc's default and a
/// realistic, mildly conservative figure for a fund-holding
/// portfolio (modeling no fee at all is less accurate and makes the
/// projection too optimistic). Override via
/// `type::config,expense_ratio:num:0.04` for a pure low-cost index
/// portfolio, a higher value for active funds, or `0` for an
/// all-individual-stock portfolio. Stored as a percentage here
/// (like `target_stock_pct`); converted to the decimal the
/// simulation wants (`/100`) at the view boundary.
expense_ratio: f64 = 0.18,
/// Retirement horizons to simulate (years). Defaults to 20,30,45.
horizons: [max_horizons]u16 = .{ 20, 30, 45 } ++ @as([max_horizons - 3]u16, @splat(0)),
horizon_count: u8 = 3,
@ -424,6 +436,7 @@ pub const UserConfig = struct {
const SrfConfig = struct {
type: []const u8 = "",
target_stock_pct: ?f64 = null,
expense_ratio: ?f64 = null,
horizon: ?u16 = null,
horizon_age: ?u16 = null,
/// Earliest-retirement promotion override: when paired with
@ -512,6 +525,7 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig {
switch (rec) {
.config => |c| {
config.target_stock_pct = c.target_stock_pct orelse config.target_stock_pct;
config.expense_ratio = c.expense_ratio orelse config.expense_ratio;
if (c.horizon) |h| {
if (!saw_horizon) {
config.horizon_count = 0;
@ -742,6 +756,11 @@ pub const SimParams = struct {
accumulation_years: u16 = 0,
annual_contribution: f64 = 0,
contribution_inflation_adjusted: bool = true,
/// Annual fund expense ratio (decimal, e.g. 0.0018 = 0.18%),
/// subtracted from the blended market return each year in both
/// phases. Defaults to 0 (no fee modeled). Mirrors FIRECalc's
/// "investment expenses" drag; its default is 0.18%.
expense_ratio: f64 = 0,
events: []const ResolvedEvent = &.{},
/// Total simulated path length (including year 0).
@ -757,9 +776,20 @@ const ShillerYearSlice = []const shiller.ShillerYear;
/// Maximum cycles available given a total horizon. Returns 0 if no
/// data covers the full horizon.
///
/// Counts every cohort whose full span fits in the data: a cohort
/// starting at index `i` reads `data[i .. i + total_years - 1]`, so
/// the valid starts are `0 .. data.len - total_years` inclusive,
/// i.e. `data.len - total_years + 1` cohorts. This matches FIRECalc's
/// convention ("1871, 1872, ... until the most recent year for which
/// there are results available") and `shiller.maxCycles`. Earlier
/// this returned `data.len - total_years`, which silently dropped the
/// single most-recent cohort (e.g. the 1996-2025 start for a 30-year
/// horizon) -- a complete, often-stressful sequence. See the FIRECalc
/// parity suite below.
fn maxCyclesFor(data: ShillerYearSlice, total_years: u16) usize {
if (data.len <= total_years) return 0;
return data.len - total_years;
if (data.len < total_years) return 0;
return data.len - total_years + 1;
}
/// Simulate a single cycle of the two-phase model:
@ -839,13 +869,15 @@ fn simulateTwoPhase(
}
}
// Market return on the post-cashflow balance. Skipped after
// failure (path callers have already locked the verdict;
// remaining buf entries get zeroed below).
// Market return on the post-cashflow balance, net of the
// fund expense ratio (FIRECalc applies "investment expenses"
// the same way). Skipped after failure (path callers have
// already locked the verdict; remaining buf entries get
// zeroed below).
if (!failed) {
const blended_return = params.stock_pct * yr.sp500_total_return +
(1.0 - params.stock_pct) * yr.bond_total_return;
portfolio *= (1.0 + blended_return);
portfolio *= (1.0 + blended_return - params.expense_ratio);
}
// Advance CPI for next year. (No-op for the verdict after
@ -984,12 +1016,12 @@ pub fn findSafeWithdrawal(
/// distribution-phase failure rate stays `1 - confidence`, with
/// `accumulation_years` of contributions feeding the portfolio first.
///
/// When `accumulation_years == 0` and contributions are zero, this
/// reduces exactly to `findSafeWithdrawal` (the equivalence is
/// pinned by the `regression: zero accumulation matches direct
/// findSafeWithdrawal` test). Used as the inner search for the
/// target-retirement-date input when the user has configured a
/// non-zero accumulation phase.
/// When `accumulation_years == 0`, contributions are zero, and
/// `expense_ratio == 0`, this reduces exactly to `findSafeWithdrawal`
/// (the equivalence is pinned by the `regression: zero accumulation
/// matches direct findSafeWithdrawal` test). Used as the inner search
/// for the target-retirement-date input when the user has configured
/// a non-zero accumulation phase.
pub fn findSafeWithdrawalWithAccumulation(
horizon: u16,
initial_value: f64,
@ -999,6 +1031,7 @@ pub fn findSafeWithdrawalWithAccumulation(
accumulation_years: u16,
annual_contribution: f64,
contribution_inflation_adjusted: bool,
expense_ratio: f64,
) WithdrawalResult {
return searchSafeWithdrawal(.{
.initial_value = initial_value,
@ -1008,6 +1041,7 @@ pub fn findSafeWithdrawalWithAccumulation(
.accumulation_years = accumulation_years,
.annual_contribution = annual_contribution,
.contribution_inflation_adjusted = contribution_inflation_adjusted,
.expense_ratio = expense_ratio,
.events = events,
}, confidence);
}
@ -1127,6 +1161,7 @@ pub fn findEarliestRetirement(
confidence: f64,
events: []const ResolvedEvent,
max_years: u16,
expense_ratio: f64,
) !EarliestRetirement {
const data = shiller.annual_returns;
@ -1141,6 +1176,7 @@ pub fn findEarliestRetirement(
.accumulation_years = n,
.annual_contribution = annual_contribution,
.contribution_inflation_adjusted = contribution_inflation_adjusted,
.expense_ratio = expense_ratio,
.events = events,
};
@ -1487,6 +1523,7 @@ pub fn runProjectionGrid(
accumulation_years: u16,
annual_contribution: f64,
contribution_inflation_adjusted: bool,
expense_ratio: f64,
) !ProjectionData {
const num_results = horizons.len * confidence_levels.len;
const withdrawals = try alloc.alloc(WithdrawalResult, num_results);
@ -1501,6 +1538,7 @@ pub fn runProjectionGrid(
accumulation_years,
annual_contribution,
contribution_inflation_adjusted,
expense_ratio,
);
}
}
@ -1516,6 +1554,7 @@ pub fn runProjectionGrid(
.accumulation_years = accumulation_years,
.annual_contribution = annual_contribution,
.contribution_inflation_adjusted = contribution_inflation_adjusted,
.expense_ratio = expense_ratio,
.events = events,
}) catch null;
}
@ -1693,13 +1732,162 @@ test "realistic portfolio safe withdrawal" {
try std.testing.expect(r95_45.annual_amount > r99_45.annual_amount);
// 30yr should be higher than 45yr at same confidence
try std.testing.expect(r99_30.annual_amount > r99_45.annual_amount);
// Should produce $290K+ at 99%/45yr based on FIRECalc reference (~$305K on $7.7M)
// FIRECalc reference: on $7.7M at 82% / 45yr / 99% (fee=0), FIRECalc
// returns ~$262.8K (audit June 2026). zfin runs ~+9% optimistic (see
// the FIRECalc parity suite below for the why), so on $8.34M it lands
// ~$310K. Bounds bracket that with margin.
try std.testing.expect(r99_45.annual_amount >= 290_000);
try std.testing.expect(r99_45.annual_amount <= 350_000);
try std.testing.expect(r99_45.withdrawal_rate >= 0.03);
try std.testing.expect(r99_45.withdrawal_rate <= 0.05);
}
// FIRECalc.com parity suite
//
// Cross-checks zfin's engine against FIRECalc.com ("FIRECalc 3.0",
// data through 1/1/2026 -- the same 1871-2025 Shiller span zfin embeds).
// Reference values were captured June 2026 by driving the FIRECalc web
// form directly. Full method, captured numbers, and root-cause analysis
// live in docs/explanation/projections-model.md "Parity with FIRECalc".
//
// The safe-withdrawal and success-rate references below use FIRECalc
// with its expense ratio set to 0% (InvExp=0) and the default "Long
// Interest" (10yr-Treasury) fixed-income model. fee=0 is the
// apples-to-apples comparison for the no-fee convenience wrappers
// (`findSafeWithdrawal`, `successRate`). zfin CAN now model a fee
// (`SimParams.expense_ratio`, configurable via projections.srf); the
// separate "expense ratio matches FIRECalc's default fee" test below
// pins zfin against FIRECalc's *default* 0.18%-fee runs.
//
// Cohort counts now match FIRECalc exactly (e.g. 126 for a 30yr
// horizon over 1871-2025) after the `maxCyclesFor` off-by-one fix.
//
// KNOWN, ACCEPTED DIVERGENCE: zfin runs systematically *more optimistic*
// than FIRECalc -- ~+6-9% on safe-withdrawal dollars and ~+2-3pp on
// success rate -- because zfin reconstructs nominal equity total returns
// from Shiller's monthly-reinvested Real Total Return Price × CPI, which
// compounds ~0.2-0.3%/yr higher than FIRECalc's equity series. This was
// isolated with a $0-spending, 100%-stock run (no withdrawal/timing/fee
// effects): for the 1966 cohort, zfin's year-30 nominal balance is
// $20.24M vs FIRECalc's $18.60M -- a pure return-series gap. It is a
// defensible modeling choice, not a bug; the tolerances below encode the
// gap so this suite is a regression guard, not an exact-match assertion.
const FcSwrCase = struct {
name: []const u8,
horizon: u16,
value: f64,
stock_pct: f64,
confidence: f64,
/// FIRECalc max-spending dollars for this scenario (InvExp=0).
fc_ref: f64,
};
test "FIRECalc parity: safe-withdrawal dollars" {
const cases = [_]FcSwrCase{
.{ .name = "100% 30y 95% $1M", .horizon = 30, .value = 1_000_000, .stock_pct = 1.00, .confidence = 0.95, .fc_ref = 39_697 },
.{ .name = "75/25 30y 95% $1M", .horizon = 30, .value = 1_000_000, .stock_pct = 0.75, .confidence = 0.95, .fc_ref = 41_221 },
.{ .name = "100% 45y 95% $1M", .horizon = 45, .value = 1_000_000, .stock_pct = 1.00, .confidence = 0.95, .fc_ref = 35_835 },
.{ .name = "100% 20y 95% $1M", .horizon = 20, .value = 1_000_000, .stock_pct = 1.00, .confidence = 0.95, .fc_ref = 45_879 },
.{ .name = "100% 30y 90% $1M", .horizon = 30, .value = 1_000_000, .stock_pct = 1.00, .confidence = 0.90, .fc_ref = 43_804 },
.{ .name = "100% 30y 99% $1M", .horizon = 30, .value = 1_000_000, .stock_pct = 1.00, .confidence = 0.99, .fc_ref = 35_864 },
.{ .name = "100% 45y 99% $7.7M", .horizon = 45, .value = 7_700_000, .stock_pct = 1.00, .confidence = 0.99, .fc_ref = 254_461 },
.{ .name = "82% 45y 99% $7.7M", .horizon = 45, .value = 7_700_000, .stock_pct = 0.82, .confidence = 0.99, .fc_ref = 262_770 },
};
for (cases) |c| {
const r = findSafeWithdrawal(c.horizon, c.value, c.stock_pct, c.confidence, &.{});
// zfin tracks FIRECalc within roughly -3% / +15%, currently
// landing ~+6-9% high (equity return-series optimism). The lower
// bound catches an engine that suddenly turns conservative; the
// upper bound catches runaway optimism.
try std.testing.expect(r.annual_amount >= c.fc_ref * 0.97);
try std.testing.expect(r.annual_amount <= c.fc_ref * 1.15);
}
}
test "FIRECalc parity: success rate" {
// $1M, $40k/yr, 30yr, InvExp=0. FIRECalc: 100% stock 94.4%
// (7/126 failed); 75/25 96.8% (4/126). zfin runs ~+2-3pp higher
// (fewer failures) for the same return-series reason.
const sr_100 = successRate(30, 1_000_000, 40_000, 1.00, &.{});
const sr_75 = successRate(30, 1_000_000, 40_000, 0.75, &.{});
// Within 6pp of FIRECalc, and never *below* it by more than 1pp
// (zfin is the more optimistic engine -- a large undershoot would be
// a regression).
try std.testing.expectApproxEqAbs(@as(f64, 0.944), sr_100, 0.06);
try std.testing.expectApproxEqAbs(@as(f64, 0.968), sr_75, 0.06);
try std.testing.expect(sr_100 >= 0.944 - 0.01);
try std.testing.expect(sr_75 >= 0.968 - 0.01);
}
test "FIRECalc parity: terminal-value percentiles" {
// S1: $1M, $40k, 30yr, 100% stock, InvExp=0. FIRECalc terminal
// values, captured from its per-cohort spreadsheet export in NOMINAL
// dollars (126 cohorts): p50 ~$5.12M, p90 ~$12.76M.
//
// Unit caveat: FIRECalc's on-screen "ending portfolio" figures are
// REAL (start-of-retirement dollars); zfin's bands are NOMINAL. The
// spreadsheet export is nominal, which is the basis used here. zfin's
// percentiles run higher (median ~+11%, p90 ~+16%) for the same
// return-series reason; the p10 gap is larger still because small
// per-year differences explode near the failure boundary, so p10 is
// intentionally not asserted.
const a = std.testing.allocator;
const bands = try computePercentileBands(a, 30, 1_000_000, 40_000, 1.00, &.{});
defer a.free(bands);
const term = bands[30];
try std.testing.expect(term.p50 >= 5_124_810 * 0.97);
try std.testing.expect(term.p50 <= 5_124_810 * 1.20);
try std.testing.expect(term.p90 >= 12_763_289 * 0.97);
try std.testing.expect(term.p90 <= 12_763_289 * 1.25);
}
test "FIRECalc parity: expense ratio matches FIRECalc's default fee" {
// Validates the `expense_ratio` model against FIRECalc runs with
// its *default* 0.18% fee enabled (InvExp=0.18). zfin's
// expense_ratio is a decimal here (0.0018 = 0.18%).
//
// Two things this pins:
// 1. The fee has the right *direction and magnitude*: enabling
// 0.18% drops zfin's SWR ~1.8%, matching FIRECalc's own
// ~2.0% fee effect (W2->W3: $41,221->$40,381).
// 2. With fees matched on BOTH sides, the residual gap is still
// ~+7-9% i.e. the fee is NOT the source of the divergence;
// the equity return series (documented above) is. So the
// same -3%/+15% tolerance band applies.
const sr_100 = successRateParams(shiller.annual_returns, .{
.initial_value = 1_000_000,
.stock_pct = 1.00,
.annual_spending = 40_000,
.distribution_years = 30,
.expense_ratio = 0.0018,
});
const sr_75 = successRateParams(shiller.annual_returns, .{
.initial_value = 1_000_000,
.stock_pct = 0.75,
.annual_spending = 40_000,
.distribution_years = 30,
.expense_ratio = 0.0018,
});
// FIRECalc fee=0.18: 100% stock 93.7%, 75/25 95.2%.
try std.testing.expectApproxEqAbs(@as(f64, 0.937), sr_100, 0.06);
try std.testing.expectApproxEqAbs(@as(f64, 0.952), sr_75, 0.06);
// Safe withdrawal with the fee on. FIRECalc fee=0.18 refs:
// 75/25 30y 95% -> $40,381; 75/25 45y 99% $7.7M -> $258,747.
const w_30 = findSafeWithdrawalWithAccumulation(30, 1_000_000, 0.75, 0.95, &.{}, 0, 0, true, 0.0018);
const w_45 = findSafeWithdrawalWithAccumulation(45, 7_700_000, 0.75, 0.99, &.{}, 0, 0, true, 0.0018);
try std.testing.expect(w_30.annual_amount >= 40_381 * 0.97);
try std.testing.expect(w_30.annual_amount <= 40_381 * 1.15);
try std.testing.expect(w_45.annual_amount >= 258_747 * 0.97);
try std.testing.expect(w_45.annual_amount <= 258_747 * 1.15);
// Sanity: enabling the fee strictly lowers the safe withdrawal
// relative to the no-fee result (same scenario).
const w_30_nofee = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &.{});
try std.testing.expect(w_30.annual_amount < w_30_nofee.annual_amount);
}
test "simulateCycle produces correct year-0 value" {
var buf: [31]f64 = undefined;
simulateCycle(&buf, 0, 30, 1_000_000, 0, 0.75, &.{});
@ -1753,6 +1941,24 @@ test "parseProjectionsConfig empty string" {
try std.testing.expectEqual(@as(u8, 3), config.horizon_count);
}
test "parseProjectionsConfig expense_ratio defaults to 0.18 and parses overrides" {
const default_config = parseProjectionsConfig("#!srfv1\n");
// Default is FIRECalc's 0.18% (a realistic fund-fee assumption),
// not 0 -- modeling no fee is less accurate and over-optimistic.
try std.testing.expectApproxEqAbs(@as(f64, 0.18), default_config.expense_ratio, 0.0001);
// A low-cost index investor overrides downward; verify parsing.
const data = "#!srfv1\ntype::config,expense_ratio:num:0.04\n";
const config = parseProjectionsConfig(data);
// Stored as a percentage (like target_stock_pct); the view layer
// divides by 100 before handing it to the simulation.
try std.testing.expectApproxEqAbs(@as(f64, 0.04), config.expense_ratio, 0.0001);
// Explicit 0 is honored (all-individual-stock portfolio).
const zeroed = parseProjectionsConfig("#!srfv1\ntype::config,expense_ratio:num:0\n");
try std.testing.expectEqual(@as(f64, 0), zeroed.expense_ratio);
}
test "parseProjectionsConfig invalid data" {
const config = parseProjectionsConfig("not valid srf");
try std.testing.expect(config.target_stock_pct == null);
@ -2286,7 +2492,7 @@ test "regression: zero accumulation matches direct findSafeWithdrawal" {
// because the two paths execute the same code with the same
// inputs any drift here means the unification broke.
const direct = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &.{});
const via_accum = findSafeWithdrawalWithAccumulation(30, 1_000_000, 0.75, 0.95, &.{}, 0, 0, true);
const via_accum = findSafeWithdrawalWithAccumulation(30, 1_000_000, 0.75, 0.95, &.{}, 0, 0, true, 0);
try std.testing.expectEqual(direct.annual_amount, via_accum.annual_amount);
try std.testing.expectEqual(direct.confidence, via_accum.confidence);
try std.testing.expectEqual(direct.withdrawal_rate, via_accum.withdrawal_rate);
@ -2371,7 +2577,7 @@ test "two-phase: SWR with accumulation exceeds same-portfolio direct SWR" {
// a higher safe withdrawal than $1M alone over a 30-year
// distribution at the same confidence.
const direct = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &.{});
const with_accum = findSafeWithdrawalWithAccumulation(30, 1_000_000, 0.75, 0.95, &.{}, 10, 100_000, true);
const with_accum = findSafeWithdrawalWithAccumulation(30, 1_000_000, 0.75, 0.95, &.{}, 10, 100_000, true, 0);
try std.testing.expect(with_accum.annual_amount > direct.annual_amount);
}
@ -2454,6 +2660,7 @@ test "findEarliestRetirement: feasible at N=0 returns 0" {
0.95, // confidence
&.{},
50, // max_years
0, // expense_ratio
);
try std.testing.expectEqual(@as(?u16, 0), r.accumulation_years);
}
@ -2474,6 +2681,7 @@ test "findEarliestRetirement: unreachable returns null" {
0.95,
&.{},
50,
0, // expense_ratio
);
try std.testing.expectEqual(@as(?u16, null), r.accumulation_years);
}
@ -2493,6 +2701,7 @@ test "findEarliestRetirement: longer distribution shifts retirement later or unc
0.95,
&.{},
50,
0, // expense_ratio
);
const long = try findEarliestRetirement(
allocator,
@ -2506,6 +2715,7 @@ test "findEarliestRetirement: longer distribution shifts retirement later or unc
0.95,
&.{},
50,
0, // expense_ratio
);
if (short.accumulation_years != null and long.accumulation_years != null) {
try std.testing.expect(long.accumulation_years.? >= short.accumulation_years.?);
@ -2526,6 +2736,7 @@ test "findEarliestRetirement: result includes portfolio statistics" {
0.95,
&.{},
50,
0, // expense_ratio
);
if (r.accumulation_years) |n| {
if (n > 0) {
@ -2804,7 +3015,7 @@ test "runProjectionGrid: structure and indexing" {
const horizons = [_]u16{ 20, 30 };
const conf = [_]f64{ 0.95, 0.99 };
const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true);
const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0);
defer freeProjectionData(allocator, data);
// 2 horizons × 2 confidence levels = 4 withdrawal results.
@ -2822,7 +3033,7 @@ test "runProjectionGrid: withdrawal monotonicity along confidence axis" {
const horizons = [_]u16{30};
const conf = [_]f64{ 0.90, 0.95, 0.99 };
const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true);
const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0);
defer freeProjectionData(allocator, data);
const w_90 = data.withdrawals[0 * horizons.len + 0].annual_amount;
@ -2838,7 +3049,7 @@ test "runProjectionGrid: withdrawal monotonicity along horizon axis" {
const horizons = [_]u16{ 20, 30, 45 };
const conf = [_]f64{0.95};
const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true);
const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0);
defer freeProjectionData(allocator, data);
const w_20 = data.withdrawals[0 * horizons.len + 0].annual_amount;
@ -2853,7 +3064,7 @@ test "runProjectionGrid: distribution-only band length is horizon + 1" {
const horizons = [_]u16{ 20, 30 };
const conf = [_]f64{ 0.95, 0.99 };
const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true);
const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0);
defer freeProjectionData(allocator, data);
// band[0] covers horizons[0] = 20 21 entries; band[1] covers
@ -2868,7 +3079,7 @@ test "runProjectionGrid: with-accumulation band length includes accumulation_yea
const conf = [_]f64{0.95};
// 10 years of accumulation + 30 years distribution 41 entries.
const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 10, 50_000, true);
const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 10, 50_000, true, 0);
defer freeProjectionData(allocator, data);
try std.testing.expectEqual(@as(usize, 41), data.bands[0].?.len);
@ -2879,7 +3090,7 @@ test "runProjectionGrid: bands are p10 ≤ p25 ≤ p50 ≤ p75 ≤ p90 at every
const horizons = [_]u16{30};
const conf = [_]f64{0.95};
const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true);
const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0);
defer freeProjectionData(allocator, data);
for (data.bands[0].?) |b| {
@ -2896,7 +3107,7 @@ test "runProjectionGrid: year 0 in every band equals total_value" {
const conf = [_]f64{0.95};
const total_value: f64 = 2_000_000;
const data = try runProjectionGrid(allocator, &horizons, &conf, total_value, 0.75, &.{}, 0, 0, true);
const data = try runProjectionGrid(allocator, &horizons, &conf, total_value, 0.75, &.{}, 0, 0, true, 0);
defer freeProjectionData(allocator, data);
for (data.bands) |b_opt| {
@ -2920,7 +3131,7 @@ test "runProjectionGrid: bands are computed at the highest-confidence withdrawal
const horizons = [_]u16{30};
const conf = [_]f64{ 0.90, 0.95, 0.99 };
const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true);
const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0);
defer freeProjectionData(allocator, data);
const wr_99 = data.withdrawals[data.ci_99 * horizons.len + 0];
@ -2952,10 +3163,10 @@ test "runProjectionGrid: accumulation passes through to both withdrawals and ban
const horizons = [_]u16{30};
const conf = [_]f64{0.95};
const dist_only = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true);
const dist_only = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0);
defer freeProjectionData(allocator, dist_only);
const with_accum = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 10, 50_000, true);
const with_accum = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 10, 50_000, true, 0);
defer freeProjectionData(allocator, with_accum);
// SWR with 10y of contributions on top should exceed SWR
@ -2971,7 +3182,7 @@ test "runProjectionGrid: zero horizons produces empty results without crashing"
const horizons = [_]u16{};
const conf = [_]f64{ 0.95, 0.99 };
const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true);
const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0);
defer freeProjectionData(allocator, data);
try std.testing.expectEqual(@as(usize, 0), data.withdrawals.len);

View file

@ -314,6 +314,9 @@ pub fn buildProjectionContext(
as_of: Date,
) !ProjectionContext {
const sim_stock_pct = if (config.target_stock_pct) |t| t / 100.0 else stock_pct;
// `expense_ratio` is stored as a percentage in UserConfig (like
// target_stock_pct); the simulation wants a decimal.
const sim_expense_ratio = config.expense_ratio / 100.0;
// Resolve the retirement boundary from the user's target
// retirement date (`retirement_age` / `retirement_at`). Returns
@ -331,6 +334,7 @@ pub fn buildProjectionContext(
accumulation_years,
config.annual_contribution,
config.contribution_inflation_adjusted,
sim_expense_ratio,
);
// Accumulation-phase stats: extract portfolio value at the
@ -378,6 +382,7 @@ pub fn buildProjectionContext(
conf,
events,
projections.max_accumulation_years,
sim_expense_ratio,
);
}
}