add age-based horizon to projections
This commit is contained in:
parent
a26470f46a
commit
f605e1f764
2 changed files with 168 additions and 4 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue