/// Historical simulation engine for retirement projections. /// /// Implements the FIRECalc algorithm: for each starting year in the Shiller /// historical dataset (1871–present), simulate a retirement of `horizon` years /// using actual market returns, bond returns, and inflation. The portfolio is /// rebalanced annually to the target stock/bond allocation. /// /// Key outputs: /// - Safe withdrawal amount at a given confidence level (binary search to $1) /// - Success rate for a given spending level /// - Percentile bands of portfolio value at each year (for charting) const std = @import("std"); const 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.0–1.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); }