add age-based horizon to projections

This commit is contained in:
Emil Lerch 2026-05-08 11:40:33 -07:00
parent a26470f46a
commit f605e1f764
Signed by: lobo
GPG key ID: A7B62D657EF764F8
2 changed files with 168 additions and 4 deletions

View file

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

View file

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