cleanup dead code / be clear about test convenience wrappers

This commit is contained in:
Emil Lerch 2026-06-24 09:25:53 -07:00
parent d078bc5a62
commit 221106b880
Signed by: lobo
GPG key ID: A7B62D657EF764F8

View file

@ -679,30 +679,6 @@ fn validRetirementTarget(raw: ?u8) ?u8 {
pub const default_return_cap: ?f64 = null; // no cap currently
pub const default_exclude_1y_from_min: bool = true; // use MIN(3Y, 5Y, 10Y), skip 1Y
pub const ProjectionConfig = struct {
/// Current total portfolio value in dollars.
portfolio_value: f64,
/// Stock allocation as a fraction (0.01.0). Remainder goes to bonds.
stock_pct: f64,
/// Retirement time horizons to simulate (in years).
horizons: []const u16,
/// Confidence levels for safe withdrawal (e.g. 0.90, 0.95, 0.99).
confidence_levels: []const f64,
/// Pre-resolved life events for the simulation.
events: []const ResolvedEvent = &.{},
// Accumulation phase
/// Whole years of accumulation prior to the distribution phase.
/// `0` (default) means the existing distribution-only behavior:
/// the simulation starts withdrawing from `portfolio_value` at
/// year 0 and runs for `horizon` years.
accumulation_years: u16 = 0,
/// Annual household contribution during the accumulation phase,
/// in today's dollars. Ignored when `accumulation_years == 0`.
annual_contribution: f64 = 0,
/// If true, the contribution grows with CPI year-over-year.
contribution_inflation_adjusted: bool = true,
};
// Results
pub const WithdrawalResult = struct {
@ -724,22 +700,6 @@ pub const YearPercentiles = struct {
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
@ -954,24 +914,6 @@ fn runAllCyclesParams(
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;
@ -987,30 +929,6 @@ fn successRateParams(data: ShillerYearSlice, params: SimParams) f64 {
// 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
@ -1367,25 +1285,6 @@ fn confidenceIndex(confidence_levels: []const f64, pct: u8) usize {
// 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
@ -1561,64 +1460,70 @@ pub fn runProjectionGrid(
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,
// Test-only convenience wrappers
//
// Thin, distribution-only, zero-fee wrappers over the production
// `*Params` entry points (`searchSafeWithdrawal`, `successRateParams`,
// `computePercentileBandsParams`). Nothing in the CLI/TUI calls these
// -- production goes through `runProjectionGrid` /
// `findSafeWithdrawalWithAccumulation`. They exist only to give the
// test suite (including the FIRECalc parity tests) an ergonomic
// primitive, so they live next to the tests and are `fn`-private.
/// Maximum annual withdrawal (today's dollars) that survives `horizon`
/// years in at least `confidence` of historical cycles. Binary search
/// to $1 precision via `searchSafeWithdrawal`.
fn findSafeWithdrawal(
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,
};
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);
}
/// Run projections for all configured horizons.
pub fn runAllProjections(
/// Success rate (fraction of cycles that survived) for a given
/// spending level.
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,
});
}
/// Percentile bands across all simulated paths for a horizon and
/// spending level. Allocates the result.
fn computePercentileBands(
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;
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,
});
}
// Tests
@ -1690,27 +1595,6 @@ test "computePercentileBands basic properties" {
}
}
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);
@ -2067,27 +1951,6 @@ test "UserConfig getConfidenceLevels" {
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};