allow for configured max accumulation years

This commit is contained in:
Emil Lerch 2026-06-25 14:38:51 -07:00
parent 7e9261f92f
commit 47462aaab5
Signed by: lobo
GPG key ID: A7B62D657EF764F8
6 changed files with 89 additions and 15 deletions

View file

@ -31,9 +31,6 @@ ranking; unlabeled items are "someday, if the mood strikes."
earlier than the other would benefit from per-person earlier than the other would benefit from per-person
`retirement_age` fields on each `type::birthdate` record, with `retirement_age` fields on each `type::birthdate` record, with
contributions stopped per-person. 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, - Multiple spending models: flat (current), decreasing (1-2% real annual decrease,
Blanchett "spending smile"). Late-life healthcare better modeled as a life event. Blanchett "spending smile"). Late-life healthcare better modeled as a life event.
- **Historical projection overlay follow-ups.** The base - **Historical projection overlay follow-ups.** The base

View file

@ -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 When you set a `target_spending` instead of a date, zfin inverts the
question: for each (horizon x confidence) cell it searches for the question: for each (horizon x confidence) cell it searches for the
**earliest** accumulation length (up to 50 years) that sustains your **earliest** accumulation length (up to `max_accumulation_years`, 50
spending, and renders the grid of answers. One cell is promoted to the years by default) that sustains your spending, and renders the grid of
answers. One cell is promoted to the
headline (see headline (see
[promotion rules](../reference/config/projections-srf.md#the-two-retirement-planning-inputs)). [promotion rules](../reference/config/projections-srf.md#the-two-retirement-planning-inputs)).
If no length within the cap works, the cell is **infeasible** -- shown 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 ## The caveat that matters most

View file

@ -151,7 +151,7 @@ configured date wins the headline; the grid is the comparison.
`pre-retirement-spending-target` sets an aggressive `pre-retirement-spending-target` sets an aggressive
`target_spending:num:2400000` and pins the headline to the `target_spending:num:2400000` and pins the headline to the
longest-horizon, highest-confidence cell -- which turns out to be longest-horizon, highest-confidence cell -- which turns out to be
unreachable inside the 50-year search: unreachable inside the default 50-year search:
```bash ```bash
ZFIN_HOME=examples/pre-retirement-spending-target zfin projections ZFIN_HOME=examples/pre-retirement-spending-target zfin projections

View file

@ -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. | | `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` | num | Desired retirement spending, in today's dollars. |
| `target_spending_inflation_adjusted` | bool | If `true` (default), target spending grows with CPI during distribution. | | `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`. | | `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` ### 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 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 drops them all and falls back to the default rule. If the promoted cell
is infeasible (no accumulation length <= 50 years sustains the is infeasible (no accumulation length within `max_accumulation_years`
spending), the headline reads "not feasible" and the grid still renders -- 50 years by default -- sustains the spending), the headline reads
so you can pick a workable anchor. "not feasible" and the grid still renders so you can pick a workable
anchor.
## The example configurations ## The example configurations

View file

@ -242,6 +242,18 @@ pub const UserConfig = struct {
/// If true, the target spending grows with CPI during the /// If true, the target spending grows with CPI during the
/// distribution phase (matches the existing SWR model). /// distribution phase (matches the existing SWR model).
target_spending_inflation_adjusted: bool = true, 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 /// Stock benchmark symbol used in the projection's
/// benchmark-comparison table and bands. Defaults to "SPY". /// benchmark-comparison table and bands. Defaults to "SPY".
/// Override via `type::config,benchmark_stock::SYMBOL` in /// Override via `type::config,benchmark_stock::SYMBOL` in
@ -468,6 +480,7 @@ const SrfConfig = struct {
contribution_inflation_adjusted: ?bool = null, contribution_inflation_adjusted: ?bool = null,
target_spending: ?f64 = null, target_spending: ?f64 = null,
target_spending_inflation_adjusted: ?bool = null, target_spending_inflation_adjusted: ?bool = null,
max_accumulation_years: ?u16 = null,
benchmark_stock: ?[]const u8 = null, benchmark_stock: ?[]const u8 = null,
benchmark_bond: ?[]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| { if (c.target_spending_inflation_adjusted) |b| {
config.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 (c.benchmark_stock) |sym| {
if (sym.len == 0 or sym.len > config.benchmark_stock_buf.len) { 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 }); 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, p90_at_retirement: f64,
}; };
/// Maximum accumulation years to search. 50 covers a 25-year-old /// Default ceiling on the accumulation years the earliest-retirement
/// planning to age 75. Hardcoded; if anyone hits this, route through /// search considers. 50 covers a 25-year-old planning to age 75.
/// projections.srf as a config field. /// Overridable per-portfolio via
pub const max_accumulation_years: u16 = 50; /// `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 /// Earliest-retirement search: given a target annual spending
/// level, find the smallest `accumulation_years` N in [0, `max_years`] /// 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); 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" { test "parseProjectionsConfig return_cap defaults to null" {
const config = parseProjectionsConfig("#!srfv1\n"); const config = parseProjectionsConfig("#!srfv1\n");
try std.testing.expectEqual(@as(?f64, null), config.return_cap); try std.testing.expectEqual(@as(?f64, null), config.return_cap);

View file

@ -381,7 +381,7 @@ pub fn buildProjectionContext(
h, h,
conf, conf,
events, events,
projections.max_accumulation_years, config.max_accumulation_years,
sim_expense_ratio, sim_expense_ratio,
); );
} }