From 8387692de180cb7b85c8c8aaeb9c7cd2ccf9df51 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Thu, 30 Apr 2026 10:10:44 -0700 Subject: [PATCH] incorporate life events (e.g. social security) into projections --- src/analytics/projections.zig | 464 +++++++++++++++++++++++++++++----- src/commands/projections.zig | 21 +- src/main.zig | 14 +- src/tui.zig | 12 + src/tui/keybinds.zig | 2 + src/tui/portfolio_tab.zig | 9 + src/tui/projections_tab.zig | 22 ++ src/views/projections.zig | 65 ++++- 8 files changed, 537 insertions(+), 72 deletions(-) diff --git a/src/analytics/projections.zig b/src/analytics/projections.zig index 79861a7..7aeb6e3 100644 --- a/src/analytics/projections.zig +++ b/src/analytics/projections.zig @@ -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); +} diff --git a/src/commands/projections.zig b/src/commands/projections.zig index b16ebe5..66b8671 100644 --- a/src/commands/projections.zig +++ b/src/commands/projections.zig @@ -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", .{}); } diff --git a/src/main.zig b/src/main.zig index 6133c5e..549cd7e 100644 --- a/src/main.zig +++ b/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); diff --git a/src/tui.zig b/src/tui.zig index bd7b637..8fd5409 100644 --- a/src/tui.zig +++ b/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(); + } + }, } } diff --git a/src/tui/keybinds.zig b/src/tui/keybinds.zig index a967215..592ae0f 100644 --- a/src/tui/keybinds.zig +++ b/src/tui/keybinds.zig @@ -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 { diff --git a/src/tui/portfolio_tab.zig b/src/tui/portfolio_tab.zig index e116ba9..9695056 100644 --- a/src/tui/portfolio_tab.zig +++ b/src/tui/portfolio_tab.zig @@ -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)"; diff --git a/src/tui/projections_tab.zig b/src/tui/projections_tab.zig index 26b112e..d5c5af2 100644 --- a/src/tui/projections_tab.zig +++ b/src/tui/projections_tab.zig @@ -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); } diff --git a/src/views/projections.zig b/src/views/projections.zig index ca4b506..b9b6438 100644 --- a/src/views/projections.zig +++ b/src/views/projections.zig @@ -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| {