incorporate life events (e.g. social security) into projections

This commit is contained in:
Emil Lerch 2026-04-30 10:10:44 -07:00
parent 4174e9bc09
commit b241feeaf0
8 changed files with 537 additions and 72 deletions

View file

@ -13,6 +13,88 @@ const std = @import("std");
const log = std.log.scoped(.projections); const log = std.log.scoped(.projections);
const shiller = @import("../data/shiller.zig"); const shiller = @import("../data/shiller.zig");
const srf = @import("srf"); 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 configuration (from projections.srf)
@ -31,8 +113,16 @@ pub const UserConfig = struct {
horizon_count: u8 = 3, horizon_count: u8 = 3,
/// Confidence levels for safe withdrawal. Always 90/95/99. /// Confidence levels for safe withdrawal. Always 90/95/99.
confidence_levels: [3]f64 = .{ 0.90, 0.95, 0.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_horizons: usize = 8;
const max_persons: usize = 4;
pub const max_events: usize = 16;
pub fn getHorizons(self: *const UserConfig) []const u16 { pub fn getHorizons(self: *const UserConfig) []const u16 {
return self.horizons[0..self.horizon_count]; return self.horizons[0..self.horizon_count];
@ -41,54 +131,152 @@ pub const UserConfig = struct {
pub fn getConfidenceLevels(self: *const UserConfig) []const f64 { pub fn getConfidenceLevels(self: *const UserConfig) []const f64 {
return &self.confidence_levels; 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;
}
/// 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,
};
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. /// Parse a projections.srf file into a UserConfig.
/// Returns default config if data is null or unparseable. /// Returns default config if data is null or unparseable.
/// ///
/// Format (one field per line): /// Format (union-tagged SRF records):
/// target_stock_pct::77 /// type::config,target_stock_pct:num:80
/// horizon::20 /// type::config,horizon:num:30
/// horizon::30 /// type::birthdate,date::1975-03-15
/// horizon::45 /// type::event,name::Social Security,start_age:num:67,amount:num:38400
///
/// Multiple `horizon` entries replace the default horizons.
pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig { pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig {
var config = UserConfig{}; var config = UserConfig{};
const raw = data orelse return config; const raw = data orelse return config;
if (raw.len == 0) return config; if (raw.len == 0) return config;
// Use a struct with all optional fields so a single .to() call handles any line.
const SrfLine = struct {
target_stock_pct: ?[]const u8 = null,
horizon: ?[]const u8 = null,
};
var reader = std.Io.Reader.fixed(raw); var reader = std.Io.Reader.fixed(raw);
var it = srf.iterator(&reader, std.heap.smp_allocator, .{ .alloc_strings = false }) catch return config; var it = srf.iterator(&reader, std.heap.smp_allocator, .{ .alloc_strings = false }) catch return config;
defer it.deinit(); defer it.deinit();
var saw_horizon = false; var saw_horizon = false;
var birthdate_seq: u8 = 0;
while (it.next() catch null) |fields| { while (it.next() catch null) |field_it| {
const line = fields.to(SrfLine) catch continue; const rec = field_it.to(SrfProjection) catch continue;
switch (rec) {
if (line.target_stock_pct) |val| { .config => |c| {
config.target_stock_pct = std.fmt.parseFloat(f64, val) catch null; if (c.target_stock_pct) |val| {
} config.target_stock_pct = val;
if (line.horizon) |val| {
if (!saw_horizon) {
config.horizon_count = 0;
saw_horizon = true;
}
if (config.horizon_count < UserConfig.max_horizons) {
const h = std.fmt.parseInt(u16, val, 10) catch continue;
if (h > 0) {
config.horizons[config.horizon_count] = h;
config.horizon_count += 1;
} }
} 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;
}
}
},
.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;
}
},
} }
} }
@ -111,6 +299,8 @@ pub const ProjectionConfig = struct {
horizons: []const u16, horizons: []const u16,
/// Confidence levels for safe withdrawal (e.g. 0.90, 0.95, 0.99). /// Confidence levels for safe withdrawal (e.g. 0.90, 0.95, 0.99).
confidence_levels: []const f64, confidence_levels: []const f64,
/// Pre-resolved life events for the simulation.
events: []const ResolvedEvent = &.{},
}; };
// Results // Results
@ -169,6 +359,7 @@ fn simulateCycle(
initial_value: f64, initial_value: f64,
annual_spending: f64, annual_spending: f64,
stock_pct: f64, stock_pct: f64,
events: []const ResolvedEvent,
) void { ) void {
const data = shiller.annual_returns; const data = shiller.annual_returns;
var portfolio = initial_value; var portfolio = initial_value;
@ -187,9 +378,12 @@ fn simulateCycle(
const yr = data[di]; const yr = data[di];
// Step 1: Withdraw spending (FIRECalc style withdraw before growth) // Step 1: Withdraw spending, offset by life event cash flows
// Year 1 spending = annual_spending; year 2+ adjusted for prior inflation var event_net: f64 = 0;
portfolio -= annual_spending * cumulative_inflation; 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) // 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; const blended_return = stock_pct * yr.sp500_total_return + (1.0 - stock_pct) * yr.bond_total_return;
@ -214,6 +408,7 @@ fn runAllCycles(
initial_value: f64, initial_value: f64,
annual_spending: f64, annual_spending: f64,
stock_pct: f64, stock_pct: f64,
events: []const ResolvedEvent,
) usize { ) usize {
const num_cycles = shiller.maxCycles(horizon); const num_cycles = shiller.maxCycles(horizon);
var survived: usize = 0; var survived: usize = 0;
@ -226,6 +421,7 @@ fn runAllCycles(
initial_value, initial_value,
annual_spending, annual_spending,
stock_pct, stock_pct,
events,
); );
// Check if portfolio survived the full horizon // Check if portfolio survived the full horizon
@ -253,6 +449,7 @@ fn successRate(
initial_value: f64, initial_value: f64,
annual_spending: f64, annual_spending: f64,
stock_pct: f64, stock_pct: f64,
events: []const ResolvedEvent,
) f64 { ) f64 {
const data = shiller.annual_returns; const data = shiller.annual_returns;
const num_cycles = shiller.maxCycles(horizon); const num_cycles = shiller.maxCycles(horizon);
@ -271,8 +468,12 @@ fn successRate(
const yr = data[di]; const yr = data[di];
// Withdraw first (FIRECalc style) // Withdraw spending, offset by life event cash flows
portfolio -= annual_spending * cumulative_inflation; 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) { if (portfolio <= 0) {
failed = true; failed = true;
break; break;
@ -305,6 +506,7 @@ pub fn findSafeWithdrawal(
initial_value: f64, initial_value: f64,
stock_pct: f64, stock_pct: f64,
confidence: f64, confidence: f64,
events: []const ResolvedEvent,
) WithdrawalResult { ) WithdrawalResult {
// Seed from the 4% rule, adjusted for horizon and confidence. // Seed from the 4% rule, adjusted for horizon and confidence.
// Base ~4% for 30yr/95%. Shorter horizons allow more; longer less. // Base ~4% for 30yr/95%. Shorter horizons allow more; longer less.
@ -319,11 +521,11 @@ pub fn findSafeWithdrawal(
var hi: f64 = @min(estimate * 1.5, initial_value); var hi: f64 = @min(estimate * 1.5, initial_value);
// Verify bounds bracket the answer; widen if not // Verify bounds bracket the answer; widen if not
if (successRate(horizon, initial_value, lo, stock_pct) < confidence) { 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 }); log.debug("findSafeWithdrawal: estimate too high, widening lo to 0 (horizon={d}, conf={d:.2})", .{ horizon, confidence });
lo = 0; lo = 0;
} }
if (successRate(horizon, initial_value, hi, stock_pct) >= confidence) { 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 }); log.debug("findSafeWithdrawal: estimate too low, widening hi to portfolio value (horizon={d}, conf={d:.2})", .{ horizon, confidence });
hi = initial_value; hi = initial_value;
} }
@ -331,7 +533,7 @@ pub fn findSafeWithdrawal(
// Binary search to $1 precision // Binary search to $1 precision
while (hi - lo > 1.0) { while (hi - lo > 1.0) {
const mid = @floor((lo + hi) / 2.0); const mid = @floor((lo + hi) / 2.0);
const rate = successRate(horizon, initial_value, mid, stock_pct); const rate = successRate(horizon, initial_value, mid, stock_pct, events);
if (rate >= confidence) { if (rate >= confidence) {
lo = mid; lo = mid;
@ -357,6 +559,7 @@ pub fn computePercentileBands(
initial_value: f64, initial_value: f64,
annual_spending: f64, annual_spending: f64,
stock_pct: f64, stock_pct: f64,
events: []const ResolvedEvent,
) ![]YearPercentiles { ) ![]YearPercentiles {
const num_cycles = shiller.maxCycles(horizon); const num_cycles = shiller.maxCycles(horizon);
if (num_cycles == 0) return &.{}; if (num_cycles == 0) return &.{};
@ -374,7 +577,7 @@ pub fn computePercentileBands(
paths[i] = path_data[i * years .. (i + 1) * years]; paths[i] = path_data[i * years .. (i + 1) * years];
} }
_ = runAllCycles(paths, horizon, initial_value, annual_spending, stock_pct); _ = runAllCycles(paths, horizon, initial_value, annual_spending, stock_pct, events);
// For each year, sort the values across all cycles and extract percentiles // For each year, sort the values across all cycles and extract percentiles
const bands = try allocator.alloc(YearPercentiles, years); const bands = try allocator.alloc(YearPercentiles, years);
@ -438,6 +641,7 @@ pub fn runProjection(
config.portfolio_value, config.portfolio_value,
config.stock_pct, config.stock_pct,
conf, conf,
config.events,
); );
} }
@ -451,6 +655,7 @@ pub fn runProjection(
config.portfolio_value, config.portfolio_value,
chart_spending, chart_spending,
config.stock_pct, config.stock_pct,
config.events,
); );
return .{ return .{
@ -482,31 +687,28 @@ pub fn runAllProjections(
// Tests // Tests
test "successRate with zero spending is 100%" { test "successRate with zero spending is 100%" {
const rate = successRate(30, 1_000_000, 0, 0.75); const rate = successRate(30, 1_000_000, 0, 0.75, &.{});
try std.testing.expectApproxEqAbs(@as(f64, 1.0), rate, 0.001); try std.testing.expectApproxEqAbs(@as(f64, 1.0), rate, 0.001);
} }
test "successRate with excessive spending is 0%" { test "successRate with excessive spending is 0%" {
// Spending the entire portfolio in year 1 should fail every cycle // Spending the entire portfolio in year 1 should fail every cycle
const rate = successRate(30, 1_000_000, 1_000_000, 0.75); const rate = successRate(30, 1_000_000, 1_000_000, 0.75, &.{});
try std.testing.expectApproxEqAbs(@as(f64, 0.0), rate, 0.001); try std.testing.expectApproxEqAbs(@as(f64, 0.0), rate, 0.001);
} }
test "successRate decreases with higher spending" { test "successRate decreases with higher spending" {
const rate_low = successRate(30, 1_000_000, 20_000, 0.75); 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_mid = successRate(30, 1_000_000, 40_000, 0.75, &.{});
const rate_high = successRate(30, 1_000_000, 60_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_low >= rate_mid);
try std.testing.expect(rate_mid >= rate_high); try std.testing.expect(rate_mid >= rate_high);
} }
test "findSafeWithdrawal produces reasonable results" { test "findSafeWithdrawal produces reasonable results" {
// Using Jan-to-Jan Shiller data with realnominal bond returns. const result = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &.{});
// Results differ from the classic 4% rule due to dataset and timing differences.
const result = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95);
// Should produce a positive withdrawal rate between 1% and 6%
try std.testing.expect(result.annual_amount >= 10_000); try std.testing.expect(result.annual_amount >= 10_000);
try std.testing.expect(result.annual_amount <= 60_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.01);
@ -514,18 +716,18 @@ test "findSafeWithdrawal produces reasonable results" {
} }
test "higher confidence means lower withdrawal" { test "higher confidence means lower withdrawal" {
const r90 = findSafeWithdrawal(30, 1_000_000, 0.75, 0.90); const r90 = findSafeWithdrawal(30, 1_000_000, 0.75, 0.90, &.{});
const r95 = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95); const r95 = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &.{});
const r99 = findSafeWithdrawal(30, 1_000_000, 0.75, 0.99); 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(r90.annual_amount >= r95.annual_amount);
try std.testing.expect(r95.annual_amount >= r99.annual_amount); try std.testing.expect(r95.annual_amount >= r99.annual_amount);
} }
test "longer horizon means lower withdrawal" { test "longer horizon means lower withdrawal" {
const r20 = findSafeWithdrawal(20, 1_000_000, 0.75, 0.95); const r20 = findSafeWithdrawal(20, 1_000_000, 0.75, 0.95, &.{});
const r30 = findSafeWithdrawal(30, 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); 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(r20.annual_amount >= r30.annual_amount);
try std.testing.expect(r30.annual_amount >= r45.annual_amount); try std.testing.expect(r30.annual_amount >= r45.annual_amount);
@ -533,7 +735,7 @@ test "longer horizon means lower withdrawal" {
test "computePercentileBands basic properties" { test "computePercentileBands basic properties" {
const allocator = std.testing.allocator; const allocator = std.testing.allocator;
const bands = try computePercentileBands(allocator, 30, 1_000_000, 30_000, 0.75); const bands = try computePercentileBands(allocator, 30, 1_000_000, 30_000, 0.75, &.{});
defer allocator.free(bands); defer allocator.free(bands);
// Should have horizon + 1 entries // Should have horizon + 1 entries
@ -585,9 +787,9 @@ test "realistic portfolio safe withdrawal" {
const portfolio = 8_340_000; const portfolio = 8_340_000;
const stock_pct = 0.825; const stock_pct = 0.825;
const r99_45 = findSafeWithdrawal(45, portfolio, stock_pct, 0.99); const r99_45 = findSafeWithdrawal(45, portfolio, stock_pct, 0.99, &.{});
const r95_45 = findSafeWithdrawal(45, portfolio, stock_pct, 0.95); const r95_45 = findSafeWithdrawal(45, portfolio, stock_pct, 0.95, &.{});
const r99_30 = findSafeWithdrawal(30, portfolio, stock_pct, 0.99); const r99_30 = findSafeWithdrawal(30, portfolio, stock_pct, 0.99, &.{});
// 95% should be higher than 99% // 95% should be higher than 99%
try std.testing.expect(r95_45.annual_amount > r99_45.annual_amount); try std.testing.expect(r95_45.annual_amount > r99_45.annual_amount);
@ -602,13 +804,13 @@ test "realistic portfolio safe withdrawal" {
test "simulateCycle produces correct year-0 value" { test "simulateCycle produces correct year-0 value" {
var buf: [31]f64 = undefined; var buf: [31]f64 = undefined;
simulateCycle(&buf, 0, 30, 1_000_000, 0, 0.75); simulateCycle(&buf, 0, 30, 1_000_000, 0, 0.75, &.{});
try std.testing.expectApproxEqAbs(@as(f64, 1_000_000), buf[0], 0.01); try std.testing.expectApproxEqAbs(@as(f64, 1_000_000), buf[0], 0.01);
} }
test "simulateCycle with zero spending grows portfolio" { test "simulateCycle with zero spending grows portfolio" {
var buf: [31]f64 = undefined; var buf: [31]f64 = undefined;
simulateCycle(&buf, 0, 30, 1_000_000, 0, 0.75); simulateCycle(&buf, 0, 30, 1_000_000, 0, 0.75, &.{});
// Over any 30-year period in history, zero spending should grow the portfolio // Over any 30-year period in history, zero spending should grow the portfolio
try std.testing.expect(buf[30] > 1_000_000); try std.testing.expect(buf[30] > 1_000_000);
} }
@ -623,7 +825,13 @@ test "parseProjectionsConfig defaults" {
} }
test "parseProjectionsConfig from SRF" { test "parseProjectionsConfig from SRF" {
const data = "#!srfv1\ntarget_stock_pct::77\nhorizon::25\nhorizon::35\nhorizon::50\n"; 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); const config = parseProjectionsConfig(data);
try std.testing.expectApproxEqAbs(@as(f64, 77.0), config.target_stock_pct.?, 0.01); 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(u8, 3), config.horizon_count);
@ -633,7 +841,7 @@ test "parseProjectionsConfig from SRF" {
} }
test "parseProjectionsConfig partial" { test "parseProjectionsConfig partial" {
const data = "#!srfv1\ntarget_stock_pct::82.5\n"; const data = "#!srfv1\ntype::config,target_stock_pct:num:82.5\n";
const config = parseProjectionsConfig(data); const config = parseProjectionsConfig(data);
try std.testing.expectApproxEqAbs(@as(f64, 82.5), config.target_stock_pct.?, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 82.5), config.target_stock_pct.?, 0.01);
// Horizons should remain default // Horizons should remain default
@ -690,3 +898,133 @@ test "runAllProjections produces results for each horizon" {
try std.testing.expectEqual(@as(usize, 2), results[0].withdrawals.len); try std.testing.expectEqual(@as(usize, 2), results[0].withdrawals.len);
try std.testing.expectEqual(@as(usize, 2), results[1].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);
}

View file

@ -18,7 +18,7 @@ const view = @import("../views/projections.zig");
const stock_benchmark = "SPY"; const stock_benchmark = "SPY";
const bond_benchmark = "AGG"; const bond_benchmark = "AGG";
pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, file_path: []const u8, color: bool, out: *std.Io.Writer) !void { pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, file_path: []const u8, events_enabled: bool, color: bool, out: *std.Io.Writer) !void {
var loaded = cli.loadPortfolio(allocator, file_path) orelse return; var loaded = cli.loadPortfolio(allocator, file_path) orelse return;
defer loaded.deinit(allocator); defer loaded.deinit(allocator);
@ -63,6 +63,7 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, file_path: []co
portfolio.totalCash(), portfolio.totalCash(),
portfolio.totalCdFaceValue(), portfolio.totalCdFaceValue(),
svc, svc,
events_enabled,
); );
const horizons = ctx.config.getHorizons(); const horizons = ctx.config.getHorizons();
const confidence_levels = ctx.config.getConfidenceLevels(); const confidence_levels = ctx.config.getConfidenceLevels();
@ -214,6 +215,24 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, file_path: []co
try cli.reset(out, color); try cli.reset(out, color);
} }
// Life events summary
{
const events = ctx.config.getEvents();
if (events.len > 0) {
const ages = ctx.config.currentAges();
try out.print("\n", .{});
try cli.setBold(out, color);
try out.print("Life Events\n", .{});
try cli.reset(out, color);
for (events) |*ev| {
const line = try view.fmtEventLine(va, ev, &ages);
try cli.setStyleIntent(out, color, line.style);
try out.print("{s}\n", .{line.text});
try cli.reset(out, color);
}
}
}
try out.print("\n", .{}); try out.print("\n", .{});
} }

View file

@ -70,6 +70,9 @@ const usage =
\\ Contributions additionally requires the portfolio file to be tracked \\ Contributions additionally requires the portfolio file to be tracked
\\ in a git repo; `git` must be on PATH. \\ in a git repo; `git` must be on PATH.
\\ \\
\\Projections command options:
\\ --no-events Exclude life events from simulation (baseline view)
\\
\\Environment Variables: \\Environment Variables:
\\ TWELVEDATA_API_KEY Twelve Data API key (primary: prices) \\ TWELVEDATA_API_KEY Twelve Data API key (primary: prices)
\\ POLYGON_API_KEY Polygon.io API key (dividends, splits) \\ POLYGON_API_KEY Polygon.io API key (dividends, splits)
@ -401,13 +404,18 @@ pub fn main() !u8 {
defer if (pf.resolved) |r| r.deinit(allocator); defer if (pf.resolved) |r| r.deinit(allocator);
try commands.analysis.run(allocator, &svc, pf.path, color, out); try commands.analysis.run(allocator, &svc, pf.path, color, out);
} else if (std.mem.eql(u8, command, "projections")) { } else if (std.mem.eql(u8, command, "projections")) {
var events_enabled = true;
for (cmd_args) |a| { for (cmd_args) |a| {
try reportUnexpectedArg("projections", a); if (std.mem.eql(u8, a, "--no-events")) {
return 1; events_enabled = false;
} else {
try reportUnexpectedArg("projections", a);
return 1;
}
} }
const pf = resolveUserPath(allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename); const pf = resolveUserPath(allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename);
defer if (pf.resolved) |r| r.deinit(allocator); defer if (pf.resolved) |r| r.deinit(allocator);
try commands.projections.run(allocator, &svc, pf.path, color, out); try commands.projections.run(allocator, &svc, pf.path, events_enabled, color, out);
} else if (std.mem.eql(u8, command, "contributions")) { } else if (std.mem.eql(u8, command, "contributions")) {
for (cmd_args) |a| { for (cmd_args) |a| {
try reportUnexpectedArg("contributions", a); try reportUnexpectedArg("contributions", a);

View file

@ -393,6 +393,7 @@ pub const App = struct {
projections_image_height: u16 = 0, projections_image_height: u16 = 0,
projections_chart_dirty: bool = true, projections_chart_dirty: bool = true,
projections_chart_visible: bool = true, projections_chart_visible: bool = true,
projections_events_enabled: bool = true,
projections_value_min: f64 = 0, projections_value_min: f64 = 0,
projections_value_max: f64 = 0, projections_value_max: f64 = 0,
// Default to `.liquid` that's the metric most worth watching // Default to `.liquid` that's the metric most worth watching
@ -1120,6 +1121,17 @@ pub const App = struct {
return ctx.consumeAndRedraw(); return ctx.consumeAndRedraw();
} }
}, },
.toggle_events => {
if (self.active_tab == .projections) {
self.projections_events_enabled = !self.projections_events_enabled;
projections_tab.freeLoaded(self);
self.projections_loaded = false;
projections_tab.loadData(self);
const label = if (self.projections_events_enabled) "Events enabled" else "Events disabled";
self.setStatus(label);
return ctx.consumeAndRedraw();
}
},
} }
} }

View file

@ -48,6 +48,7 @@ pub const Action = enum {
sort_reverse, sort_reverse,
account_filter, account_filter,
toggle_chart, toggle_chart,
toggle_events,
}; };
pub const KeyCombo = struct { pub const KeyCombo = struct {
@ -136,6 +137,7 @@ const default_bindings = [_]Binding{
.{ .action = .sort_reverse, .key = .{ .codepoint = 'o' } }, .{ .action = .sort_reverse, .key = .{ .codepoint = 'o' } },
.{ .action = .account_filter, .key = .{ .codepoint = 'a' } }, .{ .action = .account_filter, .key = .{ .codepoint = 'a' } },
.{ .action = .toggle_chart, .key = .{ .codepoint = 'v' } }, .{ .action = .toggle_chart, .key = .{ .codepoint = 'v' } },
.{ .action = .toggle_events, .key = .{ .codepoint = 'e' } },
}; };
pub fn defaults() KeyMap { pub fn defaults() KeyMap {

View file

@ -6,6 +6,7 @@ const views = @import("../views/portfolio_sections.zig");
const cli = @import("../commands/common.zig"); const cli = @import("../commands/common.zig");
const theme = @import("theme.zig"); const theme = @import("theme.zig");
const tui = @import("../tui.zig"); const tui = @import("../tui.zig");
const projections_tab = @import("projections_tab.zig");
const App = tui.App; const App = tui.App;
const StyledLine = tui.StyledLine; const StyledLine = tui.StyledLine;
@ -1304,6 +1305,14 @@ pub fn reloadPortfolioFile(app: *App) void {
app.loadAnalysisData(); app.loadAnalysisData();
} }
// Invalidate projections data projections.srf may have changed
projections_tab.freeLoaded(app);
app.projections_loaded = false;
app.projections_disabled = false;
if (app.active_tab == .projections) {
projections_tab.loadData(app);
}
if (missing > 0) { if (missing > 0) {
var warn_buf: [128]u8 = undefined; var warn_buf: [128]u8 = undefined;
const warn_msg = std.fmt.bufPrint(&warn_buf, "Reloaded. {d} symbols missing cached prices", .{missing}) catch "Reloaded (some prices missing)"; const warn_msg = std.fmt.bufPrint(&warn_buf, "Reloaded. {d} symbols missing cached prices", .{missing}) catch "Reloaded (some prices missing)";

View file

@ -54,6 +54,7 @@ pub fn loadData(app: *App) void {
portfolio.totalCash(), portfolio.totalCash(),
portfolio.totalCdFaceValue(), portfolio.totalCdFaceValue(),
app.svc, app.svc,
app.projections_events_enabled,
) catch { ) catch {
app.setStatus("Failed to compute projections"); app.setStatus("Failed to compute projections");
return; return;
@ -473,6 +474,24 @@ fn buildFooterSection(app: *App, arena: std.mem.Allocator, lines: *std.ArrayList
.style = th.mutedStyle(), .style = th.mutedStyle(),
}); });
} }
// Life events summary
try appendEventSummary(lines, arena, th, pctx);
}
fn appendEventSummary(lines: *std.ArrayListUnmanaged(StyledLine), arena: std.mem.Allocator, th: theme.Theme, pctx: view.ProjectionContext) !void {
const events = pctx.config.getEvents();
if (events.len == 0) return;
const ages = pctx.config.currentAges();
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = " Life Events", .style = th.headerStyle() });
for (events) |*ev| {
const line = try view.fmtEventLine(arena, ev, &ages);
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " {s}", .{line.text}),
.style = th.styleFor(line.style),
});
}
} }
pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine { pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
@ -740,6 +759,9 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine
}); });
} }
// Life events summary (at the bottom)
try appendEventSummary(&lines, arena, th, ctx);
return lines.toOwnedSlice(arena); return lines.toOwnedSlice(arena);
} }

