zfin/src/analytics/projections.zig

1187 lines
46 KiB
Zig
Raw Blame History

This file contains ambiguous Unicode characters

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

/// Historical simulation engine for retirement projections.
///
/// Implements the FIRECalc algorithm: for each starting year in the Shiller
/// historical dataset (1871present), simulate a retirement of `horizon` years
/// using actual market returns, bond returns, and inflation. The portfolio is
/// rebalanced annually to the target stock/bond allocation.
///
/// Key outputs:
/// - Safe withdrawal amount at a given confidence level (binary search to $1)
/// - Success rate for a given spending level
/// - Percentile bands of portfolio value at each year (for charting)
const std = @import("std");
const log = std.log.scoped(.projections);
const shiller = @import("../data/shiller.zig");
const srf = @import("srf");
const Date = @import("../models/date.zig").Date;
// ── 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 = .{0} ** max_name_len,
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) ──────────────────
/// 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 } ++ .{0} ** (max_horizons - 3),
horizon_count: u8 = 3,
/// 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 = .{0} ** max_horizons,
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 = .{Date.fromYmd(1970, 1, 1)} ** max_persons,
birthdate_count: u8 = 0,
/// Life events (income/expenses) that modify annual cash flow.
events: [max_events]LifeEvent = undefined,
event_count: u8 = 0,
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 current ages (in whole years) from birthdates.
pub fn currentAges(self: *const UserConfig) [max_persons]u16 {
return currentAgesAsOf(self, Date.fromEpoch(std.time.timestamp()));
}
/// Compute ages as of a specific date (for testing).
pub fn currentAgesAsOf(self: *const UserConfig, as_of: Date) [max_persons]u16 {
var ages: [max_persons]u16 = .{0} ** max_persons;
for (0..self.birthdate_count) |i| {
const years = Date.yearsBetween(self.birthdates[i], as_of);
ages[i] = if (years > 0) @intFromFloat(years) else 0;
}
return ages;
}
/// Resolve age-based horizons (`horizon_ages`) into year counts and
/// append them to `horizons`. For each target age, computes
/// `target_age max(currentAgesAsOf(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 ages = self.currentAgesAsOf(as_of);
var oldest: u16 = 0;
for (0..self.birthdate_count) |i| {
if (ages[i] > oldest) oldest = ages[i];
}
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;
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) [max_events]ResolvedEvent {
const ages = self.currentAges();
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;
}
};
// ── SRF parse types (private) ───────────────────────────────────
const SrfConfig = struct {
type: []const u8 = "",
target_stock_pct: ?f64 = null,
horizon: ?u16 = null,
horizon_age: ?u16 = 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.
///
/// 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 reader = std.Io.Reader.fixed(raw);
var it = srf.iterator(&reader, std.heap.smp_allocator, .{ .alloc_strings = false }) catch return config;
defer it.deinit();
var saw_horizon = false;
var birthdate_seq: u8 = 0;
while (it.next() catch null) |field_it| {
const rec = field_it.to(SrfProjection) catch continue;
switch (rec) {
.config => |c| {
if (c.target_stock_pct) |val| {
config.target_stock_pct = val;
}
if (c.horizon) |h| {
if (!saw_horizon) {
config.horizon_count = 0;
saw_horizon = true;
}
if (config.horizon_count < UserConfig.max_horizons and h > 0) {
config.horizons[config.horizon_count] = h;
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 (config.horizon_age_count < UserConfig.max_horizons and age > 0) {
config.horizon_ages[config.horizon_age_count] = age;
config.horizon_age_count += 1;
}
}
},
.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;
}
birthdate_seq += 1;
},
.event => |e| {
if (config.event_count < UserConfig.max_events and e.start_age > 0) {
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;
}
},
}
}
return config;
}
// ── Configuration ──────────────────────────────────────────────
/// Conservative return estimation defaults.
/// These are module-level constants that will eventually move to projections.srf.
pub const default_return_cap: ?f64 = null; // no cap currently
pub const default_exclude_1y_from_min: bool = true; // use MIN(3Y, 5Y, 10Y), skip 1Y
pub const ProjectionConfig = struct {
/// Current total portfolio value in dollars.
portfolio_value: f64,
/// Stock allocation as a fraction (0.01.0). Remainder goes to bonds.
stock_pct: f64,
/// Retirement time horizons to simulate (in years).
horizons: []const u16,
/// Confidence levels for safe withdrawal (e.g. 0.90, 0.95, 0.99).
confidence_levels: []const f64,
/// Pre-resolved life events for the simulation.
events: []const ResolvedEvent = &.{},
};
// ── 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 ────────────────────────────────────────────
/// 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.
///
/// Follows the FIRECalc methodology:
/// 1. Withdraw spending (year 1 = base amount, subsequent years CPI-adjusted)
/// 2. Apply market returns to the remainder
/// 3. Repeat
///
/// Returns the portfolio values at each year (length = horizon + 1).
fn simulateCycle(
buf: []f64,
start_index: usize,
horizon: u16,
initial_value: f64,
annual_spending: f64,
stock_pct: f64,
events: []const ResolvedEvent,
) void {
const data = shiller.annual_returns;
var portfolio = initial_value;
buf[0] = portfolio;
var cumulative_inflation: f64 = 1.0;
for (0..horizon) |y| {
const di = start_index + y;
if (di >= data.len) {
for (y + 1..@as(usize, horizon) + 1) |remaining| {
buf[remaining] = portfolio;
}
return;
}
const yr = data[di];
// Step 1: Withdraw spending, offset by life event cash flows
var event_net: f64 = 0;
for (events) |*ev| {
event_net += ev.cashFlow(@intCast(y), cumulative_inflation);
}
portfolio -= annual_spending * cumulative_inflation - event_net;
// Step 2: Apply market returns (nominal stock + nominal bond via GS10 yield)
const blended_return = stock_pct * yr.sp500_total_return + (1.0 - stock_pct) * yr.bond_total_return;
portfolio *= (1.0 + blended_return);
// Step 3: Update cumulative inflation for next year's spending
cumulative_inflation *= (1.0 + yr.cpi_inflation);
buf[y + 1] = portfolio;
}
}
/// 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 {
const num_cycles = shiller.maxCycles(horizon);
var survived: usize = 0;
for (0..num_cycles) |cycle| {
simulateCycle(
all_paths[cycle],
cycle,
horizon,
initial_value,
annual_spending,
stock_pct,
events,
);
// Check if portfolio survived the full horizon
var failed = false;
for (1..@as(usize, horizon) + 1) |y| {
if (all_paths[cycle][y] <= 0) {
// Zero out remaining years once the portfolio is depleted
for (y..@as(usize, horizon) + 1) |z| {
all_paths[cycle][z] = 0;
}
failed = true;
break;
}
}
if (!failed) 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 {
const data = shiller.annual_returns;
const num_cycles = shiller.maxCycles(horizon);
if (num_cycles == 0) return 0.0;
var survived: usize = 0;
for (0..num_cycles) |cycle| {
var portfolio = initial_value;
var cumulative_inflation: f64 = 1.0;
var failed = false;
for (0..horizon) |y| {
const di = cycle + y;
if (di >= data.len) break;
const yr = data[di];
// Withdraw spending, offset by life event cash flows
var event_net: f64 = 0;
for (events) |*ev| {
event_net += ev.cashFlow(@intCast(y), cumulative_inflation);
}
portfolio -= annual_spending * cumulative_inflation - event_net;
if (portfolio <= 0) {
failed = true;
break;
}
// Then grow
const blended_return = stock_pct * yr.sp500_total_return + (1.0 - stock_pct) * yr.bond_total_return;
portfolio *= (1.0 + blended_return);
// Update inflation for next year
cumulative_inflation *= (1.0 + yr.cpi_inflation);
}
if (!failed) 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).
pub fn findSafeWithdrawal(
horizon: u16,
initial_value: f64,
stock_pct: f64,
confidence: f64,
events: []const ResolvedEvent,
) WithdrawalResult {
// 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(horizon)); // >1 for short, <1 for long
const conf_adj = (1.0 - confidence) / 0.05; // 1.0 at 95%, 0.2 at 99%, 2.0 at 90%
const estimate = initial_value * base_rate * @sqrt(horizon_adj) * @sqrt(conf_adj);
// Search band: ±50% of estimate, clamped to [0, initial_value]
var lo: f64 = @max(estimate * 0.5, 0);
var hi: f64 = @min(estimate * 1.5, initial_value);
// Verify bounds bracket the answer; widen if not
if (successRate(horizon, initial_value, lo, stock_pct, events) < confidence) {
log.debug("findSafeWithdrawal: estimate too high, widening lo to 0 (horizon={d}, conf={d:.2})", .{ horizon, confidence });
lo = 0;
}
if (successRate(horizon, initial_value, hi, stock_pct, events) >= confidence) {
log.debug("findSafeWithdrawal: estimate too low, widening hi to portfolio value (horizon={d}, conf={d:.2})", .{ horizon, confidence });
hi = initial_value;
}
// Binary search to $1 precision
while (hi - lo > 1.0) {
const mid = @floor((lo + hi) / 2.0);
const rate = successRate(horizon, initial_value, mid, stock_pct, events);
if (rate >= confidence) {
lo = mid;
} else {
hi = mid;
}
}
return .{
.confidence = confidence,
.annual_amount = lo,
.withdrawal_rate = lo / initial_value,
};
}
// ── 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 {
const num_cycles = shiller.maxCycles(horizon);
if (num_cycles == 0) return &.{};
const years: usize = @as(usize, horizon) + 1;
// Allocate path storage: num_cycles rows of (horizon+1) f64s
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];
}
_ = runAllCycles(paths, horizon, initial_value, annual_spending, stock_pct, events);
// 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];
}
// Sort
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 ─────────────────────────────────────────────
/// 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);
}