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
`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

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
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

View file

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

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. |
| `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

View file

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

View file

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