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_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 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 ────────────────────────────────────────────────────
|
// ── Results ────────────────────────────────────────────────────
|
||||||
|
|
||||||
pub const WithdrawalResult = struct {
|
pub const WithdrawalResult = struct {
|
||||||
|
|
@ -724,22 +700,6 @@ pub const YearPercentiles = struct {
|
||||||
p90: 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 ────────────────────────────────────────────
|
// ── Core simulation ────────────────────────────────────────────
|
||||||
|
|
||||||
/// Parameters bundling the full two-phase simulation inputs. Used
|
/// Parameters bundling the full two-phase simulation inputs. Used
|
||||||
|
|
@ -954,24 +914,6 @@ fn runAllCyclesParams(
|
||||||
return survived;
|
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 {
|
fn successRateParams(data: ShillerYearSlice, params: SimParams) f64 {
|
||||||
const num_cycles = maxCyclesFor(data, params.totalYears());
|
const num_cycles = maxCyclesFor(data, params.totalYears());
|
||||||
if (num_cycles == 0) return 0.0;
|
if (num_cycles == 0) return 0.0;
|
||||||
|
|
@ -987,30 +929,6 @@ fn successRateParams(data: ShillerYearSlice, params: SimParams) f64 {
|
||||||
|
|
||||||
// ── Safe withdrawal search ─────────────────────────────────────
|
// ── 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
|
/// Two-phase variant of `findSafeWithdrawal`. Searches for the
|
||||||
/// largest `annual_spending` (in today's dollars) such that the
|
/// largest `annual_spending` (in today's dollars) such that the
|
||||||
/// distribution-phase failure rate stays ≤ `1 - confidence`, with
|
/// distribution-phase failure rate stays ≤ `1 - confidence`, with
|
||||||
|
|
@ -1367,25 +1285,6 @@ fn confidenceIndex(confidence_levels: []const f64, pct: u8) usize {
|
||||||
|
|
||||||
// ── Percentile bands ───────────────────────────────────────────
|
// ── 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
|
/// Two-phase variant of `computePercentileBands`. Returns bands of
|
||||||
/// length `params.totalYears() + 1`, where index 0 is the starting
|
/// length `params.totalYears() + 1`, where index 0 is the starting
|
||||||
/// portfolio and index `accumulation_years` is the post-accumulation
|
/// 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 };
|
return .{ .withdrawals = withdrawals, .bands = bands, .ci_99 = ci_99 };
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run the full projection analysis for one horizon: compute safe withdrawal
|
// ── Test-only convenience wrappers ─────────────────────────────
|
||||||
/// at each confidence level and percentile bands using the median withdrawal.
|
//
|
||||||
pub fn runProjection(
|
// Thin, distribution-only, zero-fee wrappers over the production
|
||||||
allocator: std.mem.Allocator,
|
// `*Params` entry points (`searchSafeWithdrawal`, `successRateParams`,
|
||||||
config: ProjectionConfig,
|
// `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,
|
horizon: u16,
|
||||||
) !SimulationResult {
|
initial_value: f64,
|
||||||
const num_cycles = shiller.maxCycles(horizon);
|
stock_pct: f64,
|
||||||
|
confidence: f64,
|
||||||
// Compute safe withdrawal at each confidence level
|
events: []const ResolvedEvent,
|
||||||
const withdrawals = try allocator.alloc(WithdrawalResult, config.confidence_levels.len);
|
) WithdrawalResult {
|
||||||
for (config.confidence_levels, 0..) |conf, i| {
|
return searchSafeWithdrawal(.{
|
||||||
withdrawals[i] = findSafeWithdrawal(
|
.initial_value = initial_value,
|
||||||
horizon,
|
.stock_pct = stock_pct,
|
||||||
config.portfolio_value,
|
.annual_spending = 0, // overwritten by the search loop
|
||||||
config.stock_pct,
|
.distribution_years = horizon,
|
||||||
conf,
|
.events = events,
|
||||||
config.events,
|
}, confidence);
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.
|
/// Success rate (fraction of cycles that survived) for a given
|
||||||
pub fn runAllProjections(
|
/// 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,
|
allocator: std.mem.Allocator,
|
||||||
config: ProjectionConfig,
|
horizon: u16,
|
||||||
) ![]SimulationResult {
|
initial_value: f64,
|
||||||
const results = try allocator.alloc(SimulationResult, config.horizons.len);
|
annual_spending: f64,
|
||||||
errdefer {
|
stock_pct: f64,
|
||||||
for (results) |*r| r.deinit(allocator);
|
events: []const ResolvedEvent,
|
||||||
allocator.free(results);
|
) ![]YearPercentiles {
|
||||||
}
|
return computePercentileBandsParams(allocator, .{
|
||||||
|
.initial_value = initial_value,
|
||||||
for (config.horizons, 0..) |h, i| {
|
.stock_pct = stock_pct,
|
||||||
results[i] = try runProjection(allocator, config, h);
|
.annual_spending = annual_spending,
|
||||||
}
|
.distribution_years = horizon,
|
||||||
|
.events = events,
|
||||||
return results;
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tests ──────────────────────────────────────────────────────
|
// ── 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" {
|
test "percentile interpolation" {
|
||||||
const data = [_]f64{ 10, 20, 30, 40, 50 };
|
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, 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);
|
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" {
|
test "LifeEvent.startYear basic" {
|
||||||
const ev = LifeEvent{ .start_age = 67, .person = 0, .annual_amount = 38400 };
|
const ev = LifeEvent{ .start_age = 67, .person = 0, .annual_amount = 38400 };
|
||||||
const ages = [_]u16{50};
|
const ages = [_]u16{50};
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue