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