4452 lines
192 KiB
Zig
4452 lines
192 KiB
Zig
/// 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]);
|
||
}
|