zfin/src/analytics/projections.zig

4452 lines
192 KiB
Zig
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/// Historical simulation engine for retirement projections.
///
/// Implements the FIRECalc algorithm: for each starting year in the Shiller
/// historical dataset (1871-present), simulate a retirement of `horizon` years
/// using actual market returns, bond returns, and inflation. The portfolio is
/// rebalanced annually to the target stock/bond allocation.
///
/// Key outputs:
/// - Safe withdrawal amount at a given confidence level (binary search to $1)
/// - Success rate for a given spending level
/// - Percentile bands of portfolio value at each year (for charting)
const std = @import("std");
const builtin = @import("builtin");
const log = std.log.scoped(.projections);
const shiller = @import("../data/shiller.zig");
const srf = @import("srf");
const srf_opts = @import("../srf_opts.zig");
const Date = @import("../Date.zig");
/// `log.warn` wrapper that no-ops under `zig build test`. Used for
/// validation warnings emitted while parsing user-supplied
/// `projections.srf` records: the test suite intentionally feeds
/// invalid inputs to `validRetirementTarget` and the config-loader
/// to verify they're rejected, but the resulting stderr noise
/// pollutes test output. Keep using `log.warn` directly when the
/// warning is interesting in tests too.
fn warnUser(comptime fmt: []const u8, args: anytype) void {
if (builtin.is_test) return;
log.warn(fmt, args);
}
// ── Life events ─────────────────────────────────────────────────
/// A resolved event ready for the simulation loop. All age-based timing
/// has been converted to simulation years. The simulation functions only
/// need this - no person indices, no ages array.
pub const ResolvedEvent = struct {
start_year: u16,
duration: u16, // 0 = permanent
annual_amount: f64, // positive = income, negative = expense
inflation_adjusted: bool,
pub fn isActive(self: *const ResolvedEvent, y: u16) bool {
if (y < self.start_year) return false;
if (self.duration == 0) return true;
return y < self.start_year + self.duration;
}
pub fn cashFlow(self: *const ResolvedEvent, y: u16, cumulative_inflation: f64) f64 {
if (!self.isActive(y)) return 0;
if (self.inflation_adjusted) return self.annual_amount * cumulative_inflation;
return self.annual_amount;
}
};
/// A discrete cash flow event that modifies the simulation's annual
/// withdrawal. Positive amount = income (reduces withdrawal, e.g.
/// Social Security). Negative = expense (increases withdrawal, e.g.
/// college tuition).
pub const LifeEvent = struct {
name: [max_name_len]u8 = @splat(0),
name_len: u8 = 0,
start_age: u16,
person: u8 = 0, // 0-indexed into birthdates array
duration: u16 = 0, // 0 = permanent (until end of horizon)
annual_amount: f64, // positive = income, negative = expense
inflation_adjusted: bool = true,
const max_name_len = 48;
pub fn getName(self: *const LifeEvent) []const u8 {
return self.name[0..self.name_len];
}
/// Simulation year when this event starts, given the persons' current ages.
/// Returns null if the person index is out of range.
pub fn startYear(self: *const LifeEvent, current_ages: []const u16) ?u16 {
if (self.person >= current_ages.len) return null;
const age = current_ages[self.person];
if (self.start_age <= age) return 0;
return self.start_age - age;
}
/// Is this event active in simulation year `y`?
pub fn isActive(self: *const LifeEvent, y: u16, current_ages: []const u16) bool {
const start = self.startYear(current_ages) orelse return false;
if (y < start) return false;
if (self.duration == 0) return true; // permanent
return y < start + self.duration;
}
/// Cash flow contribution for simulation year `y`.
/// Positive = income (reduces net withdrawal), negative = expense.
pub fn cashFlow(self: *const LifeEvent, y: u16, cumulative_inflation: f64, current_ages: []const u16) f64 {
if (!self.isActive(y, current_ages)) return 0;
if (self.inflation_adjusted) return self.annual_amount * cumulative_inflation;
return self.annual_amount;
}
/// Resolve this event into a ResolvedEvent using the given current ages.
/// Returns null if the person index is out of range.
pub fn resolve(self: *const LifeEvent, current_ages: []const u16) ?ResolvedEvent {
return self.resolveToAge(current_ages, null);
}
/// Resolve into a ResolvedEvent, optionally terminating the event
/// at the holder's death. When `age_of_death` is non-null, the
/// event is capped so it stops the year `person` reaches that age:
/// a dead person neither collects income (Social Security,
/// pension, wages) nor incurs their own expenses. `age_of_death ==
/// null` reproduces `resolve` exactly (the event runs its
/// configured duration / to the horizon end).
///
/// Capping shortens the effective `duration` to `death_year -
/// start` (or the configured finite duration, whichever is
/// shorter). When the holder is already dead at or before the
/// event would start, the event is returned as never-active (the
/// `start_year = maxInt` sentinel).
///
/// Survivor *benefits* that outlive the holder (a pension's
/// survivor percentage, Social Security's keep-the-higher rule)
/// are intentionally not modeled here - they're configured as a
/// separate event tied to the surviving person. See the
/// projections-srf docs.
pub fn resolveToAge(self: *const LifeEvent, current_ages: []const u16, age_of_death: ?u16) ?ResolvedEvent {
const start = self.startYear(current_ages) orelse return null;
const aod = age_of_death orelse return .{
.start_year = start,
.duration = self.duration,
.annual_amount = self.annual_amount,
.inflation_adjusted = self.inflation_adjusted,
};
// Holder's death simulation-year (years from now). The
// person index is already validated by startYear above.
const age = current_ages[self.person];
const death_year: u16 = if (aod > age) aod - age else 0;
const never_active: ResolvedEvent = .{
.start_year = std.math.maxInt(u16),
.duration = 0,
.annual_amount = self.annual_amount,
.inflation_adjusted = self.inflation_adjusted,
};
// Dead before (or exactly when) the event would begin -> no
// cash flow at all.
if (death_year <= start) return never_active;
// Active window is [start, capped_end); capped_end is the
// first dead year. For a permanent event (duration 0) the
// only bound is death; for a finite event, the earlier of its
// natural end and death.
const max_span = death_year - start; // > 0 by the check above
const capped: u16 = if (self.duration == 0)
max_span
else
@min(self.duration, max_span);
return .{
.start_year = start,
.duration = capped,
.annual_amount = self.annual_amount,
.inflation_adjusted = self.inflation_adjusted,
};
}
};
// ── User configuration (from projections.srf) ──────────────────
/// Resolved retirement boundary, derived from `UserConfig` against a
/// reference date. The simulation consumes `accumulation_years` (an
/// integer), but the display layer renders the exact `date`.
pub const ResolvedRetirement = struct {
/// Whole years of accumulation between today and the retirement
/// date. The simulation runs in 1-year steps, so this is a
/// floor - the displayed `date` is exact.
accumulation_years: u16,
/// Exact retirement date for display. `null` when `source ==
/// .none` (no accumulation phase configured / already retired)
/// or `.promoted_infeasible` (the earliest-retirement cell was
/// selected but no accumulation length sustains the target
/// spending at the promoted confidence).
date: ?Date,
source: enum {
/// No retirement date configured. The line renders "none".
none,
/// User configured `retirement_at::DATE` directly.
at_date,
/// User configured `retirement_age:num:N`, resolved against
/// the oldest birthdate.
at_age,
/// User configured `target_spending` only. The retirement
/// line shows the promoted cell's date (the headline pick
/// from the earliest-retirement grid).
promoted,
/// Same as `.promoted` but the selected cell returned no
/// feasible accumulation_years from `findEarliestRetirement`.
/// The line renders "not feasible" instead of a date.
promoted_infeasible,
},
/// The accumulation/distribution boundary as a year-offset for the
/// projection chart's x-axis. `bands[i].year == i` in production,
/// so this offset doubles as the band index. Returns `null` when
/// there's no accumulation phase to mark (`accumulation_years == 0`:
/// already retired or distribution-only), in which case the chart
/// draws no divider. Otherwise the chart draws a vertical line at
/// this offset separating the saving phase (left) from the
/// withdrawal phase (right).
pub fn boundaryYear(self: ResolvedRetirement) ?u16 {
return if (self.accumulation_years == 0) null else self.accumulation_years;
}
};
/// User-configurable projection parameters, loaded from projections.srf.
///
/// Example projections.srf (union-tagged SRF records):
/// #!srfv1
/// type::config,target_stock_pct:num:77
/// type::config,horizon:num:30
/// type::config,horizon_age:num:90 # plan to age 90 (last survivor)
/// type::birthdate,date::1975-03-15
/// type::birthdate,date::1978-06-22,person:num:2
/// type::event,name::Social Security,start_age:num:67,amount:num:38400
pub const UserConfig = struct {
/// Target stock allocation percentage (0-100). Used for simulation blending.
target_stock_pct: ?f64 = null,
/// Annual fund expense ratio as a percentage (e.g. 0.18 = 0.18%),
/// applied as a drag on the blended return each simulated year.
/// **Defaults to 0.18%** -- FIRECalc's default and a
/// realistic, mildly conservative figure for a fund-holding
/// portfolio (modeling no fee at all is less accurate and makes the
/// projection too optimistic). Override via
/// `type::config,expense_ratio:num:0.04` for a pure low-cost index
/// portfolio, a higher value for active funds, or `0` for an
/// all-individual-stock portfolio. Stored as a percentage here
/// (like `target_stock_pct`); converted to the decimal the
/// simulation wants (`/100`) at the view boundary.
expense_ratio: f64 = 0.18,
/// Optional per-position return cap, as a percentage (e.g. `30` =
/// 30%). When set, each position's conservative MIN(3Y,5Y,10Y)
/// trailing return is clamped to this ceiling before being
/// market-value weighted into the "Projected return" estimate. This
/// keeps a single outlier (e.g. NVDA's recent run) from inflating
/// the forward-looking projected return. **Defaults to `null` (no
/// cap).** Stored as a percentage here (like `target_stock_pct` /
/// `expense_ratio`); converted to the decimal the analytics want
/// (`/100`) at the view boundary. Override via
/// `type::config,return_cap:num:30` in `projections.srf`.
///
/// Note this caps the *displayed* conservative "Projected return",
/// not the Monte Carlo bands - those blend Shiller S&P/bond history
/// by the portfolio's aggregate stock_pct and never see individual
/// positions.
return_cap: ?f64 = null,
/// Retirement horizons to simulate (years). Defaults to 20,30,45.
horizons: [max_horizons]u16 = .{ 20, 30, 45 } ++ @as([max_horizons - 3]u16, @splat(0)),
horizon_count: u8 = 3,
/// Per-horizon `retirement_target` annotation (90/95/99 confidence
/// percentage, or 0 = no annotation). Parallel to `horizons`. At
/// most one horizon may carry a non-zero value; when more than
/// one is configured, all annotations are dropped (validation
/// failure -> fall back to the default promotion rule).
///
/// Used by the target-spending input to pick which (horizon,
/// confidence) cell from the Earliest retirement grid to
/// promote into the Accumulation phase block. See
/// `pickPromotedCell` for the resolution algorithm.
horizon_targets: [max_horizons]u8 = @splat(0),
/// Per-horizon age-of-death anchor, parallel to `horizons`. `0`
/// marks a plain numeric horizon (a fixed distribution length, no
/// mortality modeling - today's behavior). A non-zero value `A`
/// marks an age-anchored column: the distribution runs until the
/// last surviving member reaches age `A`, the column carries the
/// survivor spending step-down at the first death, and each
/// person's income/expense events terminate at their own death.
/// Populated by `resolveHorizonAges` when it resolves a
/// `horizon_age` record into a `horizons` slot.
horizon_death_age: [max_horizons]u16 = @splat(0),
/// Age-based horizon targets ("plan until age N"). Resolved at
/// context-load time by `resolveHorizonAges` to
/// `target_age - youngestAge(as_of)` - how long until the
/// *youngest* (last-surviving) member reaches `target_age`, since
/// the household's assets must last until the last death. Each
/// resolved entry also sets the matching `horizon_death_age` slot,
/// turning on the mortality model (survivor step-down at the first
/// death, per-person event termination).
horizon_ages: [max_horizons]u16 = @splat(0),
/// Per-`horizon_age` `retirement_target` annotation, parallel to
/// `horizon_ages`. Carried through to the resolved `horizon_targets`
/// slot when `resolveHorizonAges` appends the resolved year count.
horizon_age_targets: [max_horizons]u8 = @splat(0),
horizon_age_count: u8 = 0,
/// Confidence levels for safe withdrawal. Always 90/95/99.
confidence_levels: [3]f64 = .{ 0.90, 0.95, 0.99 },
/// Birthdates for age-based event timing.
birthdates: [max_persons]Date = @splat(Date.fromYmd(1970, 1, 1)),
birthdate_count: u8 = 0,
/// Life events (income/expenses) that modify annual cash flow.
// SAFETY: paired with `event_count`; only `events[0..event_count]` is read.
events: [max_events]LifeEvent = undefined,
event_count: u8 = 0,
// ── Accumulation phase ──────────────────────────────────────
/// Target retirement age for the oldest configured person. The
/// retirement date is the day they turn this age (clamping Feb 29
/// to Feb 28 in non-leap target years). Mutually exclusive with
/// `retirement_at`; if both are set, `retirement_at` wins.
retirement_age: ?u16 = null,
/// Absolute retirement date. Wins over `retirement_age` when both
/// are set.
retirement_at: ?Date = null,
/// Total household contributions per year, in today's dollars.
/// Defaults to zero (distribution-only behavior).
annual_contribution: f64 = 0,
/// If true, contributions grow with CPI year-over-year (modeling
/// a constant percentage of CPI-tracked income). If false,
/// contributions are nominal.
contribution_inflation_adjusted: bool = true,
/// Target annual spending in today's dollars. When set, the
/// projections command will search for the earliest retirement
/// date at which this spending level is sustainable.
target_spending: ?f64 = null,
/// If true, the target spending grows with CPI during the
/// distribution phase (matches the existing SWR model).
target_spending_inflation_adjusted: bool = true,
/// Percent of the couple's (joint) spending the surviving spouse
/// needs after the first household death, for age-anchored
/// columns. Stored as a percent (like `target_stock_pct` /
/// `expense_ratio`); converted to the `SimParams.survivor_factor`
/// decimal (`/100`) at the view boundary. **Defaults to 75** (a
/// 25% reduction), the conservative edge of the standard
/// equivalence-scale range - see the projections-model docs for
/// the sourcing (OECD-modified scale 67%, square-root scale 71%,
/// planning-software convention ~80%). Set via
/// `type::config,survivor_spending_pct:num:N`. Only takes effect
/// for a multi-person household with an age-of-death gap; a single
/// person or a same-age couple has no survivor sub-phase. Negative
/// values are rejected at parse time; otherwise any value is
/// honored (a survivor whose spending *rises* is a real case).
survivor_spending_pct: f64 = 75,
/// Signed annual *real* change in spending across the
/// distribution phase, as a fraction (e.g. -0.02 = declines
/// 2%/yr, +0.01 = rises 1%/yr). `null` -> flat real spending,
/// the historical default. Set via
/// `type::config,spending_change:num:N` where N is a whole
/// percent (negative = decline). Feeds `SimParams.
/// spending_real_change` for every horizon/confidence cell.
spending_real_change: ?f64 = null,
/// Ceiling on the accumulation years the earliest-retirement
/// search (`findEarliestRetirement`) will consider when
/// `target_spending` is set. Defaults to
/// `default_max_accumulation_years` (50). Override via
/// `type::config,max_accumulation_years:num:N` in projections.srf
/// for someone with a longer-than-50-year planning runway (a
/// young saver). Clamped at parse time to
/// `max_configurable_accumulation_years`. Only affects the
/// target-spending search path; an explicit `retirement_age` /
/// `retirement_at` derives its accumulation years directly and
/// ignores this cap.
max_accumulation_years: u16 = default_max_accumulation_years,
/// Stock benchmark symbol for the projection's benchmark-comparison
/// table and bands, as an inline buffer + length. Read it through
/// `benchmarkStock()`, never directly: `benchmark_stock_len == 0`
/// means "no override, use `default_benchmark_stock`".
///
/// Override via `type::config,benchmark_stock::SYMBOL` in
/// `projections.srf`.
///
/// This USED to be a `[]const u8` that pointed into the sibling
/// buffer when overridden. That is a self-referential struct, and
/// `parseProjectionsConfig` returns `UserConfig` BY VALUE - so the
/// returned copy's slice pointed into the dead local's frame. An
/// override therefore arrived downstream as N bytes of whatever the
/// stack had been reused for (observed: NULs), silently blanking the
/// stock leg of the benchmark table. The defaults never broke
/// because string literals live in .rodata, which is why a feature
/// that had probably never worked went unnoticed.
///
/// Do not reintroduce a stored slice here. The codebase convention
/// is inline-buffer + length + accessor precisely so a value type
/// stays copy-safe - see `LifeEvent.getName` below, plus
/// `models/quote.zig:Quote.name`, `analytics/analysis.zig:Annotation`,
/// `commands/cache.zig:FileInfo.lastDate`, and `tui.zig:ParsedArgs.symbol`.
// SAFETY: paired with `benchmark_stock_len`; only
// `benchmark_stock_buf[0..benchmark_stock_len]` is ever read, and
// `benchmarkStock()` returns the default literal when the length is 0.
benchmark_stock_buf: [16]u8 = undefined,
/// Length of the `benchmark_stock` override; 0 = use the default.
benchmark_stock_len: u8 = 0,
/// Bond benchmark symbol. Same buffer + length + accessor mechanics
/// as `benchmark_stock`; read via `benchmarkBond()`.
// SAFETY: paired with `benchmark_bond_len`; same read-only-the-prefix
// invariant as `benchmark_stock_buf`.
benchmark_bond_buf: [16]u8 = undefined,
/// Length of the `benchmark_bond` override; 0 = use the default.
benchmark_bond_len: u8 = 0,
const max_horizons: usize = 8;
const max_persons: usize = 4;
pub const max_events: usize = 16;
/// Benchmark symbols used when `projections.srf` declares no
/// override. Public so the fetch-policy layer and tests can name
/// them instead of duplicating the literals.
pub const default_benchmark_stock: []const u8 = "SPY";
pub const default_benchmark_bond: []const u8 = "AGG";
/// Errors that can arise when resolving age-based horizons.
pub const ResolveError = error{
/// `type::config,horizon_age:num:N` was specified in projections.srf
/// but no `type::birthdate` record exists to anchor the calculation.
HorizonAgeWithoutBirthdate,
};
/// The stock benchmark symbol: the user's override, else
/// `default_benchmark_stock`.
///
/// Derives the slice from `self` at call time, which is what makes
/// `UserConfig` copy-safe. Returning a stored slice into
/// `benchmark_stock_buf` instead is the bug documented on that
/// field - don't.
pub fn benchmarkStock(self: *const UserConfig) []const u8 {
if (self.benchmark_stock_len == 0) return default_benchmark_stock;
return self.benchmark_stock_buf[0..self.benchmark_stock_len];
}
/// The bond benchmark symbol: the user's override, else
/// `default_benchmark_bond`. Same copy-safety note as
/// `benchmarkStock`.
pub fn benchmarkBond(self: *const UserConfig) []const u8 {
if (self.benchmark_bond_len == 0) return default_benchmark_bond;
return self.benchmark_bond_buf[0..self.benchmark_bond_len];
}
pub fn getHorizons(self: *const UserConfig) []const u16 {
return self.horizons[0..self.horizon_count];
}
pub fn getConfidenceLevels(self: *const UserConfig) []const f64 {
return &self.confidence_levels;
}
pub fn getEvents(self: *const UserConfig) []const LifeEvent {
return self.events[0..self.event_count];
}
/// Compute ages (in whole years) as of `as_of`. Pass today's date
/// for "current ages"; pass a historical date for backfill.
pub fn currentAges(self: *const UserConfig, as_of: Date) [max_persons]u16 {
var ages: [max_persons]u16 = @splat(0);
for (0..self.birthdate_count) |i| {
ages[i] = Date.wholeYearsBetween(self.birthdates[i], as_of);
}
return ages;
}
/// Resolve age-based horizons (`horizon_ages`) into `horizons`
/// slots, anchored on the **youngest** configured person (the
/// last survivor). For each target age `A`, the appended horizon
/// value is `A - youngestAge(as_of)` - the number of years until
/// the youngest person hits `A`, i.e. how long the money must
/// last if retiring now (the total span from `as_of` to the last
/// death). The matching `horizon_death_age` slot is set to `A` so
/// the column is flagged age-anchored: downstream the distribution
/// length is derived from the retirement date (`A - youngestAge -
/// accumulation_years`), the survivor step-down anchors at the
/// oldest person's death, and per-person events terminate at each
/// holder's death. Targets already in the past (youngest age >=
/// target - everyone is gone) are silently skipped.
///
/// The youngest anchor (vs the oldest) is the financial-planning
/// standard for couples: assets must fund the household until the
/// last surviving member dies. See the projections-model docs.
///
/// Errors if `horizon_ages` is non-empty but no birthdate is configured.
/// Safe to call multiple times; subsequent calls are no-ops because
/// `horizon_age_count` is cleared after resolution.
pub fn resolveHorizonAges(self: *UserConfig, as_of: Date) ResolveError!void {
if (self.horizon_age_count == 0) return;
if (self.birthdate_count == 0) return error.HorizonAgeWithoutBirthdate;
const youngest = self.youngestAge(as_of);
for (0..self.horizon_age_count) |i| {
const target = self.horizon_ages[i];
if (target <= youngest) continue; // last survivor already past target
const years: u16 = target - youngest;
if (self.horizon_count < max_horizons) {
self.horizons[self.horizon_count] = years;
// Flag this column age-anchored so the mortality
// semantics (last-survivor horizon, survivor
// step-down, per-person event termination) apply.
self.horizon_death_age[self.horizon_count] = target;
// Carry through any retirement_target annotation from
// the source horizon_age record.
self.horizon_targets[self.horizon_count] = self.horizon_age_targets[i];
self.horizon_count += 1;
}
}
// Clear so a second call is a no-op.
self.horizon_age_count = 0;
}
/// Resolve age-based horizons using today's date. Convenience wrapper
/// around `resolveHorizonAges`.
pub fn resolveHorizonAgesNow(self: *UserConfig) ResolveError!void {
return self.resolveHorizonAges(Date.fromEpoch(std.time.timestamp()));
}
/// Sum all event cash flows for simulation year `y`.
pub fn eventNetCashFlow(self: *const UserConfig, y: u16, cumulative_inflation: f64, current_ages: []const u16) f64 {
var total: f64 = 0;
for (self.events[0..self.event_count]) |*ev| {
total += ev.cashFlow(y, cumulative_inflation, current_ages);
}
return total;
}
/// Resolve all events into ResolvedEvents for the simulation.
/// Skips events with invalid person indices.
pub fn resolveEvents(self: *const UserConfig, as_of: Date) [max_events]ResolvedEvent {
const ages = self.currentAges(as_of);
return resolveEventsWithAges(self, &ages);
}
/// Resolve all events, terminating each per-person event at its
/// holder's death (the year that person reaches `age_of_death`).
/// `age_of_death == null` is identical to `resolveEvents`. Used
/// per age-anchored column so a deceased spouse's Social Security
/// / pension / wages stop instead of paying out for the rest of
/// the (last-survivor) horizon.
pub fn resolveEventsToAge(self: *const UserConfig, as_of: Date, age_of_death: ?u16) [max_events]ResolvedEvent {
const ages = self.currentAges(as_of);
return resolveEventsWithAgesToAge(self, &ages, age_of_death);
}
/// Resolve all events using pre-computed ages (for testing).
pub fn resolveEventsWithAges(self: *const UserConfig, ages: []const u16) [max_events]ResolvedEvent {
return resolveEventsWithAgesToAge(self, ages, null);
}
/// Resolve all events using pre-computed ages, optionally
/// terminating each at its holder's death. Events with invalid
/// person indices become never-active sentinels.
pub fn resolveEventsWithAgesToAge(self: *const UserConfig, ages: []const u16, age_of_death: ?u16) [max_events]ResolvedEvent {
var resolved: [max_events]ResolvedEvent = undefined;
for (self.events[0..self.event_count], 0..) |*ev, i| {
resolved[i] = ev.resolveToAge(ages, age_of_death) orelse .{
.start_year = std.math.maxInt(u16), // effectively never active
.duration = 0,
.annual_amount = 0,
.inflation_adjusted = true,
};
}
return resolved;
}
/// Resolve the configured retirement boundary against `as_of`.
/// Returns the integer accumulation_years used by the simulation,
/// the displayed exact date, and the resolution source.
///
/// `as_of` is the reference date - pass today's date for live
/// mode, or a historical snapshot date when re-running the
/// projection against past data. The function works correctly
/// for any reference date.
///
/// Resolution rules:
/// - `retirement_at` set and not in the past (relative to
/// `as_of`) -> that date.
/// - `retirement_age` set, with at least one birthdate, and
/// the oldest person hasn't already passed that age as of
/// `as_of` -> the date that person turns the target age
/// (clamping Feb 29 to Feb 28 in non-leap target years).
/// - Otherwise -> `.none`. accumulation_years = 0.
///
/// `retirement_at` wins when both are set.
pub fn resolveRetirement(self: *const UserConfig, as_of: Date) ResolvedRetirement {
if (self.retirement_at) |d| {
if (d.lessThan(as_of)) return .{
.accumulation_years = 0,
.date = null,
.source = .none,
};
return .{
.accumulation_years = Date.wholeYearsBetween(as_of, d),
.date = d,
.source = .at_date,
};
}
if (self.retirement_age) |target_age| {
const oldest_bd = self.oldestBirthdate() orelse return .{
.accumulation_years = 0,
.date = null,
.source = .none,
};
const ret_date = oldest_bd.addYears(target_age);
if (ret_date.lessThan(as_of)) return .{
.accumulation_years = 0,
.date = null,
.source = .none,
};
return .{
.accumulation_years = Date.wholeYearsBetween(as_of, ret_date),
.date = ret_date,
.source = .at_age,
};
}
return .{ .accumulation_years = 0, .date = null, .source = .none };
}
/// Find the birthdate of the oldest configured person - the
/// earliest date in `birthdates[]`. Returns null if no
/// birthdates are configured.
///
/// Used by `resolveRetirement` (with `retirement_age`),
/// `resolveHorizonAges`, and `pickPromotedCell` to anchor any
/// "oldest person" computation against a single source of
/// truth. Pair with `Date.wholeYearsBetween(oldest, as_of)` for
/// "oldest person's age right now"; that's also packaged as
/// `oldestAge(as_of)` for caller convenience.
pub fn oldestBirthdate(self: *const UserConfig) ?Date {
if (self.birthdate_count == 0) return null;
var oldest = self.birthdates[0];
var i: u8 = 1;
while (i < self.birthdate_count) : (i += 1) {
if (self.birthdates[i].lessThan(oldest)) oldest = self.birthdates[i];
}
return oldest;
}
/// Age (whole years) of the oldest configured person as of
/// `as_of`. Returns 0 when no birthdates are configured (which
/// callers should treat as "no person to age out" rather than
/// "person aged zero").
pub fn oldestAge(self: *const UserConfig, as_of: Date) u16 {
const oldest = self.oldestBirthdate() orelse return 0;
return Date.wholeYearsBetween(oldest, as_of);
}
/// Find the birthdate of the youngest configured person - the
/// latest date in `birthdates[]`. Returns null if no birthdates
/// are configured.
///
/// The youngest person sets the *last-survivor* horizon: under a
/// single shared age-of-death, they reach it latest in calendar
/// time, so they bound how long the money must last. This is the
/// financial-planning standard for couples (fund until the last
/// death; see the projections-model docs and Blanchett 2021).
/// Pair with `Date.wholeYearsBetween(youngest, as_of)`, packaged
/// as `youngestAge(as_of)`.
pub fn youngestBirthdate(self: *const UserConfig) ?Date {
if (self.birthdate_count == 0) return null;
var youngest = self.birthdates[0];
var i: u8 = 1;
while (i < self.birthdate_count) : (i += 1) {
if (youngest.lessThan(self.birthdates[i])) youngest = self.birthdates[i];
}
return youngest;
}
/// Age (whole years) of the youngest configured person as of
/// `as_of`. Returns 0 when no birthdates are configured.
pub fn youngestAge(self: *const UserConfig, as_of: Date) u16 {
const youngest = self.youngestBirthdate() orelse return 0;
return Date.wholeYearsBetween(youngest, as_of);
}
};
// ── SRF parse types (private) ───────────────────────────────────
const SrfConfig = struct {
type: []const u8 = "",
target_stock_pct: ?f64 = null,
expense_ratio: ?f64 = null,
return_cap: ?f64 = null,
horizon: ?u16 = null,
horizon_age: ?u16 = null,
/// Earliest-retirement promotion override: when paired with
/// `horizon` or `horizon_age`, marks that horizon as the one to
/// use for the promoted retirement-line cell. Allowed values:
/// 90, 95, 99.
/// Anything else is rejected at parse time.
retirement_target: ?u8 = null,
retirement_age: ?u16 = null,
retirement_at: ?Date = null,
annual_contribution: ?f64 = null,
contribution_inflation_adjusted: ?bool = null,
target_spending: ?f64 = null,
target_spending_inflation_adjusted: ?bool = null,
/// Signed annual real spending change, in whole percent
/// (negative = decline). Parsed/clamped into
/// `UserConfig.spending_real_change` as a fraction.
spending_change: ?f64 = null,
/// Percent of joint spending the surviving spouse needs after the
/// first death (default 75 in UserConfig). Negative rejected.
survivor_spending_pct: ?f64 = null,
max_accumulation_years: ?u16 = null,
benchmark_stock: ?[]const u8 = null,
benchmark_bond: ?[]const u8 = null,
};
const SrfBirthdate = struct {
type: []const u8 = "",
date: Date,
person: ?u8 = null, // 1-indexed in SRF; null = sequential
};
const SrfEvent = struct {
type: []const u8 = "",
name: []const u8 = "",
start_age: u16 = 0,
person: u8 = 1, // 1-indexed in SRF
duration: u16 = 0,
amount: f64 = 0,
inflation_adjusted: bool = true,
};
const SrfProjection = union(enum) {
pub const srf_tag_field = "type";
config: SrfConfig,
birthdate: SrfBirthdate,
event: SrfEvent,
};
/// Clamp on the magnitude of `spending_change` (10%/yr real, in
/// either direction). A larger drift is almost certainly a units
/// typo - someone entering a fraction (0.02) where a whole percent
/// (2) was expected reads as 0.02%/yr (negligible), but the reverse
/// (entering 20 meaning 0.20) would otherwise crater spending to
/// zero within a decade. The clamp keeps a fat-fingered value from
/// silently producing nonsense.
pub const max_abs_spending_real_change: f64 = 0.10;
/// The benchmark stock/bond symbols configured by the `projections.srf` at
/// `path`.
///
/// Returns the SPY/AGG defaults when the file is absent or silent.
///
/// Lives here, next to the config it reads. Takes a resolved PATH rather than a
/// directory so this module does no path arithmetic: locating a file beside the
/// portfolio anchor is one rule that belongs in one place
/// (`commands/common.siblingPath`), and an earlier directory-taking version of
/// this had three callers each hand-rolling that join.
///
/// Command code should not call this directly - go through
/// `commands/common.demandFetchedSymbols`, so that "which symbols are fetched on
/// demand" stays a fetch-policy question and the cache sweep does not have to
/// know that the answer happens to come from projections config.
///
/// The strings are DUPED into `arena` so the result outlives the local
/// `UserConfig` this reads them from. (An overridden symbol lives in an
/// inline `[16]u8` inside that config, so borrowing would tie the
/// caller's lifetime to a function-local; `UserConfig` itself is
/// copy-safe, but a slice into one particular copy of it is not.)
pub fn benchmarkSymbols(io: std.Io, arena: std.mem.Allocator, path: []const u8) []const []const u8 {
const data = std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(64 * 1024)) catch null;
const cfg = parseProjectionsConfig(data);
const pair = arena.alloc([]const u8, 2) catch return &.{};
pair[0] = arena.dupe(u8, cfg.benchmarkStock()) catch return &.{};
pair[1] = arena.dupe(u8, cfg.benchmarkBond()) catch return &.{};
return pair;
}
/// Parse a projections.srf file into a UserConfig.
/// Returns default config if data is null or unparseable.
///
/// Uses an internal stack-backed FixedBufferAllocator for the SRF
/// iterator's scratch. The default `parse_allocator` keeps short
/// string values borrowing from `data` (no copy) and transparently
/// allocates from the iterator's fallback arena for any
/// multi-line/binary values (e.g. an event `name` containing a
/// comma, which `srf.fmt` encodes with a length prefix). The 8 KB
/// buffer comfortably fits any realistic projections.srf - a
/// handful of config + birthdate + event records. On overflow the
/// parse aborts and we return the default config, matching the
/// existing "unparseable -> defaults" contract.
///
/// Format (union-tagged SRF records):
/// type::config,target_stock_pct:num:80
/// type::config,horizon:num:30
/// type::config,spending_change:num:-2
/// type::birthdate,date::1975-03-15
/// type::event,name::Social Security,start_age:num:67,amount:num:38400
pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig {
var config = UserConfig{};
const raw = data orelse return config;
if (raw.len == 0) return config;
var scratch_buf: [8 * 1024]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&scratch_buf);
const scratch = fba.allocator();
var reader = std.Io.Reader.fixed(raw);
var it = srf.iterator(&reader, scratch, .{ .parse_allocator = .none }) catch return config;
defer it.deinit();
var saw_horizon = false;
var birthdate_seq: u8 = 0;
// Count of valid `retirement_target` annotations seen during
// parse (across both `horizon` and `horizon_age` records). More
// than one is a configuration error - we'll drop them all
// post-loop and let `pickPromotedCell` fall back to the default
// rule. A single bad value (not in {90,95,99}) is treated as
// "no annotation on this record" and doesn't poison the others.
var annotation_count: u8 = 0;
while (it.next() catch null) |field_it| {
const rec = field_it.to(SrfProjection, srf_opts.user_edited) catch |err| {
// Skip the record rather than losing the whole file, but
// name the error: a dropped record reverts that setting to
// its default without saying so. Quiet under
// `zig build test`, where fixtures feed malformed records
// on purpose.
if (!builtin.is_test) {
log.warn("projections.srf: skipping malformed record: {s}", .{@errorName(err)});
}
continue;
};
switch (rec) {
.config => |c| {
config.target_stock_pct = c.target_stock_pct orelse config.target_stock_pct;
config.expense_ratio = c.expense_ratio orelse config.expense_ratio;
if (c.return_cap) |cap| {
// A return cap is a ceiling on a position's expected
// forward return; a negative ceiling is nonsensical.
// Stored as a percent (e.g. 30 = 30%).
if (cap >= 0) {
config.return_cap = cap;
} else {
warnUser("projections: return_cap must be >= 0 (got {d}); ignoring record", .{cap});
}
}
if (c.horizon) |h| {
if (!saw_horizon) {
config.horizon_count = 0;
saw_horizon = true;
}
if (h == 0) {
log.warn("projections: horizon must be > 0; ignoring record", .{});
} else if (config.horizon_count >= UserConfig.max_horizons) {
log.warn("projections: horizon limit reached ({d}); ignoring extra horizon record (value {d})", .{ UserConfig.max_horizons, h });
} else {
config.horizons[config.horizon_count] = h;
if (validRetirementTarget(c.retirement_target)) |conf| {
config.horizon_targets[config.horizon_count] = conf;
annotation_count += 1;
}
config.horizon_count += 1;
}
}
if (c.horizon_age) |age| {
// Age-based horizons are stored raw and resolved later
// via `UserConfig.resolveHorizonAges(as_of)` once the
// view layer knows the projection date. They also count
// as "saw_horizon" so a file containing only
// `horizon_age` records replaces the default {20,30,45}
// once resolved.
if (!saw_horizon) {
config.horizon_count = 0;
saw_horizon = true;
}
if (age == 0) {
log.warn("projections: horizon_age must be > 0; ignoring record", .{});
} else if (config.horizon_age_count >= UserConfig.max_horizons) {
log.warn("projections: horizon_age limit reached ({d}); ignoring extra horizon_age record (value {d})", .{ UserConfig.max_horizons, age });
} else {
config.horizon_ages[config.horizon_age_count] = age;
if (validRetirementTarget(c.retirement_target)) |conf| {
config.horizon_age_targets[config.horizon_age_count] = conf;
annotation_count += 1;
}
config.horizon_age_count += 1;
}
}
config.retirement_age = c.retirement_age orelse config.retirement_age;
config.retirement_at = c.retirement_at orelse config.retirement_at;
if (c.annual_contribution) |amt| {
// Negative values are nonsensical (a contribution
// is income into the portfolio); drop the record.
if (amt >= 0) {
config.annual_contribution = amt;
} else {
warnUser("projections: annual_contribution must be >= 0 (got {d}); ignoring record", .{amt});
}
}
if (c.contribution_inflation_adjusted) |b| {
config.contribution_inflation_adjusted = b;
}
if (c.target_spending) |amt| {
if (amt >= 0) {
config.target_spending = amt;
} else {
warnUser("projections: target_spending must be >= 0 (got {d}); ignoring record", .{amt});
}
}
if (c.target_spending_inflation_adjusted) |b| {
config.target_spending_inflation_adjusted = b;
}
if (c.spending_change) |pct| {
// Entered as a whole percent (negative = decline,
// positive = rising real spending); stored as a
// fraction. Clamp the magnitude so a units typo
// can't drive spending to zero or absurd growth.
const frac = pct / 100.0;
const cap = max_abs_spending_real_change;
if (frac > cap) {
warnUser("projections: spending_change capped at +{d:.0}%/yr (got {d}%)", .{ cap * 100.0, pct });
config.spending_real_change = cap;
} else if (frac < -cap) {
warnUser("projections: spending_change capped at -{d:.0}%/yr (got {d}%)", .{ cap * 100.0, pct });
config.spending_real_change = -cap;
} else {
config.spending_real_change = frac;
}
}
if (c.survivor_spending_pct) |pct| {
// Percent of joint spending the survivor needs
// after the first death. Negative is nonsensical
// (spending can't be negative); otherwise any
// value is honored - a survivor whose spending
// rises above the couple's is a real, if uncommon,
// case (e.g. the deceased was the frugal one).
if (pct >= 0) {
config.survivor_spending_pct = pct;
} else {
warnUser("projections: survivor_spending_pct must be >= 0 (got {d}); ignoring record", .{pct});
}
}
if (c.max_accumulation_years) |n| {
if (n == 0) {
// A zero-year search ceiling is degenerate (it
// would only ever ask "can I retire today?").
// Almost certainly a typo; keep the default.
warnUser("projections: max_accumulation_years must be > 0; ignoring record", .{});
} else if (n > max_configurable_accumulation_years) {
// Respect the intent (the user wants a large
// ceiling) but clamp to keep the search bounded
// and inside the historical data span.
warnUser("projections: max_accumulation_years capped at {d} (got {d})", .{ max_configurable_accumulation_years, n });
config.max_accumulation_years = max_configurable_accumulation_years;
} else {
config.max_accumulation_years = n;
}
}
if (c.benchmark_stock) |sym| {
if (sym.len == 0 or sym.len > config.benchmark_stock_buf.len) {
warnUser("projections: benchmark_stock must be 1..{d} chars (got {d}); ignoring record", .{ config.benchmark_stock_buf.len, sym.len });
} else {
// Copy into our own buffer + length so the value
// outlives both the SRF iterator's backing data
// AND this function's frame. Storing a slice into
// the buffer instead would dangle the moment
// `config` is returned by value. Same shape as
// the `.event` arm's name handling below.
@memcpy(config.benchmark_stock_buf[0..sym.len], sym);
config.benchmark_stock_len = @intCast(sym.len);
}
}
if (c.benchmark_bond) |sym| {
if (sym.len == 0 or sym.len > config.benchmark_bond_buf.len) {
warnUser("projections: benchmark_bond must be 1..{d} chars (got {d}); ignoring record", .{ config.benchmark_bond_buf.len, sym.len });
} else {
@memcpy(config.benchmark_bond_buf[0..sym.len], sym);
config.benchmark_bond_len = @intCast(sym.len);
}
}
},
.birthdate => |b| {
// person is 1-indexed in SRF; convert to 0-indexed.
// If not specified, assign sequentially.
const idx: u8 = if (b.person) |p| p -| 1 else birthdate_seq;
if (idx < UserConfig.max_persons) {
config.birthdates[idx] = b.date;
if (idx >= config.birthdate_count) config.birthdate_count = idx + 1;
} else {
log.warn("projections: birthdate person index {d} exceeds limit ({d}); ignoring record", .{ idx + 1, UserConfig.max_persons });
}
birthdate_seq += 1;
},
.event => |e| {
if (e.start_age == 0) {
log.warn("projections: event '{s}' has start_age 0; ignoring record", .{e.name});
} else if (config.event_count >= UserConfig.max_events) {
log.warn("projections: event limit reached ({d}); ignoring extra event '{s}'", .{ UserConfig.max_events, e.name });
} else {
var ev = LifeEvent{
.start_age = e.start_age,
.person = e.person -| 1, // 1-indexed -> 0-indexed
.duration = e.duration,
.annual_amount = e.amount,
.inflation_adjusted = e.inflation_adjusted,
};
const len = @min(e.name.len, LifeEvent.max_name_len);
@memcpy(ev.name[0..len], e.name[0..len]);
ev.name_len = @intCast(len);
config.events[config.event_count] = ev;
config.event_count += 1;
}
},
}
}
// Validation: at most one `retirement_target` annotation may be
// present across all horizon and horizon_age records. If more
// than one was seen, drop them all and let `pickPromotedCell`
// fall back to the default rule. Logged as a warning so the
// user knows their override was ignored.
if (annotation_count > 1) {
warnUser("projections: retirement_target set on multiple horizons; ignoring all annotations and using default promotion rule", .{});
config.horizon_targets = @splat(0);
config.horizon_age_targets = @splat(0);
}
return config;
}
/// Validate a `retirement_target` SRF value. Returns the value
/// unchanged if it's exactly 90, 95, or 99; returns null otherwise
/// (logged as a warning so the user notices the typo). Used at parse
/// time; the view-layer `pickPromotedCell` trusts whatever lands in
/// `horizon_targets`.
fn validRetirementTarget(raw: ?u8) ?u8 {
const v = raw orelse return null;
if (v == 90 or v == 95 or v == 99) return v;
warnUser("projections: retirement_target must be 90, 95, or 99 (got {d}); annotation ignored", .{v});
return null;
}
// ── Results ────────────────────────────────────────────────────
pub const WithdrawalResult = struct {
/// Confidence level (e.g. 0.99 = 99%).
confidence: f64,
/// Maximum annual withdrawal that achieves this confidence.
annual_amount: f64,
/// As a fraction of starting portfolio value.
withdrawal_rate: f64,
};
pub const YearPercentiles = struct {
/// Year offset from retirement start (0 = start, 1 = after year 1, etc.)
year: u16,
p10: f64,
p25: f64,
p50: f64,
p75: f64,
p90: f64,
};
// ── Core simulation ────────────────────────────────────────────
/// Parameters bundling the full two-phase simulation inputs. Used
/// internally by all simulation entry points so the same code path
/// handles both distribution-only (today's behavior, with
/// `accumulation_years == 0`) and accumulation-then-distribution.
pub const SimParams = struct {
initial_value: f64,
stock_pct: f64,
annual_spending: f64,
spending_inflation_adjusted: bool = true,
/// Signed annual *real* change in spending, applied across the
/// distribution phase (the "spending smile" / Blanchett model).
/// A fraction: -0.02 = spending declines 2%/yr in real terms
/// ("slow-go" years), +0.01 = rises 1%/yr. `0` (the default) is
/// flat real spending - the historical behavior, byte-identical.
///
/// `annual_spending` is the *first* distribution year's spend;
/// year `d` of distribution spends `annual_spending * (1 +
/// spending_real_change)^d` in real terms, then the usual CPI
/// factor converts to nominal. Localized late-life cost humps
/// (healthcare) are modeled separately as `events`, so this is a
/// monotonic drift, not the full U-curve.
spending_real_change: f64 = 0,
/// Distribution-phase length (the "horizon" in the existing API).
distribution_years: u16,
accumulation_years: u16 = 0,
annual_contribution: f64 = 0,
contribution_inflation_adjusted: bool = true,
/// Annual fund expense ratio (decimal, e.g. 0.0018 = 0.18%),
/// subtracted from the blended market return each year in both
/// phases. Defaults to 0 (no fee modeled). Mirrors FIRECalc's
/// "investment expenses" drag; its default is 0.18%.
expense_ratio: f64 = 0,
events: []const ResolvedEvent = &.{},
/// Multiplier applied to base `annual_spending` once the first
/// household death occurs, modeling the surviving spouse's
/// reduced consumption need (couple -> single). A fraction:
/// 0.75 = the survivor spends 75% of the couple's spending (a
/// 25% reduction). `1.0` (the default) is no change - the
/// behavior for a single person, a same-age couple, or any
/// projection without an age-of-death anchor, and byte-identical
/// to the pre-survivor model. Only base spending is scaled;
/// per-person income/expense events terminate at their holder's
/// death separately (via capped event durations).
survivor_factor: f64 = 1.0,
/// Absolute simulation year (years from `as_of`/now) of the first
/// household death - the oldest configured person reaching the
/// age-of-death. In any distribution-phase year `y >=
/// first_death_year`, base spending is scaled by `survivor_factor`.
/// `null` (the default) disables the step entirely. Equal to the
/// horizon end (i.e. no gap) for a single person or same-age
/// couple, in which case the step never triggers inside the loop.
first_death_year: ?u16 = null,
/// Total simulated path length (including year 0).
pub fn totalYears(self: SimParams) u16 {
return self.accumulation_years + self.distribution_years;
}
};
/// Optional alternate market dataset, used by tests to inject a
/// synthetic constant-return / constant-CPI fixture. When `null`, the
/// global `shiller.annual_returns` is used.
const ShillerYearSlice = []const shiller.ShillerYear;
/// Maximum cycles available given a total horizon. Returns 0 if no
/// data covers the full horizon.
///
/// Counts every cohort whose full span fits in the data: a cohort
/// starting at index `i` reads `data[i .. i + total_years - 1]`, so
/// the valid starts are `0 .. data.len - total_years` inclusive,
/// i.e. `data.len - total_years + 1` cohorts. This matches FIRECalc's
/// convention ("1871, 1872, ... until the most recent year for which
/// there are results available") and `shiller.maxCycles`. Earlier
/// this returned `data.len - total_years`, which silently dropped the
/// single most-recent cohort (e.g. the 1996-2025 start for a 30-year
/// horizon) -- a complete, often-stressful sequence. See the FIRECalc
/// parity suite below.
fn maxCyclesFor(data: ShillerYearSlice, total_years: u16) usize {
if (data.len < total_years) return 0;
return data.len - total_years + 1;
}
/// Simulate a single cycle of the two-phase model:
/// 1. Accumulation: contributions in, life events, market return,
/// CPI advance. No spending. Failure not counted.
/// 2. Distribution: spending out, life events, market return, CPI
/// advance. Failure (portfolio ≤ 0) records and stops further
/// simulation, with subsequent years zeroed.
///
/// `buf` is optional. Pass a non-null buffer of length
/// `params.totalYears() + 1` when you need the full path: `buf[0]`
/// is the initial value; `buf[i]` for i ≥ 1 is the portfolio value
/// at the END of simulation year i; the retirement boundary is at
/// index `accumulation_years` (i.e. `buf[accumulation_years]` is
/// the portfolio at retirement, before the first withdrawal).
///
/// Pass `null` when you only need the survival verdict - the
/// function will return `false` as soon as it detects failure,
/// skipping the rest of the simulation and avoiding any buffer
/// writes. Saves work in the SWR binary-search inner loop where
/// `successRateParams` calls this thousands of times per search.
///
/// Returns true if the cycle survived the distribution phase.
fn simulateTwoPhase(
buf: ?[]f64,
data: ShillerYearSlice,
start_index: usize,
params: SimParams,
) bool {
const total = params.totalYears();
var portfolio = params.initial_value;
if (buf) |b| b[0] = portfolio;
var cumulative_inflation: f64 = 1.0;
// Real-spending multiplier for the current distribution year.
// Pinned at 1.0 through accumulation and the first distribution
// year (d=0), then compounded by `(1 + spending_real_change)`
// each subsequent distribution year. `spending_real_change == 0`
// leaves it at 1.0 forever -> flat real spending, byte-identical
// to the pre-smile behavior.
var spend_factor: f64 = 1.0;
var failed = false;
var y: usize = 0;
while (y < total) : (y += 1) {
const di = start_index + y;
if (di >= data.len) {
// Out of data - survived (or failed earlier and were
// walking to end for the buffer fill). Path callers
// get the tail filled with the last known value;
// null-buf callers just return.
if (buf) |b| {
for (y + 1..@as(usize, total) + 1) |k| b[k] = portfolio;
}
return !failed;
}
const yr = data[di];
const in_accumulation = y < params.accumulation_years;
// Life events apply in both phases.
var event_net: f64 = 0;
for (params.events) |*ev| {
event_net += ev.cashFlow(@intCast(y), cumulative_inflation);
}
if (in_accumulation) {
const contribution = if (params.contribution_inflation_adjusted)
params.annual_contribution * cumulative_inflation
else
params.annual_contribution;
portfolio += contribution + event_net;
} else {
const real_spending = blk: {
var rs = params.annual_spending * spend_factor;
// Survivor step-down: once the first household death
// has occurred (sim-year y at or past first_death_year),
// scale base spending to the surviving spouse's reduced
// need. No-op when first_death_year is null or
// survivor_factor is 1.0.
if (params.first_death_year) |fd| {
if (y >= fd) rs *= params.survivor_factor;
}
break :blk rs;
};
const spending = if (params.spending_inflation_adjusted)
real_spending * cumulative_inflation
else
real_spending;
portfolio -= spending - event_net;
if (portfolio <= 0 and !failed) {
// Survival-only callers exit immediately - there's
// no path to fill, and the verdict is locked in.
if (buf == null) return false;
failed = true;
}
// Compound the real-spending drift for next year. No-op
// when `spending_real_change == 0` (factor stays 1.0).
spend_factor *= (1.0 + params.spending_real_change);
}
// Market return on the post-cashflow balance, net of the
// fund expense ratio (FIRECalc applies "investment expenses"
// the same way). Skipped after failure (path callers have
// already locked the verdict; remaining buf entries get
// zeroed below).
if (!failed) {
const blended_return = params.stock_pct * yr.sp500_total_return +
(1.0 - params.stock_pct) * yr.bond_total_return;
portfolio *= (1.0 + blended_return - params.expense_ratio);
}
// Advance CPI for next year. (No-op for the verdict after
// failure, but cheap and keeps the loop body uniform.)
cumulative_inflation *= (1.0 + yr.cpi_inflation);
if (buf) |b| b[y + 1] = if (failed) 0.0 else portfolio;
}
return !failed;
}
/// Simulate a single retirement cycle starting at `start_index` in the
/// Shiller dataset, lasting `horizon` years, with the given annual spending
/// (inflation-adjusted) and stock/bond allocation.
///
/// Distribution-only convenience wrapper around `simulateTwoPhase`.
/// Preserves the existing API; new accumulation-aware code paths use
/// `simulateTwoPhase` directly.
fn simulateCycle(
buf: []f64,
start_index: usize,
horizon: u16,
initial_value: f64,
annual_spending: f64,
stock_pct: f64,
events: []const ResolvedEvent,
) void {
_ = simulateTwoPhase(buf, shiller.annual_returns, start_index, .{
.initial_value = initial_value,
.stock_pct = stock_pct,
.annual_spending = annual_spending,
.distribution_years = horizon,
.events = events,
});
}
/// Run all cycles with full SimParams: for each historical cohort,
/// simulate the two-phase path into `all_paths[cycle]` and return the
/// count that survived the distribution phase. `all_paths` must be
/// pre-allocated with dimensions [num_cycles][totalYears + 1]. Used by
/// the percentile-band builder and the earliest-retirement search.
fn runAllCyclesParams(
all_paths: [][]f64,
data: ShillerYearSlice,
params: SimParams,
) usize {
const num_cycles = maxCyclesFor(data, params.totalYears());
var survived: usize = 0;
for (0..num_cycles) |cycle| {
if (simulateTwoPhase(all_paths[cycle], data, cycle, params)) survived += 1;
}
return survived;
}
fn successRateParams(data: ShillerYearSlice, params: SimParams) f64 {
const num_cycles = maxCyclesFor(data, params.totalYears());
if (num_cycles == 0) return 0.0;
var survived: usize = 0;
for (0..num_cycles) |cycle| {
// `null` buffer -> simulateTwoPhase exits as soon as a
// failure is detected. Cheaper than collecting the full
// path when we only need the survival verdict.
if (simulateTwoPhase(null, data, cycle, params)) survived += 1;
}
return @as(f64, @floatFromInt(survived)) / @as(f64, @floatFromInt(num_cycles));
}
// ── Safe withdrawal search ─────────────────────────────────────
/// Test-only convenience wrapper around `searchSafeWithdrawal`: builds
/// a no-mortality `SimParams` from positional args so the
/// accumulation / fee / spending-drift test cases (including the
/// FIRECalc parity suite) stay readable. Production does not use it -
/// `runProjectionGridColumns` builds the `SimParams` (with the
/// per-column mortality fields) and calls `searchSafeWithdrawal`
/// directly.
///
/// When `accumulation_years == 0`, contributions are zero, and
/// `expense_ratio == 0`, this reduces exactly to `findSafeWithdrawal`
/// (the equivalence is pinned by the `regression: zero accumulation
/// matches direct findSafeWithdrawal` test).
fn findSafeWithdrawalWithAccumulation(
horizon: u16,
initial_value: f64,
stock_pct: f64,
confidence: f64,
events: []const ResolvedEvent,
accumulation_years: u16,
annual_contribution: f64,
contribution_inflation_adjusted: bool,
expense_ratio: f64,
spending_real_change: f64,
) WithdrawalResult {
return searchSafeWithdrawal(.{
.initial_value = initial_value,
.stock_pct = stock_pct,
.annual_spending = 0, // overwritten by the search loop
.spending_real_change = spending_real_change,
.distribution_years = horizon,
.accumulation_years = accumulation_years,
.annual_contribution = annual_contribution,
.contribution_inflation_adjusted = contribution_inflation_adjusted,
.expense_ratio = expense_ratio,
.events = events,
}, confidence);
}
/// Unified safe-withdrawal search. Binary-searches `annual_spending`
/// over `[lo, hi]` to $1 precision, seeded with a 4%-rule estimate
/// against the projected post-accumulation portfolio value.
///
/// `base` carries every `SimParams` field except `annual_spending`,
/// which the search overwrites per probe. Production reaches it via
/// `runProjectionGridColumns` (which builds `base` with the per-column
/// mortality fields); the `findSafeWithdrawal` /
/// `findSafeWithdrawalWithAccumulation` test wrappers delegate here too.
///
/// Bracket seeding:
/// - When `accumulation_years == 0`, `projected_value ==
/// initial_value` so the seed and bracket reduce to the
/// classic 4%-rule starting point.
/// - When non-zero, `projected_value` is a rough estimate of the
/// post-accumulation portfolio (initial × 1.06^N + N ×
/// contribution). The bracket-widening below corrects for any
/// inaccuracy in the estimate.
fn searchSafeWithdrawal(base: SimParams, confidence: f64) WithdrawalResult {
// Project the post-accumulation portfolio. For zero-accumulation
// configs `pow(1.06, 0) == 1.0` so this collapses to
// `initial_value` - same seed the original `findSafeWithdrawal`
// used.
const accum_growth_factor: f64 = std.math.pow(f64, 1.06, @as(f64, @floatFromInt(base.accumulation_years)));
const projected_value = base.initial_value * accum_growth_factor +
base.annual_contribution * @as(f64, @floatFromInt(base.accumulation_years));
// Seed from the 4% rule, adjusted for horizon and confidence.
// Base ~4% for 30yr/95%. Shorter horizons allow more; longer less.
// Higher confidence requires less.
const base_rate = 0.04;
const horizon_adj = 30.0 / @as(f64, @floatFromInt(base.distribution_years));
const conf_adj = (1.0 - confidence) / 0.05;
const estimate = projected_value * base_rate * @sqrt(horizon_adj) * @sqrt(conf_adj);
// Search band: ±50% of estimate. The lower clamp is 0; the
// upper clamp ensures we don't start below the projected value
// (relevant when the 4%-rule estimate undershoots a high
// accumulation case).
var lo: f64 = @max(estimate * 0.5, 0);
var hi: f64 = @max(estimate * 1.5, projected_value);
// Mutable probe - same struct, different `annual_spending` per
// iteration. Avoids reconstructing SimParams on every probe.
var probe = base;
// Verify bounds bracket the answer; widen if not.
probe.annual_spending = lo;
if (successRateParams(shiller.annual_returns, probe) < confidence) {
log.debug("searchSafeWithdrawal: estimate too high, widening lo to 0 (horizon={d}, conf={d:.2})", .{ base.distribution_years, confidence });
lo = 0;
}
probe.annual_spending = hi;
if (successRateParams(shiller.annual_returns, probe) >= confidence) {
log.debug("searchSafeWithdrawal: estimate too low, widening hi (horizon={d}, conf={d:.2})", .{ base.distribution_years, confidence });
hi = @max(projected_value, base.initial_value) * 4.0;
}
// Binary search to $1 precision.
while (hi - lo > 1.0) {
const mid = @floor((lo + hi) / 2.0);
probe.annual_spending = mid;
const rate = successRateParams(shiller.annual_returns, probe);
if (rate >= confidence) lo = mid else hi = mid;
}
return .{
.confidence = confidence,
.annual_amount = lo,
.withdrawal_rate = if (base.initial_value > 0) lo / base.initial_value else 0.0,
};
}
// ── Earliest-retirement search (target-spending input) ─────────
/// Result of a `findEarliestRetirement` search. `accumulation_years
/// == null` means no value of N in [0, max_years] sustains the
/// target spending at the requested confidence over the distribution
/// horizon. The portfolio statistics are computed from the same
/// historical cycles at year `accumulation_years`.
pub const EarliestRetirement = struct {
horizon: u16,
confidence: f64,
accumulation_years: ?u16,
median_at_retirement: f64,
p10_at_retirement: f64,
p90_at_retirement: f64,
/// Age-of-death anchor for this column, or 0 for a plain numeric
/// horizon. When non-zero, the column header renders "to age N"
/// and `horizon` is the distribution length at the chosen
/// `accumulation_years` (it shrinks as retirement slides later,
/// since the death date is fixed).
death_age: u16 = 0,
};
/// Portfolio percentiles at the retirement boundary (sim-year `n`),
/// across the historical cohorts. Shared by both earliest-retirement
/// search variants.
const BoundaryStats = struct { median: f64, p10: f64, p90: f64 };
/// Run the full-path simulation for `params` and extract the
/// portfolio value distribution at sim-year `n` (the retirement
/// boundary). Returns zeros when no cohort covers the span.
fn retirementBoundaryStats(
allocator: std.mem.Allocator,
data: ShillerYearSlice,
params: SimParams,
n: u16,
) !BoundaryStats {
const total = params.totalYears();
const num_cycles = maxCyclesFor(data, total);
if (num_cycles == 0) return .{ .median = 0, .p10 = 0, .p90 = 0 };
const years_len: usize = @as(usize, total) + 1;
const path_data = try allocator.alloc(f64, num_cycles * years_len);
defer allocator.free(path_data);
const paths = try allocator.alloc([]f64, num_cycles);
defer allocator.free(paths);
for (0..num_cycles) |i| {
paths[i] = path_data[i * years_len .. (i + 1) * years_len];
}
_ = runAllCyclesParams(paths, data, params);
const sort_buf = try allocator.alloc(f64, num_cycles);
defer allocator.free(sort_buf);
for (0..num_cycles) |c| {
sort_buf[c] = paths[c][@as(usize, n)];
}
std.mem.sort(f64, sort_buf, {}, std.sort.asc(f64));
return .{
.median = percentile(sort_buf, 0.50),
.p10 = percentile(sort_buf, 0.10),
.p90 = percentile(sort_buf, 0.90),
};
}
/// Default ceiling on the accumulation years the earliest-retirement
/// search considers. 50 covers a 25-year-old planning to age 75.
/// Overridable per-portfolio via
/// `type::config,max_accumulation_years:num:N` in projections.srf -
/// see `UserConfig.max_accumulation_years`.
pub const default_max_accumulation_years: u16 = 50;
/// Hard ceiling on a user-configured `max_accumulation_years`. A
/// newborn planning to age 100 is the outer edge of anything sane;
/// larger values are clamped (with a warning) to keep the search
/// bounded and comfortably inside the Shiller data span. Mirrors
/// `promotion_age_cap`'s "nobody is still accumulating past 100"
/// reasoning.
pub const max_configurable_accumulation_years: u16 = 100;
/// Earliest-retirement search: given a target annual spending
/// level, find the smallest `accumulation_years` N in [0, `max_years`]
/// such that the success
/// rate over the distribution phase ≥ `confidence`.
///
/// Returns the matching `EarliestRetirement`, with portfolio
/// statistics taken from the cycles that survived. If no N up to
/// `max_years` succeeds, `accumulation_years == null` and the
/// portfolio statistics are zero.
pub fn findEarliestRetirement(
allocator: std.mem.Allocator,
initial_value: f64,
stock_pct: f64,
annual_contribution: f64,
contribution_inflation_adjusted: bool,
target_spending: f64,
target_spending_inflation_adjusted: bool,
distribution_years: u16,
confidence: f64,
events: []const ResolvedEvent,
max_years: u16,
expense_ratio: f64,
spending_real_change: f64,
) !EarliestRetirement {
const data = shiller.annual_returns;
var n: u16 = 0;
while (n <= max_years) : (n += 1) {
const params: SimParams = .{
.initial_value = initial_value,
.stock_pct = stock_pct,
.annual_spending = target_spending,
.spending_inflation_adjusted = target_spending_inflation_adjusted,
.spending_real_change = spending_real_change,
.distribution_years = distribution_years,
.accumulation_years = n,
.annual_contribution = annual_contribution,
.contribution_inflation_adjusted = contribution_inflation_adjusted,
.expense_ratio = expense_ratio,
.events = events,
};
const rate = successRateParams(data, params);
if (rate < confidence) continue;
// Found the earliest N. Run the full path simulation once to
// extract the portfolio statistics at year N (the retirement
// boundary).
const stats = try retirementBoundaryStats(allocator, data, params, n);
return .{
.horizon = distribution_years,
.confidence = confidence,
.accumulation_years = n,
.median_at_retirement = stats.median,
.p10_at_retirement = stats.p10,
.p90_at_retirement = stats.p90,
};
}
return .{
.horizon = distribution_years,
.confidence = confidence,
.accumulation_years = null,
.median_at_retirement = 0,
.p10_at_retirement = 0,
.p90_at_retirement = 0,
};
}
/// Age-of-death-anchored earliest-retirement search. Unlike
/// `findEarliestRetirement` (fixed distribution length), the
/// distribution horizon here is *derived* from the retirement date:
/// the money must last until the last surviving member dies
/// (`total_span` years from now, the youngest reaching the
/// age-of-death). Retiring later (larger N) therefore shortens the
/// distribution, so the total simulated span is constant at
/// `total_span` and success is monotonic in N - the first feasible N
/// is the earliest retirement.
///
/// Mortality is threaded through `SimParams`: `survivor_factor` scales
/// base spending once the first death occurs at `first_death_year`,
/// and `events` are expected to be already capped at each holder's
/// death (so a deceased spouse's Social Security stops). The returned
/// cell's `horizon` is the distribution length at the chosen N, and
/// `death_age` is set so the column renders "to age N".
///
/// `max_years` is clamped internally to `total_span - 1` (you cannot
/// retire at or after the last death - there must be at least one
/// distribution year). Returns `accumulation_years == null` only when
/// no N within the cap sustains the spending, or when `total_span ==
/// 0` (the last survivor is already past the age-of-death).
pub fn findEarliestRetirementToAge(
allocator: std.mem.Allocator,
initial_value: f64,
stock_pct: f64,
annual_contribution: f64,
contribution_inflation_adjusted: bool,
target_spending: f64,
target_spending_inflation_adjusted: bool,
total_span: u16,
first_death_year: ?u16,
survivor_factor: f64,
death_age: u16,
confidence: f64,
events: []const ResolvedEvent,
max_years: u16,
expense_ratio: f64,
spending_real_change: f64,
) !EarliestRetirement {
const infeasible: EarliestRetirement = .{
.horizon = 0,
.confidence = confidence,
.accumulation_years = null,
.median_at_retirement = 0,
.p10_at_retirement = 0,
.p90_at_retirement = 0,
.death_age = death_age,
};
// The last survivor is already at/past the age-of-death: there is
// no retirement horizon to fund.
if (total_span == 0) return infeasible;
const data = shiller.annual_returns;
// Cannot retire at or after the last death; need >= 1 distribution
// year. Respect the configured accumulation ceiling too.
const n_cap = @min(max_years, total_span - 1);
var n: u16 = 0;
while (n <= n_cap) : (n += 1) {
const distribution_years = total_span - n; // >= 1 by n_cap
const params: SimParams = .{
.initial_value = initial_value,
.stock_pct = stock_pct,
.annual_spending = target_spending,
.spending_inflation_adjusted = target_spending_inflation_adjusted,
.spending_real_change = spending_real_change,
.distribution_years = distribution_years,
.accumulation_years = n,
.annual_contribution = annual_contribution,
.contribution_inflation_adjusted = contribution_inflation_adjusted,
.expense_ratio = expense_ratio,
.events = events,
.survivor_factor = survivor_factor,
.first_death_year = first_death_year,
};
const rate = successRateParams(data, params);
if (rate < confidence) continue;
const stats = try retirementBoundaryStats(allocator, data, params, n);
return .{
.horizon = distribution_years,
.confidence = confidence,
.accumulation_years = n,
.median_at_retirement = stats.median,
.p10_at_retirement = stats.p10,
.p90_at_retirement = stats.p90,
.death_age = death_age,
};
}
return infeasible;
}
// ── Earliest-retirement promotion (the "headline" cell) ────────
/// Selected (horizon, confidence) pair for the promoted retirement
/// line. The selection is independent of feasibility - the caller
/// indexes the earliest-retirement grid with this pair and renders
/// "not feasible" if the cell's `accumulation_years` is null.
pub const PromotedCell = struct {
horizon_index: usize,
confidence_index: usize,
/// True when the user explicitly tagged a horizon with a
/// `retirement_target` annotation. Diagnostic only - display
/// behavior is identical either way.
explicit: bool,
};
/// Maximum age the "longest-horizon-that-makes-sense" rule allows
/// the oldest configured person to reach by the end of the promoted
/// distribution. A 100-year-old shouldn't still be drawing down
/// their working-age portfolio; if all horizons push past this, we
/// fall through to the shortest configured horizon anyway ("fuck it"
/// branch).
pub const promotion_age_cap: u16 = 100;
/// Pick the (horizon, confidence) cell to promote into the
/// retirement line and accumulation block when the user configured
/// `target_spending` without an explicit retirement date.
///
/// Algorithm:
/// 1. If exactly one horizon is annotated with `retirement_target`,
/// honor that annotation regardless of length or feasibility.
/// 2. Else, if any age-of-death-anchored column exists, promote the
/// one with the latest death (largest age-of-death) - it's the
/// "plan to the last survivor" answer this input is about.
/// 3. Else, walk numeric horizons longest -> shortest. Pick the
/// longest whose end year keeps the oldest configured person
/// under `promotion_age_cap`.
/// 4. If even the shortest horizon overshoots, use it anyway.
/// 5. Default confidence is 99% (most conservative).
///
/// `confidence_levels` must match the order used by the earliest
/// grid - typically {.90, .95, .99} with index 2 being 99%.
///
/// `as_of` is the reference date used to compute the oldest
/// person's current age. The function works correctly for any
/// reference date - pass today for the live mode or a historical
/// snapshot date for back-dated runs.
///
/// Returns null only if no horizons are configured at all (caller
/// should treat this as "no promotion possible").
pub fn pickPromotedCell(
config: *const UserConfig,
as_of: Date,
confidence_levels: []const f64,
) ?PromotedCell {
if (config.horizon_count == 0 or confidence_levels.len == 0) return null;
// Step 1: explicit override wins.
var i: usize = 0;
while (i < config.horizon_count) : (i += 1) {
const tag = config.horizon_targets[i];
if (tag != 0) {
const ci = confidenceIndex(confidence_levels, tag);
return .{ .horizon_index = i, .confidence_index = ci, .explicit = true };
}
}
// Default confidence: highest configured (most conservative).
// Convention: arrays sorted ascending, so the last entry is the
// highest. Find the index whose value is closest to 0.99.
const default_ci = confidenceIndex(confidence_levels, 99);
// Step 2: prefer an age-of-death-anchored column - the meaningful
// "plan to the last survivor's death" answer. Among age columns,
// pick the latest death (largest age-of-death = longest, most
// conservative horizon).
{
var age_idx: ?usize = null;
var age_best: u16 = 0;
var k: usize = 0;
while (k < config.horizon_count) : (k += 1) {
const a = config.horizon_death_age[k];
if (a != 0 and a > age_best) {
age_best = a;
age_idx = k;
}
}
if (age_idx) |ai| {
return .{ .horizon_index = ai, .confidence_index = default_ci, .explicit = false };
}
}
// Step 3: longest numeric horizon where oldest person stays under
// the age cap. With no birthdates, the cap doesn't apply - just
// pick the longest horizon.
const oldest_age_as_of = config.oldestAge(as_of);
var longest_idx: usize = 0;
var longest_h: u16 = config.horizons[0];
for (1..config.horizon_count) |hi| {
if (config.horizons[hi] > longest_h) {
longest_h = config.horizons[hi];
longest_idx = hi;
}
}
if (config.birthdate_count == 0) {
return .{ .horizon_index = longest_idx, .confidence_index = default_ci, .explicit = false };
}
// Sort indices by horizon length descending.
var order: [UserConfig.max_horizons]u8 = @splat(0);
for (0..config.horizon_count) |hi| order[hi] = @intCast(hi);
const slice = order[0..config.horizon_count];
const SortCtx = struct {
horizons: []const u16,
pub fn lessThan(ctx: @This(), a: u8, b: u8) bool {
return ctx.horizons[a] > ctx.horizons[b]; // descending
}
};
std.mem.sort(u8, slice, SortCtx{ .horizons = &config.horizons }, SortCtx.lessThan);
for (slice) |hi| {
const end_age = oldest_age_as_of + config.horizons[hi];
if (end_age < promotion_age_cap) {
return .{ .horizon_index = hi, .confidence_index = default_ci, .explicit = false };
}
}
// Step 3: "fuck it" - even the shortest horizon overshoots.
// Pick the shortest (last in our descending sort).
const shortest_idx = slice[slice.len - 1];
return .{ .horizon_index = shortest_idx, .confidence_index = default_ci, .explicit = false };
}
/// Find the index in `confidence_levels` (a slice of fractions like
/// 0.90/0.95/0.99) that corresponds to the percentage `pct`. Falls
/// back to the closest match if no exact one exists. Used to
/// translate a `retirement_target` annotation (90/95/99) or the
/// default 99 into an index into the earliest-retirement grid.
fn confidenceIndex(confidence_levels: []const f64, pct: u8) usize {
const target: f64 = @as(f64, @floatFromInt(pct)) / 100.0;
var best_idx: usize = 0;
var best_diff: f64 = std.math.inf(f64);
for (confidence_levels, 0..) |c, idx| {
const diff = @abs(c - target);
if (diff < best_diff) {
best_diff = diff;
best_idx = idx;
}
}
return best_idx;
}
// ── Percentile bands ───────────────────────────────────────────
/// Two-phase variant of `computePercentileBands`. Returns bands of
/// length `params.totalYears() + 1`, where index 0 is the starting
/// portfolio and index `accumulation_years` is the post-accumulation
/// (retirement) portfolio.
pub fn computePercentileBandsParams(
allocator: std.mem.Allocator,
params: SimParams,
) ![]YearPercentiles {
const data = shiller.annual_returns;
const total = params.totalYears();
const num_cycles = maxCyclesFor(data, total);
if (num_cycles == 0) return &.{};
const years: usize = @as(usize, total) + 1;
const path_data = try allocator.alloc(f64, num_cycles * years);
defer allocator.free(path_data);
const paths = try allocator.alloc([]f64, num_cycles);
defer allocator.free(paths);
for (0..num_cycles) |i| {
paths[i] = path_data[i * years .. (i + 1) * years];
}
_ = runAllCyclesParams(paths, data, params);
// For each year, sort the values across all cycles and extract percentiles
const bands = try allocator.alloc(YearPercentiles, years);
// Temporary buffer for sorting one year's values
const sort_buf = try allocator.alloc(f64, num_cycles);
defer allocator.free(sort_buf);
for (0..years) |y| {
// Collect values for year y across all cycles
for (0..num_cycles) |c| {
sort_buf[c] = paths[c][y];
}
std.mem.sort(f64, sort_buf, {}, std.sort.asc(f64));
bands[y] = .{
.year = @intCast(y),
.p10 = percentile(sort_buf, 0.10),
.p25 = percentile(sort_buf, 0.25),
.p50 = percentile(sort_buf, 0.50),
.p75 = percentile(sort_buf, 0.75),
.p90 = percentile(sort_buf, 0.90),
};
}
return bands;
}
/// Linear interpolation percentile on a sorted slice.
fn percentile(sorted: []const f64, p: f64) f64 {
if (sorted.len == 0) return 0;
if (sorted.len == 1) return sorted[0];
const n: f64 = @floatFromInt(sorted.len - 1);
const idx = p * n;
const lo_idx: usize = @intFromFloat(@floor(idx));
const hi_idx: usize = @min(lo_idx + 1, sorted.len - 1);
const frac = idx - @floor(idx);
return sorted[lo_idx] * (1.0 - frac) + sorted[hi_idx] * frac;
}
// ── High-level API ─────────────────────────────────────────────
/// Pre-computed grid of safe-withdrawal results and percentile
/// bands across a set of horizons × confidence levels. Produced by
/// `runProjectionGrid` and consumed by both the CLI and TUI
/// projections renderers.
pub const ProjectionData = struct {
/// Safe withdrawal results, indexed `[ci * horizons.len + hi]`.
/// Owned by the caller - free with the same allocator.
withdrawals: []WithdrawalResult,
/// Per-horizon percentile bands. `null` entries indicate the
/// band computation failed for that horizon (allocator failure,
/// out-of-data, etc.). Each non-null slice is owned by the
/// caller.
bands: []?[]YearPercentiles,
/// Index into `confidence_levels` corresponding to the 99%
/// (highest configured) level. The chart and percentile-band
/// blocks anchor on this confidence.
ci_99: usize,
};
/// One column of the projection grid. A plain numeric horizon is
/// `{ .distribution_years = N }` (the defaults give today's behavior:
/// shared events, no survivor step). An age-anchored column also
/// carries per-column mortality: events already capped at each
/// holder's death, the survivor spending multiplier, and the
/// first-death simulation year where the step-down begins.
pub const GridColumn = struct {
distribution_years: u16,
events: []const ResolvedEvent = &.{},
survivor_factor: f64 = 1.0,
first_death_year: ?u16 = null,
};
/// Per-column mortality, derived from an age-of-death anchor and the
/// configured birthdates. Drives both the SWR/bands grid (with a fixed
/// retirement boundary) and the earliest-retirement search (which
/// re-derives the distribution per candidate N from `total_span`).
pub const ColumnMortality = struct {
/// Years from `as_of` until the last survivor (youngest person)
/// reaches the age-of-death - the horizon end.
total_span: u16,
/// Distribution length for a fixed `accumulation_years` retirement
/// boundary: `total_span - accumulation_years`, clamped to >= 1.
/// (The earliest-retirement search ignores this and derives its
/// own per-N distribution from `total_span`.)
distribution_years: u16,
/// Simulation year of the first household death (oldest person
/// reaching the age-of-death), where the survivor step-down
/// begins. `null` when there's no gap (single person or same-age
/// couple), so the step is inert.
first_death_year: ?u16,
/// Survivor spending multiplier (`survivor_spending_pct / 100`)
/// when a gap exists; `1.0` otherwise.
survivor_factor: f64,
};
/// Compute the mortality parameters for an age-anchored column.
/// `death_age` is the configured age-of-death; `accumulation_years`
/// is the retirement boundary for the SWR/bands path (pass 0 for the
/// earliest-retirement search, which derives the distribution itself).
///
/// The horizon ends when the *youngest* person (last survivor)
/// reaches `death_age`; the survivor step-down begins when the
/// *oldest* (first death) does. A single person or a same-age couple
/// has no gap, so no step is applied.
pub fn columnMortality(
config: *const UserConfig,
as_of: Date,
death_age: u16,
accumulation_years: u16,
) ColumnMortality {
const youngest = config.youngestAge(as_of);
const oldest = config.oldestAge(as_of);
const total_span: u16 = if (death_age > youngest) death_age - youngest else 0;
const first_from_now: u16 = if (death_age > oldest) death_age - oldest else 0;
// A real survivor phase exists only when the oldest dies strictly
// before the youngest (an age gap in a multi-person household).
const has_gap = first_from_now < total_span;
const dist: u16 = if (total_span > accumulation_years)
total_span - accumulation_years
else
1; // degenerate: retirement at/after the last death; clamp to 1
return .{
.total_span = total_span,
.distribution_years = dist,
.first_death_year = if (has_gap) first_from_now else null,
.survivor_factor = if (has_gap) config.survivor_spending_pct / 100.0 else 1.0,
};
}
/// Columns-aware variant of `runProjectionGrid`. Each column carries
/// its own distribution length, resolved events, and mortality
/// (survivor step-down + first-death year), so a single grid can mix
/// plain numeric horizons with age-of-death-anchored ones. Indexing
/// matches `runProjectionGrid`: `withdrawals[ci * columns.len + hi]`,
/// `bands[hi]`. Caller owns `withdrawals`, `bands`, and every non-null
/// `bands` entry.
pub fn runProjectionGridColumns(
alloc: std.mem.Allocator,
columns: []const GridColumn,
confidence_levels: []const f64,
total_value: f64,
stock_pct: f64,
accumulation_years: u16,
annual_contribution: f64,
contribution_inflation_adjusted: bool,
expense_ratio: f64,
spending_real_change: f64,
) !ProjectionData {
const num_results = columns.len * confidence_levels.len;
const withdrawals = try alloc.alloc(WithdrawalResult, num_results);
errdefer alloc.free(withdrawals);
for (confidence_levels, 0..) |conf, ci| {
for (columns, 0..) |col, hi| {
withdrawals[ci * columns.len + hi] = searchSafeWithdrawal(.{
.initial_value = total_value,
.stock_pct = stock_pct,
.annual_spending = 0, // overwritten by the search loop
.spending_real_change = spending_real_change,
.distribution_years = col.distribution_years,
.accumulation_years = accumulation_years,
.annual_contribution = annual_contribution,
.contribution_inflation_adjusted = contribution_inflation_adjusted,
.expense_ratio = expense_ratio,
.events = col.events,
.survivor_factor = col.survivor_factor,
.first_death_year = col.first_death_year,
}, conf);
}
}
const ci_99 = confidence_levels.len - 1;
const bands = try alloc.alloc(?[]YearPercentiles, columns.len);
for (columns, 0..) |col, hi| {
const wr = withdrawals[ci_99 * columns.len + hi];
bands[hi] = computePercentileBandsParams(alloc, .{
.initial_value = total_value,
.stock_pct = stock_pct,
.annual_spending = wr.annual_amount,
.spending_real_change = spending_real_change,
.distribution_years = col.distribution_years,
.accumulation_years = accumulation_years,
.annual_contribution = annual_contribution,
.contribution_inflation_adjusted = contribution_inflation_adjusted,
.expense_ratio = expense_ratio,
.events = col.events,
.survivor_factor = col.survivor_factor,
.first_death_year = col.first_death_year,
}) catch null;
}
return .{ .withdrawals = withdrawals, .bands = bands, .ci_99 = ci_99 };
}
/// Test-only convenience wrapper over `runProjectionGridColumns`:
/// builds plain numeric (no-mortality) columns from a bare horizon
/// list, sharing one `events` set, so the grid tests read cleanly.
/// Byte-identical to passing those columns directly, since the
/// `survivor_factor`/`first_death_year` defaults (1.0 / null) leave
/// the simulation untouched. Production builds columns with
/// per-column mortality and calls `runProjectionGridColumns` directly.
///
/// Caller owns `withdrawals`, `bands`, and every non-null entry
/// inside `bands`. Free with the same allocator.
fn runProjectionGrid(
alloc: std.mem.Allocator,
horizons: []const u16,
confidence_levels: []const f64,
total_value: f64,
stock_pct: f64,
events: []const ResolvedEvent,
accumulation_years: u16,
annual_contribution: f64,
contribution_inflation_adjusted: bool,
expense_ratio: f64,
spending_real_change: f64,
) !ProjectionData {
const columns = try alloc.alloc(GridColumn, horizons.len);
defer alloc.free(columns);
for (horizons, 0..) |h, i| columns[i] = .{ .distribution_years = h, .events = events };
return runProjectionGridColumns(
alloc,
columns,
confidence_levels,
total_value,
stock_pct,
accumulation_years,
annual_contribution,
contribution_inflation_adjusted,
expense_ratio,
spending_real_change,
);
}
// ── Spending trough (the "how low does it get" callout) ────────
/// The lowest-spending year of a projection's distribution phase,
/// in today's dollars. Surfaced next to the first-year safe
/// withdrawal so a user running a declining ("slow-go") spending
/// model can see how little they spend at the bottom.
pub const SpendingTrough = struct {
/// Minimum total real spending (today's dollars) reached.
amount: f64,
/// Distribution-year offset (0-based) where the minimum occurs.
year_offset: u16,
/// Years from `as_of` to that year, 1-based (1 = first
/// retirement year). Equals `accumulation_years + year_offset + 1`.
years_from_now: u16,
/// Calendar date of the trough year (`as_of` advanced by
/// `accumulation_years + year_offset`).
date: Date,
};
/// Find the lowest-spending distribution year, in today's dollars.
///
/// Base spending follows the real-change drift: distribution year
/// `d` spends `first_year_spend * (1 + spending_real_change)^d` in
/// real terms. EXPENSE life events (negative `annual_amount`, e.g.
/// late-life healthcare) add to spending - that is what produces a
/// mid-retirement trough rather than a monotonic slide to the final
/// year. Income events (Social Security, positive amounts) are
/// funding rather than spending and are excluded.
///
/// Today's-dollar (real) terms throughout: each active expense event
/// contributes its configured magnitude. This is a deterministic
/// display approximation - it does not erode non-inflation-adjusted
/// events across time the way the per-cycle simulation does - but it
/// gives a single, stable number for the callout. `as_of` anchors
/// the calendar year.
///
/// Returns `null` only for a zero-length distribution phase.
pub fn spendingTrough(
first_year_spend: f64,
spending_real_change: f64,
events: []const ResolvedEvent,
accumulation_years: u16,
distribution_years: u16,
as_of: Date,
) ?SpendingTrough {
if (distribution_years == 0) return null;
var min_amount: f64 = std.math.floatMax(f64);
var min_d: u16 = 0;
var factor: f64 = 1.0;
var d: u16 = 0;
while (d < distribution_years) : (d += 1) {
const sim_year = accumulation_years + d;
var spend = first_year_spend * factor;
for (events) |*ev| {
// Only expense events count as spending; income (SS etc.)
// funds withdrawals but is not consumption.
if (ev.annual_amount < 0 and ev.isActive(sim_year)) {
spend += -ev.annual_amount;
}
}
if (spend < min_amount) {
min_amount = spend;
min_d = d;
}
factor *= (1.0 + spending_real_change);
}
return .{
.amount = min_amount,
.year_offset = min_d,
.years_from_now = accumulation_years + min_d + 1,
.date = as_of.addYears(accumulation_years + min_d),
};
}
//
// Thin, distribution-only, zero-fee wrappers over the production
// `*Params` entry points (`searchSafeWithdrawal`, `successRateParams`,
// `computePercentileBandsParams`). Nothing in the CLI/TUI calls these
// -- production goes through `runProjectionGrid` /
// `findSafeWithdrawalWithAccumulation`. They exist only to give the
// test suite (including the FIRECalc parity tests) an ergonomic
// primitive, so they live next to the tests and are `fn`-private.
/// Maximum annual withdrawal (today's dollars) that survives `horizon`
/// years in at least `confidence` of historical cycles. Binary search
/// to $1 precision via `searchSafeWithdrawal`.
fn findSafeWithdrawal(
horizon: u16,
initial_value: f64,
stock_pct: f64,
confidence: f64,
events: []const ResolvedEvent,
) WithdrawalResult {
return searchSafeWithdrawal(.{
.initial_value = initial_value,
.stock_pct = stock_pct,
.annual_spending = 0, // overwritten by the search loop
.distribution_years = horizon,
.events = events,
}, confidence);
}
/// Success rate (fraction of cycles that survived) for a given
/// spending level.
fn successRate(
horizon: u16,
initial_value: f64,
annual_spending: f64,
stock_pct: f64,
events: []const ResolvedEvent,
) f64 {
return successRateParams(shiller.annual_returns, .{
.initial_value = initial_value,
.stock_pct = stock_pct,
.annual_spending = annual_spending,
.distribution_years = horizon,
.events = events,
});
}
/// Percentile bands across all simulated paths for a horizon and
/// spending level. Allocates the result.
fn computePercentileBands(
allocator: std.mem.Allocator,
horizon: u16,
initial_value: f64,
annual_spending: f64,
stock_pct: f64,
events: []const ResolvedEvent,
) ![]YearPercentiles {
return computePercentileBandsParams(allocator, .{
.initial_value = initial_value,
.stock_pct = stock_pct,
.annual_spending = annual_spending,
.distribution_years = horizon,
.events = events,
});
}
// ── Tests ──────────────────────────────────────────────────────
test "successRate with zero spending is 100%" {
const rate = successRate(30, 1_000_000, 0, 0.75, &.{});
try std.testing.expectApproxEqAbs(@as(f64, 1.0), rate, 0.001);
}
test "successRate with excessive spending is 0%" {
// Spending the entire portfolio in year 1 should fail every cycle
const rate = successRate(30, 1_000_000, 1_000_000, 0.75, &.{});
try std.testing.expectApproxEqAbs(@as(f64, 0.0), rate, 0.001);
}
test "successRate decreases with higher spending" {
const rate_low = successRate(30, 1_000_000, 20_000, 0.75, &.{});
const rate_mid = successRate(30, 1_000_000, 40_000, 0.75, &.{});
const rate_high = successRate(30, 1_000_000, 60_000, 0.75, &.{});
try std.testing.expect(rate_low >= rate_mid);
try std.testing.expect(rate_mid >= rate_high);
}
test "findSafeWithdrawal produces reasonable results" {
const result = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &.{});
try std.testing.expect(result.annual_amount >= 10_000);
try std.testing.expect(result.annual_amount <= 60_000);
try std.testing.expect(result.withdrawal_rate >= 0.01);
try std.testing.expect(result.withdrawal_rate <= 0.06);
}
test "higher confidence means lower withdrawal" {
const r90 = findSafeWithdrawal(30, 1_000_000, 0.75, 0.90, &.{});
const r95 = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &.{});
const r99 = findSafeWithdrawal(30, 1_000_000, 0.75, 0.99, &.{});
try std.testing.expect(r90.annual_amount >= r95.annual_amount);
try std.testing.expect(r95.annual_amount >= r99.annual_amount);
}
test "longer horizon means lower withdrawal" {
const r20 = findSafeWithdrawal(20, 1_000_000, 0.75, 0.95, &.{});
const r30 = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &.{});
const r45 = findSafeWithdrawal(45, 1_000_000, 0.75, 0.95, &.{});
try std.testing.expect(r20.annual_amount >= r30.annual_amount);
try std.testing.expect(r30.annual_amount >= r45.annual_amount);
}
test "computePercentileBands basic properties" {
const allocator = std.testing.allocator;
const bands = try computePercentileBands(allocator, 30, 1_000_000, 30_000, 0.75, &.{});
defer allocator.free(bands);
// Should have horizon + 1 entries
try std.testing.expectEqual(@as(usize, 31), bands.len);
// Year 0 should be the starting value for all percentiles
try std.testing.expectApproxEqAbs(@as(f64, 1_000_000), bands[0].p50, 1.0);
// Percentiles should be ordered at each year
for (bands) |b| {
try std.testing.expect(b.p10 <= b.p25);
try std.testing.expect(b.p25 <= b.p50);
try std.testing.expect(b.p50 <= b.p75);
try std.testing.expect(b.p75 <= b.p90);
}
}
test "percentile interpolation" {
const data = [_]f64{ 10, 20, 30, 40, 50 };
try std.testing.expectApproxEqAbs(@as(f64, 10.0), percentile(&data, 0.0), 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 30.0), percentile(&data, 0.5), 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 50.0), percentile(&data, 1.0), 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 20.0), percentile(&data, 0.25), 0.01);
}
test "realistic portfolio safe withdrawal" {
// Approximate real portfolio: ~$8.34M, ~82.5% stocks
const portfolio = 8_340_000;
const stock_pct = 0.825;
const r99_45 = findSafeWithdrawal(45, portfolio, stock_pct, 0.99, &.{});
const r95_45 = findSafeWithdrawal(45, portfolio, stock_pct, 0.95, &.{});
const r99_30 = findSafeWithdrawal(30, portfolio, stock_pct, 0.99, &.{});
// 95% should be higher than 99%
try std.testing.expect(r95_45.annual_amount > r99_45.annual_amount);
// 30yr should be higher than 45yr at same confidence
try std.testing.expect(r99_30.annual_amount > r99_45.annual_amount);
// FIRECalc reference: on $7.7M at 82% / 45yr / 99% (fee=0), FIRECalc
// returns ~$262.8K (audit June 2026). zfin runs ~+9% optimistic (see
// the FIRECalc parity suite below for the why), so on $8.34M it lands
// ~$310K. Bounds bracket that with margin.
try std.testing.expect(r99_45.annual_amount >= 290_000);
try std.testing.expect(r99_45.annual_amount <= 350_000);
try std.testing.expect(r99_45.withdrawal_rate >= 0.03);
try std.testing.expect(r99_45.withdrawal_rate <= 0.05);
}
// ── FIRECalc.com parity suite ──────────────────────────────────
//
// Cross-checks zfin's engine against FIRECalc.com ("FIRECalc 3.0",
// data through 1/1/2026 -- the same 1871-2025 Shiller span zfin embeds).
// Reference values were captured June 2026 by driving the FIRECalc web
// form directly. Full method, captured numbers, and root-cause analysis
// live in docs/explanation/projections-model.md -> "Parity with FIRECalc".
//
// The safe-withdrawal and success-rate references below use FIRECalc
// with its expense ratio set to 0% (InvExp=0) and the default "Long
// Interest" (10yr-Treasury) fixed-income model. fee=0 is the
// apples-to-apples comparison for the no-fee convenience wrappers
// (`findSafeWithdrawal`, `successRate`). zfin CAN now model a fee
// (`SimParams.expense_ratio`, configurable via projections.srf); the
// separate "expense ratio matches FIRECalc's default fee" test below
// pins zfin against FIRECalc's *default* 0.18%-fee runs.
//
// Cohort counts now match FIRECalc exactly (e.g. 126 for a 30yr
// horizon over 1871-2025) after the `maxCyclesFor` off-by-one fix.
//
// KNOWN, ACCEPTED DIVERGENCE: zfin runs systematically *more optimistic*
// than FIRECalc -- ~+6-9% on safe-withdrawal dollars and ~+2-3pp on
// success rate -- because zfin reconstructs nominal equity total returns
// from Shiller's monthly-reinvested Real Total Return Price × CPI, which
// compounds ~0.2-0.3%/yr higher than FIRECalc's equity series. This was
// isolated with a $0-spending, 100%-stock run (no withdrawal/timing/fee
// effects): for the 1966 cohort, zfin's year-30 nominal balance is
// $20.24M vs FIRECalc's $18.60M -- a pure return-series gap. It is a
// defensible modeling choice, not a bug; the tolerances below encode the
// gap so this suite is a regression guard, not an exact-match assertion.
const FcSwrCase = struct {
name: []const u8,
horizon: u16,
value: f64,
stock_pct: f64,
confidence: f64,
/// FIRECalc max-spending dollars for this scenario (InvExp=0).
fc_ref: f64,
};
test "FIRECalc parity: safe-withdrawal dollars" {
const cases = [_]FcSwrCase{
.{ .name = "100% 30y 95% $1M", .horizon = 30, .value = 1_000_000, .stock_pct = 1.00, .confidence = 0.95, .fc_ref = 39_697 },
.{ .name = "75/25 30y 95% $1M", .horizon = 30, .value = 1_000_000, .stock_pct = 0.75, .confidence = 0.95, .fc_ref = 41_221 },
.{ .name = "100% 45y 95% $1M", .horizon = 45, .value = 1_000_000, .stock_pct = 1.00, .confidence = 0.95, .fc_ref = 35_835 },
.{ .name = "100% 20y 95% $1M", .horizon = 20, .value = 1_000_000, .stock_pct = 1.00, .confidence = 0.95, .fc_ref = 45_879 },
.{ .name = "100% 30y 90% $1M", .horizon = 30, .value = 1_000_000, .stock_pct = 1.00, .confidence = 0.90, .fc_ref = 43_804 },
.{ .name = "100% 30y 99% $1M", .horizon = 30, .value = 1_000_000, .stock_pct = 1.00, .confidence = 0.99, .fc_ref = 35_864 },
.{ .name = "100% 45y 99% $7.7M", .horizon = 45, .value = 7_700_000, .stock_pct = 1.00, .confidence = 0.99, .fc_ref = 254_461 },
.{ .name = "82% 45y 99% $7.7M", .horizon = 45, .value = 7_700_000, .stock_pct = 0.82, .confidence = 0.99, .fc_ref = 262_770 },
};
for (cases) |c| {
const r = findSafeWithdrawal(c.horizon, c.value, c.stock_pct, c.confidence, &.{});
// zfin tracks FIRECalc within roughly -3% / +15%, currently
// landing ~+6-9% high (equity return-series optimism). The lower
// bound catches an engine that suddenly turns conservative; the
// upper bound catches runaway optimism.
try std.testing.expect(r.annual_amount >= c.fc_ref * 0.97);
try std.testing.expect(r.annual_amount <= c.fc_ref * 1.15);
}
}
test "FIRECalc parity: success rate" {
// $1M, $40k/yr, 30yr, InvExp=0. FIRECalc: 100% stock -> 94.4%
// (7/126 failed); 75/25 -> 96.8% (4/126). zfin runs ~+2-3pp higher
// (fewer failures) for the same return-series reason.
const sr_100 = successRate(30, 1_000_000, 40_000, 1.00, &.{});
const sr_75 = successRate(30, 1_000_000, 40_000, 0.75, &.{});
// Within 6pp of FIRECalc, and never *below* it by more than 1pp
// (zfin is the more optimistic engine -- a large undershoot would be
// a regression).
try std.testing.expectApproxEqAbs(@as(f64, 0.944), sr_100, 0.06);
try std.testing.expectApproxEqAbs(@as(f64, 0.968), sr_75, 0.06);
try std.testing.expect(sr_100 >= 0.944 - 0.01);
try std.testing.expect(sr_75 >= 0.968 - 0.01);
}
test "FIRECalc parity: terminal-value percentiles" {
// S1: $1M, $40k, 30yr, 100% stock, InvExp=0. FIRECalc terminal
// values, captured from its per-cohort spreadsheet export in NOMINAL
// dollars (126 cohorts): p50 ~$5.12M, p90 ~$12.76M.
//
// Unit caveat: FIRECalc's on-screen "ending portfolio" figures are
// REAL (start-of-retirement dollars); zfin's bands are NOMINAL. The
// spreadsheet export is nominal, which is the basis used here. zfin's
// percentiles run higher (median ~+11%, p90 ~+16%) for the same
// return-series reason; the p10 gap is larger still because small
// per-year differences explode near the failure boundary, so p10 is
// intentionally not asserted.
const a = std.testing.allocator;
const bands = try computePercentileBands(a, 30, 1_000_000, 40_000, 1.00, &.{});
defer a.free(bands);
const term = bands[30];
try std.testing.expect(term.p50 >= 5_124_810 * 0.97);
try std.testing.expect(term.p50 <= 5_124_810 * 1.20);
try std.testing.expect(term.p90 >= 12_763_289 * 0.97);
try std.testing.expect(term.p90 <= 12_763_289 * 1.25);
}
test "FIRECalc parity: expense ratio matches FIRECalc's default fee" {
// Validates the `expense_ratio` model against FIRECalc runs with
// its *default* 0.18% fee enabled (InvExp=0.18). zfin's
// expense_ratio is a decimal here (0.0018 = 0.18%).
//
// Two things this pins:
// 1. The fee has the right *direction and magnitude*: enabling
// 0.18% drops zfin's SWR ~1.8%, matching FIRECalc's own
// ~2.0% fee effect (W2->W3: $41,221->$40,381).
// 2. With fees matched on BOTH sides, the residual gap is still
// ~+7-9% - i.e. the fee is NOT the source of the divergence;
// the equity return series (documented above) is. So the
// same -3%/+15% tolerance band applies.
const sr_100 = successRateParams(shiller.annual_returns, .{
.initial_value = 1_000_000,
.stock_pct = 1.00,
.annual_spending = 40_000,
.distribution_years = 30,
.expense_ratio = 0.0018,
});
const sr_75 = successRateParams(shiller.annual_returns, .{
.initial_value = 1_000_000,
.stock_pct = 0.75,
.annual_spending = 40_000,
.distribution_years = 30,
.expense_ratio = 0.0018,
});
// FIRECalc fee=0.18: 100% stock 93.7%, 75/25 95.2%.
try std.testing.expectApproxEqAbs(@as(f64, 0.937), sr_100, 0.06);
try std.testing.expectApproxEqAbs(@as(f64, 0.952), sr_75, 0.06);
// Safe withdrawal with the fee on. FIRECalc fee=0.18 refs:
// 75/25 30y 95% -> $40,381; 75/25 45y 99% $7.7M -> $258,747.
const w_30 = findSafeWithdrawalWithAccumulation(30, 1_000_000, 0.75, 0.95, &.{}, 0, 0, true, 0.0018, 0);
const w_45 = findSafeWithdrawalWithAccumulation(45, 7_700_000, 0.75, 0.99, &.{}, 0, 0, true, 0.0018, 0);
try std.testing.expect(w_30.annual_amount >= 40_381 * 0.97);
try std.testing.expect(w_30.annual_amount <= 40_381 * 1.15);
try std.testing.expect(w_45.annual_amount >= 258_747 * 0.97);
try std.testing.expect(w_45.annual_amount <= 258_747 * 1.15);
// Sanity: enabling the fee strictly lowers the safe withdrawal
// relative to the no-fee result (same scenario).
const w_30_nofee = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &.{});
try std.testing.expect(w_30.annual_amount < w_30_nofee.annual_amount);
}
test "simulateCycle produces correct year-0 value" {
var buf: [31]f64 = undefined;
simulateCycle(&buf, 0, 30, 1_000_000, 0, 0.75, &.{});
try std.testing.expectApproxEqAbs(@as(f64, 1_000_000), buf[0], 0.01);
}
test "simulateCycle with zero spending grows portfolio" {
var buf: [31]f64 = undefined;
simulateCycle(&buf, 0, 30, 1_000_000, 0, 0.75, &.{});
// Over any 30-year period in history, zero spending should grow the portfolio
try std.testing.expect(buf[30] > 1_000_000);
}
test "parseProjectionsConfig defaults" {
const config = parseProjectionsConfig(null);
try std.testing.expect(config.target_stock_pct == null);
try std.testing.expectEqual(@as(u8, 3), config.horizon_count);
try std.testing.expectEqual(@as(u16, 20), config.getHorizons()[0]);
try std.testing.expectEqual(@as(u16, 30), config.getHorizons()[1]);
try std.testing.expectEqual(@as(u16, 45), config.getHorizons()[2]);
}
test "parseProjectionsConfig from SRF" {
const data =
\\#!srfv1
\\type::config,target_stock_pct:num:77
\\type::config,horizon:num:25
\\type::config,horizon:num:35
\\type::config,horizon:num:50
;
const config = parseProjectionsConfig(data);
try std.testing.expectApproxEqAbs(@as(f64, 77.0), config.target_stock_pct.?, 0.01);
try std.testing.expectEqual(@as(u8, 3), config.horizon_count);
try std.testing.expectEqual(@as(u16, 25), config.getHorizons()[0]);
try std.testing.expectEqual(@as(u16, 35), config.getHorizons()[1]);
try std.testing.expectEqual(@as(u16, 50), config.getHorizons()[2]);
}
test "parseProjectionsConfig partial" {
const data = "#!srfv1\ntype::config,target_stock_pct:num:82.5\n";
const config = parseProjectionsConfig(data);
try std.testing.expectApproxEqAbs(@as(f64, 82.5), config.target_stock_pct.?, 0.01);
// Horizons should remain default
try std.testing.expectEqual(@as(u8, 3), config.horizon_count);
try std.testing.expectEqual(@as(u16, 20), config.getHorizons()[0]);
}
test "parseProjectionsConfig empty string" {
const config = parseProjectionsConfig("");
try std.testing.expect(config.target_stock_pct == null);
try std.testing.expectEqual(@as(u8, 3), config.horizon_count);
}
test "parseProjectionsConfig expense_ratio defaults to 0.18 and parses overrides" {
const default_config = parseProjectionsConfig("#!srfv1\n");
// Default is FIRECalc's 0.18% (a realistic fund-fee assumption),
// not 0 -- modeling no fee is less accurate and over-optimistic.
try std.testing.expectApproxEqAbs(@as(f64, 0.18), default_config.expense_ratio, 0.0001);
// A low-cost index investor overrides downward; verify parsing.
const data = "#!srfv1\ntype::config,expense_ratio:num:0.04\n";
const config = parseProjectionsConfig(data);
// Stored as a percentage (like target_stock_pct); the view layer
// divides by 100 before handing it to the simulation.
try std.testing.expectApproxEqAbs(@as(f64, 0.04), config.expense_ratio, 0.0001);
// Explicit 0 is honored (all-individual-stock portfolio).
const zeroed = parseProjectionsConfig("#!srfv1\ntype::config,expense_ratio:num:0\n");
try std.testing.expectEqual(@as(f64, 0), zeroed.expense_ratio);
}
test "parseProjectionsConfig invalid data" {
const config = parseProjectionsConfig("not valid srf");
try std.testing.expect(config.target_stock_pct == null);
}
test "parseProjectionsConfig horizon_age parsed raw" {
const data =
\\#!srfv1
\\type::config,horizon_age:num:90
\\type::config,horizon_age:num:95
\\type::birthdate,date::1975-03-15
;
const config = parseProjectionsConfig(data);
// horizon_ages are stored raw; not yet resolved into horizons.
try std.testing.expectEqual(@as(u8, 2), config.horizon_age_count);
try std.testing.expectEqual(@as(u16, 90), config.horizon_ages[0]);
try std.testing.expectEqual(@as(u16, 95), config.horizon_ages[1]);
// A horizon_age record counts as "saw_horizon", so the default
// {20,30,45} is cleared. horizon_count is 0 until resolution.
try std.testing.expectEqual(@as(u8, 0), config.horizon_count);
}
test "resolveHorizonAges uses youngest birthdate (last-survivor semantics)" {
// Person 1: born 1975, ~50 as of mid-2025. Person 2: born 1980, ~45.
// Target age 90 -> anchored on the YOUNGEST (last survivor): the money
// must last until person 2 reaches 90, i.e. 90 - 45 = 45 years. The
// oldest-anchored answer would have been 90 - 50 = 40 - this asserts we
// switched to the youngest. `as_of` is a couple weeks past the June
// birthdays so both ages are unambiguous (clear of the 365.25-day
// exact-anniversary floor).
var config = parseProjectionsConfig(
\\#!srfv1
\\type::config,horizon_age:num:90
\\type::birthdate,date::1975-06-15
\\type::birthdate,date::1980-06-15,person:num:2
);
const as_of = Date.fromYmd(2025, 7, 1);
try config.resolveHorizonAges(as_of);
try std.testing.expectEqual(@as(u8, 1), config.horizon_count);
try std.testing.expectEqual(@as(u16, 45), config.horizons[0]);
// Column flagged age-anchored at the death age.
try std.testing.expectEqual(@as(u16, 90), config.horizon_death_age[0]);
// Resolved; horizon_age_count cleared to make resolve idempotent.
try std.testing.expectEqual(@as(u8, 0), config.horizon_age_count);
}
test "resolveHorizonAges errors without a birthdate" {
var config = parseProjectionsConfig(
\\#!srfv1
\\type::config,horizon_age:num:90
);
const as_of = Date.fromYmd(2025, 1, 1);
try std.testing.expectError(error.HorizonAgeWithoutBirthdate, config.resolveHorizonAges(as_of));
}
test "resolveHorizonAges skips targets already in the past" {
// Single person, age 60 as of 2025 (youngest == oldest); target 40 is
// already past - skipped. Age 90 resolves (90 - 60 = 30).
var config = parseProjectionsConfig(
\\#!srfv1
\\type::config,horizon_age:num:40
\\type::config,horizon_age:num:90
\\type::birthdate,date::1965-01-01
);
const as_of = Date.fromYmd(2025, 6, 15);
try config.resolveHorizonAges(as_of);
// Only age 90 resolves (90 - 60 = 30).
try std.testing.expectEqual(@as(u8, 1), config.horizon_count);
try std.testing.expectEqual(@as(u16, 30), config.horizons[0]);
try std.testing.expectEqual(@as(u16, 90), config.horizon_death_age[0]);
}
test "resolveHorizonAges mixes with explicit horizon records" {
var config = parseProjectionsConfig(
\\#!srfv1
\\type::config,horizon:num:30
\\type::config,horizon_age:num:95
\\type::birthdate,date::1975-06-15
);
const as_of = Date.fromYmd(2025, 6, 15);
try config.resolveHorizonAges(as_of);
// Explicit 30 from `horizon`, then appended 95 - 50 = 45 from `horizon_age`.
try std.testing.expectEqual(@as(u8, 2), config.horizon_count);
try std.testing.expectEqual(@as(u16, 30), config.horizons[0]);
try std.testing.expectEqual(@as(u16, 45), config.horizons[1]);
// Numeric column carries no death age; age column carries 95.
try std.testing.expectEqual(@as(u16, 0), config.horizon_death_age[0]);
try std.testing.expectEqual(@as(u16, 95), config.horizon_death_age[1]);
}
test "resolveHorizonAges is a no-op when nothing to resolve" {
var config = parseProjectionsConfig(
\\#!srfv1
\\type::config,horizon:num:30
);
// No birthdate, no horizon_age -> should succeed, not error.
const as_of = Date.fromYmd(2025, 1, 1);
try config.resolveHorizonAges(as_of);
try std.testing.expectEqual(@as(u8, 1), config.horizon_count);
try std.testing.expectEqual(@as(u16, 30), config.horizons[0]);
}
test "UserConfig getHorizons default" {
const config = UserConfig{};
const horizons = config.getHorizons();
try std.testing.expectEqual(@as(usize, 3), horizons.len);
try std.testing.expectEqual(@as(u16, 20), horizons[0]);
try std.testing.expectEqual(@as(u16, 30), horizons[1]);
try std.testing.expectEqual(@as(u16, 45), horizons[2]);
}
test "UserConfig getConfidenceLevels" {
const config = UserConfig{};
const levels = config.getConfidenceLevels();
try std.testing.expectEqual(@as(usize, 3), levels.len);
try std.testing.expectApproxEqAbs(@as(f64, 0.90), levels[0], 0.001);
try std.testing.expectApproxEqAbs(@as(f64, 0.95), levels[1], 0.001);
try std.testing.expectApproxEqAbs(@as(f64, 0.99), levels[2], 0.001);
}
test "LifeEvent.startYear basic" {
const ev = LifeEvent{ .start_age = 67, .person = 0, .annual_amount = 38400 };
const ages = [_]u16{50};
try std.testing.expectEqual(@as(?u16, 17), ev.startYear(&ages));
}
test "LifeEvent.startYear already active" {
const ev = LifeEvent{ .start_age = 40, .person = 0, .annual_amount = 38400 };
const ages = [_]u16{50};
try std.testing.expectEqual(@as(?u16, 0), ev.startYear(&ages));
}
test "LifeEvent.startYear person out of range" {
const ev = LifeEvent{ .start_age = 67, .person = 5, .annual_amount = 38400 };
const ages = [_]u16{50};
try std.testing.expectEqual(@as(?u16, null), ev.startYear(&ages));
}
test "LifeEvent.isActive permanent" {
const ev = LifeEvent{ .start_age = 60, .person = 0, .duration = 0, .annual_amount = 38400 };
const ages = [_]u16{50};
try std.testing.expect(!ev.isActive(9, &ages)); // before start (year 10)
try std.testing.expect(ev.isActive(10, &ages)); // start year
try std.testing.expect(ev.isActive(30, &ages)); // well after
}
test "LifeEvent.isActive with duration" {
const ev = LifeEvent{ .start_age = 53, .person = 0, .duration = 4, .annual_amount = -60000 };
const ages = [_]u16{50};
try std.testing.expect(!ev.isActive(2, &ages)); // before start
try std.testing.expect(ev.isActive(3, &ages)); // year 3 (age 53)
try std.testing.expect(ev.isActive(6, &ages)); // year 6 (age 56, last year)
try std.testing.expect(!ev.isActive(7, &ages)); // year 7 (age 57, past duration)
}
test "LifeEvent.cashFlow inflation adjusted" {
const ev = LifeEvent{ .start_age = 50, .person = 0, .annual_amount = 10000, .inflation_adjusted = true };
const ages = [_]u16{50};
try std.testing.expectApproxEqAbs(@as(f64, 12000), ev.cashFlow(0, 1.2, &ages), 0.01);
}
test "LifeEvent.cashFlow nominal" {
const ev = LifeEvent{ .start_age = 50, .person = 0, .annual_amount = 10000, .inflation_adjusted = false };
const ages = [_]u16{50};
try std.testing.expectApproxEqAbs(@as(f64, 10000), ev.cashFlow(0, 1.2, &ages), 0.01);
}
test "LifeEvent.cashFlow inactive returns zero" {
const ev = LifeEvent{ .start_age = 67, .person = 0, .annual_amount = 38400 };
const ages = [_]u16{50};
try std.testing.expectApproxEqAbs(@as(f64, 0), ev.cashFlow(5, 1.0, &ages), 0.01);
}
test "parseProjectionsConfig birthdates and events" {
const data =
\\#!srfv1
\\type::config,target_stock_pct:num:80
\\type::config,horizon:num:30
\\type::birthdate,date::1975-03-15
\\type::birthdate,date::1978-06-22,person:num:2
\\type::event,name::Social Security,start_age:num:67,person:num:1,amount:num:38400
\\type::event,name::College,start_age:num:53,duration:num:4,amount:num:-60000,inflation_adjusted:bool:false
;
const config = parseProjectionsConfig(data);
try std.testing.expectApproxEqAbs(@as(f64, 80.0), config.target_stock_pct.?, 0.01);
try std.testing.expectEqual(@as(u8, 1), config.horizon_count);
try std.testing.expectEqual(@as(u8, 2), config.birthdate_count);
try std.testing.expectEqual(@as(i16, 1975), config.birthdates[0].year());
try std.testing.expectEqual(@as(i16, 1978), config.birthdates[1].year());
try std.testing.expectEqual(@as(u8, 2), config.event_count);
// First event: Social Security
const ev0 = config.events[0];
try std.testing.expectEqualStrings("Social Security", ev0.getName());
try std.testing.expectEqual(@as(u16, 67), ev0.start_age);
try std.testing.expectEqual(@as(u8, 0), ev0.person);
try std.testing.expectEqual(@as(u16, 0), ev0.duration);
try std.testing.expectApproxEqAbs(@as(f64, 38400), ev0.annual_amount, 0.01);
try std.testing.expect(ev0.inflation_adjusted);
// Second event: College
const ev1 = config.events[1];
try std.testing.expectEqualStrings("College", ev1.getName());
try std.testing.expectEqual(@as(u16, 53), ev1.start_age);
try std.testing.expectEqual(@as(u16, 4), ev1.duration);
try std.testing.expectApproxEqAbs(@as(f64, -60000), ev1.annual_amount, 0.01);
try std.testing.expect(!ev1.inflation_adjusted);
}
test "income event increases safe withdrawal" {
// With a permanent $20K/yr income event starting immediately,
// the safe withdrawal should be higher than without.
const no_events = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &.{});
const income_event = [_]ResolvedEvent{.{
.start_year = 0,
.duration = 0,
.annual_amount = 20_000,
.inflation_adjusted = true,
}};
const with_income = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &income_event);
try std.testing.expect(with_income.annual_amount > no_events.annual_amount);
// The increase should be roughly $20K (the income offsets withdrawal)
const diff = with_income.annual_amount - no_events.annual_amount;
try std.testing.expect(diff >= 15_000 and diff <= 25_000);
}
test "expense event decreases safe withdrawal" {
const no_events = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &.{});
const expense_event = [_]ResolvedEvent{.{
.start_year = 0,
.duration = 5,
.annual_amount = -20_000,
.inflation_adjusted = true,
}};
const with_expense = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &expense_event);
try std.testing.expect(with_expense.annual_amount < no_events.annual_amount);
}
test "UserConfig.eventNetCashFlow sums active events" {
var config = UserConfig{};
config.birthdate_count = 1;
config.birthdates[0] = Date.fromYmd(1975, 1, 1);
config.events[0] = .{ .start_age = 50, .person = 0, .annual_amount = 30000 };
config.events[1] = .{ .start_age = 55, .person = 0, .annual_amount = 10000 };
config.event_count = 2;
const ages = [_]u16{50};
// At year 0: only first event active (age 50)
try std.testing.expectApproxEqAbs(@as(f64, 30000), config.eventNetCashFlow(0, 1.0, &ages), 0.01);
// At year 5: both active (ages 55, 55)
try std.testing.expectApproxEqAbs(@as(f64, 40000), config.eventNetCashFlow(5, 1.0, &ages), 0.01);
}
// ── Accumulation phase tests ───────────────────────────────────
test "parseProjectionsConfig parses retirement_age" {
const data =
\\#!srfv1
\\type::config,retirement_age:num:65
;
const config = parseProjectionsConfig(data);
try std.testing.expectEqual(@as(?u16, 65), config.retirement_age);
try std.testing.expectEqual(@as(?Date, null), config.retirement_at);
}
test "parseProjectionsConfig parses retirement_at" {
const data =
\\#!srfv1
\\type::config,retirement_at::2036-07-01
;
const config = parseProjectionsConfig(data);
try std.testing.expect(config.retirement_at != null);
try std.testing.expectEqual(@as(i16, 2036), config.retirement_at.?.year());
try std.testing.expectEqual(@as(u8, 7), config.retirement_at.?.month());
try std.testing.expectEqual(@as(u8, 1), config.retirement_at.?.day());
}
test "parseProjectionsConfig parses annual_contribution" {
const data =
\\#!srfv1
\\type::config,annual_contribution:num:100000
\\type::config,contribution_inflation_adjusted:bool:false
;
const config = parseProjectionsConfig(data);
try std.testing.expectApproxEqAbs(@as(f64, 100_000), config.annual_contribution, 0.01);
try std.testing.expect(!config.contribution_inflation_adjusted);
}
test "parseProjectionsConfig rejects negative annual_contribution" {
const data =
\\#!srfv1
\\type::config,annual_contribution:num:-50000
;
const config = parseProjectionsConfig(data);
// Negative dropped; default zero retained.
try std.testing.expectApproxEqAbs(@as(f64, 0), config.annual_contribution, 0.01);
}
test "parseProjectionsConfig parses target_spending" {
const data =
\\#!srfv1
\\type::config,target_spending:num:80000
\\type::config,target_spending_inflation_adjusted:bool:false
;
const config = parseProjectionsConfig(data);
try std.testing.expectApproxEqAbs(@as(f64, 80_000), config.target_spending.?, 0.01);
try std.testing.expect(!config.target_spending_inflation_adjusted);
}
test "parseProjectionsConfig rejects negative target_spending" {
const data =
\\#!srfv1
\\type::config,target_spending:num:-1000
;
const config = parseProjectionsConfig(data);
try std.testing.expectEqual(@as(?f64, null), config.target_spending);
}
test "parseProjectionsConfig max_accumulation_years defaults to 50" {
const config = parseProjectionsConfig("#!srfv1\n");
try std.testing.expectEqual(default_max_accumulation_years, config.max_accumulation_years);
}
test "parseProjectionsConfig parses max_accumulation_years override" {
const data =
\\#!srfv1
\\type::config,max_accumulation_years:num:65
;
const config = parseProjectionsConfig(data);
try std.testing.expectEqual(@as(u16, 65), config.max_accumulation_years);
}
test "parseProjectionsConfig rejects zero max_accumulation_years" {
const data =
\\#!srfv1
\\type::config,max_accumulation_years:num:0
;
const config = parseProjectionsConfig(data);
// Zero is degenerate; dropped, default retained.
try std.testing.expectEqual(default_max_accumulation_years, config.max_accumulation_years);
}
test "parseProjectionsConfig clamps oversized max_accumulation_years to ceiling" {
const data =
\\#!srfv1
\\type::config,max_accumulation_years:num:500
;
const config = parseProjectionsConfig(data);
try std.testing.expectEqual(max_configurable_accumulation_years, config.max_accumulation_years);
}
test "parseProjectionsConfig return_cap defaults to null" {
const config = parseProjectionsConfig("#!srfv1\n");
try std.testing.expectEqual(@as(?f64, null), config.return_cap);
}
test "parseProjectionsConfig parses return_cap as a percent" {
const data =
\\#!srfv1
\\type::config,return_cap:num:30
;
const config = parseProjectionsConfig(data);
// Stored as a percentage (like target_stock_pct / expense_ratio);
// the view layer divides by 100 before handing it to the analytics.
try std.testing.expectApproxEqAbs(@as(f64, 30), config.return_cap.?, 0.0001);
}
test "parseProjectionsConfig rejects negative return_cap" {
const data =
\\#!srfv1
\\type::config,return_cap:num:-5
;
const config = parseProjectionsConfig(data);
// Negative ceiling is nonsensical; dropped, default null retained.
try std.testing.expectEqual(@as(?f64, null), config.return_cap);
}
test "parseProjectionsConfig benchmark defaults are SPY and AGG" {
const config = parseProjectionsConfig(null);
try std.testing.expectEqualStrings("SPY", config.benchmarkStock());
try std.testing.expectEqualStrings("AGG", config.benchmarkBond());
// len == 0 is what "no override" means; the buffers stay unread.
try std.testing.expectEqual(@as(u8, 0), config.benchmark_stock_len);
try std.testing.expectEqual(@as(u8, 0), config.benchmark_bond_len);
}
test "parseProjectionsConfig parses benchmark_stock and benchmark_bond" {
const data =
\\#!srfv1
\\type::config,benchmark_stock::VTI
\\type::config,benchmark_bond::BND
;
const config = parseProjectionsConfig(data);
try std.testing.expectEqualStrings("VTI", config.benchmarkStock());
try std.testing.expectEqualStrings("BND", config.benchmarkBond());
}
test "parseProjectionsConfig: an override survives copying the config by value" {
// THE REGRESSION TEST. `benchmark_stock` used to be a `[]const u8`
// pointing into `benchmark_stock_buf` - a self-reference - while
// `parseProjectionsConfig` returns `UserConfig` BY VALUE. Every copy
// after the first therefore carried a slice into a dead frame.
//
// The pre-existing tests all read the symbol one statement after the
// parse call, in the frame that received the return value, so the
// dead bytes were still intact and all of them passed. This one
// copies the struct, scribbles over the stack, and only then reads -
// which is what production does via `ProjectionContext`.
const data =
\\#!srfv1
\\type::config,benchmark_stock::VTI
\\type::config,benchmark_bond::BND
;
var copies: [4]UserConfig = undefined;
copies[0] = parseProjectionsConfig(data);
// Copy through a chain, the way buildContextFromParts ->
// buildProjectionContext -> ProjectionContext does.
copies[1] = copies[0];
copies[2] = copies[1];
copies[3] = copies[2];
// Churn the stack that `parseProjectionsConfig` used, so a dangling
// pointer reads garbage rather than stale-but-correct bytes.
stackChurn();
for (copies) |c| {
try std.testing.expectEqualStrings("VTI", c.benchmarkStock());
try std.testing.expectEqualStrings("BND", c.benchmarkBond());
}
}
/// Overwrite a chunk of stack so a dangling slice into a returned-by-value
/// struct reads scribble instead of stale-but-intact bytes. `noinline` and
/// the volatile-ish sum keep the optimizer from eliding it.
noinline fn stackChurn() void {
var scratch: [16 * 1024]u8 = undefined;
@memset(&scratch, 0xAA);
var sum: usize = 0;
for (scratch) |b| sum +%= b;
std.mem.doNotOptimizeAway(sum);
}
test "parseProjectionsConfig partial benchmark override falls back to default" {
// Only benchmark_stock configured - benchmark_bond stays at default.
const data =
\\#!srfv1
\\type::config,benchmark_stock::QQQ
;
const config = parseProjectionsConfig(data);
try std.testing.expectEqualStrings("QQQ", config.benchmarkStock());
try std.testing.expectEqualStrings("AGG", config.benchmarkBond());
try std.testing.expectEqual(@as(u8, 0), config.benchmark_bond_len);
}
test "parseProjectionsConfig rejects oversized benchmark symbol" {
// 17-char symbol exceeds the 16-byte buffer; should be ignored.
const data =
\\#!srfv1
\\type::config,benchmark_stock::ABCDEFGHIJKLMNOPQ
;
const config = parseProjectionsConfig(data);
try std.testing.expectEqualStrings("SPY", config.benchmarkStock());
try std.testing.expectEqual(@as(u8, 0), config.benchmark_stock_len);
}
test "UserConfig: a 16-char symbol fits exactly (boundary)" {
const data =
\\#!srfv1
\\type::config,benchmark_stock::ABCDEFGHIJKLMNOP
;
const config = parseProjectionsConfig(data);
try std.testing.expectEqualStrings("ABCDEFGHIJKLMNOP", config.benchmarkStock());
try std.testing.expectEqual(@as(u8, 16), config.benchmark_stock_len);
}
test "parseProjectionsConfig parses both retirement_age and retirement_at" {
// Both fields can be set in the file; resolver picks retirement_at.
// Parsing just stores both raw.
const data =
\\#!srfv1
\\type::config,retirement_age:num:65
\\type::config,retirement_at::2036-07-01
;
const config = parseProjectionsConfig(data);
try std.testing.expectEqual(@as(?u16, 65), config.retirement_age);
try std.testing.expect(config.retirement_at != null);
}
test "resolveRetirement: retirement_at in future" {
var config = UserConfig{};
config.retirement_at = Date.fromYmd(2036, 7, 1);
const today = Date.fromYmd(2026, 7, 1);
const r = config.resolveRetirement(today);
try std.testing.expectEqual(@as(u16, 10), r.accumulation_years);
try std.testing.expect(r.date != null);
try std.testing.expect(r.date.?.eql(Date.fromYmd(2036, 7, 1)));
try std.testing.expectEqual(.at_date, r.source);
}
test "resolveRetirement: retirement_at in past degrades to none" {
var config = UserConfig{};
config.retirement_at = Date.fromYmd(2020, 1, 1);
const today = Date.fromYmd(2026, 7, 1);
const r = config.resolveRetirement(today);
try std.testing.expectEqual(@as(u16, 0), r.accumulation_years);
try std.testing.expect(r.date == null);
try std.testing.expectEqual(.none, r.source);
}
test "resolveRetirement: retirement_age with birthday already passed this year" {
// Born 1975-03-15; today 2025-06-01 (past 03-15 this year).
// Target 65 -> date 2040-03-15; accumulation_years = floor(years between today and 2040-03-15).
var config = UserConfig{};
config.birthdate_count = 1;
config.birthdates[0] = Date.fromYmd(1975, 3, 15);
config.retirement_age = 65;
const today = Date.fromYmd(2025, 6, 1);
const r = config.resolveRetirement(today);
try std.testing.expect(r.date != null);
try std.testing.expect(r.date.?.eql(Date.fromYmd(2040, 3, 15)));
try std.testing.expectEqual(.at_age, r.source);
// ~14.78 years -> floor = 14
try std.testing.expectEqual(@as(u16, 14), r.accumulation_years);
}
test "resolveRetirement: retirement_age with birthday still ahead this year" {
// Born 1975-08-15; today 2025-06-01 (before 08-15 this year).
// Target 65 -> date 2040-08-15; ~15.21 years -> floor = 15.
var config = UserConfig{};
config.birthdate_count = 1;
config.birthdates[0] = Date.fromYmd(1975, 8, 15);
config.retirement_age = 65;
const today = Date.fromYmd(2025, 6, 1);
const r = config.resolveRetirement(today);
try std.testing.expectEqual(@as(u16, 15), r.accumulation_years);
try std.testing.expect(r.date.?.eql(Date.fromYmd(2040, 8, 15)));
}
test "resolveRetirement: retirement_age already past degrades to none" {
var config = UserConfig{};
config.birthdate_count = 1;
config.birthdates[0] = Date.fromYmd(1965, 1, 1); // age ~60 in 2025
config.retirement_age = 40; // already past
const today = Date.fromYmd(2025, 6, 1);
const r = config.resolveRetirement(today);
try std.testing.expectEqual(.none, r.source);
}
test "resolveRetirement: retirement_age with no birthdate degrades to none" {
var config = UserConfig{};
config.retirement_age = 65;
const today = Date.fromYmd(2025, 6, 1);
const r = config.resolveRetirement(today);
try std.testing.expectEqual(.none, r.source);
}
test "resolveRetirement: multi-person uses oldest birthdate" {
// Person 1: born 1975-03-15 (oldest). Person 2: born 1980-06-15.
// Target age 65 -> date is for person 1: 2040-03-15.
var config = UserConfig{};
config.birthdate_count = 2;
config.birthdates[0] = Date.fromYmd(1975, 3, 15);
config.birthdates[1] = Date.fromYmd(1980, 6, 15);
config.retirement_age = 65;
const today = Date.fromYmd(2025, 6, 1);
const r = config.resolveRetirement(today);
try std.testing.expect(r.date.?.eql(Date.fromYmd(2040, 3, 15)));
}
test "resolveRetirement: multi-person uses oldest regardless of order" {
// Person 1 (slot 0) is the YOUNGER one. Resolver should still
// pick slot 1 (the older) for the retirement date.
var config = UserConfig{};
config.birthdate_count = 2;
config.birthdates[0] = Date.fromYmd(1980, 6, 15);
config.birthdates[1] = Date.fromYmd(1975, 3, 15);
config.retirement_age = 65;
const today = Date.fromYmd(2025, 6, 1);
const r = config.resolveRetirement(today);
try std.testing.expect(r.date.?.eql(Date.fromYmd(2040, 3, 15)));
}
test "resolveRetirement: retirement_at wins when both set" {
var config = UserConfig{};
config.birthdate_count = 1;
config.birthdates[0] = Date.fromYmd(1975, 3, 15);
config.retirement_age = 65;
config.retirement_at = Date.fromYmd(2030, 1, 1);
const today = Date.fromYmd(2025, 6, 1);
const r = config.resolveRetirement(today);
try std.testing.expectEqual(.at_date, r.source);
try std.testing.expect(r.date.?.eql(Date.fromYmd(2030, 1, 1)));
}
test "resolveRetirement: none when neither field is set" {
const config = UserConfig{};
const today = Date.fromYmd(2025, 6, 1);
const r = config.resolveRetirement(today);
try std.testing.expectEqual(.none, r.source);
try std.testing.expectEqual(@as(u16, 0), r.accumulation_years);
try std.testing.expect(r.date == null);
}
test "resolveRetirement: retirement_age and retirement_at agree on same boundary" {
// Configure retirement_at and retirement_age such that both
// resolve to the same accumulation_years. retirement_at wins per
// the rule, but the integer years should match.
var c1 = UserConfig{};
c1.retirement_at = Date.fromYmd(2036, 7, 1);
var c2 = UserConfig{};
c2.birthdate_count = 1;
c2.birthdates[0] = Date.fromYmd(1971, 7, 1); // turns 65 on 2036-07-01
c2.retirement_age = 65;
const today = Date.fromYmd(2026, 7, 1);
const r1 = c1.resolveRetirement(today);
const r2 = c2.resolveRetirement(today);
try std.testing.expectEqual(r1.accumulation_years, r2.accumulation_years);
try std.testing.expect(r1.date.?.eql(r2.date.?));
}
test "ResolvedRetirement.boundaryYear: zero accumulation -> null, positive -> offset" {
// No accumulation phase (already retired / distribution-only):
// no divider to draw.
const none_r: ResolvedRetirement = .{ .accumulation_years = 0, .date = null, .source = .none };
try std.testing.expectEqual(@as(?u16, null), none_r.boundaryYear());
// An accumulation phase: the boundary offset equals
// accumulation_years (which doubles as the band index).
const acc_r: ResolvedRetirement = .{ .accumulation_years = 12, .date = Date.fromYmd(2038, 1, 1), .source = .at_age };
try std.testing.expectEqual(@as(?u16, 12), acc_r.boundaryYear());
}
// ── Two-phase simulation regression tests ──────────────────────
test "regression: findSafeWithdrawal(30, 1M, 0.75, 0.95) unchanged" {
// Pin the post-refactor value of the canonical SWR call. If this
// test ever fails, the two-phase refactor changed
// distribution-only behavior - investigate before bumping the
// golden value. Captured 2026-05-12.
const r = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &.{});
// Use a tight band - the binary search has $1 precision, so
// anything farther than a few dollars off is a real change.
try std.testing.expect(r.annual_amount >= 38_000);
try std.testing.expect(r.annual_amount <= 50_000);
// Snapshot the exact value as well so we notice silent drift.
// Actual value at refactor time was determined empirically.
const expected = 44_036.0;
try std.testing.expectApproxEqAbs(expected, r.annual_amount, 5.0);
}
test "regression: zero accumulation matches direct findSafeWithdrawal" {
// Both wrappers go through `searchSafeWithdrawal`; with
// accumulation_years=0 and zero contributions, the bracket
// seeding and search loop are identical. Tolerance is 0
// because the two paths execute the same code with the same
// inputs - any drift here means the unification broke.
const direct = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &.{});
const via_accum = findSafeWithdrawalWithAccumulation(30, 1_000_000, 0.75, 0.95, &.{}, 0, 0, true, 0, 0);
try std.testing.expectEqual(direct.annual_amount, via_accum.annual_amount);
try std.testing.expectEqual(direct.confidence, via_accum.confidence);
try std.testing.expectEqual(direct.withdrawal_rate, via_accum.withdrawal_rate);
}
test "two-phase: 10y accumulation with $100k/yr contributions raises post-accum portfolio" {
// Compare the median portfolio at year 10 with vs without
// contributions. Contributions should produce a meaningfully
// higher median.
const allocator = std.testing.allocator;
const params_no_contrib: SimParams = .{
.initial_value = 1_000_000,
.stock_pct = 0.75,
.annual_spending = 0,
.distribution_years = 30,
.accumulation_years = 10,
.annual_contribution = 0,
};
const params_with_contrib: SimParams = .{
.initial_value = 1_000_000,
.stock_pct = 0.75,
.annual_spending = 0,
.distribution_years = 30,
.accumulation_years = 10,
.annual_contribution = 100_000,
};
const bands_no = try computePercentileBandsParams(allocator, params_no_contrib);
defer allocator.free(bands_no);
const bands_with = try computePercentileBandsParams(allocator, params_with_contrib);
defer allocator.free(bands_with);
// Both bands span 40 years (10 accum + 30 dist) -> 41 entries.
try std.testing.expectEqual(@as(usize, 41), bands_no.len);
try std.testing.expectEqual(@as(usize, 41), bands_with.len);
// Year-0 starts the same in both.
try std.testing.expectApproxEqAbs(@as(f64, 1_000_000), bands_no[0].p50, 1.0);
try std.testing.expectApproxEqAbs(@as(f64, 1_000_000), bands_with[0].p50, 1.0);
// At the retirement boundary (year 10), with-contributions
// median should exceed without by significantly more than
// 10 × $100k (compounding helps).
try std.testing.expect(bands_with[10].p50 > bands_no[10].p50 + 1_000_000);
}
test "two-phase: nominal contributions produce lower year-10 median than CPI-adjusted" {
// CPI-adjusted contributions grow over time; nominal stay flat.
// Over 10 years, CPI-adjusted should accumulate more.
const allocator = std.testing.allocator;
const cpi_adj: SimParams = .{
.initial_value = 1_000_000,
.stock_pct = 0.75,
.annual_spending = 0,
.distribution_years = 30,
.accumulation_years = 10,
.annual_contribution = 100_000,
.contribution_inflation_adjusted = true,
};
const nominal: SimParams = .{
.initial_value = 1_000_000,
.stock_pct = 0.75,
.annual_spending = 0,
.distribution_years = 30,
.accumulation_years = 10,
.annual_contribution = 100_000,
.contribution_inflation_adjusted = false,
};
const b_cpi = try computePercentileBandsParams(allocator, cpi_adj);
defer allocator.free(b_cpi);
const b_nom = try computePercentileBandsParams(allocator, nominal);
defer allocator.free(b_nom);
// Median at year 10 should be higher with CPI-adjusted (over
// any sufficiently inflationary historical window the diff is
// positive; CPI is non-negative on the long term).
try std.testing.expect(b_cpi[10].p50 >= b_nom[10].p50);
}
test "two-phase: SWR with accumulation exceeds same-portfolio direct SWR" {
// 10 years of $100k contributions on top of $1M should produce
// a higher safe withdrawal than $1M alone over a 30-year
// distribution at the same confidence.
const direct = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &.{});
const with_accum = findSafeWithdrawalWithAccumulation(30, 1_000_000, 0.75, 0.95, &.{}, 10, 100_000, true, 0, 0);
try std.testing.expect(with_accum.annual_amount > direct.annual_amount);
}
test "simulateTwoPhase: null-buf and non-null-buf agree on verdict" {
// Locks in the invariant that calling simulateTwoPhase with
// null produces the same survival bit as calling it with a
// path buffer. This is the load-bearing equivalence that lets
// `successRateParams` use the cheaper null-buf path while
// `runAllCyclesParams` uses the path-storing version, with
// both producing the same answer about whether a given cycle
// failed.
//
// Cover three regimes: clear survivor, clear failure, and a
// marginal case driven by an extreme spending level.
const cases = [_]struct {
params: SimParams,
starts: []const usize,
}{
.{
// Clear survivor: zero spending.
.params = .{
.initial_value = 1_000_000,
.stock_pct = 0.75,
.annual_spending = 0,
.distribution_years = 30,
},
.starts = &.{ 0, 25, 50, 75 },
},
.{
// Clear failure: spend $200k/yr from $500k, 30 years.
.params = .{
.initial_value = 500_000,
.stock_pct = 0.75,
.annual_spending = 200_000,
.distribution_years = 30,
},
.starts = &.{ 0, 25, 50, 75 },
},
.{
// Marginal: 10y accumulation then 30y of moderate spend.
.params = .{
.initial_value = 1_000_000,
.stock_pct = 0.75,
.annual_spending = 60_000,
.distribution_years = 30,
.accumulation_years = 10,
.annual_contribution = 50_000,
},
.starts = &.{ 0, 30, 60 },
},
};
var buf: [101]f64 = undefined; // max total ≈ 50 + 50, slack
for (cases) |case| {
for (case.starts) |start| {
const total = case.params.totalYears();
std.debug.assert(total + 1 <= buf.len);
const verdict_null = simulateTwoPhase(null, shiller.annual_returns, start, case.params);
const verdict_buf = simulateTwoPhase(buf[0 .. total + 1], shiller.annual_returns, start, case.params);
try std.testing.expectEqual(verdict_null, verdict_buf);
}
}
}
// ── findEarliestRetirement tests ───────────────────────────────
test "findEarliestRetirement: feasible at N=0 returns 0" {
// $10M portfolio, $40k/yr spending, 30y distribution, 95%
// confidence - feasible immediately (1.6× the 4% rule).
const allocator = std.testing.allocator;
const r = try findEarliestRetirement(
allocator,
10_000_000, // initial_value
0.75, // stock_pct
0, // annual_contribution
true,
40_000, // target_spending
true,
30, // distribution_years
0.95, // confidence
&.{},
50, // max_years
0, // expense_ratio
0, // spending_real_change
);
try std.testing.expectEqual(@as(?u16, 0), r.accumulation_years);
}
test "findEarliestRetirement: unreachable returns null" {
// $1M portfolio, $1M/yr spending, no contributions: never
// feasible. Returns null.
const allocator = std.testing.allocator;
const r = try findEarliestRetirement(
allocator,
1_000_000,
0.75,
0, // no contributions
true,
1_000_000, // target spending = entire portfolio every year
true,
30,
0.95,
&.{},
50,
0, // expense_ratio
0, // spending_real_change
);
try std.testing.expectEqual(@as(?u16, null), r.accumulation_years);
}
test "findEarliestRetirement: longer distribution shifts retirement later or unchanged" {
// Same setup, just two horizons.
const allocator = std.testing.allocator;
const short = try findEarliestRetirement(
allocator,
1_000_000,
0.75,
50_000,
true,
80_000,
true,
20, // 20-year distribution
0.95,
&.{},
50,
0, // expense_ratio
0, // spending_real_change
);
const long = try findEarliestRetirement(
allocator,
1_000_000,
0.75,
50_000,
true,
80_000,
true,
45, // 45-year distribution
0.95,
&.{},
50,
0, // expense_ratio
0, // spending_real_change
);
if (short.accumulation_years != null and long.accumulation_years != null) {
try std.testing.expect(long.accumulation_years.? >= short.accumulation_years.?);
}
}
test "findEarliestRetirement: result includes portfolio statistics" {
const allocator = std.testing.allocator;
const r = try findEarliestRetirement(
allocator,
2_000_000,
0.75,
100_000,
true,
80_000,
true,
30,
0.95,
&.{},
50,
0, // expense_ratio
0, // spending_real_change
);
if (r.accumulation_years) |n| {
if (n > 0) {
// Median portfolio at retirement should be >= initial
// value (we accumulate before drawing down).
try std.testing.expect(r.median_at_retirement >= 1_500_000);
// p10 ≤ p50 ≤ p90.
try std.testing.expect(r.p10_at_retirement <= r.median_at_retirement);
try std.testing.expect(r.median_at_retirement <= r.p90_at_retirement);
}
}
}
// ── Mortality: youngest anchor, survivor step-down, event termination ──
test "youngestBirthdate / youngestAge pick the latest-born person" {
var config = UserConfig{};
config.birthdates[0] = Date.fromYmd(1958, 3, 1);
config.birthdates[1] = Date.fromYmd(1965, 9, 20);
config.birthdate_count = 2;
const as_of = Date.fromYmd(2025, 1, 1);
// Youngest = latest birthdate (1965).
try std.testing.expectEqual(Date.fromYmd(1965, 9, 20), config.youngestBirthdate().?);
// Oldest = earliest birthdate (1958) - the existing helper, sanity.
try std.testing.expectEqual(Date.fromYmd(1958, 3, 1), config.oldestBirthdate().?);
// Youngest is younger than oldest as of the same date.
try std.testing.expect(config.youngestAge(as_of) < config.oldestAge(as_of));
}
test "youngestBirthdate is null with no birthdates" {
const config = UserConfig{};
try std.testing.expectEqual(@as(?Date, null), config.youngestBirthdate());
try std.testing.expectEqual(@as(u16, 0), config.youngestAge(Date.fromYmd(2025, 1, 1)));
}
test "parse survivor_spending_pct: default, explicit, above-100, and negative-rejected" {
// Default when unset.
const dflt = parseProjectionsConfig(
\\#!srfv1
\\type::config,horizon:num:30
);
try std.testing.expectEqual(@as(f64, 75), dflt.survivor_spending_pct);
// Explicit value honored.
const set = parseProjectionsConfig(
\\#!srfv1
\\type::config,survivor_spending_pct:num:60
);
try std.testing.expectEqual(@as(f64, 60), set.survivor_spending_pct);
// Above 100 is allowed (a survivor whose spending rises).
const high = parseProjectionsConfig(
\\#!srfv1
\\type::config,survivor_spending_pct:num:110
);
try std.testing.expectEqual(@as(f64, 110), high.survivor_spending_pct);
// Negative is rejected -> default retained.
const neg = parseProjectionsConfig(
\\#!srfv1
\\type::config,survivor_spending_pct:num:-20
);
try std.testing.expectEqual(@as(f64, 75), neg.survivor_spending_pct);
}
test "LifeEvent.resolveToAge: permanent income terminates at the holder's death" {
// SS at age 70 for a person currently 50 -> starts sim-year 20.
// Age-of-death 90 -> dies sim-year 40. The permanent event is
// capped to [20, 40): duration 20.
const ev = LifeEvent{ .start_age = 70, .annual_amount = 38_400 };
const ages = [_]u16{50};
const uncapped = ev.resolveToAge(&ages, null).?;
try std.testing.expectEqual(@as(u16, 20), uncapped.start_year);
try std.testing.expectEqual(@as(u16, 0), uncapped.duration); // permanent
const capped = ev.resolveToAge(&ages, 90).?;
try std.testing.expectEqual(@as(u16, 20), capped.start_year);
try std.testing.expectEqual(@as(u16, 20), capped.duration);
try std.testing.expect(capped.isActive(39));
try std.testing.expect(!capped.isActive(40)); // dead
}
test "LifeEvent.resolveToAge: dead before the event would start -> never active" {
// Event at age 70, person 50 -> starts year 20, but age-of-death
// 60 -> dies year 10, before the event begins. Never active.
const ev = LifeEvent{ .start_age = 70, .annual_amount = 10_000 };
const ages = [_]u16{50};
const capped = ev.resolveToAge(&ages, 60).?;
try std.testing.expect(!capped.isActive(20));
try std.testing.expect(!capped.isActive(0));
}
test "LifeEvent.resolveToAge: finite duration shorter than death is preserved" {
// Tuition at age 60 (start year 10) for 4 years; death at 90 (year
// 40) is well beyond, so the 4-year duration is unchanged.
const ev = LifeEvent{ .start_age = 60, .duration = 4, .annual_amount = -55_000 };
const ages = [_]u16{50};
const capped = ev.resolveToAge(&ages, 90).?;
try std.testing.expectEqual(@as(u16, 10), capped.start_year);
try std.testing.expectEqual(@as(u16, 4), capped.duration);
}
test "resolveEventsToAge caps each holder's events at their own death" {
var config = UserConfig{};
config.birthdates[0] = Date.fromYmd(1965, 1, 1); // ~60 as of 2025
config.birthdates[1] = Date.fromYmd(1970, 1, 1); // ~55 as of 2025
config.birthdate_count = 2;
// Person 0 SS at 70; person 1 SS at 70.
config.events[0] = LifeEvent{ .start_age = 70, .person = 0, .annual_amount = 30_000 };
config.events[1] = LifeEvent{ .start_age = 70, .person = 1, .annual_amount = 28_000 };
config.event_count = 2;
const as_of = Date.fromYmd(2025, 1, 1);
const resolved = config.resolveEventsToAge(as_of, 90);
// Person 0 (~60): SS at year 10, dies ~year 30 -> active just before
// 30, gone at 30.
try std.testing.expect(resolved[0].isActive(29));
try std.testing.expect(!resolved[0].isActive(30));
// Person 1 (~55): dies ~year 35, later than person 0.
try std.testing.expect(resolved[1].isActive(34));
try std.testing.expect(!resolved[1].isActive(35));
}
test "simulateTwoPhase: survivor step-down lets a portfolio survive that flat spending exhausts" {
// Constant zero return / zero inflation: a pure cash-flow ledger.
const flat = shiller.ShillerYear{
.year = 2000,
.sp500_total_return = 0,
.bond_total_return = 0,
.cpi_inflation = 0,
};
const data = [_]shiller.ShillerYear{flat} ** 11;
const base = SimParams{
.initial_value = 100,
.stock_pct = 1.0,
.annual_spending = 10,
.distribution_years = 10,
};
// No survivor step: spends 10/yr * 10yr = 100 -> exhausts (fails).
try std.testing.expect(!simulateTwoPhase(null, &data, 0, base));
// Survivor step at year 5 to 50%: 5*10 + 5*5 = 75 < 100 -> survives.
var stepped = base;
stepped.first_death_year = 5;
stepped.survivor_factor = 0.5;
try std.testing.expect(simulateTwoPhase(null, &data, 0, stepped));
}
test "findEarliestRetirementToAge: infeasible when the last survivor is already past the age" {
const allocator = std.testing.allocator;
const r = try findEarliestRetirementToAge(
allocator,
1_000_000,
0.75,
0,
true,
40_000,
true,
0, // total_span == 0: everyone already at/past age-of-death
null,
1.0,
95, // death_age (propagated for rendering)
0.95,
&.{},
50,
0,
0,
);
try std.testing.expectEqual(@as(?u16, null), r.accumulation_years);
try std.testing.expectEqual(@as(u16, 95), r.death_age);
}
test "findEarliestRetirementToAge: feasible-now case carries the death age" {
const allocator = std.testing.allocator;
const r = try findEarliestRetirementToAge(
allocator,
10_000_000, // ample
0.75,
0,
true,
40_000, // modest spend
true,
30, // total_span (last survivor 30y out)
null,
1.0,
95,
0.95,
&.{},
50,
0,
0,
);
try std.testing.expectEqual(@as(?u16, 0), r.accumulation_years);
try std.testing.expectEqual(@as(u16, 95), r.death_age);
// Distribution at N=0 is the full span.
try std.testing.expectEqual(@as(u16, 30), r.horizon);
}
test "findEarliestRetirementToAge: a survivor spending cut never delays retirement" {
const allocator = std.testing.allocator;
// Flat: survivor_factor 1.0, no first death.
const flat = try findEarliestRetirementToAge(
allocator,
1_000_000,
0.80,
40_000, // annual_contribution
true,
70_000, // target_spending (tight enough to need accumulation)
true,
35, // total_span
null,
1.0,
95,
0.95,
&.{},
50,
0,
0,
);
// Survivor cut to 60% at year 15 (first death).
const cut = try findEarliestRetirementToAge(
allocator,
1_000_000,
0.80,
40_000,
true,
70_000,
true,
35,
15,
0.60,
95,
0.95,
&.{},
50,
0,
0,
);
if (flat.accumulation_years) |f| {
// The reduced post-first-death spending can only help: the
// earliest feasible retirement is no later than the flat case.
try std.testing.expect(cut.accumulation_years != null);
try std.testing.expect(cut.accumulation_years.? <= f);
}
}
test "columnMortality: single person, couple with gap, and degenerate cases" {
var single = UserConfig{};
single.birthdates[0] = Date.fromYmd(1960, 1, 1); // ~66 as of 2026
single.birthdate_count = 1;
single.survivor_spending_pct = 70;
const as_of = Date.fromYmd(2026, 1, 1);
// Single person: no gap -> factor 1.0, no first-death step.
const m_single = columnMortality(&single, as_of, 95, 0);
try std.testing.expectEqual(@as(u16, 29), m_single.total_span); // 95 - 66
try std.testing.expectEqual(@as(?u16, null), m_single.first_death_year);
try std.testing.expectEqual(@as(f64, 1.0), m_single.survivor_factor);
// Couple with an age gap: oldest dies first (step), youngest sets span.
var couple = UserConfig{};
couple.birthdates[0] = Date.fromYmd(1960, 1, 1); // ~66 -> dies at 95 in 29y
couple.birthdates[1] = Date.fromYmd(1966, 1, 1); // ~60 -> dies at 95 in 35y
couple.birthdate_count = 2;
couple.survivor_spending_pct = 70;
const m_couple = columnMortality(&couple, as_of, 95, 0);
try std.testing.expectEqual(@as(u16, 35), m_couple.total_span); // youngest
try std.testing.expectEqual(@as(?u16, 29), m_couple.first_death_year); // oldest
try std.testing.expectEqual(@as(f64, 0.70), m_couple.survivor_factor);
// Degenerate: age-of-death already reached by the youngest -> span 0.
const m_past = columnMortality(&couple, as_of, 50, 0);
try std.testing.expectEqual(@as(u16, 0), m_past.total_span);
// Distribution clamps to >= 1 when accumulation meets/exceeds span.
const m_clamp = columnMortality(&couple, as_of, 95, 40); // acc 40 > span 35
try std.testing.expectEqual(@as(u16, 1), m_clamp.distribution_years);
}
test "findEarliestRetirementToAge: infeasible within cap when spending is absurd" {
const allocator = std.testing.allocator;
// total_span 30, but spending far exceeds what any accumulation
// length within the cap can sustain -> exhausts the loop and
// returns the infeasible sentinel.
const r = try findEarliestRetirementToAge(
allocator,
500_000,
0.75,
0, // no contributions
true,
2_000_000, // $2M/yr on a $500k base: never sustainable
true,
30,
null,
1.0,
95,
0.95,
&.{},
25, // cap below total_span - 1
0,
0,
);
try std.testing.expectEqual(@as(?u16, null), r.accumulation_years);
try std.testing.expectEqual(@as(u16, 95), r.death_age);
}
test "resolveToAge: out-of-range person index yields a never-active sentinel" {
var config = UserConfig{};
config.birthdates[0] = Date.fromYmd(1970, 1, 1);
config.birthdate_count = 1;
// Event references person index 4, past the 4-slot persons array
// -> startYear returns null -> never-active sentinel.
config.events[0] = LifeEvent{ .start_age = 70, .person = 4, .annual_amount = 1000 };
config.event_count = 1;
const resolved = config.resolveEventsToAge(Date.fromYmd(2026, 1, 1), 95);
try std.testing.expectEqual(@as(u16, std.math.maxInt(u16)), resolved[0].start_year);
try std.testing.expect(!resolved[0].isActive(10));
}
test "ResolvedEvent.cashFlow: non-inflation-adjusted returns the flat amount" {
const ev: ResolvedEvent = .{
.start_year = 0,
.duration = 0,
.annual_amount = 24_000,
.inflation_adjusted = false,
};
// cumulative_inflation is ignored when inflation_adjusted is false.
try std.testing.expectEqual(@as(f64, 24_000), ev.cashFlow(3, 1.5));
}
// ── ResolvedRetirement formatter tests ─────────────────────────
test "fmtRetirementLine: none case" {
var buf: [128]u8 = undefined;
const line = retirementLineForTest(&buf, .{
.accumulation_years = 0,
.date = null,
.source = .none,
});
try std.testing.expectEqualStrings("Years until possible retirement: none", line);
}
test "fmtRetirementLine: at_date case" {
var buf: [128]u8 = undefined;
const line = retirementLineForTest(&buf, .{
.accumulation_years = 10,
.date = Date.fromYmd(2036, 7, 1),
.source = .at_date,
});
try std.testing.expectEqualStrings("Years until possible retirement: 10 (2036-07-01)", line);
}
test "fmtRetirementLine: at_age case" {
var buf: [128]u8 = undefined;
const line = retirementLineForTest(&buf, .{
.accumulation_years = 14,
.date = Date.fromYmd(2040, 3, 15),
.source = .at_age,
});
try std.testing.expectEqualStrings("Years until possible retirement: 14 (2040-03-15)", line);
}
/// Test-only adapter to avoid dragging the views/projections.zig
/// module into this file's import surface. Mirrors
/// `views.fmtRetirementLine` exactly; if the formatter ever moves,
/// update both.
fn retirementLineForTest(buf: []u8, resolved: ResolvedRetirement) []const u8 {
if (resolved.source == .none) {
return std.fmt.bufPrint(buf, "Years until possible retirement: none", .{}) catch "Years until possible retirement: none";
}
var date_buf: [10]u8 = undefined;
const date_str = if (resolved.date) |d| (std.fmt.bufPrint(&date_buf, "{f}", .{d}) catch "????-??-??") else "????-??-??";
return std.fmt.bufPrint(buf, "Years until possible retirement: {d} ({s})", .{
resolved.accumulation_years,
date_str,
}) catch "Years until possible retirement: ?";
}
// ── pickPromotedCell tests ─────────────────────────────────────
test "pickPromotedCell: age-anchored column is preferred over numeric horizons" {
var config = UserConfig{};
config.horizon_count = 3;
config.horizons = .{ 30, 18, 33 } ++ @as([UserConfig.max_horizons - 3]u16, @splat(0));
// Columns 1 and 2 are age-anchored (90 and 95); column 0 is numeric.
config.horizon_death_age = .{ 0, 90, 95 } ++ @as([UserConfig.max_horizons - 3]u16, @splat(0));
config.birthdate_count = 1;
config.birthdates[0] = Date.fromYmd(1965, 4, 12);
const today = Date.fromYmd(2026, 5, 12);
const confs = [_]f64{ 0.90, 0.95, 0.99 };
const pc = pickPromotedCell(&config, today, &confs).?;
// The latest death (95, index 2) wins, at the 99% default.
try std.testing.expectEqual(@as(usize, 2), pc.horizon_index);
try std.testing.expectEqual(@as(usize, 2), pc.confidence_index);
try std.testing.expect(!pc.explicit);
}
test "pickPromotedCell: explicit retirement_target still wins over age columns" {
var config = UserConfig{};
config.horizon_count = 2;
config.horizons = .{ 30, 33 } ++ @as([UserConfig.max_horizons - 2]u16, @splat(0));
config.horizon_death_age = .{ 0, 95 } ++ @as([UserConfig.max_horizons - 2]u16, @splat(0));
config.horizon_targets = .{ 90, 0 } ++ @as([UserConfig.max_horizons - 2]u8, @splat(0));
config.birthdate_count = 1;
config.birthdates[0] = Date.fromYmd(1965, 4, 12);
const today = Date.fromYmd(2026, 5, 12);
const confs = [_]f64{ 0.90, 0.95, 0.99 };
const pc = pickPromotedCell(&config, today, &confs).?;
// The explicitly-tagged numeric column 0 (target 90%) wins.
try std.testing.expectEqual(@as(usize, 0), pc.horizon_index);
try std.testing.expectEqual(@as(usize, 0), pc.confidence_index); // 90%
try std.testing.expect(pc.explicit);
}
test "pickPromotedCell: longest horizon selected when oldest stays under cap" {
var config = UserConfig{};
config.horizon_count = 3;
config.horizons = .{ 25, 35, 50 } ++ @as([UserConfig.max_horizons - 3]u16, @splat(0));
config.birthdate_count = 1;
config.birthdates[0] = Date.fromYmd(1981, 4, 12); // ~age 45 in 2026
const today = Date.fromYmd(2026, 5, 12);
const confs = [_]f64{ 0.90, 0.95, 0.99 };
const pc = pickPromotedCell(&config, today, &confs).?;
// Longest is 50; 45 + 50 = 95 < 100 -> 50yr horizon picked.
try std.testing.expectEqual(@as(usize, 2), pc.horizon_index);
try std.testing.expectEqual(@as(usize, 2), pc.confidence_index); // 99% default
try std.testing.expect(!pc.explicit);
}
test "pickPromotedCell: longest horizon overshoots, second-longest selected" {
var config = UserConfig{};
config.horizon_count = 3;
config.horizons = .{ 25, 35, 50 } ++ @as([UserConfig.max_horizons - 3]u16, @splat(0));
config.birthdate_count = 1;
config.birthdates[0] = Date.fromYmd(1968, 4, 12); // ~age 58 in 2026
const today = Date.fromYmd(2026, 5, 12);
const confs = [_]f64{ 0.90, 0.95, 0.99 };
const pc = pickPromotedCell(&config, today, &confs).?;
// Longest is 50; 58 + 50 = 108 >= 100 -> skip.
// Next is 35; 58 + 35 = 93 < 100 -> pick.
try std.testing.expectEqual(@as(u16, 35), config.horizons[pc.horizon_index]);
try std.testing.expectEqual(@as(usize, 2), pc.confidence_index);
}
test "pickPromotedCell: all horizons overshoot, fall through to shortest" {
var config = UserConfig{};
config.horizon_count = 3;
config.horizons = .{ 25, 35, 50 } ++ @as([UserConfig.max_horizons - 3]u16, @splat(0));
config.birthdate_count = 1;
config.birthdates[0] = Date.fromYmd(1948, 4, 12); // ~age 78 in 2026
const today = Date.fromYmd(2026, 5, 12);
const confs = [_]f64{ 0.90, 0.95, 0.99 };
const pc = pickPromotedCell(&config, today, &confs).?;
// All overshoot 100. Shortest is 25 -> pick it (fuck-it branch).
try std.testing.expectEqual(@as(u16, 25), config.horizons[pc.horizon_index]);
}
test "pickPromotedCell: explicit retirement_target wins regardless of length" {
var config = UserConfig{};
config.horizon_count = 3;
config.horizons = .{ 25, 35, 50 } ++ @as([UserConfig.max_horizons - 3]u16, @splat(0));
// Annotate the SHORTEST horizon - overrides default rule which
// would pick the longest.
config.horizon_targets[0] = 95;
config.birthdate_count = 1;
config.birthdates[0] = Date.fromYmd(1981, 4, 12);
const today = Date.fromYmd(2026, 5, 12);
const confs = [_]f64{ 0.90, 0.95, 0.99 };
const pc = pickPromotedCell(&config, today, &confs).?;
try std.testing.expectEqual(@as(u16, 25), config.horizons[pc.horizon_index]);
try std.testing.expectEqual(@as(usize, 1), pc.confidence_index); // 95% -> index 1
try std.testing.expect(pc.explicit);
}
test "pickPromotedCell: no birthdates falls through to longest horizon" {
var config = UserConfig{};
config.horizon_count = 3;
config.horizons = .{ 25, 35, 50 } ++ @as([UserConfig.max_horizons - 3]u16, @splat(0));
const today = Date.fromYmd(2026, 5, 12);
const confs = [_]f64{ 0.90, 0.95, 0.99 };
const pc = pickPromotedCell(&config, today, &confs).?;
try std.testing.expectEqual(@as(u16, 50), config.horizons[pc.horizon_index]);
try std.testing.expectEqual(@as(usize, 2), pc.confidence_index); // 99%
}
test "pickPromotedCell: zero horizons returns null" {
var config = UserConfig{};
config.horizon_count = 0;
const today = Date.fromYmd(2026, 5, 12);
const confs = [_]f64{ 0.90, 0.95, 0.99 };
try std.testing.expect(pickPromotedCell(&config, today, &confs) == null);
}
test "parseProjectionsConfig: retirement_target on horizon record" {
const data =
\\#!srfv1
\\type::config,horizon:num:25
\\type::config,horizon:num:35,retirement_target:num:95
\\type::config,horizon:num:50
;
const config = parseProjectionsConfig(data);
try std.testing.expectEqual(@as(u8, 3), config.horizon_count);
try std.testing.expectEqual(@as(u8, 0), config.horizon_targets[0]);
try std.testing.expectEqual(@as(u8, 95), config.horizon_targets[1]);
try std.testing.expectEqual(@as(u8, 0), config.horizon_targets[2]);
}
test "parseProjectionsConfig: retirement_target on horizon_age survives resolution" {
const data =
\\#!srfv1
\\type::config,horizon_age:num:90,retirement_target:num:99
\\type::birthdate,date::1975-01-01
;
var config = parseProjectionsConfig(data);
try std.testing.expectEqual(@as(u8, 99), config.horizon_age_targets[0]);
// Resolve: youngest age (single person here) in 2025 is 50 -> horizon 40.
try config.resolveHorizonAges(Date.fromYmd(2025, 6, 15));
try std.testing.expectEqual(@as(u8, 1), config.horizon_count);
try std.testing.expectEqual(@as(u16, 40), config.horizons[0]);
try std.testing.expectEqual(@as(u8, 99), config.horizon_targets[0]);
}
test "parseProjectionsConfig: invalid retirement_target value dropped silently per record" {
const data =
\\#!srfv1
\\type::config,horizon:num:25
\\type::config,horizon:num:35,retirement_target:num:80
\\type::config,horizon:num:50,retirement_target:num:99
;
const config = parseProjectionsConfig(data);
// Record with retirement_target:80 keeps the horizon but drops
// the invalid annotation. The 99 on the third horizon is the
// ONLY valid annotation, so it stays.
try std.testing.expectEqual(@as(u8, 3), config.horizon_count);
try std.testing.expectEqual(@as(u8, 0), config.horizon_targets[0]);
try std.testing.expectEqual(@as(u8, 0), config.horizon_targets[1]);
try std.testing.expectEqual(@as(u8, 99), config.horizon_targets[2]);
}
test "parseProjectionsConfig: multiple retirement_target annotations all dropped" {
const data =
\\#!srfv1
\\type::config,horizon:num:25,retirement_target:num:95
\\type::config,horizon:num:35,retirement_target:num:99
\\type::config,horizon:num:50
;
const config = parseProjectionsConfig(data);
// Validation post-pass: > 1 annotation -> drop them all.
try std.testing.expectEqual(@as(u8, 0), config.horizon_targets[0]);
try std.testing.expectEqual(@as(u8, 0), config.horizon_targets[1]);
try std.testing.expectEqual(@as(u8, 0), config.horizon_targets[2]);
}
test "validRetirementTarget: 90/95/99 pass, others fail" {
try std.testing.expectEqual(@as(?u8, 90), validRetirementTarget(90));
try std.testing.expectEqual(@as(?u8, 95), validRetirementTarget(95));
try std.testing.expectEqual(@as(?u8, 99), validRetirementTarget(99));
try std.testing.expectEqual(@as(?u8, null), validRetirementTarget(null));
try std.testing.expectEqual(@as(?u8, null), validRetirementTarget(0));
try std.testing.expectEqual(@as(?u8, null), validRetirementTarget(85));
try std.testing.expectEqual(@as(?u8, null), validRetirementTarget(100));
}
// ── oldestBirthdate / oldestAge tests ──────────────────────────
test "oldestBirthdate: no birthdates returns null" {
const config = UserConfig{};
try std.testing.expectEqual(@as(?Date, null), config.oldestBirthdate());
}
test "oldestBirthdate: single birthdate returns it" {
var config = UserConfig{};
config.birthdate_count = 1;
config.birthdates[0] = Date.fromYmd(1981, 4, 12);
const result = config.oldestBirthdate();
try std.testing.expect(result.?.eql(Date.fromYmd(1981, 4, 12)));
}
test "oldestBirthdate: multi-person picks earliest date" {
var config = UserConfig{};
config.birthdate_count = 2;
config.birthdates[0] = Date.fromYmd(1983, 9, 8);
config.birthdates[1] = Date.fromYmd(1981, 4, 12); // older
const result = config.oldestBirthdate();
try std.testing.expect(result.?.eql(Date.fromYmd(1981, 4, 12)));
}
test "oldestBirthdate: multi-person regardless of slot order" {
var config = UserConfig{};
config.birthdate_count = 2;
config.birthdates[0] = Date.fromYmd(1981, 4, 12); // older
config.birthdates[1] = Date.fromYmd(1983, 9, 8);
const result = config.oldestBirthdate();
try std.testing.expect(result.?.eql(Date.fromYmd(1981, 4, 12)));
}
test "oldestAge: no birthdates returns 0" {
const config = UserConfig{};
const as_of = Date.fromYmd(2026, 5, 12);
try std.testing.expectEqual(@as(u16, 0), config.oldestAge(as_of));
}
test "oldestAge: derives whole years from oldest birthdate" {
var config = UserConfig{};
config.birthdate_count = 2;
config.birthdates[0] = Date.fromYmd(1981, 4, 12);
config.birthdates[1] = Date.fromYmd(1983, 9, 8);
// 1981-04-12 -> 2026-05-12 spans 45 full years.
const as_of = Date.fromYmd(2026, 5, 12);
try std.testing.expectEqual(@as(u16, 45), config.oldestAge(as_of));
}
// ── runProjectionGrid tests ────────────────────────────────────
/// Free a `ProjectionData` produced by `runProjectionGrid`. Used by
/// the tests below to keep their cleanup blocks tidy.
fn freeProjectionData(allocator: std.mem.Allocator, data: ProjectionData) void {
allocator.free(data.withdrawals);
for (data.bands) |b| {
if (b) |slice| allocator.free(slice);
}
allocator.free(data.bands);
}
test "runProjectionGrid: structure and indexing" {
const allocator = std.testing.allocator;
const horizons = [_]u16{ 20, 30 };
const conf = [_]f64{ 0.95, 0.99 };
const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0, 0);
defer freeProjectionData(allocator, data);
// 2 horizons × 2 confidence levels = 4 withdrawal results.
try std.testing.expectEqual(@as(usize, 4), data.withdrawals.len);
// 2 bands (one per horizon).
try std.testing.expectEqual(@as(usize, 2), data.bands.len);
// ci_99 is the last (highest) confidence index.
try std.testing.expectEqual(@as(usize, 1), data.ci_99);
}
test "runProjectionGrid: withdrawal monotonicity along confidence axis" {
// Same horizon, lower confidence -> higher allowed spending.
// Indexing: withdrawals[ci * horizons.len + hi].
const allocator = std.testing.allocator;
const horizons = [_]u16{30};
const conf = [_]f64{ 0.90, 0.95, 0.99 };
const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0, 0);
defer freeProjectionData(allocator, data);
const w_90 = data.withdrawals[0 * horizons.len + 0].annual_amount;
const w_95 = data.withdrawals[1 * horizons.len + 0].annual_amount;
const w_99 = data.withdrawals[2 * horizons.len + 0].annual_amount;
try std.testing.expect(w_90 >= w_95);
try std.testing.expect(w_95 >= w_99);
}
test "runProjectionGrid: withdrawal monotonicity along horizon axis" {
// Same confidence, longer horizon -> lower allowed spending.
const allocator = std.testing.allocator;
const horizons = [_]u16{ 20, 30, 45 };
const conf = [_]f64{0.95};
const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0, 0);
defer freeProjectionData(allocator, data);
const w_20 = data.withdrawals[0 * horizons.len + 0].annual_amount;
const w_30 = data.withdrawals[0 * horizons.len + 1].annual_amount;
const w_45 = data.withdrawals[0 * horizons.len + 2].annual_amount;
try std.testing.expect(w_20 >= w_30);
try std.testing.expect(w_30 >= w_45);
}
test "runProjectionGrid: distribution-only band length is horizon + 1" {
const allocator = std.testing.allocator;
const horizons = [_]u16{ 20, 30 };
const conf = [_]f64{ 0.95, 0.99 };
const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0, 0);
defer freeProjectionData(allocator, data);
// band[0] covers horizons[0] = 20 -> 21 entries; band[1] covers
// horizons[1] = 30 -> 31 entries.
try std.testing.expectEqual(@as(usize, 21), data.bands[0].?.len);
try std.testing.expectEqual(@as(usize, 31), data.bands[1].?.len);
}
test "runProjectionGrid: with-accumulation band length includes accumulation_years" {
const allocator = std.testing.allocator;
const horizons = [_]u16{30};
const conf = [_]f64{0.95};
// 10 years of accumulation + 30 years distribution -> 41 entries.
const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 10, 50_000, true, 0, 0);
defer freeProjectionData(allocator, data);
try std.testing.expectEqual(@as(usize, 41), data.bands[0].?.len);
}
test "runProjectionGrid: bands are p10 ≤ p25 ≤ p50 ≤ p75 ≤ p90 at every year" {
const allocator = std.testing.allocator;
const horizons = [_]u16{30};
const conf = [_]f64{0.95};
const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0, 0);
defer freeProjectionData(allocator, data);
for (data.bands[0].?) |b| {
try std.testing.expect(b.p10 <= b.p25);
try std.testing.expect(b.p25 <= b.p50);
try std.testing.expect(b.p50 <= b.p75);
try std.testing.expect(b.p75 <= b.p90);
}
}
test "runProjectionGrid: year 0 in every band equals total_value" {
const allocator = std.testing.allocator;
const horizons = [_]u16{ 20, 30 };
const conf = [_]f64{0.95};
const total_value: f64 = 2_000_000;
const data = try runProjectionGrid(allocator, &horizons, &conf, total_value, 0.75, &.{}, 0, 0, true, 0, 0);
defer freeProjectionData(allocator, data);
for (data.bands) |b_opt| {
const b = b_opt.?;
try std.testing.expectApproxEqAbs(total_value, b[0].p10, 1.0);
try std.testing.expectApproxEqAbs(total_value, b[0].p50, 1.0);
try std.testing.expectApproxEqAbs(total_value, b[0].p90, 1.0);
}
}
test "runProjectionGrid: bands are computed at the highest-confidence withdrawal" {
// The chart anchors on `ci_99` - the LAST entry in
// `confidence_levels` - by feeding that withdrawal rate into
// `computePercentileBandsParams`. With confidence_levels =
// {.90, .95, .99}, the bands should reflect spending at 99%
// (the smallest, most-conservative withdrawal).
//
// Verification: re-running the band computation with the
// 99%-confidence withdrawal should produce identical bands.
const allocator = std.testing.allocator;
const horizons = [_]u16{30};
const conf = [_]f64{ 0.90, 0.95, 0.99 };
const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0, 0);
defer freeProjectionData(allocator, data);
const wr_99 = data.withdrawals[data.ci_99 * horizons.len + 0];
const expected = try computePercentileBandsParams(allocator, .{
.initial_value = 1_000_000,
.stock_pct = 0.75,
.annual_spending = wr_99.annual_amount,
.distribution_years = 30,
});
defer allocator.free(expected);
const actual = data.bands[0].?;
try std.testing.expectEqual(expected.len, actual.len);
for (expected, actual) |exp, act| {
try std.testing.expectEqual(exp.year, act.year);
try std.testing.expectEqual(exp.p10, act.p10);
try std.testing.expectEqual(exp.p50, act.p50);
try std.testing.expectEqual(exp.p90, act.p90);
}
}
test "runProjectionGrid: accumulation passes through to both withdrawals and bands" {
// Same horizon, same confidence, same starting portfolio:
// 10 years of $50k contributions should produce a meaningfully
// higher safe withdrawal than zero accumulation (the
// post-accumulation portfolio is bigger), AND the bands should
// be longer (accumulation_years + distribution_years + 1).
const allocator = std.testing.allocator;
const horizons = [_]u16{30};
const conf = [_]f64{0.95};
const dist_only = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0, 0);
defer freeProjectionData(allocator, dist_only);
const with_accum = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 10, 50_000, true, 0, 0);
defer freeProjectionData(allocator, with_accum);
// SWR with 10y of contributions on top should exceed SWR
// without.
try std.testing.expect(with_accum.withdrawals[0].annual_amount > dist_only.withdrawals[0].annual_amount);
// Band length differs by exactly accumulation_years.
try std.testing.expectEqual(dist_only.bands[0].?.len + 10, with_accum.bands[0].?.len);
}
test "runProjectionGrid: zero horizons produces empty results without crashing" {
const allocator = std.testing.allocator;
const horizons = [_]u16{};
const conf = [_]f64{ 0.95, 0.99 };
const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0, 0);
defer freeProjectionData(allocator, data);
try std.testing.expectEqual(@as(usize, 0), data.withdrawals.len);
try std.testing.expectEqual(@as(usize, 0), data.bands.len);
}
// ── Spending-drift (the "smile") tests ─────────────────────────
test "spending_real_change: declining spending raises safe withdrawal, rising lowers it" {
// Same portfolio, horizon, and confidence - only the spending
// trajectory differs. Spending less in the slow-go years frees up
// a higher first-year draw; spending more requires a lower one.
const flat = findSafeWithdrawalWithAccumulation(30, 1_000_000, 0.75, 0.95, &.{}, 0, 0, true, 0, 0);
const declining = findSafeWithdrawalWithAccumulation(30, 1_000_000, 0.75, 0.95, &.{}, 0, 0, true, 0, -0.02);
const rising = findSafeWithdrawalWithAccumulation(30, 1_000_000, 0.75, 0.95, &.{}, 0, 0, true, 0, 0.02);
try std.testing.expect(declining.annual_amount > flat.annual_amount);
try std.testing.expect(rising.annual_amount < flat.annual_amount);
}
test "spending_real_change: zero drift is identical to the flat model" {
// The default (rate 0) must reproduce the pre-smile behavior
// exactly - the regression pin for every existing projection.
const flat = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &.{});
const zero_drift = findSafeWithdrawalWithAccumulation(30, 1_000_000, 0.75, 0.95, &.{}, 0, 0, true, 0, 0);
try std.testing.expectEqual(flat.annual_amount, zero_drift.annual_amount);
}
test "spendingTrough: monotonic decline bottoms out in the final year" {
const as_of = Date.fromYmd(2026, 1, 1);
const t = spendingTrough(60_000, -0.02, &.{}, 0, 30, as_of).?;
// No events -> spending falls every year -> trough is the last
// distribution year (d = 29).
try std.testing.expectEqual(@as(u16, 29), t.year_offset);
try std.testing.expectEqual(@as(u16, 30), t.years_from_now);
const expected = 60_000.0 * std.math.pow(f64, 0.98, 29);
try std.testing.expectApproxEqAbs(expected, t.amount, 1.0);
try std.testing.expectEqual(@as(i16, 2055), t.date.year());
}
test "spendingTrough: a late healthcare expense pulls the trough to mid-retirement" {
const as_of = Date.fromYmd(2026, 1, 1);
// Base spending declines 2%/yr; a permanent +$40k/yr healthcare
// expense begins at distribution year 20. Spending slides until
// then, then jumps - so the trough is the year just before the
// hump (d = 19), not the final year. This is the whole reason the
// trough is computed rather than read off the last year.
const healthcare = [_]ResolvedEvent{.{
.start_year = 20,
.duration = 0,
.annual_amount = -40_000,
.inflation_adjusted = true,
}};
const t = spendingTrough(60_000, -0.02, &healthcare, 0, 30, as_of).?;
try std.testing.expectEqual(@as(u16, 19), t.year_offset);
}
test "spendingTrough: rising spending bottoms out in the first year" {
const as_of = Date.fromYmd(2026, 1, 1);
const t = spendingTrough(50_000, 0.01, &.{}, 0, 30, as_of).?;
try std.testing.expectEqual(@as(u16, 0), t.year_offset);
try std.testing.expectEqual(@as(u16, 1), t.years_from_now);
try std.testing.expectApproxEqAbs(@as(f64, 50_000), t.amount, 0.01);
}
test "spendingTrough: income events do not count as spending" {
const as_of = Date.fromYmd(2026, 1, 1);
// A Social Security income event funds withdrawals but is not
// consumption, so it must not lower the reported spending trough.
const ss = [_]ResolvedEvent{.{
.start_year = 5,
.duration = 0,
.annual_amount = 30_000, // positive = income
.inflation_adjusted = true,
}};
const with_income = spendingTrough(60_000, -0.02, &ss, 0, 30, as_of).?;
const without = spendingTrough(60_000, -0.02, &.{}, 0, 30, as_of).?;
try std.testing.expectEqual(without.amount, with_income.amount);
try std.testing.expectEqual(without.year_offset, with_income.year_offset);
}
test "spendingTrough: accumulation phase offsets the trough year and date" {
const as_of = Date.fromYmd(2026, 1, 1);
// 10 accumulation years, then 20 distribution years declining
// 1%/yr. Trough at the last distribution year (d = 19);
// years_from_now = 10 + 19 + 1 = 30; calendar 2026 + 29 = 2055.
const t = spendingTrough(50_000, -0.01, &.{}, 10, 20, as_of).?;
try std.testing.expectEqual(@as(u16, 19), t.year_offset);
try std.testing.expectEqual(@as(u16, 30), t.years_from_now);
try std.testing.expectEqual(@as(i16, 2055), t.date.year());
}
test "spendingTrough: zero distribution years returns null" {
const as_of = Date.fromYmd(2026, 1, 1);
try std.testing.expectEqual(
@as(?SpendingTrough, null),
spendingTrough(60_000, -0.02, &.{}, 0, 0, as_of),
);
}
test "parseProjectionsConfig spending_change negative is a decline" {
const config = parseProjectionsConfig("#!srfv1\ntype::config,spending_change:num:-2\n");
try std.testing.expectApproxEqAbs(@as(f64, -0.02), config.spending_real_change.?, 1e-9);
}
test "parseProjectionsConfig spending_change positive is a rise" {
const config = parseProjectionsConfig("#!srfv1\ntype::config,spending_change:num:1\n");
try std.testing.expectApproxEqAbs(@as(f64, 0.01), config.spending_real_change.?, 1e-9);
}
test "parseProjectionsConfig spending_change absent stays null (flat)" {
const config = parseProjectionsConfig("#!srfv1\ntype::config,horizon:num:30\n");
try std.testing.expectEqual(@as(?f64, null), config.spending_real_change);
}
test "parseProjectionsConfig spending_change magnitude is clamped both directions" {
const hi = parseProjectionsConfig("#!srfv1\ntype::config,spending_change:num:50\n");
try std.testing.expectApproxEqAbs(max_abs_spending_real_change, hi.spending_real_change.?, 1e-9);
const lo = parseProjectionsConfig("#!srfv1\ntype::config,spending_change:num:-50\n");
try std.testing.expectApproxEqAbs(-max_abs_spending_real_change, lo.spending_real_change.?, 1e-9);
}
test "integration: declining model + late healthcare troughs mid-retirement" {
// Mirrors the shipped `examples/post-retirement-smile` config
// (keep the two in sync). Exercises the full path - parse the
// signed-percent drift, resolve the life events, and compute the
// trough. Composing the 2%/yr decline with the age-80 healthcare
// expense must put the spending trough in mid-retirement (the
// year just before the expense begins), not at the final
// distribution year - the whole reason the trough is searched for
// rather than read off the last year. (The shipped example file
// itself is validated by running the binary against it; @embedFile
// can't reach outside src/.)
const cfg =
\\#!srfv1
\\type::config,target_stock_pct:num:60
\\type::config,spending_change:num:-2
\\type::config,horizon:num:30
\\type::birthdate,date::1958-02-19
\\type::birthdate,date::1961-07-04,person:num:2
\\type::event,name::Social Security (Robin),start_age:num:67,person:num:1,amount:num:34800
\\type::event,name::Healthcare (late-life),start_age:num:80,person:num:1,amount:num:-55000
;
const config = parseProjectionsConfig(cfg);
// -2 whole percent -> -0.02 fraction.
try std.testing.expectApproxEqAbs(@as(f64, -0.02), config.spending_real_change.?, 1e-9);
// Resolve the life events against a fixed reference date (not
// "today" - tests must be deterministic).
const as_of = Date.fromYmd(2026, 6, 26);
const resolved = config.resolveEvents(as_of);
const events = resolved[0..config.event_count];
// Find the resolved start year of the lone expense event (the
// late-life healthcare bump).
var hc_start: ?u16 = null;
for (events) |ev| {
if (ev.annual_amount < 0) hc_start = ev.start_year;
}
try std.testing.expect(hc_start != null);
const dist_years: u16 = 30;
const t = spendingTrough(150_000, config.spending_real_change.?, events, 0, dist_years, as_of).?;
// Trough is the year just before healthcare starts...
try std.testing.expectEqual(hc_start.? - 1, t.year_offset);
// ...which is strictly before the final distribution year.
try std.testing.expect(t.year_offset < dist_years - 1);
}
test "benchmarkSymbols: defaults to SPY/AGG when projections.srf is absent" {
// These two are fetched for the benchmark comparison and held nowhere, which
// is why AGG went stale unnoticed while SPY - which doubles as a `ticker::`
// alias on a real holding - stayed current.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const pair = benchmarkSymbols(std.testing.io, arena_state.allocator(), "/nonexistent/projections.srf");
try std.testing.expectEqual(@as(usize, 2), pair.len);
try std.testing.expectEqualStrings("SPY", pair[0]);
try std.testing.expectEqualStrings("AGG", pair[1]);
}
test "benchmarkSymbols: an override is honoured and outlives the config" {
const allocator = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const dir = try tmp.dir.realPathFileAlloc(io, ".", allocator);
defer allocator.free(dir);
{
const f = try tmp.dir.createFile(io, "projections.srf", .{});
defer f.close(io);
var buf: [256]u8 = undefined;
var w = f.writer(io, &buf);
try w.interface.writeAll("#!srfv1\ntype::config,benchmark_stock::VTI,benchmark_bond::BND\n");
try w.interface.flush();
}
var arena_state = std.heap.ArenaAllocator.init(allocator);
defer arena_state.deinit();
const path = try std.fs.path.join(arena_state.allocator(), &.{ dir, "projections.srf" });
// Duped, not borrowed: an override lives in a [16]u8 inside the UserConfig,
// which dies with this call.
const pair = benchmarkSymbols(io, arena_state.allocator(), path);
try std.testing.expectEqual(@as(usize, 2), pair.len);
try std.testing.expectEqualStrings("VTI", pair[0]);
try std.testing.expectEqualStrings("BND", pair[1]);
}