From 221106b880e7bcbde6293b65118d0842c8269a3d Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Wed, 24 Jun 2026 09:25:53 -0700 Subject: [PATCH] cleanup dead code / be clear about test convenience wrappers --- src/analytics/projections.zig | 257 ++++++++-------------------------- 1 file changed, 60 insertions(+), 197 deletions(-) diff --git a/src/analytics/projections.zig b/src/analytics/projections.zig index f67ef94..fff7389 100644 --- a/src/analytics/projections.zig +++ b/src/analytics/projections.zig @@ -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.0–1.0). Remainder goes to bonds. - stock_pct: f64, - /// Retirement time horizons to simulate (in years). - horizons: []const u16, - /// Confidence levels for safe withdrawal (e.g. 0.90, 0.95, 0.99). - confidence_levels: []const f64, - /// Pre-resolved life events for the simulation. - events: []const ResolvedEvent = &.{}, - // ── Accumulation phase ────────────────────────────────────── - /// Whole years of accumulation prior to the distribution phase. - /// `0` (default) means the existing distribution-only behavior: - /// the simulation starts withdrawing from `portfolio_value` at - /// year 0 and runs for `horizon` years. - accumulation_years: u16 = 0, - /// Annual household contribution during the accumulation phase, - /// in today's dollars. Ignored when `accumulation_years == 0`. - annual_contribution: f64 = 0, - /// If true, the contribution grows with CPI year-over-year. - contribution_inflation_adjusted: bool = true, -}; - // ── Results ──────────────────────────────────────────────────── pub const WithdrawalResult = struct { @@ -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};