add fund expense ratio for projections/description of deltas vis-a-vis FireCALC
This commit is contained in:
parent
c34e97aadd
commit
d078bc5a62
7 changed files with 422 additions and 60 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -10,3 +10,4 @@ coverage/
|
||||||
!examples/**/*.srf
|
!examples/**/*.srf
|
||||||
scripts/
|
scripts/
|
||||||
.tmp/
|
.tmp/
|
||||||
|
.gstack/
|
||||||
|
|
|
||||||
26
TODO.md
26
TODO.md
|
|
@ -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`
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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 (10th–90th percentile): $5,807,693.45 to $18,240,675.15
|
Range (10th–90th 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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue