diff --git a/src/analytics/projections.zig b/src/analytics/projections.zig index 7aeb6e3..519b94d 100644 --- a/src/analytics/projections.zig +++ b/src/analytics/projections.zig @@ -100,17 +100,27 @@ pub const LifeEvent = struct { /// User-configurable projection parameters, loaded from projections.srf. /// -/// Example projections.srf: +/// Example projections.srf (union-tagged SRF records): /// #!srfv1 -/// target_stock_pct::77 -/// horizons::20,30,45 -/// birthdates::1975-03-15,1978-06-22 +/// type::config,target_stock_pct:num:77 +/// type::config,horizon:num:30 +/// type::config,horizon_age:num:90 # resolves to (90 − oldest current age) +/// type::birthdate,date::1975-03-15 +/// type::birthdate,date::1978-06-22,person:num:2 +/// type::event,name::Social Security,start_age:num:67,amount:num:38400 pub const UserConfig = struct { /// Target stock allocation percentage (0-100). Used for simulation blending. target_stock_pct: ?f64 = null, /// Retirement horizons to simulate (years). Defaults to 20,30,45. horizons: [max_horizons]u16 = .{ 20, 30, 45 } ++ .{0} ** (max_horizons - 3), horizon_count: u8 = 3, + /// Age-based horizon targets. Resolved at context-load time to + /// `target_age − max(currentAges())` years — i.e. how long until the + /// oldest configured person hits `target_age`. Rationale: the first + /// person to hit the target age sets the meaningful planning horizon, + /// because spending typically drops substantially after the first death. + horizon_ages: [max_horizons]u16 = .{0} ** max_horizons, + horizon_age_count: u8 = 0, /// 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. @@ -124,6 +134,13 @@ pub const UserConfig = struct { const max_persons: usize = 4; pub const max_events: usize = 16; + /// Errors that can arise when resolving age-based horizons. + pub const ResolveError = error{ + /// `type::config,horizon_age:num:N` was specified in projections.srf + /// but no `type::birthdate` record exists to anchor the calculation. + HorizonAgeWithoutBirthdate, + }; + pub fn getHorizons(self: *const UserConfig) []const u16 { return self.horizons[0..self.horizon_count]; } @@ -151,6 +168,44 @@ pub const UserConfig = struct { return ages; } + /// Resolve age-based horizons (`horizon_ages`) into year counts and + /// append them to `horizons`. For each target age, computes + /// `target_age − max(currentAgesAsOf(as_of))` — the number of years + /// until the oldest configured person hits that age. Targets that are + /// already in the past (oldest age ≥ target) are silently skipped. + /// + /// Errors if `horizon_ages` is non-empty but no birthdate is configured. + /// Safe to call multiple times; subsequent calls are no-ops because + /// `horizon_age_count` is cleared after resolution. + pub fn resolveHorizonAges(self: *UserConfig, as_of: Date) ResolveError!void { + if (self.horizon_age_count == 0) return; + if (self.birthdate_count == 0) return error.HorizonAgeWithoutBirthdate; + + const ages = self.currentAgesAsOf(as_of); + var oldest: u16 = 0; + for (0..self.birthdate_count) |i| { + if (ages[i] > oldest) oldest = ages[i]; + } + + for (0..self.horizon_age_count) |i| { + const target = self.horizon_ages[i]; + if (target <= oldest) continue; // already past target, skip silently + const years: u16 = target - oldest; + if (self.horizon_count < max_horizons) { + self.horizons[self.horizon_count] = years; + self.horizon_count += 1; + } + } + // Clear so a second call is a no-op. + self.horizon_age_count = 0; + } + + /// Resolve age-based horizons using today's date. Convenience wrapper + /// around `resolveHorizonAges`. + pub fn resolveHorizonAgesNow(self: *UserConfig) ResolveError!void { + return self.resolveHorizonAges(Date.fromEpoch(std.time.timestamp())); + } + /// 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; @@ -188,6 +243,7 @@ const SrfConfig = struct { type: []const u8 = "", target_stock_pct: ?f64 = null, horizon: ?u16 = null, + horizon_age: ?u16 = null, }; const SrfBirthdate = struct { @@ -250,6 +306,22 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig { config.horizon_count += 1; } } + if (c.horizon_age) |age| { + // Age-based horizons are stored raw and resolved later + // via `UserConfig.resolveHorizonAges(as_of)` once the + // view layer knows the projection date. They also count + // as "saw_horizon" so a file containing only + // `horizon_age` records replaces the default {20,30,45} + // once resolved. + if (!saw_horizon) { + config.horizon_count = 0; + saw_horizon = true; + } + if (config.horizon_age_count < UserConfig.max_horizons and age > 0) { + config.horizon_ages[config.horizon_age_count] = age; + config.horizon_age_count += 1; + } + } }, .birthdate => |b| { // person is 1-indexed in SRF; convert to 0-indexed. @@ -860,6 +932,91 @@ test "parseProjectionsConfig invalid data" { try std.testing.expect(config.target_stock_pct == null); } +test "parseProjectionsConfig horizon_age parsed raw" { + const data = + \\#!srfv1 + \\type::config,horizon_age:num:90 + \\type::config,horizon_age:num:95 + \\type::birthdate,date::1975-03-15 + ; + const config = parseProjectionsConfig(data); + // horizon_ages are stored raw; not yet resolved into horizons. + try std.testing.expectEqual(@as(u8, 2), config.horizon_age_count); + try std.testing.expectEqual(@as(u16, 90), config.horizon_ages[0]); + try std.testing.expectEqual(@as(u16, 95), config.horizon_ages[1]); + // A horizon_age record counts as "saw_horizon", so the default + // {20,30,45} is cleared. horizon_count is 0 until resolution. + try std.testing.expectEqual(@as(u8, 0), config.horizon_count); +} + +test "resolveHorizonAges uses oldest birthdate (first-to-hit semantics)" { + // Person 1: born 1975, age 50 as of 2025. Person 2: born 1980, age 45. + // Target age 90 → 90 − 50 = 40 years (first to hit 90 is the older). + var config = parseProjectionsConfig( + \\#!srfv1 + \\type::config,horizon_age:num:90 + \\type::birthdate,date::1975-06-15 + \\type::birthdate,date::1980-06-15,person:num:2 + ); + const as_of = Date.fromYmd(2025, 6, 15); + try config.resolveHorizonAges(as_of); + try std.testing.expectEqual(@as(u8, 1), config.horizon_count); + try std.testing.expectEqual(@as(u16, 40), config.horizons[0]); + // Resolved; horizon_age_count cleared to make resolve idempotent. + try std.testing.expectEqual(@as(u8, 0), config.horizon_age_count); +} + +test "resolveHorizonAges errors without a birthdate" { + var config = parseProjectionsConfig( + \\#!srfv1 + \\type::config,horizon_age:num:90 + ); + const as_of = Date.fromYmd(2025, 1, 1); + try std.testing.expectError(error.HorizonAgeWithoutBirthdate, config.resolveHorizonAges(as_of)); +} + +test "resolveHorizonAges skips targets already in the past" { + // Oldest age is 60 as of 2025; target 40 is already past — skipped. + var config = parseProjectionsConfig( + \\#!srfv1 + \\type::config,horizon_age:num:40 + \\type::config,horizon_age:num:90 + \\type::birthdate,date::1965-01-01 + ); + const as_of = Date.fromYmd(2025, 6, 15); + try config.resolveHorizonAges(as_of); + // Only age 90 resolves (90 − 60 = 30). + try std.testing.expectEqual(@as(u8, 1), config.horizon_count); + try std.testing.expectEqual(@as(u16, 30), config.horizons[0]); +} + +test "resolveHorizonAges mixes with explicit horizon records" { + var config = parseProjectionsConfig( + \\#!srfv1 + \\type::config,horizon:num:30 + \\type::config,horizon_age:num:95 + \\type::birthdate,date::1975-06-15 + ); + const as_of = Date.fromYmd(2025, 6, 15); + try config.resolveHorizonAges(as_of); + // Explicit 30 from `horizon`, then appended 95 − 50 = 45 from `horizon_age`. + try std.testing.expectEqual(@as(u8, 2), config.horizon_count); + try std.testing.expectEqual(@as(u16, 30), config.horizons[0]); + try std.testing.expectEqual(@as(u16, 45), config.horizons[1]); +} + +test "resolveHorizonAges is a no-op when nothing to resolve" { + var config = parseProjectionsConfig( + \\#!srfv1 + \\type::config,horizon:num:30 + ); + // No birthdate, no horizon_age → should succeed, not error. + const as_of = Date.fromYmd(2025, 1, 1); + try config.resolveHorizonAges(as_of); + try std.testing.expectEqual(@as(u8, 1), config.horizon_count); + try std.testing.expectEqual(@as(u16, 30), config.horizons[0]); +} + test "UserConfig getHorizons default" { const config = UserConfig{}; const horizons = config.getHorizons(); diff --git a/src/views/projections.zig b/src/views/projections.zig index 9a77473..4b8c8ce 100644 --- a/src/views/projections.zig +++ b/src/views/projections.zig @@ -333,6 +333,13 @@ fn buildContextFromParts( var config = projections.parseProjectionsConfig(proj_data); if (!events_enabled) config.event_count = 0; + // Resolve age-based horizons (if any) against the projection's as-of + // date. For live mode (`as_of == null`), use today. This turns + // `horizon_age:num:N` records into concrete year counts appended to + // `config.horizons` — see `UserConfig.resolveHorizonAges`. + const horizon_anchor = as_of orelse Date.fromEpoch(std.time.timestamp()); + try config.resolveHorizonAges(horizon_anchor); + // Load metadata for classification const meta_path = try std.fmt.allocPrint(alloc, "{s}metadata.srf", .{portfolio_dir}); defer alloc.free(meta_path);