incorporate life events (e.g. social security) into projections
This commit is contained in:
parent
c58edb4f1c
commit
8387692de1
8 changed files with 537 additions and 72 deletions
|
|
@ -13,6 +13,88 @@ const std = @import("std");
|
|||
const log = std.log.scoped(.projections);
|
||||
const shiller = @import("../data/shiller.zig");
|
||||
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) ──────────────────
|
||||
|
||||
|
|
@ -31,8 +113,16 @@ pub const UserConfig = struct {
|
|||
horizon_count: u8 = 3,
|
||||
/// Confidence levels for safe withdrawal. Always 90/95/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_persons: usize = 4;
|
||||
pub const max_events: usize = 16;
|
||||
|
||||
pub fn getHorizons(self: *const UserConfig) []const u16 {
|
||||
return self.horizons[0..self.horizon_count];
|
||||
|
|
@ -41,54 +131,152 @@ pub const UserConfig = struct {
|
|||
pub fn getConfidenceLevels(self: *const UserConfig) []const f64 {
|
||||
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.
|
||||
/// Returns default config if data is null or unparseable.
|
||||
///
|
||||
/// Format (one field per line):
|
||||
/// target_stock_pct::77
|
||||
/// horizon::20
|
||||
/// horizon::30
|
||||
/// horizon::45
|
||||
///
|
||||
/// Multiple `horizon` entries replace the default horizons.
|
||||
/// Format (union-tagged SRF records):
|
||||
/// type::config,target_stock_pct:num:80
|
||||
/// type::config,horizon:num:30
|
||||
/// type::birthdate,date::1975-03-15
|
||||
/// type::event,name::Social Security,start_age:num:67,amount:num:38400
|
||||
pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig {
|
||||
var config = UserConfig{};
|
||||
const raw = data orelse 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 it = srf.iterator(&reader, std.heap.smp_allocator, .{ .alloc_strings = false }) catch return config;
|
||||
defer it.deinit();
|
||||
|
||||
var saw_horizon = false;
|
||||
var birthdate_seq: u8 = 0;
|
||||
|
||||
while (it.next() catch null) |fields| {
|
||||
const line = fields.to(SrfLine) catch continue;
|
||||
|
||||
if (line.target_stock_pct) |val| {
|
||||
config.target_stock_pct = std.fmt.parseFloat(f64, val) catch null;
|
||||
}
|
||||
|
||||
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;
|
||||
while (it.next() catch null) |field_it| {
|
||||
const rec = field_it.to(SrfProjection) catch continue;
|
||||
switch (rec) {
|
||||
.config => |c| {
|
||||
if (c.target_stock_pct) |val| {
|
||||
config.target_stock_pct = val;
|
||||
}
|
||||
}
|
||||
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,
|
||||
/// 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 = &.{},
|
||||
};
|
||||
|
||||
// ── Results ────────────────────────────────────────────────────
|
||||
|
|
@ -169,6 +359,7 @@ fn simulateCycle(
|
|||
initial_value: f64,
|
||||
annual_spending: f64,
|
||||
stock_pct: f64,
|
||||
events: []const ResolvedEvent,
|
||||
) void {
|
||||
const data = shiller.annual_returns;
|
||||
var portfolio = initial_value;
|
||||
|
|
@ -187,9 +378,12 @@ fn simulateCycle(
|
|||
|
||||
const yr = data[di];
|
||||
|
||||
// Step 1: Withdraw spending (FIRECalc style — withdraw before growth)
|
||||
// Year 1 spending = annual_spending; year 2+ adjusted for prior inflation
|
||||
portfolio -= annual_spending * cumulative_inflation;
|
||||
// Step 1: Withdraw spending, offset by life event cash flows
|
||||
var event_net: f64 = 0;
|
||||
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)
|
||||
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,
|
||||
annual_spending: f64,
|
||||
stock_pct: f64,
|
||||
events: []const ResolvedEvent,
|
||||
) usize {
|
||||
const num_cycles = shiller.maxCycles(horizon);
|
||||
var survived: usize = 0;
|
||||
|
|
@ -226,6 +421,7 @@ fn runAllCycles(
|
|||
initial_value,
|
||||
annual_spending,
|
||||
stock_pct,
|
||||
events,
|
||||
);
|
||||
|
||||
// Check if portfolio survived the full horizon
|
||||
|
|
@ -253,6 +449,7 @@ fn successRate(
|
|||
initial_value: f64,
|
||||
annual_spending: f64,
|
||||
stock_pct: f64,
|
||||
events: []const ResolvedEvent,
|
||||
) f64 {
|
||||
const data = shiller.annual_returns;
|
||||
const num_cycles = shiller.maxCycles(horizon);
|
||||
|
|
@ -271,8 +468,12 @@ fn successRate(
|
|||
|
||||
const yr = data[di];
|
||||
|
||||
// Withdraw first (FIRECalc style)
|
||||
portfolio -= annual_spending * cumulative_inflation;
|
||||
// Withdraw spending, offset by life event cash flows
|
||||
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) {
|
||||
failed = true;
|
||||
break;
|
||||
|
|
@ -305,6 +506,7 @@ pub fn findSafeWithdrawal(
|
|||
initial_value: f64,
|
||||
stock_pct: f64,
|
||||
confidence: f64,
|
||||
events: []const ResolvedEvent,
|
||||
) WithdrawalResult {
|
||||
// Seed from the 4% rule, adjusted for horizon and confidence.
|
||||
// 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);
|
||||
|
||||
// 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 });
|
||||
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 });
|
||||
hi = initial_value;
|
||||
}
|
||||
|
|
@ -331,7 +533,7 @@ pub fn findSafeWithdrawal(
|
|||
// Binary search to $1 precision
|
||||
while (hi - lo > 1.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) {
|
||||
lo = mid;
|
||||
|
|
@ -357,6 +559,7 @@ pub fn computePercentileBands(
|
|||
initial_value: f64,
|
||||
annual_spending: f64,
|
||||
stock_pct: f64,
|
||||
events: []const ResolvedEvent,
|
||||
) ![]YearPercentiles {
|
||||
const num_cycles = shiller.maxCycles(horizon);
|
||||
if (num_cycles == 0) return &.{};
|
||||
|
|
@ -374,7 +577,7 @@ pub fn computePercentileBands(
|
|||
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
|
||||
const bands = try allocator.alloc(YearPercentiles, years);
|
||||
|
|
@ -438,6 +641,7 @@ pub fn runProjection(
|
|||
config.portfolio_value,
|
||||
config.stock_pct,
|
||||
conf,
|
||||
config.events,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -451,6 +655,7 @@ pub fn runProjection(
|
|||
config.portfolio_value,
|
||||
chart_spending,
|
||||
config.stock_pct,
|
||||
config.events,
|
||||
);
|
||||
|
||||
return .{
|
||||
|
|
@ -482,31 +687,28 @@ pub fn runAllProjections(
|
|||
// ── Tests ──────────────────────────────────────────────────────
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
test "successRate with excessive spending is 0%" {
|
||||
// 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);
|
||||
}
|
||||
|
||||
test "successRate decreases with higher spending" {
|
||||
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_high = successRate(30, 1_000_000, 60_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_high = successRate(30, 1_000_000, 60_000, 0.75, &.{});
|
||||
|
||||
try std.testing.expect(rate_low >= rate_mid);
|
||||
try std.testing.expect(rate_mid >= rate_high);
|
||||
}
|
||||
|
||||
test "findSafeWithdrawal produces reasonable results" {
|
||||
// Using Jan-to-Jan Shiller data with real→nominal bond returns.
|
||||
// Results differ from the classic 4% rule due to dataset and timing differences.
|
||||
const result = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95);
|
||||
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 <= 60_000);
|
||||
try std.testing.expect(result.withdrawal_rate >= 0.01);
|
||||
|
|
@ -514,18 +716,18 @@ test "findSafeWithdrawal produces reasonable results" {
|
|||
}
|
||||
|
||||
test "higher confidence means lower withdrawal" {
|
||||
const r90 = findSafeWithdrawal(30, 1_000_000, 0.75, 0.90);
|
||||
const r95 = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95);
|
||||
const r99 = findSafeWithdrawal(30, 1_000_000, 0.75, 0.99);
|
||||
const r90 = findSafeWithdrawal(30, 1_000_000, 0.75, 0.90, &.{});
|
||||
const r95 = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &.{});
|
||||
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(r95.annual_amount >= r99.annual_amount);
|
||||
}
|
||||
|
||||
test "longer horizon means lower withdrawal" {
|
||||
const r20 = findSafeWithdrawal(20, 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 r20 = findSafeWithdrawal(20, 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, &.{});
|
||||
|
||||
try std.testing.expect(r20.annual_amount >= r30.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" {
|
||||
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);
|
||||
|
||||
// Should have horizon + 1 entries
|
||||
|
|
@ -585,9 +787,9 @@ test "realistic portfolio safe withdrawal" {
|
|||
const portfolio = 8_340_000;
|
||||
const stock_pct = 0.825;
|
||||
|
||||
const r99_45 = findSafeWithdrawal(45, portfolio, stock_pct, 0.99);
|
||||
const r95_45 = findSafeWithdrawal(45, portfolio, stock_pct, 0.95);
|
||||
const r99_30 = findSafeWithdrawal(30, 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 r99_30 = findSafeWithdrawal(30, portfolio, stock_pct, 0.99, &.{});
|
||||
|
||||
// 95% should be higher than 99%
|
||||
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" {
|
||||
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);
|
||||
}
|
||||
|
||||
test "simulateCycle with zero spending grows portfolio" {
|
||||
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
|
||||
try std.testing.expect(buf[30] > 1_000_000);
|
||||
}
|
||||
|
|
@ -623,7 +825,13 @@ test "parseProjectionsConfig defaults" {
|
|||
}
|
||||
|
||||
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);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 77.0), config.target_stock_pct.?, 0.01);
|
||||
try std.testing.expectEqual(@as(u8, 3), config.horizon_count);
|
||||
|
|
@ -633,7 +841,7 @@ test "parseProjectionsConfig from SRF" {
|
|||
}
|
||||
|
||||
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);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 82.5), config.target_stock_pct.?, 0.01);
|
||||
// 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[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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ const view = @import("../views/projections.zig");
|
|||
const stock_benchmark = "SPY";
|
||||
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;
|
||||
defer loaded.deinit(allocator);
|
||||
|
||||
|
|
@ -63,6 +63,7 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, file_path: []co
|
|||
portfolio.totalCash(),
|
||||
portfolio.totalCdFaceValue(),
|
||||
svc,
|
||||
events_enabled,
|
||||
);
|
||||
const horizons = ctx.config.getHorizons();
|
||||
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);
|
||||
}
|
||||
|
||||
// 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", .{});
|
||||
}
|
||||
|
||||
|
|
|
|||
14
src/main.zig
14
src/main.zig
|
|
@ -70,6 +70,9 @@ const usage =
|
|||
\\ Contributions additionally requires the portfolio file to be tracked
|
||||
\\ in a git repo; `git` must be on PATH.
|
||||
\\
|
||||
\\Projections command options:
|
||||
\\ --no-events Exclude life events from simulation (baseline view)
|
||||
\\
|
||||
\\Environment Variables:
|
||||
\\ TWELVEDATA_API_KEY Twelve Data API key (primary: prices)
|
||||
\\ 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);
|
||||
try commands.analysis.run(allocator, &svc, pf.path, color, out);
|
||||
} else if (std.mem.eql(u8, command, "projections")) {
|
||||
var events_enabled = true;
|
||||
for (cmd_args) |a| {
|
||||
try reportUnexpectedArg("projections", a);
|
||||
return 1;
|
||||
if (std.mem.eql(u8, a, "--no-events")) {
|
||||
events_enabled = false;
|
||||
} else {
|
||||
try reportUnexpectedArg("projections", a);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
const pf = resolveUserPath(allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename);
|
||||
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")) {
|
||||
for (cmd_args) |a| {
|
||||
try reportUnexpectedArg("contributions", a);
|
||||
|
|
|
|||
12
src/tui.zig
12
src/tui.zig
|
|
@ -393,6 +393,7 @@ pub const App = struct {
|
|||
projections_image_height: u16 = 0,
|
||||
projections_chart_dirty: bool = true,
|
||||
projections_chart_visible: bool = true,
|
||||
projections_events_enabled: bool = true,
|
||||
projections_value_min: f64 = 0,
|
||||
projections_value_max: f64 = 0,
|
||||
// Default to `.liquid` — that's the metric most worth watching
|
||||
|
|
@ -1120,6 +1121,17 @@ pub const App = struct {
|
|||
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();
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ pub const Action = enum {
|
|||
sort_reverse,
|
||||
account_filter,
|
||||
toggle_chart,
|
||||
toggle_events,
|
||||
};
|
||||
|
||||
pub const KeyCombo = struct {
|
||||
|
|
@ -136,6 +137,7 @@ const default_bindings = [_]Binding{
|
|||
.{ .action = .sort_reverse, .key = .{ .codepoint = 'o' } },
|
||||
.{ .action = .account_filter, .key = .{ .codepoint = 'a' } },
|
||||
.{ .action = .toggle_chart, .key = .{ .codepoint = 'v' } },
|
||||
.{ .action = .toggle_events, .key = .{ .codepoint = 'e' } },
|
||||
};
|
||||
|
||||
pub fn defaults() KeyMap {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ const views = @import("../views/portfolio_sections.zig");
|
|||
const cli = @import("../commands/common.zig");
|
||||
const theme = @import("theme.zig");
|
||||
const tui = @import("../tui.zig");
|
||||
const projections_tab = @import("projections_tab.zig");
|
||||
|
||||
const App = tui.App;
|
||||
const StyledLine = tui.StyledLine;
|
||||
|
|
@ -1304,6 +1305,14 @@ pub fn reloadPortfolioFile(app: *App) void {
|
|||
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) {
|
||||
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)";
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ pub fn loadData(app: *App) void {
|
|||
portfolio.totalCash(),
|
||||
portfolio.totalCdFaceValue(),
|
||||
app.svc,
|
||||
app.projections_events_enabled,
|
||||
) catch {
|
||||
app.setStatus("Failed to compute projections");
|
||||
return;
|
||||
|
|
@ -473,6 +474,24 @@ fn buildFooterSection(app: *App, arena: std.mem.Allocator, lines: *std.ArrayList
|
|||
.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 {
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -158,12 +158,13 @@ pub fn computeProjectionData(
|
|||
confidence_levels: []const f64,
|
||||
total_value: f64,
|
||||
stock_pct: f64,
|
||||
events: []const projections.ResolvedEvent,
|
||||
) !ProjectionData {
|
||||
const num_results = horizons.len * confidence_levels.len;
|
||||
const withdrawals = try alloc.alloc(projections.WithdrawalResult, num_results);
|
||||
for (confidence_levels, 0..) |conf, ci| {
|
||||
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;
|
||||
|
|
@ -175,6 +176,7 @@ pub fn computeProjectionData(
|
|||
total_value,
|
||||
withdrawals[ci_99 * horizons.len + hi].annual_amount,
|
||||
stock_pct,
|
||||
events,
|
||||
) catch null;
|
||||
}
|
||||
return .{ .withdrawals = withdrawals, .bands = bands, .ci_99 = ci_99 };
|
||||
|
|
@ -187,9 +189,10 @@ pub fn buildProjectionContext(
|
|||
stock_pct: f64,
|
||||
bond_pct: f64,
|
||||
total_value: f64,
|
||||
events: []const projections.ResolvedEvent,
|
||||
) !ProjectionContext {
|
||||
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 .{
|
||||
.comparison = comparison,
|
||||
.config = config,
|
||||
|
|
@ -219,13 +222,15 @@ pub fn loadProjectionContext(
|
|||
cash_value: f64,
|
||||
cd_value: f64,
|
||||
svc: *zfin.DataService,
|
||||
events_enabled: bool,
|
||||
) !ProjectionContext {
|
||||
// Load projections.srf
|
||||
const proj_path = try std.fmt.allocPrint(alloc, "{s}projections.srf", .{portfolio_dir});
|
||||
defer alloc.free(proj_path);
|
||||
const proj_data = std.fs.cwd().readFileAlloc(alloc, proj_path, 64 * 1024) catch null;
|
||||
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
|
||||
const meta_path = try std.fmt.allocPrint(alloc, "{s}metadata.srf", .{portfolio_dir});
|
||||
|
|
@ -285,7 +290,11 @@ pub fn loadProjectionContext(
|
|||
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) ─────────────────
|
||||
|
|
@ -391,6 +400,52 @@ pub fn buildPercentileRow(
|
|||
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 ──────────────────────────────────────────────────────
|
||||
|
||||
test "fmtReturnCell positive" {
|
||||
|
|
@ -566,7 +621,7 @@ test "computeProjectionData produces correct structure" {
|
|||
const horizons = [_]u16{ 20, 30 };
|
||||
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 {
|
||||
allocator.free(data.withdrawals);
|
||||
for (data.bands) |b| {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue