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 c58edb4f1c
commit 8387692de1
Signed by: lobo
GPG key ID: A7B62D657EF764F8
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 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 realnominal 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);
}

View file

@ -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", .{});
}

View file

@ -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);

View file

@ -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();
}
},
}
}

View file

@ -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 {

View file

@ -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)";

View file

@ -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);
}

View file

@ -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| {