zfin/src/analytics/projections.zig

2883 lines
118 KiB
Zig
Raw Blame History

This file contains ambiguous Unicode characters

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

/// Historical simulation engine for retirement projections.
///
/// Implements the FIRECalc algorithm: for each starting year in the Shiller
/// historical dataset (1871present), 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 log = std.log.scoped(.projections);
const shiller = @import("../data/shiller.zig");
const srf = @import("srf");
const Date = @import("../Date.zig");
// ── 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 {
const start = self.startYear(current_ages) orelse return null;
return .{
.start_year = start,
.duration = self.duration,
.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,
},
};
/// 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 # resolves to (90 oldest current age)
/// 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,
/// 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),
/// Age-based horizon targets. Resolved at context-load time to
/// `target_age max(currentAges())` years — i.e. how long until the
/// oldest configured person hits `target_age`. Rationale: the first
/// person to hit the target age sets the meaningful planning horizon,
/// because spending typically drops substantially after the first death.
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.
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,
const max_horizons: usize = 8;
const max_persons: usize = 4;
pub const max_events: usize = 16;
/// 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,
};
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 year counts and
/// append them to `horizons`. For each target age, computes
/// `target_age max(currentAges(as_of))` — the number of years
/// until the oldest configured person hits that age. Targets that are
/// already in the past (oldest age ≥ target) are silently skipped.
///
/// 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 oldest = self.oldestAge(as_of);
for (0..self.horizon_age_count) |i| {
const target = self.horizon_ages[i];
if (target <= oldest) continue; // already past target, skip silently
const years: u16 = target - oldest;
if (self.horizon_count < max_horizons) {
self.horizons[self.horizon_count] = years;
// 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 using pre-computed ages (for testing).
pub fn resolveEventsWithAges(self: *const UserConfig, ages: []const u16) [max_events]ResolvedEvent {
var resolved: [max_events]ResolvedEvent = undefined;
for (self.events[0..self.event_count], 0..) |*ev, i| {
resolved[i] = ev.resolve(ages) 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);
}
};
// ── SRF parse types (private) ───────────────────────────────────
const SrfConfig = struct {
type: []const u8 = "",
target_stock_pct: ?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,
};
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,
};
/// 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 (`alloc_strings = false` keeps strings borrowing
/// from `data`, so the iterator only needs scratch for field-row
/// bookkeeping). 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::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, .{ .alloc_strings = false }) 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) catch continue;
switch (rec) {
.config => |c| {
config.target_stock_pct = c.target_stock_pct orelse config.target_stock_pct;
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 {
log.warn("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 {
log.warn("projections: target_spending must be >= 0 (got {d}); ignoring record", .{amt});
}
}
if (c.target_spending_inflation_adjusted) |b| {
config.target_spending_inflation_adjusted = b;
}
},
.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) {
log.warn("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;
log.warn("projections: retirement_target must be 90, 95, or 99 (got {d}); annotation ignored", .{v});
return null;
}
// ── Configuration ──────────────────────────────────────────────
/// Conservative return estimation defaults.
/// These are module-level constants that will eventually move to projections.srf.
pub const default_return_cap: ?f64 = null; // no cap currently
pub const default_exclude_1y_from_min: bool = true; // use MIN(3Y, 5Y, 10Y), skip 1Y
pub const ProjectionConfig = struct {
/// Current total portfolio value in dollars.
portfolio_value: f64,
/// Stock allocation as a fraction (0.01.0). Remainder goes to bonds.
stock_pct: f64,
/// Retirement time horizons to simulate (in years).
horizons: []const u16,
/// Confidence levels for safe withdrawal (e.g. 0.90, 0.95, 0.99).
confidence_levels: []const f64,
/// Pre-resolved life events for the simulation.
events: []const ResolvedEvent = &.{},
// ── Accumulation phase ──────────────────────────────────────
/// Whole years of accumulation prior to the distribution phase.
/// `0` (default) means the existing distribution-only behavior:
/// the simulation starts withdrawing from `portfolio_value` at
/// year 0 and runs for `horizon` years.
accumulation_years: u16 = 0,
/// Annual household contribution during the accumulation phase,
/// in today's dollars. Ignored when `accumulation_years == 0`.
annual_contribution: f64 = 0,
/// If true, the contribution grows with CPI year-over-year.
contribution_inflation_adjusted: bool = true,
};
// ── 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,
};
pub const SimulationResult = struct {
horizon: u16,
/// Number of historical cycles simulated.
num_cycles: usize,
/// Safe withdrawal amounts at each requested confidence level.
withdrawals: []WithdrawalResult,
/// Portfolio value percentiles at each year (for charting).
/// Length = horizon + 1 (includes year 0 = starting value).
percentile_bands: []YearPercentiles,
pub fn deinit(self: *SimulationResult, allocator: std.mem.Allocator) void {
allocator.free(self.withdrawals);
allocator.free(self.percentile_bands);
}
};
// ── 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,
/// 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,
events: []const ResolvedEvent = &.{},
/// 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.
fn maxCyclesFor(data: ShillerYearSlice, total_years: u16) usize {
if (data.len <= total_years) return 0;
return data.len - total_years;
}
/// 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;
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 spending = if (params.spending_inflation_adjusted)
params.annual_spending * cumulative_inflation
else
params.annual_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;
}
}
// Market return on the post-cashflow balance. 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);
}
// 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 the full historical simulation: for each possible starting year,
/// simulate a retirement of `horizon` years. Returns the number of cycles
/// where the portfolio survived (never went to zero).
///
/// `all_paths` is a 2D buffer: [cycle_index][year] = portfolio value.
/// Must be pre-allocated with dimensions [num_cycles][horizon + 1].
fn runAllCycles(
all_paths: [][]f64,
horizon: u16,
initial_value: f64,
annual_spending: f64,
stock_pct: f64,
events: []const ResolvedEvent,
) usize {
return runAllCyclesParams(all_paths, shiller.annual_returns, .{
.initial_value = initial_value,
.stock_pct = stock_pct,
.annual_spending = annual_spending,
.distribution_years = horizon,
.events = events,
});
}
/// Run all cycles with full SimParams — accumulation-aware variant
/// used by both the distribution-only wrapper above and the
/// earliest-retirement search (`findEarliestRetirement`).
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;
}
/// Compute the success rate (fraction of cycles that survived) for a
/// given spending level. Lightweight version that doesn't store full paths.
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,
});
}
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 ─────────────────────────────────────
/// Find the maximum annual withdrawal amount (in today's dollars) such that
/// the portfolio survives `horizon` years in at least `confidence` fraction
/// of all historical cycles.
///
/// Uses binary search with $1 precision, seeded with a 4%-rule estimate
/// to narrow the search band (~10 iterations instead of ~23).
///
/// Distribution-only convenience wrapper around `searchSafeWithdrawal`.
pub 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);
}
/// Two-phase variant of `findSafeWithdrawal`. Searches for the
/// largest `annual_spending` (in today's dollars) such that the
/// distribution-phase failure rate stays ≤ `1 - confidence`, with
/// `accumulation_years` of contributions feeding the portfolio first.
///
/// When `accumulation_years == 0` and contributions are zero, this
/// reduces exactly to `findSafeWithdrawal` (the equivalence is
/// pinned by the `regression: zero accumulation matches direct
/// findSafeWithdrawal` test). Used as the inner search for the
/// target-retirement-date input when the user has configured a
/// non-zero accumulation phase.
pub 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,
) WithdrawalResult {
return searchSafeWithdrawal(.{
.initial_value = initial_value,
.stock_pct = stock_pct,
.annual_spending = 0, // overwritten by the search loop
.distribution_years = horizon,
.accumulation_years = accumulation_years,
.annual_contribution = annual_contribution,
.contribution_inflation_adjusted = contribution_inflation_adjusted,
.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. Both
/// `findSafeWithdrawal` (zero accumulation) and
/// `findSafeWithdrawalWithAccumulation` (non-zero accumulation)
/// delegate here.
///
/// 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,
};
/// Maximum accumulation years to search. 50 covers a 25-year-old
/// planning to age 75. Hardcoded; if anyone hits this, route through
/// projections.srf as a config field.
pub const max_accumulation_years: u16 = 50;
/// 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,
) !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,
.distribution_years = distribution_years,
.accumulation_years = n,
.annual_contribution = annual_contribution,
.contribution_inflation_adjusted = contribution_inflation_adjusted,
.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 total = params.totalYears();
const num_cycles = maxCyclesFor(data, total);
if (num_cycles == 0) {
return .{
.horizon = distribution_years,
.confidence = confidence,
.accumulation_years = n,
.median_at_retirement = 0,
.p10_at_retirement = 0,
.p90_at_retirement = 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 .{
.horizon = distribution_years,
.confidence = confidence,
.accumulation_years = n,
.median_at_retirement = percentile(sort_buf, 0.50),
.p10_at_retirement = percentile(sort_buf, 0.10),
.p90_at_retirement = percentile(sort_buf, 0.90),
};
}
return .{
.horizon = distribution_years,
.confidence = confidence,
.accumulation_years = null,
.median_at_retirement = 0,
.p10_at_retirement = 0,
.p90_at_retirement = 0,
};
}
// ── 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, walk horizons longest → shortest. Pick the longest
/// whose end year keeps the oldest configured person under
/// `promotion_age_cap`.
/// 3. If even the shortest horizon overshoots, use it anyway.
/// 4. 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: longest 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 ───────────────────────────────────────────
/// Compute percentile bands from all simulated paths for a given horizon
/// and spending level. Allocates the result.
pub 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,
});
}
/// 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 = @as(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,
};
/// Run the full projection-display batch up front: a safe-withdrawal
/// grid across every (horizon × confidence) pair, plus a percentile
/// band per horizon at the highest configured confidence (used for
/// the chart and the terminal-portfolio-value table).
///
/// Two distinct computations bundled in one call:
///
/// 1. **Safe-withdrawal grid.** For each `(horizon, confidence)`
/// pair in `confidence_levels × horizons`, binary-searches the
/// maximum annual spending the portfolio can sustain across
/// that horizon at that confidence. Stored in
/// `withdrawals[ci * horizons.len + hi]`.
///
/// 2. **Percentile bands.** For each horizon, runs the full
/// historical simulation at the highest-confidence withdrawal
/// rate and extracts p10/p25/p50/p75/p90 of the portfolio
/// value at each year. The highest-confidence rate is the
/// most conservative spending level, so the chart shows the
/// steepest survivable drawdown. Stored in `bands[hi]`.
///
/// All four call sites in the codebase (CLI projections command,
/// TUI projections tab, the `--vs` comparison renderer, and the
/// view-model integration test) want exactly this bundle, so it's
/// computed once per projection rather than re-derived per render.
///
/// Accumulation parameters are always honored — pass `0` /
/// `0` / `true` for the distribution-only case (already-retired
/// users, no contributions configured). The simulation core
/// produces identical results when accumulation degenerates to
/// zero, so this single function covers every input combination
/// `projections.srf` allows.
///
/// `confidence_levels` should be sorted ascending; `ci_99` in the
/// returned struct refers to the LAST entry, which by convention
/// is the highest (most-conservative) confidence.
///
/// Caller owns `withdrawals`, `bands`, and every non-null entry
/// inside `bands`. Free with the same allocator.
pub 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,
) !ProjectionData {
const num_results = horizons.len * confidence_levels.len;
const withdrawals = try alloc.alloc(WithdrawalResult, num_results);
for (confidence_levels, 0..) |conf, ci| {
for (horizons, 0..) |h, hi| {
withdrawals[ci * horizons.len + hi] = findSafeWithdrawalWithAccumulation(
h,
total_value,
stock_pct,
conf,
events,
accumulation_years,
annual_contribution,
contribution_inflation_adjusted,
);
}
}
const ci_99 = confidence_levels.len - 1;
const bands = try alloc.alloc(?[]YearPercentiles, horizons.len);
for (horizons, 0..) |h, hi| {
const wr = withdrawals[ci_99 * horizons.len + hi];
bands[hi] = computePercentileBandsParams(alloc, .{
.initial_value = total_value,
.stock_pct = stock_pct,
.annual_spending = wr.annual_amount,
.distribution_years = h,
.accumulation_years = accumulation_years,
.annual_contribution = annual_contribution,
.contribution_inflation_adjusted = contribution_inflation_adjusted,
.events = events,
}) catch null;
}
return .{ .withdrawals = withdrawals, .bands = bands, .ci_99 = ci_99 };
}
/// Run the full projection analysis for one horizon: compute safe withdrawal
/// at each confidence level and percentile bands using the median withdrawal.
pub fn runProjection(
allocator: std.mem.Allocator,
config: ProjectionConfig,
horizon: u16,
) !SimulationResult {
const num_cycles = shiller.maxCycles(horizon);
// Compute safe withdrawal at each confidence level
const withdrawals = try allocator.alloc(WithdrawalResult, config.confidence_levels.len);
for (config.confidence_levels, 0..) |conf, i| {
withdrawals[i] = findSafeWithdrawal(
horizon,
config.portfolio_value,
config.stock_pct,
conf,
config.events,
);
}
// Use the median confidence level's withdrawal for the percentile chart
const median_idx = config.confidence_levels.len / 2;
const chart_spending = if (withdrawals.len > 0) withdrawals[median_idx].annual_amount else 0;
const bands = try computePercentileBands(
allocator,
horizon,
config.portfolio_value,
chart_spending,
config.stock_pct,
config.events,
);
return .{
.horizon = horizon,
.num_cycles = num_cycles,
.withdrawals = withdrawals,
.percentile_bands = bands,
};
}
/// Run projections for all configured horizons.
pub fn runAllProjections(
allocator: std.mem.Allocator,
config: ProjectionConfig,
) ![]SimulationResult {
const results = try allocator.alloc(SimulationResult, config.horizons.len);
errdefer {
for (results) |*r| r.deinit(allocator);
allocator.free(results);
}
for (config.horizons, 0..) |h, i| {
results[i] = try runProjection(allocator, config, h);
}
return results;
}
// ── 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 "runProjection produces valid results" {
const allocator = std.testing.allocator;
const config = ProjectionConfig{
.portfolio_value = 1_000_000,
.stock_pct = 0.75,
.horizons = &.{ 20, 30 },
.confidence_levels = &.{ 0.90, 0.95, 0.99 },
};
var result = try runProjection(allocator, config, 30);
defer result.deinit(allocator);
try std.testing.expectEqual(@as(u16, 30), result.horizon);
try std.testing.expectEqual(@as(usize, 3), result.withdrawals.len);
try std.testing.expectEqual(@as(usize, 31), result.percentile_bands.len);
// Withdrawals should be ordered: 90% > 95% > 99%
try std.testing.expect(result.withdrawals[0].annual_amount >= result.withdrawals[1].annual_amount);
try std.testing.expect(result.withdrawals[1].annual_amount >= result.withdrawals[2].annual_amount);
}
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);
// Should produce $290K+ at 99%/45yr based on FIRECalc reference (~$305K on $7.7M)
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);
}
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 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 oldest birthdate (first-to-hit semantics)" {
// Person 1: born 1975, age 50 as of 2025. Person 2: born 1980, age 45.
// Target age 90 → 90 50 = 40 years (first to hit 90 is the older).
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, 6, 15);
try config.resolveHorizonAges(as_of);
try std.testing.expectEqual(@as(u8, 1), config.horizon_count);
try std.testing.expectEqual(@as(u16, 40), config.horizons[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" {
// Oldest age is 60 as of 2025; target 40 is already past — skipped.
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]);
}
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]);
}
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 "runAllProjections produces results for each horizon" {
const allocator = std.testing.allocator;
const config = ProjectionConfig{
.portfolio_value = 1_000_000,
.stock_pct = 0.75,
.horizons = &.{ 20, 30 },
.confidence_levels = &.{ 0.95, 0.99 },
};
const results = try runAllProjections(allocator, config);
defer {
for (results) |*r| r.deinit(allocator);
allocator.free(results);
}
try std.testing.expectEqual(@as(usize, 2), results.len);
try std.testing.expectEqual(@as(u16, 20), results[0].horizon);
try std.testing.expectEqual(@as(u16, 30), results[1].horizon);
try std.testing.expectEqual(@as(usize, 2), results[0].withdrawals.len);
try std.testing.expectEqual(@as(usize, 2), results[1].withdrawals.len);
}
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 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.?));
}
// ── 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);
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);
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
);
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,
);
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,
);
const long = try findEarliestRetirement(
allocator,
1_000_000,
0.75,
50_000,
true,
80_000,
true,
45, // 45-year distribution
0.95,
&.{},
50,
);
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,
);
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);
}
}
}
// ── 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: 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: oldest age 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);
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);
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);
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);
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);
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);
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);
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);
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);
defer freeProjectionData(allocator, dist_only);
const with_accum = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 10, 50_000, true);
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);
defer freeProjectionData(allocator, data);
try std.testing.expectEqual(@as(usize, 0), data.withdrawals.len);
try std.testing.expectEqual(@as(usize, 0), data.bands.len);
}