View file

@ -158,12 +158,13 @@ pub fn computeProjectionData(
confidence_levels: []const f64, confidence_levels: []const f64,
total_value: f64, total_value: f64,
stock_pct: f64, stock_pct: f64,
events: []const projections.ResolvedEvent,
) !ProjectionData { ) !ProjectionData {
const num_results = horizons.len * confidence_levels.len; const num_results = horizons.len * confidence_levels.len;
const withdrawals = try alloc.alloc(projections.WithdrawalResult, num_results); const withdrawals = try alloc.alloc(projections.WithdrawalResult, num_results);
for (confidence_levels, 0..) |conf, ci| { for (confidence_levels, 0..) |conf, ci| {
for (horizons, 0..) |h, hi| { for (horizons, 0..) |h, hi| {
withdrawals[ci * horizons.len + hi] = projections.findSafeWithdrawal(h, total_value, stock_pct, conf); withdrawals[ci * horizons.len + hi] = projections.findSafeWithdrawal(h, total_value, stock_pct, conf, events);
} }
} }
const ci_99 = confidence_levels.len - 1; const ci_99 = confidence_levels.len - 1;
@ -175,6 +176,7 @@ pub fn computeProjectionData(
total_value, total_value,
withdrawals[ci_99 * horizons.len + hi].annual_amount, withdrawals[ci_99 * horizons.len + hi].annual_amount,
stock_pct, stock_pct,
events,
) catch null; ) catch null;
} }
return .{ .withdrawals = withdrawals, .bands = bands, .ci_99 = ci_99 }; return .{ .withdrawals = withdrawals, .bands = bands, .ci_99 = ci_99 };
@ -187,9 +189,10 @@ pub fn buildProjectionContext(
stock_pct: f64, stock_pct: f64,
bond_pct: f64, bond_pct: f64,
total_value: f64, total_value: f64,
events: []const projections.ResolvedEvent,
) !ProjectionContext { ) !ProjectionContext {
const sim_stock_pct = if (config.target_stock_pct) |t| t / 100.0 else stock_pct; const sim_stock_pct = if (config.target_stock_pct) |t| t / 100.0 else stock_pct;
const data = try computeProjectionData(alloc, config.getHorizons(), config.getConfidenceLevels(), total_value, sim_stock_pct); const data = try computeProjectionData(alloc, config.getHorizons(), config.getConfidenceLevels(), total_value, sim_stock_pct, events);
return .{ return .{
.comparison = comparison, .comparison = comparison,
.config = config, .config = config,
@ -219,13 +222,15 @@ pub fn loadProjectionContext(
cash_value: f64, cash_value: f64,
cd_value: f64, cd_value: f64,
svc: *zfin.DataService, svc: *zfin.DataService,
events_enabled: bool,
) !ProjectionContext { ) !ProjectionContext {
// Load projections.srf // Load projections.srf
const proj_path = try std.fmt.allocPrint(alloc, "{s}projections.srf", .{portfolio_dir}); const proj_path = try std.fmt.allocPrint(alloc, "{s}projections.srf", .{portfolio_dir});
defer alloc.free(proj_path); defer alloc.free(proj_path);
const proj_data = std.fs.cwd().readFileAlloc(alloc, proj_path, 64 * 1024) catch null; const proj_data = std.fs.cwd().readFileAlloc(alloc, proj_path, 64 * 1024) catch null;
defer if (proj_data) |d| alloc.free(d); defer if (proj_data) |d| alloc.free(d);
const config = projections.parseProjectionsConfig(proj_data); var config = projections.parseProjectionsConfig(proj_data);
if (!events_enabled) config.event_count = 0;
// Load metadata for classification // Load metadata for classification
const meta_path = try std.fmt.allocPrint(alloc, "{s}metadata.srf", .{portfolio_dir}); const meta_path = try std.fmt.allocPrint(alloc, "{s}metadata.srf", .{portfolio_dir});
@ -285,7 +290,11 @@ pub fn loadProjectionContext(
agg_week, agg_week,
); );
return buildProjectionContext(alloc, config, comparison, split.stock_pct, split.bond_pct, total_value); // Resolve events to simulation years
const resolved = config.resolveEvents();
const resolved_events = resolved[0..config.event_count];
return buildProjectionContext(alloc, config, comparison, split.stock_pct, split.bond_pct, total_value, resolved_events);
} }
// Table row builders (shared by CLI and TUI) // Table row builders (shared by CLI and TUI)
@ -391,6 +400,52 @@ pub fn buildPercentileRow(
return .{ .text = try row.toOwnedSlice(arena), .style = style }; return .{ .text = try row.toOwnedSlice(arena), .style = style };
} }
// Event summary (shared by CLI and TUI)
pub const EventLine = struct {
text: []const u8,
style: StyleIntent,
};
/// Format a single event line for display.
/// Output: " Social Security (Emil) +$38,400/yr age 67 (in 17yr)"
pub fn fmtEventLine(arena: std.mem.Allocator, ev: *const projections.LifeEvent, current_ages: []const u16) !EventLine {
const name = ev.getName();
const amount = ev.annual_amount;
const is_income = amount >= 0;
const style: StyleIntent = if (is_income) .positive else .negative;
var amt_buf: [24]u8 = undefined;
const sign: []const u8 = if (is_income) "+" else "-";
const abs_amount = @abs(amount);
const amt_str = fmt.fmtMoneyAbs(&amt_buf, abs_amount);
// Strip decimals
const amt_nodec = if (std.mem.lastIndexOfScalar(u8, amt_str, '.')) |dot|
amt_str[0..dot]
else
amt_str;
const start_yr = ev.startYear(current_ages);
const timing = if (start_yr) |sy| blk: {
if (sy == 0)
break :blk try std.fmt.allocPrint(arena, "age {d} (now)", .{ev.start_age})
else
break :blk try std.fmt.allocPrint(arena, "age {d} (in {d}yr)", .{ ev.start_age, sy });
} else try std.fmt.allocPrint(arena, "age {d}", .{ev.start_age});
const dur_str = if (ev.duration > 0)
try std.fmt.allocPrint(arena, ", {d}yr", .{ev.duration})
else
"";
const nominal_str: []const u8 = if (!ev.inflation_adjusted) ", nominal" else "";
const text = try std.fmt.allocPrint(arena, " {s: <28} {s}{s}/yr {s}{s}{s}", .{
name, sign, amt_nodec, timing, dur_str, nominal_str,
});
return .{ .text = text, .style = style };
}
// Tests // Tests
test "fmtReturnCell positive" { test "fmtReturnCell positive" {
@ -566,7 +621,7 @@ test "computeProjectionData produces correct structure" {
const horizons = [_]u16{ 20, 30 }; const horizons = [_]u16{ 20, 30 };
const conf = [_]f64{ 0.95, 0.99 }; const conf = [_]f64{ 0.95, 0.99 };
const data = try computeProjectionData(allocator, &horizons, &conf, 1000000, 0.75); const data = try computeProjectionData(allocator, &horizons, &conf, 1000000, 0.75, &.{});
defer { defer {
allocator.free(data.withdrawals); allocator.free(data.withdrawals);
for (data.bands) |b| { for (data.bands) |b| {