cleanup dead code / be clear about test convenience wrappers
This commit is contained in:
parent
d078bc5a62
commit
221106b880
1 changed files with 60 additions and 197 deletions
|
|
@ -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};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue