allow for configured max accumulation years
This commit is contained in:
parent
7e9261f92f
commit
47462aaab5
6 changed files with 89 additions and 15 deletions
3
TODO.md
3
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -381,7 +381,7 @@ pub fn buildProjectionContext(
|
|||
h,
|
||||
conf,
|
||||
events,
|
||||
projections.max_accumulation_years,
|
||||
config.max_accumulation_years,
|
||||
sim_expense_ratio,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue