1187 lines
46 KiB
Zig
1187 lines
46 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("../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.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 = &.{},
|
||
};
|
||
|
||
// ── 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);
|
||
}
|