diff --git a/TODO.md b/TODO.md index 82794ed..6718dfd 100644 --- a/TODO.md +++ b/TODO.md @@ -31,9 +31,6 @@ ranking; unlabeled items are "someday, if the mood strikes." earlier than the other would benefit from per-person `retirement_age` fields on each `type::birthdate` record, with contributions stopped per-person. -- **Configurable max_accumulation_years - priority LOW.** - Hardcoded at 50 years. Route through `projections.srf` if anyone - hits the cap. - Multiple spending models: flat (current), decreasing (1-2% real annual decrease, Blanchett "spending smile"). Late-life healthcare better modeled as a life event. - **Historical projection overlay follow-ups.** The base diff --git a/docs/explanation/projections-model.md b/docs/explanation/projections-model.md index e5b07b0..b1fdb40 100644 --- a/docs/explanation/projections-model.md +++ b/docs/explanation/projections-model.md @@ -98,12 +98,15 @@ and longer horizons both lower the safe number. When you set a `target_spending` instead of a date, zfin inverts the question: for each (horizon x confidence) cell it searches for the -**earliest** accumulation length (up to 50 years) that sustains your -spending, and renders the grid of answers. One cell is promoted to the +**earliest** accumulation length (up to `max_accumulation_years`, 50 +years by default) that sustains your spending, and renders the grid of +answers. One cell is promoted to the headline (see [promotion rules](../reference/config/projections-srf.md#the-two-retirement-planning-inputs)). If no length within the cap works, the cell is **infeasible** -- shown -honestly rather than fudged. +honestly rather than fudged. A young saver with a runway longer than 50 +years can raise the cap via +[`max_accumulation_years`](../reference/config/projections-srf.md#config-fields). ## The caveat that matters most diff --git a/docs/guides/plan-retirement.md b/docs/guides/plan-retirement.md index 5a4490d..88dbbef 100644 --- a/docs/guides/plan-retirement.md +++ b/docs/guides/plan-retirement.md @@ -151,7 +151,7 @@ configured date wins the headline; the grid is the comparison. `pre-retirement-spending-target` sets an aggressive `target_spending:num:2400000` and pins the headline to the longest-horizon, highest-confidence cell -- which turns out to be -unreachable inside the 50-year search: +unreachable inside the default 50-year search: ```bash ZFIN_HOME=examples/pre-retirement-spending-target zfin projections diff --git a/docs/reference/config/projections-srf.md b/docs/reference/config/projections-srf.md index 4651085..2e21fed 100644 --- a/docs/reference/config/projections-srf.md +++ b/docs/reference/config/projections-srf.md @@ -45,6 +45,7 @@ type::event,name::Social Security,start_age:num:70,amount:num:38400 | `contribution_inflation_adjusted` | bool | If `true` (default), contributions grow with CPI year over year. | | `target_spending` | num | Desired retirement spending, in today's dollars. | | `target_spending_inflation_adjusted` | bool | If `true` (default), target spending grows with CPI during distribution. | +| `max_accumulation_years` | num | Ceiling (in years) the earliest-retirement search scans when `target_spending` is set. Default `50`, capped at `100`. | | `retirement_target` | num | Annotation on a `horizon`/`horizon_age` line that overrides the earliest-retirement promotion rule. Allowed: `90`, `95`, `99`. | ### Choosing an `expense_ratio` @@ -175,9 +176,10 @@ type::config,horizon:num:35,retirement_target:num:95 At most one horizon may carry the annotation; configuring more than one drops them all and falls back to the default rule. If the promoted cell -is infeasible (no accumulation length <= 50 years sustains the -spending), the headline reads "not feasible" and the grid still renders -so you can pick a workable anchor. +is infeasible (no accumulation length within `max_accumulation_years` +-- 50 years by default -- sustains the spending), the headline reads +"not feasible" and the grid still renders so you can pick a workable +anchor. ## The example configurations diff --git a/src/analytics/projections.zig b/src/analytics/projections.zig index b11b033..97c5500 100644 --- a/src/analytics/projections.zig +++ b/src/analytics/projections.zig @@ -242,6 +242,18 @@ pub const UserConfig = struct { /// If true, the target spending grows with CPI during the /// distribution phase (matches the existing SWR model). target_spending_inflation_adjusted: bool = true, + /// Ceiling on the accumulation years the earliest-retirement + /// search (`findEarliestRetirement`) will consider when + /// `target_spending` is set. Defaults to + /// `default_max_accumulation_years` (50). Override via + /// `type::config,max_accumulation_years:num:N` in projections.srf + /// for someone with a longer-than-50-year planning runway (a + /// young saver). Clamped at parse time to + /// `max_configurable_accumulation_years`. Only affects the + /// target-spending search path; an explicit `retirement_age` / + /// `retirement_at` derives its accumulation years directly and + /// ignores this cap. + max_accumulation_years: u16 = default_max_accumulation_years, /// Stock benchmark symbol used in the projection's /// benchmark-comparison table and bands. Defaults to "SPY". /// Override via `type::config,benchmark_stock::SYMBOL` in @@ -468,6 +480,7 @@ const SrfConfig = struct { contribution_inflation_adjusted: ?bool = null, target_spending: ?f64 = null, target_spending_inflation_adjusted: ?bool = null, + max_accumulation_years: ?u16 = null, benchmark_stock: ?[]const u8 = null, benchmark_bond: ?[]const u8 = null, }; @@ -619,6 +632,22 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig { if (c.target_spending_inflation_adjusted) |b| { config.target_spending_inflation_adjusted = b; } + if (c.max_accumulation_years) |n| { + if (n == 0) { + // A zero-year search ceiling is degenerate (it + // would only ever ask "can I retire today?"). + // Almost certainly a typo; keep the default. + warnUser("projections: max_accumulation_years must be > 0; ignoring record", .{}); + } else if (n > max_configurable_accumulation_years) { + // Respect the intent (the user wants a large + // ceiling) but clamp to keep the search bounded + // and inside the historical data span. + warnUser("projections: max_accumulation_years capped at {d} (got {d})", .{ max_configurable_accumulation_years, n }); + config.max_accumulation_years = max_configurable_accumulation_years; + } else { + config.max_accumulation_years = n; + } + } if (c.benchmark_stock) |sym| { if (sym.len == 0 or sym.len > config.benchmark_stock_buf.len) { warnUser("projections: benchmark_stock must be 1..{d} chars (got {d}); ignoring record", .{ config.benchmark_stock_buf.len, sym.len }); @@ -1073,10 +1102,20 @@ pub const EarliestRetirement = struct { p90_at_retirement: f64, }; -/// Maximum accumulation years to search. 50 covers a 25-year-old -/// planning to age 75. Hardcoded; if anyone hits this, route through -/// projections.srf as a config field. -pub const max_accumulation_years: u16 = 50; +/// Default ceiling on the accumulation years the earliest-retirement +/// search considers. 50 covers a 25-year-old planning to age 75. +/// Overridable per-portfolio via +/// `type::config,max_accumulation_years:num:N` in projections.srf - +/// see `UserConfig.max_accumulation_years`. +pub const default_max_accumulation_years: u16 = 50; + +/// Hard ceiling on a user-configured `max_accumulation_years`. A +/// newborn planning to age 100 is the outer edge of anything sane; +/// larger values are clamped (with a warning) to keep the search +/// bounded and comfortably inside the Shiller data span. Mirrors +/// `promotion_age_cap`'s "nobody is still accumulating past 100" +/// reasoning. +pub const max_configurable_accumulation_years: u16 = 100; /// Earliest-retirement search: given a target annual spending /// level, find the smallest `accumulation_years` N in [0, `max_years`] @@ -2166,6 +2205,39 @@ test "parseProjectionsConfig rejects negative target_spending" { try std.testing.expectEqual(@as(?f64, null), config.target_spending); } +test "parseProjectionsConfig max_accumulation_years defaults to 50" { + const config = parseProjectionsConfig("#!srfv1\n"); + try std.testing.expectEqual(default_max_accumulation_years, config.max_accumulation_years); +} + +test "parseProjectionsConfig parses max_accumulation_years override" { + const data = + \\#!srfv1 + \\type::config,max_accumulation_years:num:65 + ; + const config = parseProjectionsConfig(data); + try std.testing.expectEqual(@as(u16, 65), config.max_accumulation_years); +} + +test "parseProjectionsConfig rejects zero max_accumulation_years" { + const data = + \\#!srfv1 + \\type::config,max_accumulation_years:num:0 + ; + const config = parseProjectionsConfig(data); + // Zero is degenerate; dropped, default retained. + try std.testing.expectEqual(default_max_accumulation_years, config.max_accumulation_years); +} + +test "parseProjectionsConfig clamps oversized max_accumulation_years to ceiling" { + const data = + \\#!srfv1 + \\type::config,max_accumulation_years:num:500 + ; + const config = parseProjectionsConfig(data); + try std.testing.expectEqual(max_configurable_accumulation_years, config.max_accumulation_years); +} + test "parseProjectionsConfig return_cap defaults to null" { const config = parseProjectionsConfig("#!srfv1\n"); try std.testing.expectEqual(@as(?f64, null), config.return_cap); diff --git a/src/views/projections.zig b/src/views/projections.zig index 72b0e10..e260a9d 100644 --- a/src/views/projections.zig +++ b/src/views/projections.zig @@ -381,7 +381,7 @@ pub fn buildProjectionContext( h, conf, events, - projections.max_accumulation_years, + config.max_accumulation_years, sim_expense_ratio, ); }