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 !examples/**/*.srf
scripts/ scripts/
.tmp/ .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 faithfulness one notch. Pick whichever has the highest
payoff vs. complexity when this gets revisited. 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 ## `--export-chart` follow-ups — priority LOW
V1 of `--export-chart <PATH>` shipped for `quote` and `projections` 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 Treat the projection as a disciplined way to compare scenarios and
visualize sequence risk -- not as a promise about your specific future. 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 ## Assumptions to keep in mind
- **Allocation** is a single stock/bond blend (`target_stock_pct`), not - **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 real (today's-dollar) and balances are nominal. See
[How inflation is handled](#how-inflation-is-handled). [How inflation is handled](#how-inflation-is-handled).
- **Taxes** are not modeled. Withdrawal figures are pre-tax. - **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 - **Imported-value overlays** scale today's allocation to a historical
total when lot-level history isn't available, because a `liquid::` total when lot-level history isn't available, because a `liquid::`
row can't reconstruct past composition. 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 of those real outcomes is exactly what becomes the percentile bands and
safe-withdrawal numbers below. safe-withdrawal numbers below.
Because it's the same method over the same dataset, results should track Because it's the same method over the same dataset, results track
[FIRECalc.com](https://firecalc.com/) closely. For the full method and [FIRECalc.com](https://firecalc.com/) closely -- within roughly +6-9% on
its assumptions, see 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). [The retirement projection model](../explanation/projections-model.md).
## Keeping the Shiller data current ## Keeping the Shiller data current
@ -91,8 +94,8 @@ projected portfolio at retirement:
Accumulation phase: Accumulation phase:
Years until possible retirement: 19 (2046-04-12, ages 65/62) Years until possible retirement: 19 (2046-04-12, ages 65/62)
Annual contributions: $80,000 (CPI-adjusted) Annual contributions: $80,000 (CPI-adjusted)
Median portfolio at retirement: $7,871,732.10 Median portfolio at retirement: $7,599,829.01
Range (10th90th percentile): $5,807,693.45 to $18,240,675.15 Range (10th90th percentile): $5,576,011.69 to $17,552,083.29
``` ```
Below it, the **Safe Withdrawal** table shows the sustainable annual Below it, the **Safe Withdrawal** table shows the sustainable annual
@ -102,12 +105,12 @@ simulation):
``` ```
Safe Withdrawal (FIRECalc historical simulation) Safe Withdrawal (FIRECalc historical simulation)
25 Year 35 Year 50 Year 25 Year 35 Year 50 Year
90% safe withdrawal $347,601 $311,857 $308,728 90% safe withdrawal $331,658 $298,238 $291,267
99% safe withdrawal $314,920 $293,374 $264,002 99% safe withdrawal $301,038 $280,477 $251,851
``` ```
Read it as: "retiring in 2046 with this portfolio, I could withdraw 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) ## 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 | | Field | Type | Description |
|--------------------------------------|------|--------------------------------------------------------------------------------------------------------------------------------| |--------------------------------------|------|--------------------------------------------------------------------------------------------------------------------------------|
| `target_stock_pct` | num | Asset-allocation target (0-100). Sets the simulation's stock/bond blend. | | `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` | 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. | | `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_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. | | `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`. | | `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 ## `birthdate` fields
| Field | Type | Description | | Field | Type | Description |

View file

@ -157,6 +157,18 @@ pub const ResolvedRetirement = struct {
pub const UserConfig = struct { pub const UserConfig = struct {
/// Target stock allocation percentage (0-100). Used for simulation blending. /// Target stock allocation percentage (0-100). Used for simulation blending.
target_stock_pct: ?f64 = null, 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. /// Retirement horizons to simulate (years). Defaults to 20,30,45.
horizons: [max_horizons]u16 = .{ 20, 30, 45 } ++ @as([max_horizons - 3]u16, @splat(0)), horizons: [max_horizons]u16 = .{ 20, 30, 45 } ++ @as([max_horizons - 3]u16, @splat(0)),
horizon_count: u8 = 3, horizon_count: u8 = 3,
@ -424,6 +436,7 @@ pub const UserConfig = struct {
const SrfConfig = struct { const SrfConfig = struct {
type: []const u8 = "", type: []const u8 = "",
target_stock_pct: ?f64 = null, target_stock_pct: ?f64 = null,
expense_ratio: ?f64 = null,
horizon: ?u16 = null, horizon: ?u16 = null,
horizon_age: ?u16 = null, horizon_age: ?u16 = null,
/// Earliest-retirement promotion override: when paired with /// Earliest-retirement promotion override: when paired with
@ -512,6 +525,7 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig {
switch (rec) { switch (rec) {
.config => |c| { .config => |c| {
config.target_stock_pct = c.target_stock_pct orelse config.target_stock_pct; 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 (c.horizon) |h| {
if (!saw_horizon) { if (!saw_horizon) {
config.horizon_count = 0; config.horizon_count = 0;
@ -742,6 +756,11 @@ pub const SimParams = struct {
accumulation_years: u16 = 0, accumulation_years: u16 = 0,
annual_contribution: f64 = 0, annual_contribution: f64 = 0,
contribution_inflation_adjusted: bool = true, 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 = &.{}, events: []const ResolvedEvent = &.{},
/// Total simulated path length (including year 0). /// 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 /// Maximum cycles available given a total horizon. Returns 0 if no
/// data covers the full horizon. /// 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 { fn maxCyclesFor(data: ShillerYearSlice, total_years: u16) usize {
if (data.len <= total_years) return 0; if (data.len < total_years) return 0;
return data.len - total_years; return data.len - total_years + 1;
} }
/// Simulate a single cycle of the two-phase model: /// Simulate a single cycle of the two-phase model:
@ -839,13 +869,15 @@ fn simulateTwoPhase(
} }
} }
// Market return on the post-cashflow balance. Skipped after // Market return on the post-cashflow balance, net of the
// failure (path callers have already locked the verdict; // fund expense ratio (FIRECalc applies "investment expenses"
// remaining buf entries get zeroed below). // the same way). Skipped after failure (path callers have
// already locked the verdict; remaining buf entries get
// zeroed below).
if (!failed) { if (!failed) {
const blended_return = params.stock_pct * yr.sp500_total_return + const blended_return = params.stock_pct * yr.sp500_total_return +
(1.0 - params.stock_pct) * yr.bond_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 // 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 /// distribution-phase failure rate stays `1 - confidence`, with
/// `accumulation_years` of contributions feeding the portfolio first. /// `accumulation_years` of contributions feeding the portfolio first.
/// ///
/// When `accumulation_years == 0` and contributions are zero, this /// When `accumulation_years == 0`, contributions are zero, and
/// reduces exactly to `findSafeWithdrawal` (the equivalence is /// `expense_ratio == 0`, this reduces exactly to `findSafeWithdrawal`
/// pinned by the `regression: zero accumulation matches direct /// (the equivalence is pinned by the `regression: zero accumulation
/// findSafeWithdrawal` test). Used as the inner search for the /// matches direct findSafeWithdrawal` test). Used as the inner search
/// target-retirement-date input when the user has configured a /// for the target-retirement-date input when the user has configured
/// non-zero accumulation phase. /// a non-zero accumulation phase.
pub fn findSafeWithdrawalWithAccumulation( pub fn findSafeWithdrawalWithAccumulation(
horizon: u16, horizon: u16,
initial_value: f64, initial_value: f64,
@ -999,6 +1031,7 @@ pub fn findSafeWithdrawalWithAccumulation(
accumulation_years: u16, accumulation_years: u16,
annual_contribution: f64, annual_contribution: f64,
contribution_inflation_adjusted: bool, contribution_inflation_adjusted: bool,
expense_ratio: f64,
) WithdrawalResult { ) WithdrawalResult {
return searchSafeWithdrawal(.{ return searchSafeWithdrawal(.{
.initial_value = initial_value, .initial_value = initial_value,
@ -1008,6 +1041,7 @@ pub fn findSafeWithdrawalWithAccumulation(
.accumulation_years = accumulation_years, .accumulation_years = accumulation_years,
.annual_contribution = annual_contribution, .annual_contribution = annual_contribution,
.contribution_inflation_adjusted = contribution_inflation_adjusted, .contribution_inflation_adjusted = contribution_inflation_adjusted,
.expense_ratio = expense_ratio,
.events = events, .events = events,
}, confidence); }, confidence);
} }
@ -1127,6 +1161,7 @@ pub fn findEarliestRetirement(
confidence: f64, confidence: f64,
events: []const ResolvedEvent, events: []const ResolvedEvent,
max_years: u16, max_years: u16,
expense_ratio: f64,
) !EarliestRetirement { ) !EarliestRetirement {
const data = shiller.annual_returns; const data = shiller.annual_returns;
@ -1141,6 +1176,7 @@ pub fn findEarliestRetirement(
.accumulation_years = n, .accumulation_years = n,
.annual_contribution = annual_contribution, .annual_contribution = annual_contribution,
.contribution_inflation_adjusted = contribution_inflation_adjusted, .contribution_inflation_adjusted = contribution_inflation_adjusted,
.expense_ratio = expense_ratio,
.events = events, .events = events,
}; };
@ -1487,6 +1523,7 @@ pub fn runProjectionGrid(
accumulation_years: u16, accumulation_years: u16,
annual_contribution: f64, annual_contribution: f64,
contribution_inflation_adjusted: bool, contribution_inflation_adjusted: bool,
expense_ratio: f64,
) !ProjectionData { ) !ProjectionData {
const num_results = horizons.len * confidence_levels.len; const num_results = horizons.len * confidence_levels.len;
const withdrawals = try alloc.alloc(WithdrawalResult, num_results); const withdrawals = try alloc.alloc(WithdrawalResult, num_results);
@ -1501,6 +1538,7 @@ pub fn runProjectionGrid(
accumulation_years, accumulation_years,
annual_contribution, annual_contribution,
contribution_inflation_adjusted, contribution_inflation_adjusted,
expense_ratio,
); );
} }
} }
@ -1516,6 +1554,7 @@ pub fn runProjectionGrid(
.accumulation_years = accumulation_years, .accumulation_years = accumulation_years,
.annual_contribution = annual_contribution, .annual_contribution = annual_contribution,
.contribution_inflation_adjusted = contribution_inflation_adjusted, .contribution_inflation_adjusted = contribution_inflation_adjusted,
.expense_ratio = expense_ratio,
.events = events, .events = events,
}) catch null; }) catch null;
} }
@ -1693,13 +1732,162 @@ test "realistic portfolio safe withdrawal" {
try std.testing.expect(r95_45.annual_amount > r99_45.annual_amount); try std.testing.expect(r95_45.annual_amount > r99_45.annual_amount);
// 30yr should be higher than 45yr at same confidence // 30yr should be higher than 45yr at same confidence
try std.testing.expect(r99_30.annual_amount > r99_45.annual_amount); 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 >= 290_000);
try std.testing.expect(r99_45.annual_amount <= 350_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.03);
try std.testing.expect(r99_45.withdrawal_rate <= 0.05); 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" { test "simulateCycle produces correct year-0 value" {
var buf: [31]f64 = undefined; var buf: [31]f64 = undefined;
simulateCycle(&buf, 0, 30, 1_000_000, 0, 0.75, &.{}); 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); 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" { test "parseProjectionsConfig invalid data" {
const config = parseProjectionsConfig("not valid srf"); const config = parseProjectionsConfig("not valid srf");
try std.testing.expect(config.target_stock_pct == null); 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 // because the two paths execute the same code with the same
// inputs any drift here means the unification broke. // inputs any drift here means the unification broke.
const direct = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &.{}); 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.annual_amount, via_accum.annual_amount);
try std.testing.expectEqual(direct.confidence, via_accum.confidence); try std.testing.expectEqual(direct.confidence, via_accum.confidence);
try std.testing.expectEqual(direct.withdrawal_rate, via_accum.withdrawal_rate); 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 // a higher safe withdrawal than $1M alone over a 30-year
// distribution at the same confidence. // distribution at the same confidence.
const direct = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &.{}); 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); 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 0.95, // confidence
&.{}, &.{},
50, // max_years 50, // max_years
0, // expense_ratio
); );
try std.testing.expectEqual(@as(?u16, 0), r.accumulation_years); try std.testing.expectEqual(@as(?u16, 0), r.accumulation_years);
} }
@ -2474,6 +2681,7 @@ test "findEarliestRetirement: unreachable returns null" {
0.95, 0.95,
&.{}, &.{},
50, 50,
0, // expense_ratio
); );
try std.testing.expectEqual(@as(?u16, null), r.accumulation_years); 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, 0.95,
&.{}, &.{},
50, 50,
0, // expense_ratio
); );
const long = try findEarliestRetirement( const long = try findEarliestRetirement(
allocator, allocator,
@ -2506,6 +2715,7 @@ test "findEarliestRetirement: longer distribution shifts retirement later or unc
0.95, 0.95,
&.{}, &.{},
50, 50,
0, // expense_ratio
); );
if (short.accumulation_years != null and long.accumulation_years != null) { if (short.accumulation_years != null and long.accumulation_years != null) {
try std.testing.expect(long.accumulation_years.? >= short.accumulation_years.?); try std.testing.expect(long.accumulation_years.? >= short.accumulation_years.?);
@ -2526,6 +2736,7 @@ test "findEarliestRetirement: result includes portfolio statistics" {
0.95, 0.95,
&.{}, &.{},
50, 50,
0, // expense_ratio
); );
if (r.accumulation_years) |n| { if (r.accumulation_years) |n| {
if (n > 0) { if (n > 0) {
@ -2804,7 +3015,7 @@ test "runProjectionGrid: structure and indexing" {
const horizons = [_]u16{ 20, 30 }; const horizons = [_]u16{ 20, 30 };
const conf = [_]f64{ 0.95, 0.99 }; 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); defer freeProjectionData(allocator, data);
// 2 horizons × 2 confidence levels = 4 withdrawal results. // 2 horizons × 2 confidence levels = 4 withdrawal results.
@ -2822,7 +3033,7 @@ test "runProjectionGrid: withdrawal monotonicity along confidence axis" {
const horizons = [_]u16{30}; const horizons = [_]u16{30};
const conf = [_]f64{ 0.90, 0.95, 0.99 }; 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); defer freeProjectionData(allocator, data);
const w_90 = data.withdrawals[0 * horizons.len + 0].annual_amount; 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 horizons = [_]u16{ 20, 30, 45 };
const conf = [_]f64{0.95}; 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); defer freeProjectionData(allocator, data);
const w_20 = data.withdrawals[0 * horizons.len + 0].annual_amount; 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 horizons = [_]u16{ 20, 30 };
const conf = [_]f64{ 0.95, 0.99 }; 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); defer freeProjectionData(allocator, data);
// band[0] covers horizons[0] = 20 21 entries; band[1] covers // 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}; const conf = [_]f64{0.95};
// 10 years of accumulation + 30 years distribution 41 entries. // 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); defer freeProjectionData(allocator, data);
try std.testing.expectEqual(@as(usize, 41), data.bands[0].?.len); 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 horizons = [_]u16{30};
const conf = [_]f64{0.95}; 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); defer freeProjectionData(allocator, data);
for (data.bands[0].?) |b| { 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 conf = [_]f64{0.95};
const total_value: f64 = 2_000_000; 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); defer freeProjectionData(allocator, data);
for (data.bands) |b_opt| { for (data.bands) |b_opt| {
@ -2920,7 +3131,7 @@ test "runProjectionGrid: bands are computed at the highest-confidence withdrawal
const horizons = [_]u16{30}; const horizons = [_]u16{30};
const conf = [_]f64{ 0.90, 0.95, 0.99 }; 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); defer freeProjectionData(allocator, data);
const wr_99 = data.withdrawals[data.ci_99 * horizons.len + 0]; 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 horizons = [_]u16{30};
const conf = [_]f64{0.95}; 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); 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); defer freeProjectionData(allocator, with_accum);
// SWR with 10y of contributions on top should exceed SWR // 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 horizons = [_]u16{};
const conf = [_]f64{ 0.95, 0.99 }; 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); defer freeProjectionData(allocator, data);
try std.testing.expectEqual(@as(usize, 0), data.withdrawals.len); try std.testing.expectEqual(@as(usize, 0), data.withdrawals.len);

View file

@ -314,6 +314,9 @@ pub fn buildProjectionContext(
as_of: Date, as_of: Date,
) !ProjectionContext { ) !ProjectionContext {
const sim_stock_pct = if (config.target_stock_pct) |t| t / 100.0 else stock_pct; 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 // Resolve the retirement boundary from the user's target
// retirement date (`retirement_age` / `retirement_at`). Returns // retirement date (`retirement_age` / `retirement_at`). Returns
@ -331,6 +334,7 @@ pub fn buildProjectionContext(
accumulation_years, accumulation_years,
config.annual_contribution, config.annual_contribution,
config.contribution_inflation_adjusted, config.contribution_inflation_adjusted,
sim_expense_ratio,
); );
// Accumulation-phase stats: extract portfolio value at the // Accumulation-phase stats: extract portfolio value at the
@ -378,6 +382,7 @@ pub fn buildProjectionContext(
conf, conf,
events, events,
projections.max_accumulation_years, projections.max_accumulation_years,
sim_expense_ratio,
); );
} }
} }