diff --git a/AGENTS.md b/AGENTS.md index ef9bed5..4329669 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,6 +105,72 @@ If you find yourself writing `Timestamp.now(io, ...)` somewhere not on that list, either add a justifying comment or refactor the function to take a value parameter. +### Time and money helpers — CHECK FIRST before adding any new function + +This project is, at its core, thousands of lines of code about +time and money. Almost any helper you think you need to add for +date arithmetic, age calculation, dollar formatting, percentage +formatting, currency rounding, or trailing-zero handling **already +exists** under some name. Adding a near-duplicate is a recurring, +easy-to-make mistake that silently bloats the codebase with three +slightly-different ways to do the same thing. + +**Before writing any new helper that touches time or money, you +MUST search for existing implementations.** Not "search if it +seems familiar" — search every time. Examples of helpers that +already exist and have caught me out: + +- `Date.addYears` / `Date.subtractYears` — calendar-year math + with Feb 29 → Feb 28 clamping. +- `Date.yearsBetween` — 365.25-day approximation, returns f64. +- `Date.wholeYearsBetween` — floored, returns u16. +- `Date.ageOn` — calendar-precise age (handles "birthday hasn't + occurred this year yet"). Distinct from `wholeYearsBetween`. +- `Date.format` — "YYYY-MM-DD". +- `format.fmtMoneyAbs` — "$1,234.56" with commas, always 2 dp. +- `format.fmtMoneyAbsWhole` — "$1,234" rounded to whole dollars. +- `format.fmtMoneyAbsTrim` — like `fmtMoneyAbs` but elides `.00`. +- `format.fmtIntCommas` — "1,234,567" without `$`. +- `format.formatReturn` / `views.history.fmtSignedMoneyBuf` — signed money/percent for trailing-returns and gain/loss displays. + +**Search recipes that catch the most cases:** + +``` +# Money formatters +grep -rn "fn fmt.*[Mm]oney\|fn fmt.*[Dd]ollar\|fn .*[Ww]hole" src/ +grep -rn "@round.*amount\|@intFromFloat(@round" src/ +grep -rn "endsWith.*\\\".00\\\"\|lastIndexOfScalar.*'\\\\.'" src/ + +# Date / age helpers +grep -rn "fn age\|ageOn\|addYears\|subtractYears\|yearsBetween" src/ +grep -rn "month() <\|day() <\|on\\.year() -" src/ # ad-hoc age math + +# Time / now +grep -rn "Timestamp.now\|fromEpoch\|toEpoch" src/ +``` + +If the search turns up an existing helper that does what you need, +**use it**. If it turns up something close-but-not-quite, prefer +extending the existing helper (new variant, optional parameter, +generalized signature) over adding a parallel one. Three closely +related functions in the same module is a smell; the user will +ask you to consolidate them and you'll have wasted both your time +and theirs. + +If the search confirms nothing exists, add the new helper to the +right module: + +- Date / calendar math → `src/models/date.zig`, as a `Date` method + when the receiver is natural. +- Money / number formatting → `src/format.zig`. +- Per-domain formatting that wraps the above → keep in the + domain's view module (e.g. `views/history.zig` for the + history-display sign-prefix wrapper). + +Add tests in the same file. Money helpers belong next to +`fmtMoneyAbs`'s tests; date helpers belong next to +`yearsBetween`'s tests. + ### NEVER invoke ripgrep. EVER. **Do not run `rg` in the Bash tool.** Not for open-ended search, not for diff --git a/README.md b/README.md index 93b1394..a2641f8 100644 --- a/README.md +++ b/README.md @@ -510,6 +510,166 @@ account::HSA,tax_type::hsa Accounts not listed in `accounts.srf` appear as "Unknown" in the tax type breakdown. +## Projections configuration (projections.srf) + +The `projections` command runs Monte-Carlo-style historical +simulations of your retirement portfolio over the Shiller dataset +(1871–present). Configuration lives in `projections.srf` next to +`portfolio.srf`. The file is optional — without it, the command +runs with sensible defaults (20/30/45-year horizons, 90/95/99% +confidence levels, no accumulation phase). + +```srf +#!srfv1 +# Asset allocation target (drives sim stock/bond blend) +type::config,target_stock_pct:num:80 + +# Distribution-phase horizons (years to project past retirement) +type::config,horizon:num:25 +type::config,horizon:num:35 + +# Or: horizon-by-age, resolves to "years until oldest hits this age" +type::config,horizon_age:num:95 + +# Birthdates (drive horizon_age + life-event timing + retirement_age) +type::birthdate,date::1981-04-12 +type::birthdate,date::1983-09-08,person:num:2 + +# Life events (positive = income, negative = expense). See below. +type::event,name::Social Security (Pat),start_age:num:70,person:num:1,amount:num:38400 +type::event,name::College Tuition,start_age:num:50,person:num:1,duration:num:4,amount:num:-55000 +``` + +### How the simulation runs + +The simulation always operates in two phases: + +- **Accumulation phase** — contributions added each year, no + spending. Length is determined by your retirement-date input + (see below). When you have no input configured, this phase has + zero years (already-retired view). +- **Distribution phase** — annual spending withdrawn (CPI-adjusted + by default), no contributions. Length is the configured + `horizon`. + +Life events apply to both phases. + +### Two ways to ask the question + +`projections.srf` accepts two retirement-planning inputs that can +be set independently or together. Each shapes a different display +block: + +#### Target retirement date + +Set either `retirement_age` (resolved against the oldest +birthdate) or `retirement_at` (an absolute calendar date) to +anchor a retirement boundary. The display answers +"given my retirement date, what can I spend?" + +```srf +type::config,retirement_age:num:65 # oldest person hits 65 +# or +type::config,retirement_at::2046-04-12 + +type::config,annual_contribution:num:80000 +type::config,contribution_inflation_adjusted:bool:true +``` + +When both are set, `retirement_at` wins. Output renders the +**Accumulation phase** block: median portfolio at retirement, +p10–p90 range, and the dated headline "Years until possible +retirement: N (DATE, ages A/B)" line. + +#### Target spending + +Set `target_spending` to anchor a desired retirement income. The +display answers "given my desired spending, when can I retire?" + +```srf +type::config,target_spending:num:80000 +type::config,target_spending_inflation_adjusted:bool:true +``` + +Output renders an **Earliest retirement** grid (one cell per +horizon × confidence) showing the earliest year that supports the +target spending at that confidence over that distribution +horizon. One cell from the grid is **promoted** into the +Accumulation phase block as the headline retirement line. + +The default promotion rule walks horizons longest → shortest at +99% confidence (most conservative), preferring the longest +horizon whose end year keeps the oldest configured person under +age 100. Override the default with a per-horizon annotation: + +```srf +type::config,horizon:num:35,retirement_target:num:95 +``` + +This forces "use the 35yr × 95% cell as the headline." Allowed +values are `90`, `95`, `99`. At most one horizon may carry the +annotation; configuring more than one drops them all and falls +back to the default rule. + +When the promoted cell is infeasible (no value of +`accumulation_years` ≤ 50 sustains the target spending), the +headline renders "Years until possible retirement: not feasible" +and the contribution / median lines are suppressed. Cells in the +grid that hit the same wall render "infeasible" in red. + +#### Both inputs configured + +When both a target retirement date and a target spending are +configured, both display blocks render back-to-back. The +configured retirement date wins for the headline; the Earliest +retirement grid is rendered below for comparison ("you set 2046; +at 95% confidence over 30 years you could retire as early as +YYYY"). + +#### Neither configured + +Distribution-only mode — appropriate for already-retired users. +The Accumulation phase block reduces to a single soft +"Years until possible retirement: none" line; everything else +behaves like the legacy projection display. + +### Life events + +Life events modify the simulation's annual cash flow. Positive +amounts are income (offset withdrawals); negative amounts are +expenses (added to withdrawals). Events are CPI-adjusted by +default; set `inflation_adjusted:bool:false` for nominal events. + +```srf +# Permanent income starting at age 70 +type::event,name::Social Security,start_age:num:70,amount:num:38400 + +# 4-year expense starting at age 50 +type::event,name::College Tuition,start_age:num:50,duration:num:4,amount:num:-55000 + +# Per-person events (defaults to person 1) +type::event,name::Pension,start_age:num:65,person:num:2,amount:num:24000 +``` + +### Configuration field reference + +| Field | Type | Description | +| -------------------------------------- | ---- | --------------------------------------------------------------------------------- | +| `target_stock_pct` | num | Asset-allocation target (0–100). Used for sim stock/bond blend. | +| `horizon` | num | Distribution-phase length in years. Repeat for multiple horizons. | +| `horizon_age` | num | Resolves to `target_age − oldest_current_age`. Repeat for multiple. | +| `retirement_target` (on `horizon[_age]`) | num | Override the default earliest-retirement promotion. Allowed: 90, 95, 99. | +| `retirement_age` | num | Years old the OLDEST configured person must be to retire. | +| `retirement_at` | date | Absolute calendar date for retirement. Wins over `retirement_age` if both set. | +| `annual_contribution` | num | Yearly contribution during accumulation, in today's dollars. | +| `contribution_inflation_adjusted` | bool | If true (default), contributions grow with CPI year-over-year. | +| `target_spending` | num | Target retirement spending in today's dollars. | +| `target_spending_inflation_adjusted` | bool | If true (default), target spending grows with CPI in the distribution phase. | + +See `examples/pre-retirement-{age,spending,spending-target,both}/` +and `examples/post-retirement/` for fully-configured walkthroughs +of each combination. + ## CLI commands ``` diff --git a/src/analytics/projections.zig b/src/analytics/projections.zig index c253c5e..73a0559 100644 --- a/src/analytics/projections.zig +++ b/src/analytics/projections.zig @@ -98,6 +98,39 @@ pub const LifeEvent = struct { // ── User configuration (from projections.srf) ────────────────── +/// Resolved retirement boundary, derived from `UserConfig` against a +/// reference date. The simulation consumes `accumulation_years` (an +/// integer), but the display layer renders the exact `date`. +pub const ResolvedRetirement = struct { + /// Whole years of accumulation between today and the retirement + /// date. The simulation runs in 1-year steps, so this is a + /// floor — the displayed `date` is exact. + accumulation_years: u16, + /// Exact retirement date for display. `null` when `source == + /// .none` (no accumulation phase configured / already retired) + /// or `.promoted_infeasible` (the earliest-retirement cell was + /// selected but no accumulation length sustains the target + /// spending at the promoted confidence). + date: ?Date, + source: enum { + /// No retirement date configured. The line renders "none". + none, + /// User configured `retirement_at::DATE` directly. + at_date, + /// User configured `retirement_age:num:N`, resolved against + /// the oldest birthdate. + at_age, + /// User configured `target_spending` only. The retirement + /// line shows the promoted cell's date (the headline pick + /// from the earliest-retirement grid). + promoted, + /// Same as `.promoted` but the selected cell returned no + /// feasible accumulation_years from `findEarliestRetirement`. + /// The line renders "not feasible" instead of a date. + promoted_infeasible, + }, +}; + /// User-configurable projection parameters, loaded from projections.srf. /// /// Example projections.srf (union-tagged SRF records): @@ -114,12 +147,27 @@ pub const UserConfig = struct { /// Retirement horizons to simulate (years). Defaults to 20,30,45. horizons: [max_horizons]u16 = .{ 20, 30, 45 } ++ @as([max_horizons - 3]u16, @splat(0)), horizon_count: u8 = 3, + /// Per-horizon `retirement_target` annotation (90/95/99 confidence + /// percentage, or 0 = no annotation). Parallel to `horizons`. At + /// most one horizon may carry a non-zero value; when more than + /// one is configured, all annotations are dropped (validation + /// failure → fall back to the default promotion rule). + /// + /// Used by the target-spending input to pick which (horizon, + /// confidence) cell from the Earliest retirement grid to + /// promote into the Accumulation phase block. See + /// `pickPromotedCell` for the resolution algorithm. + horizon_targets: [max_horizons]u8 = @splat(0), /// 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 = @splat(0), + /// Per-`horizon_age` `retirement_target` annotation, parallel to + /// `horizon_ages`. Carried through to the resolved `horizon_targets` + /// slot when `resolveHorizonAges` appends the resolved year count. + horizon_age_targets: [max_horizons]u8 = @splat(0), horizon_age_count: u8 = 0, /// Confidence levels for safe withdrawal. Always 90/95/99. confidence_levels: [3]f64 = .{ 0.90, 0.95, 0.99 }, @@ -129,6 +177,29 @@ pub const UserConfig = struct { /// Life events (income/expenses) that modify annual cash flow. events: [max_events]LifeEvent = undefined, event_count: u8 = 0, + // ── Accumulation phase ────────────────────────────────────── + /// Target retirement age for the oldest configured person. The + /// retirement date is the day they turn this age (clamping Feb 29 + /// to Feb 28 in non-leap target years). Mutually exclusive with + /// `retirement_at`; if both are set, `retirement_at` wins. + retirement_age: ?u16 = null, + /// Absolute retirement date. Wins over `retirement_age` when both + /// are set. + retirement_at: ?Date = null, + /// Total household contributions per year, in today's dollars. + /// Defaults to zero (distribution-only behavior). + annual_contribution: f64 = 0, + /// If true, contributions grow with CPI year-over-year (modeling + /// a constant percentage of CPI-tracked income). If false, + /// contributions are nominal. + contribution_inflation_adjusted: bool = true, + /// Target annual spending in today's dollars. When set, the + /// projections command will search for the earliest retirement + /// date at which this spending level is sustainable. + target_spending: ?f64 = null, + /// If true, the target spending grows with CPI during the + /// distribution phase (matches the existing SWR model). + target_spending_inflation_adjusted: bool = true, const max_horizons: usize = 8; const max_persons: usize = 4; @@ -158,8 +229,7 @@ pub const UserConfig = struct { pub fn currentAges(self: *const UserConfig, as_of: Date) [max_persons]u16 { var ages: [max_persons]u16 = @splat(0); for (0..self.birthdate_count) |i| { - const years = Date.yearsBetween(self.birthdates[i], as_of); - ages[i] = if (years > 0) @intFromFloat(years) else 0; + ages[i] = Date.wholeYearsBetween(self.birthdates[i], as_of); } return ages; } @@ -177,11 +247,7 @@ pub const UserConfig = struct { if (self.horizon_age_count == 0) return; if (self.birthdate_count == 0) return error.HorizonAgeWithoutBirthdate; - const ages = self.currentAges(as_of); - var oldest: u16 = 0; - for (0..self.birthdate_count) |i| { - if (ages[i] > oldest) oldest = ages[i]; - } + const oldest = self.oldestAge(as_of); for (0..self.horizon_age_count) |i| { const target = self.horizon_ages[i]; @@ -189,6 +255,9 @@ pub const UserConfig = struct { const years: u16 = target - oldest; if (self.horizon_count < max_horizons) { self.horizons[self.horizon_count] = years; + // Carry through any retirement_target annotation from + // the source horizon_age record. + self.horizon_targets[self.horizon_count] = self.horizon_age_targets[i]; self.horizon_count += 1; } } @@ -231,6 +300,88 @@ pub const UserConfig = struct { } return resolved; } + + /// Resolve the configured retirement boundary against `as_of`. + /// Returns the integer accumulation_years used by the simulation, + /// the displayed exact date, and the resolution source. + /// + /// `as_of` is the reference date — pass today's date for live + /// mode, or a historical snapshot date when re-running the + /// projection against past data. The function works correctly + /// for any reference date. + /// + /// Resolution rules: + /// - `retirement_at` set and not in the past (relative to + /// `as_of`) → that date. + /// - `retirement_age` set, with at least one birthdate, and + /// the oldest person hasn't already passed that age as of + /// `as_of` → the date that person turns the target age + /// (clamping Feb 29 to Feb 28 in non-leap target years). + /// - Otherwise → `.none`. accumulation_years = 0. + /// + /// `retirement_at` wins when both are set. + pub fn resolveRetirement(self: *const UserConfig, as_of: Date) ResolvedRetirement { + if (self.retirement_at) |d| { + if (d.lessThan(as_of)) return .{ + .accumulation_years = 0, + .date = null, + .source = .none, + }; + return .{ + .accumulation_years = Date.wholeYearsBetween(as_of, d), + .date = d, + .source = .at_date, + }; + } + if (self.retirement_age) |target_age| { + const oldest_bd = self.oldestBirthdate() orelse return .{ + .accumulation_years = 0, + .date = null, + .source = .none, + }; + const ret_date = oldest_bd.addYears(target_age); + if (ret_date.lessThan(as_of)) return .{ + .accumulation_years = 0, + .date = null, + .source = .none, + }; + return .{ + .accumulation_years = Date.wholeYearsBetween(as_of, ret_date), + .date = ret_date, + .source = .at_age, + }; + } + return .{ .accumulation_years = 0, .date = null, .source = .none }; + } + + /// Find the birthdate of the oldest configured person — the + /// earliest date in `birthdates[]`. Returns null if no + /// birthdates are configured. + /// + /// Used by `resolveRetirement` (with `retirement_age`), + /// `resolveHorizonAges`, and `pickPromotedCell` to anchor any + /// "oldest person" computation against a single source of + /// truth. Pair with `Date.wholeYearsBetween(oldest, as_of)` for + /// "oldest person's age right now"; that's also packaged as + /// `oldestAge(as_of)` for caller convenience. + pub fn oldestBirthdate(self: *const UserConfig) ?Date { + if (self.birthdate_count == 0) return null; + var oldest = self.birthdates[0]; + var i: u8 = 1; + while (i < self.birthdate_count) : (i += 1) { + if (self.birthdates[i].lessThan(oldest)) oldest = self.birthdates[i]; + } + return oldest; + } + + /// Age (whole years) of the oldest configured person as of + /// `as_of`. Returns 0 when no birthdates are configured (which + /// callers should treat as "no person to age out" rather than + /// "person aged zero"). + pub fn oldestAge(self: *const UserConfig, as_of: Date) u16 { + const oldest = self.oldestBirthdate() orelse return 0; + return Date.wholeYearsBetween(oldest, as_of); + } }; // ── SRF parse types (private) ─────────────────────────────────── @@ -240,6 +391,18 @@ const SrfConfig = struct { target_stock_pct: ?f64 = null, horizon: ?u16 = null, horizon_age: ?u16 = null, + /// Earliest-retirement promotion override: when paired with + /// `horizon` or `horizon_age`, marks that horizon as the one to + /// use for the promoted retirement-line cell. Allowed values: + /// 90, 95, 99. + /// Anything else is rejected at parse time. + retirement_target: ?u8 = null, + retirement_age: ?u16 = null, + retirement_at: ?Date = null, + annual_contribution: ?f64 = null, + contribution_inflation_adjusted: ?bool = null, + target_spending: ?f64 = null, + target_spending_inflation_adjusted: ?bool = null, }; const SrfBirthdate = struct { @@ -296,21 +459,34 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig { var saw_horizon = false; var birthdate_seq: u8 = 0; + // Count of valid `retirement_target` annotations seen during + // parse (across both `horizon` and `horizon_age` records). More + // than one is a configuration error — we'll drop them all + // post-loop and let `pickPromotedCell` fall back to the default + // rule. A single bad value (not in {90,95,99}) is treated as + // "no annotation on this record" and doesn't poison the others. + var annotation_count: u8 = 0; while (it.next() catch null) |field_it| { const rec = field_it.to(SrfProjection) catch continue; switch (rec) { .config => |c| { - if (c.target_stock_pct) |val| { - config.target_stock_pct = val; - } + config.target_stock_pct = c.target_stock_pct orelse config.target_stock_pct; if (c.horizon) |h| { if (!saw_horizon) { config.horizon_count = 0; saw_horizon = true; } - if (config.horizon_count < UserConfig.max_horizons and h > 0) { + if (h == 0) { + log.warn("projections: horizon must be > 0; ignoring record", .{}); + } else if (config.horizon_count >= UserConfig.max_horizons) { + log.warn("projections: horizon limit reached ({d}); ignoring extra horizon record (value {d})", .{ UserConfig.max_horizons, h }); + } else { config.horizons[config.horizon_count] = h; + if (validRetirementTarget(c.retirement_target)) |conf| { + config.horizon_targets[config.horizon_count] = conf; + annotation_count += 1; + } config.horizon_count += 1; } } @@ -325,11 +501,43 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig { config.horizon_count = 0; saw_horizon = true; } - if (config.horizon_age_count < UserConfig.max_horizons and age > 0) { + if (age == 0) { + log.warn("projections: horizon_age must be > 0; ignoring record", .{}); + } else if (config.horizon_age_count >= UserConfig.max_horizons) { + log.warn("projections: horizon_age limit reached ({d}); ignoring extra horizon_age record (value {d})", .{ UserConfig.max_horizons, age }); + } else { config.horizon_ages[config.horizon_age_count] = age; + if (validRetirementTarget(c.retirement_target)) |conf| { + config.horizon_age_targets[config.horizon_age_count] = conf; + annotation_count += 1; + } config.horizon_age_count += 1; } } + config.retirement_age = c.retirement_age orelse config.retirement_age; + config.retirement_at = c.retirement_at orelse config.retirement_at; + if (c.annual_contribution) |amt| { + // Negative values are nonsensical (a contribution + // is income into the portfolio); drop the record. + if (amt >= 0) { + config.annual_contribution = amt; + } else { + log.warn("projections: annual_contribution must be >= 0 (got {d}); ignoring record", .{amt}); + } + } + if (c.contribution_inflation_adjusted) |b| { + config.contribution_inflation_adjusted = b; + } + if (c.target_spending) |amt| { + if (amt >= 0) { + config.target_spending = amt; + } else { + log.warn("projections: target_spending must be >= 0 (got {d}); ignoring record", .{amt}); + } + } + if (c.target_spending_inflation_adjusted) |b| { + config.target_spending_inflation_adjusted = b; + } }, .birthdate => |b| { // person is 1-indexed in SRF; convert to 0-indexed. @@ -338,11 +546,17 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig { if (idx < UserConfig.max_persons) { config.birthdates[idx] = b.date; if (idx >= config.birthdate_count) config.birthdate_count = idx + 1; + } else { + log.warn("projections: birthdate person index {d} exceeds limit ({d}); ignoring record", .{ idx + 1, UserConfig.max_persons }); } birthdate_seq += 1; }, .event => |e| { - if (config.event_count < UserConfig.max_events and e.start_age > 0) { + if (e.start_age == 0) { + log.warn("projections: event '{s}' has start_age 0; ignoring record", .{e.name}); + } else if (config.event_count >= UserConfig.max_events) { + log.warn("projections: event limit reached ({d}); ignoring extra event '{s}'", .{ UserConfig.max_events, e.name }); + } else { var ev = LifeEvent{ .start_age = e.start_age, .person = e.person -| 1, // 1-indexed → 0-indexed @@ -360,9 +574,32 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig { } } + // Validation: at most one `retirement_target` annotation may be + // present across all horizon and horizon_age records. If more + // than one was seen, drop them all and let `pickPromotedCell` + // fall back to the default rule. Logged as a warning so the + // user knows their override was ignored. + if (annotation_count > 1) { + log.warn("projections: retirement_target set on multiple horizons; ignoring all annotations and using default promotion rule", .{}); + config.horizon_targets = @splat(0); + config.horizon_age_targets = @splat(0); + } + return config; } +/// Validate a `retirement_target` SRF value. Returns the value +/// unchanged if it's exactly 90, 95, or 99; returns null otherwise +/// (logged as a warning so the user notices the typo). Used at parse +/// time; the view-layer `pickPromotedCell` trusts whatever lands in +/// `horizon_targets`. +fn validRetirementTarget(raw: ?u8) ?u8 { + const v = raw orelse return null; + if (v == 90 or v == 95 or v == 99) return v; + log.warn("projections: retirement_target must be 90, 95, or 99 (got {d}); annotation ignored", .{v}); + return null; +} + // ── Configuration ────────────────────────────────────────────── /// Conservative return estimation defaults. @@ -381,6 +618,17 @@ pub const ProjectionConfig = struct { confidence_levels: []const f64, /// Pre-resolved life events for the simulation. events: []const ResolvedEvent = &.{}, + // ── Accumulation phase ────────────────────────────────────── + /// Whole years of accumulation prior to the distribution phase. + /// `0` (default) means the existing distribution-only behavior: + /// the simulation starts withdrawing from `portfolio_value` at + /// year 0 and runs for `horizon` years. + accumulation_years: u16 = 0, + /// Annual household contribution during the accumulation phase, + /// in today's dollars. Ignored when `accumulation_years == 0`. + annual_contribution: f64 = 0, + /// If true, the contribution grows with CPI year-over-year. + contribution_inflation_adjusted: bool = true, }; // ── Results ──────────────────────────────────────────────────── @@ -422,16 +670,143 @@ pub const SimulationResult = struct { // ── Core simulation ──────────────────────────────────────────── +/// Parameters bundling the full two-phase simulation inputs. Used +/// internally by all simulation entry points so the same code path +/// handles both distribution-only (today's behavior, with +/// `accumulation_years == 0`) and accumulation-then-distribution. +pub const SimParams = struct { + initial_value: f64, + stock_pct: f64, + annual_spending: f64, + spending_inflation_adjusted: bool = true, + /// Distribution-phase length (the "horizon" in the existing API). + distribution_years: u16, + accumulation_years: u16 = 0, + annual_contribution: f64 = 0, + contribution_inflation_adjusted: bool = true, + events: []const ResolvedEvent = &.{}, + + /// Total simulated path length (including year 0). + pub fn totalYears(self: SimParams) u16 { + return self.accumulation_years + self.distribution_years; + } +}; + +/// Optional alternate market dataset, used by tests to inject a +/// synthetic constant-return / constant-CPI fixture. When `null`, the +/// global `shiller.annual_returns` is used. +const ShillerYearSlice = []const shiller.ShillerYear; + +/// Maximum cycles available given a total horizon. Returns 0 if no +/// data covers the full horizon. +fn maxCyclesFor(data: ShillerYearSlice, total_years: u16) usize { + if (data.len <= total_years) return 0; + return data.len - total_years; +} + +/// Simulate a single cycle of the two-phase model: +/// 1. Accumulation: contributions in, life events, market return, +/// CPI advance. No spending. Failure not counted. +/// 2. Distribution: spending out, life events, market return, CPI +/// advance. Failure (portfolio ≤ 0) records and stops further +/// simulation, with subsequent years zeroed. +/// +/// `buf` is optional. Pass a non-null buffer of length +/// `params.totalYears() + 1` when you need the full path: `buf[0]` +/// is the initial value; `buf[i]` for i ≥ 1 is the portfolio value +/// at the END of simulation year i; the retirement boundary is at +/// index `accumulation_years` (i.e. `buf[accumulation_years]` is +/// the portfolio at retirement, before the first withdrawal). +/// +/// Pass `null` when you only need the survival verdict — the +/// function will return `false` as soon as it detects failure, +/// skipping the rest of the simulation and avoiding any buffer +/// writes. Saves work in the SWR binary-search inner loop where +/// `successRateParams` calls this thousands of times per search. +/// +/// Returns true if the cycle survived the distribution phase. +fn simulateTwoPhase( + buf: ?[]f64, + data: ShillerYearSlice, + start_index: usize, + params: SimParams, +) bool { + const total = params.totalYears(); + var portfolio = params.initial_value; + if (buf) |b| b[0] = portfolio; + + var cumulative_inflation: f64 = 1.0; + var failed = false; + + var y: usize = 0; + while (y < total) : (y += 1) { + const di = start_index + y; + if (di >= data.len) { + // Out of data — survived (or failed earlier and were + // walking to end for the buffer fill). Path callers + // get the tail filled with the last known value; + // null-buf callers just return. + if (buf) |b| { + for (y + 1..@as(usize, total) + 1) |k| b[k] = portfolio; + } + return !failed; + } + + const yr = data[di]; + const in_accumulation = y < params.accumulation_years; + + // Life events apply in both phases. + var event_net: f64 = 0; + for (params.events) |*ev| { + event_net += ev.cashFlow(@intCast(y), cumulative_inflation); + } + + if (in_accumulation) { + const contribution = if (params.contribution_inflation_adjusted) + params.annual_contribution * cumulative_inflation + else + params.annual_contribution; + portfolio += contribution + event_net; + } else { + const spending = if (params.spending_inflation_adjusted) + params.annual_spending * cumulative_inflation + else + params.annual_spending; + portfolio -= spending - event_net; + if (portfolio <= 0 and !failed) { + // Survival-only callers exit immediately — there's + // no path to fill, and the verdict is locked in. + if (buf == null) return false; + failed = true; + } + } + + // Market return on the post-cashflow balance. Skipped after + // failure (path callers have already locked the verdict; + // remaining buf entries get zeroed below). + if (!failed) { + const blended_return = params.stock_pct * yr.sp500_total_return + + (1.0 - params.stock_pct) * yr.bond_total_return; + portfolio *= (1.0 + blended_return); + } + + // Advance CPI for next year. (No-op for the verdict after + // failure, but cheap and keeps the loop body uniform.) + cumulative_inflation *= (1.0 + yr.cpi_inflation); + + if (buf) |b| b[y + 1] = if (failed) 0.0 else portfolio; + } + + return !failed; +} + /// Simulate a single retirement cycle starting at `start_index` in the /// Shiller dataset, lasting `horizon` years, with the given annual spending /// (inflation-adjusted) and stock/bond allocation. /// -/// Follows the FIRECalc methodology: -/// 1. Withdraw spending (year 1 = base amount, subsequent years CPI-adjusted) -/// 2. Apply market returns to the remainder -/// 3. Repeat -/// -/// Returns the portfolio values at each year (length = horizon + 1). +/// Distribution-only convenience wrapper around `simulateTwoPhase`. +/// Preserves the existing API; new accumulation-aware code paths use +/// `simulateTwoPhase` directly. fn simulateCycle( buf: []f64, start_index: usize, @@ -441,39 +816,13 @@ fn simulateCycle( stock_pct: f64, events: []const ResolvedEvent, ) void { - const data = shiller.annual_returns; - var portfolio = initial_value; - buf[0] = portfolio; - - var cumulative_inflation: f64 = 1.0; - - for (0..horizon) |y| { - const di = start_index + y; - if (di >= data.len) { - for (y + 1..@as(usize, horizon) + 1) |remaining| { - buf[remaining] = portfolio; - } - return; - } - - const yr = data[di]; - - // Step 1: Withdraw spending, offset by life event cash flows - var event_net: f64 = 0; - for (events) |*ev| { - event_net += ev.cashFlow(@intCast(y), cumulative_inflation); - } - portfolio -= annual_spending * cumulative_inflation - event_net; - - // Step 2: Apply market returns (nominal stock + nominal bond via GS10 yield) - const blended_return = stock_pct * yr.sp500_total_return + (1.0 - stock_pct) * yr.bond_total_return; - portfolio *= (1.0 + blended_return); - - // Step 3: Update cumulative inflation for next year's spending - cumulative_inflation *= (1.0 + yr.cpi_inflation); - - buf[y + 1] = portfolio; - } + _ = simulateTwoPhase(buf, shiller.annual_returns, start_index, .{ + .initial_value = initial_value, + .stock_pct = stock_pct, + .annual_spending = annual_spending, + .distribution_years = horizon, + .events = events, + }); } /// Run the full historical simulation: for each possible starting year, @@ -490,35 +839,28 @@ fn runAllCycles( stock_pct: f64, events: []const ResolvedEvent, ) usize { - const num_cycles = shiller.maxCycles(horizon); + return runAllCyclesParams(all_paths, shiller.annual_returns, .{ + .initial_value = initial_value, + .stock_pct = stock_pct, + .annual_spending = annual_spending, + .distribution_years = horizon, + .events = events, + }); +} + +/// Run all cycles with full SimParams — accumulation-aware variant +/// used by both the distribution-only wrapper above and the +/// earliest-retirement search (`findEarliestRetirement`). +fn runAllCyclesParams( + all_paths: [][]f64, + data: ShillerYearSlice, + params: SimParams, +) usize { + const num_cycles = maxCyclesFor(data, params.totalYears()); var survived: usize = 0; - for (0..num_cycles) |cycle| { - simulateCycle( - all_paths[cycle], - cycle, - horizon, - initial_value, - annual_spending, - stock_pct, - events, - ); - - // Check if portfolio survived the full horizon - var failed = false; - for (1..@as(usize, horizon) + 1) |y| { - if (all_paths[cycle][y] <= 0) { - // Zero out remaining years once the portfolio is depleted - for (y..@as(usize, horizon) + 1) |z| { - all_paths[cycle][z] = 0; - } - failed = true; - break; - } - } - if (!failed) survived += 1; + if (simulateTwoPhase(all_paths[cycle], data, cycle, params)) survived += 1; } - return survived; } @@ -531,45 +873,25 @@ fn successRate( stock_pct: f64, events: []const ResolvedEvent, ) f64 { - const data = shiller.annual_returns; - const num_cycles = shiller.maxCycles(horizon); + return successRateParams(shiller.annual_returns, .{ + .initial_value = initial_value, + .stock_pct = stock_pct, + .annual_spending = annual_spending, + .distribution_years = horizon, + .events = events, + }); +} + +fn successRateParams(data: ShillerYearSlice, params: SimParams) f64 { + const num_cycles = maxCyclesFor(data, params.totalYears()); if (num_cycles == 0) return 0.0; - var survived: usize = 0; - for (0..num_cycles) |cycle| { - var portfolio = initial_value; - var cumulative_inflation: f64 = 1.0; - var failed = false; - - for (0..horizon) |y| { - const di = cycle + y; - if (di >= data.len) break; - - const yr = data[di]; - - // Withdraw spending, offset by life event cash flows - var event_net: f64 = 0; - for (events) |*ev| { - event_net += ev.cashFlow(@intCast(y), cumulative_inflation); - } - portfolio -= annual_spending * cumulative_inflation - event_net; - if (portfolio <= 0) { - failed = true; - break; - } - - // Then grow - const blended_return = stock_pct * yr.sp500_total_return + (1.0 - stock_pct) * yr.bond_total_return; - portfolio *= (1.0 + blended_return); - - // Update inflation for next year - cumulative_inflation *= (1.0 + yr.cpi_inflation); - } - - if (!failed) survived += 1; + // `null` buffer → simulateTwoPhase exits as soon as a + // failure is detected. Cheaper than collecting the full + // path when we only need the survival verdict. + if (simulateTwoPhase(null, data, cycle, params)) survived += 1; } - return @as(f64, @floatFromInt(survived)) / @as(f64, @floatFromInt(num_cycles)); } @@ -581,6 +903,8 @@ fn successRate( /// /// Uses binary search with $1 precision, seeded with a 4%-rule estimate /// to narrow the search band (~10 iterations instead of ~23). +/// +/// Distribution-only convenience wrapper around `searchSafeWithdrawal`. pub fn findSafeWithdrawal( horizon: u16, initial_value: f64, @@ -588,47 +912,365 @@ pub fn findSafeWithdrawal( confidence: f64, events: []const ResolvedEvent, ) WithdrawalResult { + return searchSafeWithdrawal(.{ + .initial_value = initial_value, + .stock_pct = stock_pct, + .annual_spending = 0, // overwritten by the search loop + .distribution_years = horizon, + .events = events, + }, confidence); +} + +/// Two-phase variant of `findSafeWithdrawal`. Searches for the +/// largest `annual_spending` (in today's dollars) such that the +/// distribution-phase failure rate stays ≤ `1 - confidence`, with +/// `accumulation_years` of contributions feeding the portfolio first. +/// +/// When `accumulation_years == 0` and contributions are zero, this +/// reduces exactly to `findSafeWithdrawal` (the equivalence is +/// pinned by the `regression: zero accumulation matches direct +/// findSafeWithdrawal` test). Used as the inner search for the +/// target-retirement-date input when the user has configured a +/// non-zero accumulation phase. +pub fn findSafeWithdrawalWithAccumulation( + horizon: u16, + initial_value: f64, + stock_pct: f64, + confidence: f64, + events: []const ResolvedEvent, + accumulation_years: u16, + annual_contribution: f64, + contribution_inflation_adjusted: bool, +) WithdrawalResult { + return searchSafeWithdrawal(.{ + .initial_value = initial_value, + .stock_pct = stock_pct, + .annual_spending = 0, // overwritten by the search loop + .distribution_years = horizon, + .accumulation_years = accumulation_years, + .annual_contribution = annual_contribution, + .contribution_inflation_adjusted = contribution_inflation_adjusted, + .events = events, + }, confidence); +} + +/// Unified safe-withdrawal search. Binary-searches `annual_spending` +/// over `[lo, hi]` to $1 precision, seeded with a 4%-rule estimate +/// against the projected post-accumulation portfolio value. +/// +/// `base` carries every `SimParams` field except `annual_spending`, +/// which the search overwrites per probe. Both +/// `findSafeWithdrawal` (zero accumulation) and +/// `findSafeWithdrawalWithAccumulation` (non-zero accumulation) +/// delegate here. +/// +/// Bracket seeding: +/// - When `accumulation_years == 0`, `projected_value == +/// initial_value` so the seed and bracket reduce to the +/// classic 4%-rule starting point. +/// - When non-zero, `projected_value` is a rough estimate of the +/// post-accumulation portfolio (initial × 1.06^N + N × +/// contribution). The bracket-widening below corrects for any +/// inaccuracy in the estimate. +fn searchSafeWithdrawal(base: SimParams, confidence: f64) WithdrawalResult { + // Project the post-accumulation portfolio. For zero-accumulation + // configs `pow(1.06, 0) == 1.0` so this collapses to + // `initial_value` — same seed the original `findSafeWithdrawal` + // used. + const accum_growth_factor: f64 = std.math.pow(f64, 1.06, @as(f64, @floatFromInt(base.accumulation_years))); + const projected_value = base.initial_value * accum_growth_factor + + base.annual_contribution * @as(f64, @floatFromInt(base.accumulation_years)); + // Seed from the 4% rule, adjusted for horizon and confidence. // Base ~4% for 30yr/95%. Shorter horizons allow more; longer less. // Higher confidence requires less. const base_rate = 0.04; - const horizon_adj = 30.0 / @as(f64, @floatFromInt(horizon)); // >1 for short, <1 for long - const conf_adj = (1.0 - confidence) / 0.05; // 1.0 at 95%, 0.2 at 99%, 2.0 at 90% - const estimate = initial_value * base_rate * @sqrt(horizon_adj) * @sqrt(conf_adj); + const horizon_adj = 30.0 / @as(f64, @floatFromInt(base.distribution_years)); + const conf_adj = (1.0 - confidence) / 0.05; + const estimate = projected_value * base_rate * @sqrt(horizon_adj) * @sqrt(conf_adj); - // Search band: ±50% of estimate, clamped to [0, initial_value] + // Search band: ±50% of estimate. The lower clamp is 0; the + // upper clamp ensures we don't start below the projected value + // (relevant when the 4%-rule estimate undershoots a high + // accumulation case). var lo: f64 = @max(estimate * 0.5, 0); - var hi: f64 = @min(estimate * 1.5, initial_value); + var hi: f64 = @max(estimate * 1.5, projected_value); - // Verify bounds bracket the answer; widen if not - if (successRate(horizon, initial_value, lo, stock_pct, events) < confidence) { - log.debug("findSafeWithdrawal: estimate too high, widening lo to 0 (horizon={d}, conf={d:.2})", .{ horizon, confidence }); + // Mutable probe — same struct, different `annual_spending` per + // iteration. Avoids reconstructing SimParams on every probe. + var probe = base; + + // Verify bounds bracket the answer; widen if not. + probe.annual_spending = lo; + if (successRateParams(shiller.annual_returns, probe) < confidence) { + log.debug("searchSafeWithdrawal: estimate too high, widening lo to 0 (horizon={d}, conf={d:.2})", .{ base.distribution_years, confidence }); lo = 0; } - if (successRate(horizon, initial_value, hi, stock_pct, events) >= confidence) { - log.debug("findSafeWithdrawal: estimate too low, widening hi to portfolio value (horizon={d}, conf={d:.2})", .{ horizon, confidence }); - hi = initial_value; + probe.annual_spending = hi; + if (successRateParams(shiller.annual_returns, probe) >= confidence) { + log.debug("searchSafeWithdrawal: estimate too low, widening hi (horizon={d}, conf={d:.2})", .{ base.distribution_years, confidence }); + hi = @max(projected_value, base.initial_value) * 4.0; } - // Binary search to $1 precision + // Binary search to $1 precision. while (hi - lo > 1.0) { const mid = @floor((lo + hi) / 2.0); - const rate = successRate(horizon, initial_value, mid, stock_pct, events); - - if (rate >= confidence) { - lo = mid; - } else { - hi = mid; - } + probe.annual_spending = mid; + const rate = successRateParams(shiller.annual_returns, probe); + if (rate >= confidence) lo = mid else hi = mid; } return .{ .confidence = confidence, .annual_amount = lo, - .withdrawal_rate = lo / initial_value, + .withdrawal_rate = if (base.initial_value > 0) lo / base.initial_value else 0.0, }; } +// ── Earliest-retirement search (target-spending input) ───────── + +/// Result of a `findEarliestRetirement` search. `accumulation_years +/// == null` means no value of N in [0, max_years] sustains the +/// target spending at the requested confidence over the distribution +/// horizon. The portfolio statistics are computed from the same +/// historical cycles at year `accumulation_years`. +pub const EarliestRetirement = struct { + horizon: u16, + confidence: f64, + accumulation_years: ?u16, + median_at_retirement: f64, + p10_at_retirement: f64, + 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; + +/// Earliest-retirement search: given a target annual spending +/// level, find the smallest `accumulation_years` N in [0, `max_years`] +/// such that the success +/// rate over the distribution phase ≥ `confidence`. +/// +/// Returns the matching `EarliestRetirement`, with portfolio +/// statistics taken from the cycles that survived. If no N up to +/// `max_years` succeeds, `accumulation_years == null` and the +/// portfolio statistics are zero. +pub fn findEarliestRetirement( + allocator: std.mem.Allocator, + initial_value: f64, + stock_pct: f64, + annual_contribution: f64, + contribution_inflation_adjusted: bool, + target_spending: f64, + target_spending_inflation_adjusted: bool, + distribution_years: u16, + confidence: f64, + events: []const ResolvedEvent, + max_years: u16, +) !EarliestRetirement { + const data = shiller.annual_returns; + + var n: u16 = 0; + while (n <= max_years) : (n += 1) { + const params: SimParams = .{ + .initial_value = initial_value, + .stock_pct = stock_pct, + .annual_spending = target_spending, + .spending_inflation_adjusted = target_spending_inflation_adjusted, + .distribution_years = distribution_years, + .accumulation_years = n, + .annual_contribution = annual_contribution, + .contribution_inflation_adjusted = contribution_inflation_adjusted, + .events = events, + }; + + const rate = successRateParams(data, params); + if (rate < confidence) continue; + + // Found the earliest N. Run the full path simulation once to + // extract the portfolio statistics at year N (the retirement + // boundary). + const total = params.totalYears(); + const num_cycles = maxCyclesFor(data, total); + if (num_cycles == 0) { + return .{ + .horizon = distribution_years, + .confidence = confidence, + .accumulation_years = n, + .median_at_retirement = 0, + .p10_at_retirement = 0, + .p90_at_retirement = 0, + }; + } + + const years_len: usize = @as(usize, total) + 1; + const path_data = try allocator.alloc(f64, num_cycles * years_len); + defer allocator.free(path_data); + const paths = try allocator.alloc([]f64, num_cycles); + defer allocator.free(paths); + for (0..num_cycles) |i| { + paths[i] = path_data[i * years_len .. (i + 1) * years_len]; + } + _ = runAllCyclesParams(paths, data, params); + + const sort_buf = try allocator.alloc(f64, num_cycles); + defer allocator.free(sort_buf); + for (0..num_cycles) |c| { + sort_buf[c] = paths[c][@as(usize, n)]; + } + std.mem.sort(f64, sort_buf, {}, std.sort.asc(f64)); + + return .{ + .horizon = distribution_years, + .confidence = confidence, + .accumulation_years = n, + .median_at_retirement = percentile(sort_buf, 0.50), + .p10_at_retirement = percentile(sort_buf, 0.10), + .p90_at_retirement = percentile(sort_buf, 0.90), + }; + } + + return .{ + .horizon = distribution_years, + .confidence = confidence, + .accumulation_years = null, + .median_at_retirement = 0, + .p10_at_retirement = 0, + .p90_at_retirement = 0, + }; +} + +// ── Earliest-retirement promotion (the "headline" cell) ──────── + +/// Selected (horizon, confidence) pair for the promoted retirement +/// line. The selection is independent of feasibility — the caller +/// indexes the earliest-retirement grid with this pair and renders +/// "not feasible" if the cell's `accumulation_years` is null. +pub const PromotedCell = struct { + horizon_index: usize, + confidence_index: usize, + /// True when the user explicitly tagged a horizon with a + /// `retirement_target` annotation. Diagnostic only — display + /// behavior is identical either way. + explicit: bool, +}; + +/// Maximum age the "longest-horizon-that-makes-sense" rule allows +/// the oldest configured person to reach by the end of the promoted +/// distribution. A 100-year-old shouldn't still be drawing down +/// their working-age portfolio; if all horizons push past this, we +/// fall through to the shortest configured horizon anyway ("fuck it" +/// branch). +pub const promotion_age_cap: u16 = 100; + +/// Pick the (horizon, confidence) cell to promote into the +/// retirement line and accumulation block when the user configured +/// `target_spending` without an explicit retirement date. +/// +/// Algorithm: +/// 1. If exactly one horizon is annotated with `retirement_target`, +/// honor that annotation regardless of length or feasibility. +/// 2. Else, walk horizons longest → shortest. Pick the longest +/// whose end year keeps the oldest configured person under +/// `promotion_age_cap`. +/// 3. If even the shortest horizon overshoots, use it anyway. +/// 4. Default confidence is 99% (most conservative). +/// +/// `confidence_levels` must match the order used by the earliest +/// grid — typically {.90, .95, .99} with index 2 being 99%. +/// +/// `as_of` is the reference date used to compute the oldest +/// person's current age. The function works correctly for any +/// reference date — pass today for the live mode or a historical +/// snapshot date for back-dated runs. +/// +/// Returns null only if no horizons are configured at all (caller +/// should treat this as "no promotion possible"). +pub fn pickPromotedCell( + config: *const UserConfig, + as_of: Date, + confidence_levels: []const f64, +) ?PromotedCell { + if (config.horizon_count == 0 or confidence_levels.len == 0) return null; + + // Step 1: explicit override wins. + var i: usize = 0; + while (i < config.horizon_count) : (i += 1) { + const tag = config.horizon_targets[i]; + if (tag != 0) { + const ci = confidenceIndex(confidence_levels, tag); + return .{ .horizon_index = i, .confidence_index = ci, .explicit = true }; + } + } + + // Default confidence: highest configured (most conservative). + // Convention: arrays sorted ascending, so the last entry is the + // highest. Find the index whose value is closest to 0.99. + const default_ci = confidenceIndex(confidence_levels, 99); + + // Step 2: longest horizon where oldest person stays under the + // age cap. With no birthdates, the cap doesn't apply — just + // pick the longest horizon. + const oldest_age_as_of = config.oldestAge(as_of); + + var longest_idx: usize = 0; + var longest_h: u16 = config.horizons[0]; + for (1..config.horizon_count) |hi| { + if (config.horizons[hi] > longest_h) { + longest_h = config.horizons[hi]; + longest_idx = hi; + } + } + if (config.birthdate_count == 0) { + return .{ .horizon_index = longest_idx, .confidence_index = default_ci, .explicit = false }; + } + + // Sort indices by horizon length descending. + var order: [UserConfig.max_horizons]u8 = @splat(0); + for (0..config.horizon_count) |hi| order[hi] = @intCast(hi); + const slice = order[0..config.horizon_count]; + const SortCtx = struct { + horizons: []const u16, + pub fn lessThan(ctx: @This(), a: u8, b: u8) bool { + return ctx.horizons[a] > ctx.horizons[b]; // descending + } + }; + std.mem.sort(u8, slice, SortCtx{ .horizons = &config.horizons }, SortCtx.lessThan); + + for (slice) |hi| { + const end_age = oldest_age_as_of + config.horizons[hi]; + if (end_age < promotion_age_cap) { + return .{ .horizon_index = hi, .confidence_index = default_ci, .explicit = false }; + } + } + + // Step 3: "fuck it" — even the shortest horizon overshoots. + // Pick the shortest (last in our descending sort). + const shortest_idx = slice[slice.len - 1]; + return .{ .horizon_index = shortest_idx, .confidence_index = default_ci, .explicit = false }; +} + +/// Find the index in `confidence_levels` (a slice of fractions like +/// 0.90/0.95/0.99) that corresponds to the percentage `pct`. Falls +/// back to the closest match if no exact one exists. Used to +/// translate a `retirement_target` annotation (90/95/99) or the +/// default 99 into an index into the earliest-retirement grid. +fn confidenceIndex(confidence_levels: []const f64, pct: u8) usize { + const target: f64 = @as(f64, @floatFromInt(pct)) / 100.0; + var best_idx: usize = 0; + var best_diff: f64 = std.math.inf(f64); + for (confidence_levels, 0..) |c, idx| { + const diff = @abs(c - target); + if (diff < best_diff) { + best_diff = diff; + best_idx = idx; + } + } + return best_idx; +} + // ── Percentile bands ─────────────────────────────────────────── /// Compute percentile bands from all simulated paths for a given horizon @@ -641,12 +1283,30 @@ pub fn computePercentileBands( stock_pct: f64, events: []const ResolvedEvent, ) ![]YearPercentiles { - const num_cycles = shiller.maxCycles(horizon); + return computePercentileBandsParams(allocator, .{ + .initial_value = initial_value, + .stock_pct = stock_pct, + .annual_spending = annual_spending, + .distribution_years = horizon, + .events = events, + }); +} + +/// Two-phase variant of `computePercentileBands`. Returns bands of +/// length `params.totalYears() + 1`, where index 0 is the starting +/// portfolio and index `accumulation_years` is the post-accumulation +/// (retirement) portfolio. +pub fn computePercentileBandsParams( + allocator: std.mem.Allocator, + params: SimParams, +) ![]YearPercentiles { + const data = shiller.annual_returns; + const total = params.totalYears(); + const num_cycles = maxCyclesFor(data, total); if (num_cycles == 0) return &.{}; - const years: usize = @as(usize, horizon) + 1; + const years: usize = @as(usize, total) + 1; - // Allocate path storage: num_cycles rows of (horizon+1) f64s const path_data = try allocator.alloc(f64, num_cycles * years); defer allocator.free(path_data); @@ -657,7 +1317,7 @@ pub fn computePercentileBands( paths[i] = path_data[i * years .. (i + 1) * years]; } - _ = runAllCycles(paths, horizon, initial_value, annual_spending, stock_pct, events); + _ = runAllCyclesParams(paths, data, params); // For each year, sort the values across all cycles and extract percentiles const bands = try allocator.alloc(YearPercentiles, years); @@ -671,8 +1331,6 @@ pub fn computePercentileBands( for (0..num_cycles) |c| { sort_buf[c] = paths[c][y]; } - - // Sort std.mem.sort(f64, sort_buf, {}, std.sort.asc(f64)); bands[y] = .{ @@ -704,6 +1362,108 @@ fn percentile(sorted: []const f64, p: f64) f64 { // ── High-level API ───────────────────────────────────────────── +/// Pre-computed grid of safe-withdrawal results and percentile +/// bands across a set of horizons × confidence levels. Produced by +/// `runProjectionGrid` and consumed by both the CLI and TUI +/// projections renderers. +pub const ProjectionData = struct { + /// Safe withdrawal results, indexed `[ci * horizons.len + hi]`. + /// Owned by the caller — free with the same allocator. + withdrawals: []WithdrawalResult, + /// Per-horizon percentile bands. `null` entries indicate the + /// band computation failed for that horizon (allocator failure, + /// out-of-data, etc.). Each non-null slice is owned by the + /// caller. + bands: []?[]YearPercentiles, + /// Index into `confidence_levels` corresponding to the 99% + /// (highest configured) level. The chart and percentile-band + /// blocks anchor on this confidence. + ci_99: usize, +}; + +/// Run the full projection-display batch up front: a safe-withdrawal +/// grid across every (horizon × confidence) pair, plus a percentile +/// band per horizon at the highest configured confidence (used for +/// the chart and the terminal-portfolio-value table). +/// +/// Two distinct computations bundled in one call: +/// +/// 1. **Safe-withdrawal grid.** For each `(horizon, confidence)` +/// pair in `confidence_levels × horizons`, binary-searches the +/// maximum annual spending the portfolio can sustain across +/// that horizon at that confidence. Stored in +/// `withdrawals[ci * horizons.len + hi]`. +/// +/// 2. **Percentile bands.** For each horizon, runs the full +/// historical simulation at the highest-confidence withdrawal +/// rate and extracts p10/p25/p50/p75/p90 of the portfolio +/// value at each year. The highest-confidence rate is the +/// most conservative spending level, so the chart shows the +/// steepest survivable drawdown. Stored in `bands[hi]`. +/// +/// All four call sites in the codebase (CLI projections command, +/// TUI projections tab, the `--vs` comparison renderer, and the +/// view-model integration test) want exactly this bundle, so it's +/// computed once per projection rather than re-derived per render. +/// +/// Accumulation parameters are always honored — pass `0` / +/// `0` / `true` for the distribution-only case (already-retired +/// users, no contributions configured). The simulation core +/// produces identical results when accumulation degenerates to +/// zero, so this single function covers every input combination +/// `projections.srf` allows. +/// +/// `confidence_levels` should be sorted ascending; `ci_99` in the +/// returned struct refers to the LAST entry, which by convention +/// is the highest (most-conservative) confidence. +/// +/// Caller owns `withdrawals`, `bands`, and every non-null entry +/// inside `bands`. Free with the same allocator. +pub fn runProjectionGrid( + alloc: std.mem.Allocator, + horizons: []const u16, + confidence_levels: []const f64, + total_value: f64, + stock_pct: f64, + events: []const ResolvedEvent, + accumulation_years: u16, + annual_contribution: f64, + contribution_inflation_adjusted: bool, +) !ProjectionData { + const num_results = horizons.len * confidence_levels.len; + const withdrawals = try alloc.alloc(WithdrawalResult, num_results); + for (confidence_levels, 0..) |conf, ci| { + for (horizons, 0..) |h, hi| { + withdrawals[ci * horizons.len + hi] = findSafeWithdrawalWithAccumulation( + h, + total_value, + stock_pct, + conf, + events, + accumulation_years, + annual_contribution, + contribution_inflation_adjusted, + ); + } + } + const ci_99 = confidence_levels.len - 1; + const bands = try alloc.alloc(?[]YearPercentiles, horizons.len); + for (horizons, 0..) |h, hi| { + const wr = withdrawals[ci_99 * horizons.len + hi]; + bands[hi] = computePercentileBandsParams(alloc, .{ + .initial_value = total_value, + .stock_pct = stock_pct, + .annual_spending = wr.annual_amount, + .distribution_years = h, + .accumulation_years = accumulation_years, + .annual_contribution = annual_contribution, + .contribution_inflation_adjusted = contribution_inflation_adjusted, + .events = events, + }) catch null; + } + return .{ .withdrawals = withdrawals, .bands = bands, .ci_99 = ci_99 }; +} + /// Run the full projection analysis for one horizon: compute safe withdrawal /// at each confidence level and percentile bands using the median withdrawal. pub fn runProjection( @@ -1193,3 +1953,931 @@ test "UserConfig.eventNetCashFlow sums active events" { // At year 5: both active (ages 55, 55) try std.testing.expectApproxEqAbs(@as(f64, 40000), config.eventNetCashFlow(5, 1.0, &ages), 0.01); } + +// ── Accumulation phase tests ─────────────────────────────────── + +test "parseProjectionsConfig parses retirement_age" { + const data = + \\#!srfv1 + \\type::config,retirement_age:num:65 + ; + const config = parseProjectionsConfig(data); + try std.testing.expectEqual(@as(?u16, 65), config.retirement_age); + try std.testing.expectEqual(@as(?Date, null), config.retirement_at); +} + +test "parseProjectionsConfig parses retirement_at" { + const data = + \\#!srfv1 + \\type::config,retirement_at::2036-07-01 + ; + const config = parseProjectionsConfig(data); + try std.testing.expect(config.retirement_at != null); + try std.testing.expectEqual(@as(i16, 2036), config.retirement_at.?.year()); + try std.testing.expectEqual(@as(u8, 7), config.retirement_at.?.month()); + try std.testing.expectEqual(@as(u8, 1), config.retirement_at.?.day()); +} + +test "parseProjectionsConfig parses annual_contribution" { + const data = + \\#!srfv1 + \\type::config,annual_contribution:num:100000 + \\type::config,contribution_inflation_adjusted:bool:false + ; + const config = parseProjectionsConfig(data); + try std.testing.expectApproxEqAbs(@as(f64, 100_000), config.annual_contribution, 0.01); + try std.testing.expect(!config.contribution_inflation_adjusted); +} + +test "parseProjectionsConfig rejects negative annual_contribution" { + const data = + \\#!srfv1 + \\type::config,annual_contribution:num:-50000 + ; + const config = parseProjectionsConfig(data); + // Negative dropped; default zero retained. + try std.testing.expectApproxEqAbs(@as(f64, 0), config.annual_contribution, 0.01); +} + +test "parseProjectionsConfig parses target_spending" { + const data = + \\#!srfv1 + \\type::config,target_spending:num:80000 + \\type::config,target_spending_inflation_adjusted:bool:false + ; + const config = parseProjectionsConfig(data); + try std.testing.expectApproxEqAbs(@as(f64, 80_000), config.target_spending.?, 0.01); + try std.testing.expect(!config.target_spending_inflation_adjusted); +} + +test "parseProjectionsConfig rejects negative target_spending" { + const data = + \\#!srfv1 + \\type::config,target_spending:num:-1000 + ; + const config = parseProjectionsConfig(data); + try std.testing.expectEqual(@as(?f64, null), config.target_spending); +} + +test "parseProjectionsConfig parses both retirement_age and retirement_at" { + // Both fields can be set in the file; resolver picks retirement_at. + // Parsing just stores both raw. + const data = + \\#!srfv1 + \\type::config,retirement_age:num:65 + \\type::config,retirement_at::2036-07-01 + ; + const config = parseProjectionsConfig(data); + try std.testing.expectEqual(@as(?u16, 65), config.retirement_age); + try std.testing.expect(config.retirement_at != null); +} + +test "resolveRetirement: retirement_at in future" { + var config = UserConfig{}; + config.retirement_at = Date.fromYmd(2036, 7, 1); + const today = Date.fromYmd(2026, 7, 1); + const r = config.resolveRetirement(today); + try std.testing.expectEqual(@as(u16, 10), r.accumulation_years); + try std.testing.expect(r.date != null); + try std.testing.expect(r.date.?.eql(Date.fromYmd(2036, 7, 1))); + try std.testing.expectEqual(.at_date, r.source); +} + +test "resolveRetirement: retirement_at in past degrades to none" { + var config = UserConfig{}; + config.retirement_at = Date.fromYmd(2020, 1, 1); + const today = Date.fromYmd(2026, 7, 1); + const r = config.resolveRetirement(today); + try std.testing.expectEqual(@as(u16, 0), r.accumulation_years); + try std.testing.expect(r.date == null); + try std.testing.expectEqual(.none, r.source); +} + +test "resolveRetirement: retirement_age with birthday already passed this year" { + // Born 1975-03-15; today 2025-06-01 (past 03-15 this year). + // Target 65 → date 2040-03-15; accumulation_years = floor(years between today and 2040-03-15). + var config = UserConfig{}; + config.birthdate_count = 1; + config.birthdates[0] = Date.fromYmd(1975, 3, 15); + config.retirement_age = 65; + const today = Date.fromYmd(2025, 6, 1); + const r = config.resolveRetirement(today); + try std.testing.expect(r.date != null); + try std.testing.expect(r.date.?.eql(Date.fromYmd(2040, 3, 15))); + try std.testing.expectEqual(.at_age, r.source); + // ~14.78 years → floor = 14 + try std.testing.expectEqual(@as(u16, 14), r.accumulation_years); +} + +test "resolveRetirement: retirement_age with birthday still ahead this year" { + // Born 1975-08-15; today 2025-06-01 (before 08-15 this year). + // Target 65 → date 2040-08-15; ~15.21 years → floor = 15. + var config = UserConfig{}; + config.birthdate_count = 1; + config.birthdates[0] = Date.fromYmd(1975, 8, 15); + config.retirement_age = 65; + const today = Date.fromYmd(2025, 6, 1); + const r = config.resolveRetirement(today); + try std.testing.expectEqual(@as(u16, 15), r.accumulation_years); + try std.testing.expect(r.date.?.eql(Date.fromYmd(2040, 8, 15))); +} + +test "resolveRetirement: retirement_age already past degrades to none" { + var config = UserConfig{}; + config.birthdate_count = 1; + config.birthdates[0] = Date.fromYmd(1965, 1, 1); // age ~60 in 2025 + config.retirement_age = 40; // already past + const today = Date.fromYmd(2025, 6, 1); + const r = config.resolveRetirement(today); + try std.testing.expectEqual(.none, r.source); +} + +test "resolveRetirement: retirement_age with no birthdate degrades to none" { + var config = UserConfig{}; + config.retirement_age = 65; + const today = Date.fromYmd(2025, 6, 1); + const r = config.resolveRetirement(today); + try std.testing.expectEqual(.none, r.source); +} + +test "resolveRetirement: multi-person uses oldest birthdate" { + // Person 1: born 1975-03-15 (oldest). Person 2: born 1980-06-15. + // Target age 65 → date is for person 1: 2040-03-15. + var config = UserConfig{}; + config.birthdate_count = 2; + config.birthdates[0] = Date.fromYmd(1975, 3, 15); + config.birthdates[1] = Date.fromYmd(1980, 6, 15); + config.retirement_age = 65; + const today = Date.fromYmd(2025, 6, 1); + const r = config.resolveRetirement(today); + try std.testing.expect(r.date.?.eql(Date.fromYmd(2040, 3, 15))); +} + +test "resolveRetirement: multi-person uses oldest regardless of order" { + // Person 1 (slot 0) is the YOUNGER one. Resolver should still + // pick slot 1 (the older) for the retirement date. + var config = UserConfig{}; + config.birthdate_count = 2; + config.birthdates[0] = Date.fromYmd(1980, 6, 15); + config.birthdates[1] = Date.fromYmd(1975, 3, 15); + config.retirement_age = 65; + const today = Date.fromYmd(2025, 6, 1); + const r = config.resolveRetirement(today); + try std.testing.expect(r.date.?.eql(Date.fromYmd(2040, 3, 15))); +} + +test "resolveRetirement: retirement_at wins when both set" { + var config = UserConfig{}; + config.birthdate_count = 1; + config.birthdates[0] = Date.fromYmd(1975, 3, 15); + config.retirement_age = 65; + config.retirement_at = Date.fromYmd(2030, 1, 1); + const today = Date.fromYmd(2025, 6, 1); + const r = config.resolveRetirement(today); + try std.testing.expectEqual(.at_date, r.source); + try std.testing.expect(r.date.?.eql(Date.fromYmd(2030, 1, 1))); +} + +test "resolveRetirement: none when neither field is set" { + const config = UserConfig{}; + const today = Date.fromYmd(2025, 6, 1); + const r = config.resolveRetirement(today); + try std.testing.expectEqual(.none, r.source); + try std.testing.expectEqual(@as(u16, 0), r.accumulation_years); + try std.testing.expect(r.date == null); +} + +test "resolveRetirement: retirement_age and retirement_at agree on same boundary" { + // Configure retirement_at and retirement_age such that both + // resolve to the same accumulation_years. retirement_at wins per + // the rule, but the integer years should match. + var c1 = UserConfig{}; + c1.retirement_at = Date.fromYmd(2036, 7, 1); + var c2 = UserConfig{}; + c2.birthdate_count = 1; + c2.birthdates[0] = Date.fromYmd(1971, 7, 1); // turns 65 on 2036-07-01 + c2.retirement_age = 65; + + const today = Date.fromYmd(2026, 7, 1); + const r1 = c1.resolveRetirement(today); + const r2 = c2.resolveRetirement(today); + try std.testing.expectEqual(r1.accumulation_years, r2.accumulation_years); + try std.testing.expect(r1.date.?.eql(r2.date.?)); +} + +// ── Two-phase simulation regression tests ────────────────────── + +test "regression: findSafeWithdrawal(30, 1M, 0.75, 0.95) unchanged" { + // Pin the post-refactor value of the canonical SWR call. If this + // test ever fails, the two-phase refactor changed + // distribution-only behavior — investigate before bumping the + // golden value. Captured 2026-05-12. + const r = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &.{}); + // Use a tight band — the binary search has $1 precision, so + // anything farther than a few dollars off is a real change. + try std.testing.expect(r.annual_amount >= 38_000); + try std.testing.expect(r.annual_amount <= 50_000); + // Snapshot the exact value as well so we notice silent drift. + // Actual value at refactor time was determined empirically. + const expected = 44_036.0; + try std.testing.expectApproxEqAbs(expected, r.annual_amount, 5.0); +} + +test "regression: zero accumulation matches direct findSafeWithdrawal" { + // Both wrappers go through `searchSafeWithdrawal`; with + // accumulation_years=0 and zero contributions, the bracket + // seeding and search loop are identical. Tolerance is 0 + // because the two paths execute the same code with the same + // inputs — any drift here means the unification broke. + const direct = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &.{}); + const via_accum = findSafeWithdrawalWithAccumulation(30, 1_000_000, 0.75, 0.95, &.{}, 0, 0, true); + try std.testing.expectEqual(direct.annual_amount, via_accum.annual_amount); + try std.testing.expectEqual(direct.confidence, via_accum.confidence); + try std.testing.expectEqual(direct.withdrawal_rate, via_accum.withdrawal_rate); +} + +test "two-phase: 10y accumulation with $100k/yr contributions raises post-accum portfolio" { + // Compare the median portfolio at year 10 with vs without + // contributions. Contributions should produce a meaningfully + // higher median. + const allocator = std.testing.allocator; + + const params_no_contrib: SimParams = .{ + .initial_value = 1_000_000, + .stock_pct = 0.75, + .annual_spending = 0, + .distribution_years = 30, + .accumulation_years = 10, + .annual_contribution = 0, + }; + const params_with_contrib: SimParams = .{ + .initial_value = 1_000_000, + .stock_pct = 0.75, + .annual_spending = 0, + .distribution_years = 30, + .accumulation_years = 10, + .annual_contribution = 100_000, + }; + + const bands_no = try computePercentileBandsParams(allocator, params_no_contrib); + defer allocator.free(bands_no); + const bands_with = try computePercentileBandsParams(allocator, params_with_contrib); + defer allocator.free(bands_with); + + // Both bands span 40 years (10 accum + 30 dist) → 41 entries. + try std.testing.expectEqual(@as(usize, 41), bands_no.len); + try std.testing.expectEqual(@as(usize, 41), bands_with.len); + + // Year-0 starts the same in both. + try std.testing.expectApproxEqAbs(@as(f64, 1_000_000), bands_no[0].p50, 1.0); + try std.testing.expectApproxEqAbs(@as(f64, 1_000_000), bands_with[0].p50, 1.0); + + // At the retirement boundary (year 10), with-contributions + // median should exceed without by significantly more than + // 10 × $100k (compounding helps). + try std.testing.expect(bands_with[10].p50 > bands_no[10].p50 + 1_000_000); +} + +test "two-phase: nominal contributions produce lower year-10 median than CPI-adjusted" { + // CPI-adjusted contributions grow over time; nominal stay flat. + // Over 10 years, CPI-adjusted should accumulate more. + const allocator = std.testing.allocator; + const cpi_adj: SimParams = .{ + .initial_value = 1_000_000, + .stock_pct = 0.75, + .annual_spending = 0, + .distribution_years = 30, + .accumulation_years = 10, + .annual_contribution = 100_000, + .contribution_inflation_adjusted = true, + }; + const nominal: SimParams = .{ + .initial_value = 1_000_000, + .stock_pct = 0.75, + .annual_spending = 0, + .distribution_years = 30, + .accumulation_years = 10, + .annual_contribution = 100_000, + .contribution_inflation_adjusted = false, + }; + const b_cpi = try computePercentileBandsParams(allocator, cpi_adj); + defer allocator.free(b_cpi); + const b_nom = try computePercentileBandsParams(allocator, nominal); + defer allocator.free(b_nom); + // Median at year 10 should be higher with CPI-adjusted (over + // any sufficiently inflationary historical window the diff is + // positive; CPI is non-negative on the long term). + try std.testing.expect(b_cpi[10].p50 >= b_nom[10].p50); +} + +test "two-phase: SWR with accumulation exceeds same-portfolio direct SWR" { + // 10 years of $100k contributions on top of $1M should produce + // a higher safe withdrawal than $1M alone over a 30-year + // distribution at the same confidence. + const direct = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &.{}); + const with_accum = findSafeWithdrawalWithAccumulation(30, 1_000_000, 0.75, 0.95, &.{}, 10, 100_000, true); + try std.testing.expect(with_accum.annual_amount > direct.annual_amount); +} + +test "simulateTwoPhase: null-buf and non-null-buf agree on verdict" { + // Locks in the invariant that calling simulateTwoPhase with + // null produces the same survival bit as calling it with a + // path buffer. This is the load-bearing equivalence that lets + // `successRateParams` use the cheaper null-buf path while + // `runAllCyclesParams` uses the path-storing version, with + // both producing the same answer about whether a given cycle + // failed. + // + // Cover three regimes: clear survivor, clear failure, and a + // marginal case driven by an extreme spending level. + const cases = [_]struct { + params: SimParams, + starts: []const usize, + }{ + .{ + // Clear survivor: zero spending. + .params = .{ + .initial_value = 1_000_000, + .stock_pct = 0.75, + .annual_spending = 0, + .distribution_years = 30, + }, + .starts = &.{ 0, 25, 50, 75 }, + }, + .{ + // Clear failure: spend $200k/yr from $500k, 30 years. + .params = .{ + .initial_value = 500_000, + .stock_pct = 0.75, + .annual_spending = 200_000, + .distribution_years = 30, + }, + .starts = &.{ 0, 25, 50, 75 }, + }, + .{ + // Marginal: 10y accumulation then 30y of moderate spend. + .params = .{ + .initial_value = 1_000_000, + .stock_pct = 0.75, + .annual_spending = 60_000, + .distribution_years = 30, + .accumulation_years = 10, + .annual_contribution = 50_000, + }, + .starts = &.{ 0, 30, 60 }, + }, + }; + + var buf: [101]f64 = undefined; // max total ≈ 50 + 50, slack + for (cases) |case| { + for (case.starts) |start| { + const total = case.params.totalYears(); + std.debug.assert(total + 1 <= buf.len); + const verdict_null = simulateTwoPhase(null, shiller.annual_returns, start, case.params); + const verdict_buf = simulateTwoPhase(buf[0 .. total + 1], shiller.annual_returns, start, case.params); + try std.testing.expectEqual(verdict_null, verdict_buf); + } + } +} + +// ── findEarliestRetirement tests ─────────────────────────────── + +test "findEarliestRetirement: feasible at N=0 returns 0" { + // $10M portfolio, $40k/yr spending, 30y distribution, 95% + // confidence — feasible immediately (1.6× the 4% rule). + const allocator = std.testing.allocator; + const r = try findEarliestRetirement( + allocator, + 10_000_000, // initial_value + 0.75, // stock_pct + 0, // annual_contribution + true, + 40_000, // target_spending + true, + 30, // distribution_years + 0.95, // confidence + &.{}, + 50, // max_years + ); + try std.testing.expectEqual(@as(?u16, 0), r.accumulation_years); +} + +test "findEarliestRetirement: unreachable returns null" { + // $1M portfolio, $1M/yr spending, no contributions: never + // feasible. Returns null. + const allocator = std.testing.allocator; + const r = try findEarliestRetirement( + allocator, + 1_000_000, + 0.75, + 0, // no contributions + true, + 1_000_000, // target spending = entire portfolio every year + true, + 30, + 0.95, + &.{}, + 50, + ); + try std.testing.expectEqual(@as(?u16, null), r.accumulation_years); +} + +test "findEarliestRetirement: longer distribution shifts retirement later or unchanged" { + // Same setup, just two horizons. + const allocator = std.testing.allocator; + const short = try findEarliestRetirement( + allocator, + 1_000_000, + 0.75, + 50_000, + true, + 80_000, + true, + 20, // 20-year distribution + 0.95, + &.{}, + 50, + ); + const long = try findEarliestRetirement( + allocator, + 1_000_000, + 0.75, + 50_000, + true, + 80_000, + true, + 45, // 45-year distribution + 0.95, + &.{}, + 50, + ); + if (short.accumulation_years != null and long.accumulation_years != null) { + try std.testing.expect(long.accumulation_years.? >= short.accumulation_years.?); + } +} + +test "findEarliestRetirement: result includes portfolio statistics" { + const allocator = std.testing.allocator; + const r = try findEarliestRetirement( + allocator, + 2_000_000, + 0.75, + 100_000, + true, + 80_000, + true, + 30, + 0.95, + &.{}, + 50, + ); + if (r.accumulation_years) |n| { + if (n > 0) { + // Median portfolio at retirement should be >= initial + // value (we accumulate before drawing down). + try std.testing.expect(r.median_at_retirement >= 1_500_000); + // p10 ≤ p50 ≤ p90. + try std.testing.expect(r.p10_at_retirement <= r.median_at_retirement); + try std.testing.expect(r.median_at_retirement <= r.p90_at_retirement); + } + } +} + +// ── ResolvedRetirement formatter tests ───────────────────────── + +test "fmtRetirementLine: none case" { + var buf: [128]u8 = undefined; + const line = retirementLineForTest(&buf, .{ + .accumulation_years = 0, + .date = null, + .source = .none, + }); + try std.testing.expectEqualStrings("Years until possible retirement: none", line); +} + +test "fmtRetirementLine: at_date case" { + var buf: [128]u8 = undefined; + const line = retirementLineForTest(&buf, .{ + .accumulation_years = 10, + .date = Date.fromYmd(2036, 7, 1), + .source = .at_date, + }); + try std.testing.expectEqualStrings("Years until possible retirement: 10 (2036-07-01)", line); +} + +test "fmtRetirementLine: at_age case" { + var buf: [128]u8 = undefined; + const line = retirementLineForTest(&buf, .{ + .accumulation_years = 14, + .date = Date.fromYmd(2040, 3, 15), + .source = .at_age, + }); + try std.testing.expectEqualStrings("Years until possible retirement: 14 (2040-03-15)", line); +} + +/// Test-only adapter to avoid dragging the views/projections.zig +/// module into this file's import surface. Mirrors +/// `views.fmtRetirementLine` exactly; if the formatter ever moves, +/// update both. +fn retirementLineForTest(buf: []u8, resolved: ResolvedRetirement) []const u8 { + if (resolved.source == .none) { + return std.fmt.bufPrint(buf, "Years until possible retirement: none", .{}) catch "Years until possible retirement: none"; + } + var date_buf: [10]u8 = undefined; + const date_str = if (resolved.date) |d| d.format(&date_buf) else "????-??-??"; + return std.fmt.bufPrint(buf, "Years until possible retirement: {d} ({s})", .{ + resolved.accumulation_years, + date_str, + }) catch "Years until possible retirement: ?"; +} + +// ── pickPromotedCell tests ───────────────────────────────────── + +test "pickPromotedCell: longest horizon selected when oldest stays under cap" { + var config = UserConfig{}; + config.horizon_count = 3; + config.horizons = .{ 25, 35, 50 } ++ @as([UserConfig.max_horizons - 3]u16, @splat(0)); + config.birthdate_count = 1; + config.birthdates[0] = Date.fromYmd(1981, 4, 12); // ~age 45 in 2026 + const today = Date.fromYmd(2026, 5, 12); + const confs = [_]f64{ 0.90, 0.95, 0.99 }; + const pc = pickPromotedCell(&config, today, &confs).?; + // Longest is 50; 45 + 50 = 95 < 100 → 50yr horizon picked. + try std.testing.expectEqual(@as(usize, 2), pc.horizon_index); + try std.testing.expectEqual(@as(usize, 2), pc.confidence_index); // 99% default + try std.testing.expect(!pc.explicit); +} + +test "pickPromotedCell: longest horizon overshoots, second-longest selected" { + var config = UserConfig{}; + config.horizon_count = 3; + config.horizons = .{ 25, 35, 50 } ++ @as([UserConfig.max_horizons - 3]u16, @splat(0)); + config.birthdate_count = 1; + config.birthdates[0] = Date.fromYmd(1968, 4, 12); // ~age 58 in 2026 + const today = Date.fromYmd(2026, 5, 12); + const confs = [_]f64{ 0.90, 0.95, 0.99 }; + const pc = pickPromotedCell(&config, today, &confs).?; + // Longest is 50; 58 + 50 = 108 >= 100 → skip. + // Next is 35; 58 + 35 = 93 < 100 → pick. + try std.testing.expectEqual(@as(u16, 35), config.horizons[pc.horizon_index]); + try std.testing.expectEqual(@as(usize, 2), pc.confidence_index); +} + +test "pickPromotedCell: all horizons overshoot, fall through to shortest" { + var config = UserConfig{}; + config.horizon_count = 3; + config.horizons = .{ 25, 35, 50 } ++ @as([UserConfig.max_horizons - 3]u16, @splat(0)); + config.birthdate_count = 1; + config.birthdates[0] = Date.fromYmd(1948, 4, 12); // ~age 78 in 2026 + const today = Date.fromYmd(2026, 5, 12); + const confs = [_]f64{ 0.90, 0.95, 0.99 }; + const pc = pickPromotedCell(&config, today, &confs).?; + // All overshoot 100. Shortest is 25 → pick it (fuck-it branch). + try std.testing.expectEqual(@as(u16, 25), config.horizons[pc.horizon_index]); +} + +test "pickPromotedCell: explicit retirement_target wins regardless of length" { + var config = UserConfig{}; + config.horizon_count = 3; + config.horizons = .{ 25, 35, 50 } ++ @as([UserConfig.max_horizons - 3]u16, @splat(0)); + // Annotate the SHORTEST horizon — overrides default rule which + // would pick the longest. + config.horizon_targets[0] = 95; + config.birthdate_count = 1; + config.birthdates[0] = Date.fromYmd(1981, 4, 12); + const today = Date.fromYmd(2026, 5, 12); + const confs = [_]f64{ 0.90, 0.95, 0.99 }; + const pc = pickPromotedCell(&config, today, &confs).?; + try std.testing.expectEqual(@as(u16, 25), config.horizons[pc.horizon_index]); + try std.testing.expectEqual(@as(usize, 1), pc.confidence_index); // 95% → index 1 + try std.testing.expect(pc.explicit); +} + +test "pickPromotedCell: no birthdates falls through to longest horizon" { + var config = UserConfig{}; + config.horizon_count = 3; + config.horizons = .{ 25, 35, 50 } ++ @as([UserConfig.max_horizons - 3]u16, @splat(0)); + const today = Date.fromYmd(2026, 5, 12); + const confs = [_]f64{ 0.90, 0.95, 0.99 }; + const pc = pickPromotedCell(&config, today, &confs).?; + try std.testing.expectEqual(@as(u16, 50), config.horizons[pc.horizon_index]); + try std.testing.expectEqual(@as(usize, 2), pc.confidence_index); // 99% +} + +test "pickPromotedCell: zero horizons returns null" { + var config = UserConfig{}; + config.horizon_count = 0; + const today = Date.fromYmd(2026, 5, 12); + const confs = [_]f64{ 0.90, 0.95, 0.99 }; + try std.testing.expect(pickPromotedCell(&config, today, &confs) == null); +} + +test "parseProjectionsConfig: retirement_target on horizon record" { + const data = + \\#!srfv1 + \\type::config,horizon:num:25 + \\type::config,horizon:num:35,retirement_target:num:95 + \\type::config,horizon:num:50 + ; + const config = parseProjectionsConfig(data); + try std.testing.expectEqual(@as(u8, 3), config.horizon_count); + try std.testing.expectEqual(@as(u8, 0), config.horizon_targets[0]); + try std.testing.expectEqual(@as(u8, 95), config.horizon_targets[1]); + try std.testing.expectEqual(@as(u8, 0), config.horizon_targets[2]); +} + +test "parseProjectionsConfig: retirement_target on horizon_age survives resolution" { + const data = + \\#!srfv1 + \\type::config,horizon_age:num:90,retirement_target:num:99 + \\type::birthdate,date::1975-01-01 + ; + var config = parseProjectionsConfig(data); + try std.testing.expectEqual(@as(u8, 99), config.horizon_age_targets[0]); + // Resolve: oldest age in 2025 is 50 → horizon 40. + try config.resolveHorizonAges(Date.fromYmd(2025, 6, 15)); + try std.testing.expectEqual(@as(u8, 1), config.horizon_count); + try std.testing.expectEqual(@as(u16, 40), config.horizons[0]); + try std.testing.expectEqual(@as(u8, 99), config.horizon_targets[0]); +} + +test "parseProjectionsConfig: invalid retirement_target value dropped silently per record" { + const data = + \\#!srfv1 + \\type::config,horizon:num:25 + \\type::config,horizon:num:35,retirement_target:num:80 + \\type::config,horizon:num:50,retirement_target:num:99 + ; + const config = parseProjectionsConfig(data); + // Record with retirement_target:80 keeps the horizon but drops + // the invalid annotation. The 99 on the third horizon is the + // ONLY valid annotation, so it stays. + try std.testing.expectEqual(@as(u8, 3), config.horizon_count); + try std.testing.expectEqual(@as(u8, 0), config.horizon_targets[0]); + try std.testing.expectEqual(@as(u8, 0), config.horizon_targets[1]); + try std.testing.expectEqual(@as(u8, 99), config.horizon_targets[2]); +} + +test "parseProjectionsConfig: multiple retirement_target annotations all dropped" { + const data = + \\#!srfv1 + \\type::config,horizon:num:25,retirement_target:num:95 + \\type::config,horizon:num:35,retirement_target:num:99 + \\type::config,horizon:num:50 + ; + const config = parseProjectionsConfig(data); + // Validation post-pass: > 1 annotation → drop them all. + try std.testing.expectEqual(@as(u8, 0), config.horizon_targets[0]); + try std.testing.expectEqual(@as(u8, 0), config.horizon_targets[1]); + try std.testing.expectEqual(@as(u8, 0), config.horizon_targets[2]); +} + +test "validRetirementTarget: 90/95/99 pass, others fail" { + try std.testing.expectEqual(@as(?u8, 90), validRetirementTarget(90)); + try std.testing.expectEqual(@as(?u8, 95), validRetirementTarget(95)); + try std.testing.expectEqual(@as(?u8, 99), validRetirementTarget(99)); + try std.testing.expectEqual(@as(?u8, null), validRetirementTarget(null)); + try std.testing.expectEqual(@as(?u8, null), validRetirementTarget(0)); + try std.testing.expectEqual(@as(?u8, null), validRetirementTarget(85)); + try std.testing.expectEqual(@as(?u8, null), validRetirementTarget(100)); +} + +// ── oldestBirthdate / oldestAge tests ────────────────────────── + +test "oldestBirthdate: no birthdates returns null" { + const config = UserConfig{}; + try std.testing.expectEqual(@as(?Date, null), config.oldestBirthdate()); +} + +test "oldestBirthdate: single birthdate returns it" { + var config = UserConfig{}; + config.birthdate_count = 1; + config.birthdates[0] = Date.fromYmd(1981, 4, 12); + const result = config.oldestBirthdate(); + try std.testing.expect(result.?.eql(Date.fromYmd(1981, 4, 12))); +} + +test "oldestBirthdate: multi-person picks earliest date" { + var config = UserConfig{}; + config.birthdate_count = 2; + config.birthdates[0] = Date.fromYmd(1983, 9, 8); + config.birthdates[1] = Date.fromYmd(1981, 4, 12); // older + const result = config.oldestBirthdate(); + try std.testing.expect(result.?.eql(Date.fromYmd(1981, 4, 12))); +} + +test "oldestBirthdate: multi-person regardless of slot order" { + var config = UserConfig{}; + config.birthdate_count = 2; + config.birthdates[0] = Date.fromYmd(1981, 4, 12); // older + config.birthdates[1] = Date.fromYmd(1983, 9, 8); + const result = config.oldestBirthdate(); + try std.testing.expect(result.?.eql(Date.fromYmd(1981, 4, 12))); +} + +test "oldestAge: no birthdates returns 0" { + const config = UserConfig{}; + const as_of = Date.fromYmd(2026, 5, 12); + try std.testing.expectEqual(@as(u16, 0), config.oldestAge(as_of)); +} + +test "oldestAge: derives whole years from oldest birthdate" { + var config = UserConfig{}; + config.birthdate_count = 2; + config.birthdates[0] = Date.fromYmd(1981, 4, 12); + config.birthdates[1] = Date.fromYmd(1983, 9, 8); + // 1981-04-12 → 2026-05-12 spans 45 full years. + const as_of = Date.fromYmd(2026, 5, 12); + try std.testing.expectEqual(@as(u16, 45), config.oldestAge(as_of)); +} + +// ── runProjectionGrid tests ──────────────────────────────────── + +/// Free a `ProjectionData` produced by `runProjectionGrid`. Used by +/// the tests below to keep their cleanup blocks tidy. +fn freeProjectionData(allocator: std.mem.Allocator, data: ProjectionData) void { + allocator.free(data.withdrawals); + for (data.bands) |b| { + if (b) |slice| allocator.free(slice); + } + allocator.free(data.bands); +} + +test "runProjectionGrid: structure and indexing" { + const allocator = std.testing.allocator; + const horizons = [_]u16{ 20, 30 }; + const conf = [_]f64{ 0.95, 0.99 }; + + const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true); + defer freeProjectionData(allocator, data); + + // 2 horizons × 2 confidence levels = 4 withdrawal results. + try std.testing.expectEqual(@as(usize, 4), data.withdrawals.len); + // 2 bands (one per horizon). + try std.testing.expectEqual(@as(usize, 2), data.bands.len); + // ci_99 is the last (highest) confidence index. + try std.testing.expectEqual(@as(usize, 1), data.ci_99); +} + +test "runProjectionGrid: withdrawal monotonicity along confidence axis" { + // Same horizon, lower confidence → higher allowed spending. + // Indexing: withdrawals[ci * horizons.len + hi]. + const allocator = std.testing.allocator; + const horizons = [_]u16{30}; + const conf = [_]f64{ 0.90, 0.95, 0.99 }; + + const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true); + defer freeProjectionData(allocator, data); + + const w_90 = data.withdrawals[0 * horizons.len + 0].annual_amount; + const w_95 = data.withdrawals[1 * horizons.len + 0].annual_amount; + const w_99 = data.withdrawals[2 * horizons.len + 0].annual_amount; + try std.testing.expect(w_90 >= w_95); + try std.testing.expect(w_95 >= w_99); +} + +test "runProjectionGrid: withdrawal monotonicity along horizon axis" { + // Same confidence, longer horizon → lower allowed spending. + const allocator = std.testing.allocator; + const horizons = [_]u16{ 20, 30, 45 }; + const conf = [_]f64{0.95}; + + const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true); + defer freeProjectionData(allocator, data); + + const w_20 = data.withdrawals[0 * horizons.len + 0].annual_amount; + const w_30 = data.withdrawals[0 * horizons.len + 1].annual_amount; + const w_45 = data.withdrawals[0 * horizons.len + 2].annual_amount; + try std.testing.expect(w_20 >= w_30); + try std.testing.expect(w_30 >= w_45); +} + +test "runProjectionGrid: distribution-only band length is horizon + 1" { + const allocator = std.testing.allocator; + const horizons = [_]u16{ 20, 30 }; + const conf = [_]f64{ 0.95, 0.99 }; + + const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true); + defer freeProjectionData(allocator, data); + + // band[0] covers horizons[0] = 20 → 21 entries; band[1] covers + // horizons[1] = 30 → 31 entries. + try std.testing.expectEqual(@as(usize, 21), data.bands[0].?.len); + try std.testing.expectEqual(@as(usize, 31), data.bands[1].?.len); +} + +test "runProjectionGrid: with-accumulation band length includes accumulation_years" { + const allocator = std.testing.allocator; + const horizons = [_]u16{30}; + const conf = [_]f64{0.95}; + + // 10 years of accumulation + 30 years distribution → 41 entries. + const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 10, 50_000, true); + defer freeProjectionData(allocator, data); + + try std.testing.expectEqual(@as(usize, 41), data.bands[0].?.len); +} + +test "runProjectionGrid: bands are p10 ≤ p25 ≤ p50 ≤ p75 ≤ p90 at every year" { + const allocator = std.testing.allocator; + const horizons = [_]u16{30}; + const conf = [_]f64{0.95}; + + const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true); + defer freeProjectionData(allocator, data); + + for (data.bands[0].?) |b| { + try std.testing.expect(b.p10 <= b.p25); + try std.testing.expect(b.p25 <= b.p50); + try std.testing.expect(b.p50 <= b.p75); + try std.testing.expect(b.p75 <= b.p90); + } +} + +test "runProjectionGrid: year 0 in every band equals total_value" { + const allocator = std.testing.allocator; + const horizons = [_]u16{ 20, 30 }; + const conf = [_]f64{0.95}; + + const total_value: f64 = 2_000_000; + const data = try runProjectionGrid(allocator, &horizons, &conf, total_value, 0.75, &.{}, 0, 0, true); + defer freeProjectionData(allocator, data); + + for (data.bands) |b_opt| { + const b = b_opt.?; + try std.testing.expectApproxEqAbs(total_value, b[0].p10, 1.0); + try std.testing.expectApproxEqAbs(total_value, b[0].p50, 1.0); + try std.testing.expectApproxEqAbs(total_value, b[0].p90, 1.0); + } +} + +test "runProjectionGrid: bands are computed at the highest-confidence withdrawal" { + // The chart anchors on `ci_99` — the LAST entry in + // `confidence_levels` — by feeding that withdrawal rate into + // `computePercentileBandsParams`. With confidence_levels = + // {.90, .95, .99}, the bands should reflect spending at 99% + // (the smallest, most-conservative withdrawal). + // + // Verification: re-running the band computation with the + // 99%-confidence withdrawal should produce identical bands. + const allocator = std.testing.allocator; + const horizons = [_]u16{30}; + const conf = [_]f64{ 0.90, 0.95, 0.99 }; + + const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true); + defer freeProjectionData(allocator, data); + + const wr_99 = data.withdrawals[data.ci_99 * horizons.len + 0]; + const expected = try computePercentileBandsParams(allocator, .{ + .initial_value = 1_000_000, + .stock_pct = 0.75, + .annual_spending = wr_99.annual_amount, + .distribution_years = 30, + }); + defer allocator.free(expected); + + const actual = data.bands[0].?; + try std.testing.expectEqual(expected.len, actual.len); + for (expected, actual) |exp, act| { + try std.testing.expectEqual(exp.year, act.year); + try std.testing.expectEqual(exp.p10, act.p10); + try std.testing.expectEqual(exp.p50, act.p50); + try std.testing.expectEqual(exp.p90, act.p90); + } +} + +test "runProjectionGrid: accumulation passes through to both withdrawals and bands" { + // Same horizon, same confidence, same starting portfolio: + // 10 years of $50k contributions should produce a meaningfully + // higher safe withdrawal than zero accumulation (the + // post-accumulation portfolio is bigger), AND the bands should + // be longer (accumulation_years + distribution_years + 1). + const allocator = std.testing.allocator; + const horizons = [_]u16{30}; + const conf = [_]f64{0.95}; + + const dist_only = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true); + defer freeProjectionData(allocator, dist_only); + + const with_accum = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 10, 50_000, true); + defer freeProjectionData(allocator, with_accum); + + // SWR with 10y of contributions on top should exceed SWR + // without. + try std.testing.expect(with_accum.withdrawals[0].annual_amount > dist_only.withdrawals[0].annual_amount); + + // Band length differs by exactly accumulation_years. + try std.testing.expectEqual(dist_only.bands[0].?.len + 10, with_accum.bands[0].?.len); +} + +test "runProjectionGrid: zero horizons produces empty results without crashing" { + const allocator = std.testing.allocator; + const horizons = [_]u16{}; + const conf = [_]f64{ 0.95, 0.99 }; + + const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true); + defer freeProjectionData(allocator, data); + + try std.testing.expectEqual(@as(usize, 0), data.withdrawals.len); + try std.testing.expectEqual(@as(usize, 0), data.bands.len); +} diff --git a/src/commands/projections.zig b/src/commands/projections.zig index 087c58b..496a752 100644 --- a/src/commands/projections.zig +++ b/src/commands/projections.zig @@ -227,6 +227,10 @@ pub fn run( } } + // ── Accumulation phase / Earliest retirement blocks ────────── + try renderAccumulationBlock(out, color, va, ctx); + try renderEarliestBlock(out, color, va, ctx, as_of); + // ── Braille chart: median portfolio value ───────────────────── if (horizons.len > 0) { const last_idx = horizons.len - 1; @@ -698,6 +702,100 @@ fn writeCell(out: *std.Io.Writer, color: bool, cell: view.ReturnCell, width: usi } } +/// Render the "Accumulation phase" block (driven by the user's +/// target retirement date — `retirement_age` / `retirement_at` — +/// or by the promoted cell from the earliest-retirement search when +/// only `target_spending` is configured). +/// +/// Always emits the "Years until possible retirement" line — including +/// `none` for the already-retired case, where the entire block reduces +/// to that single line. When a retirement date is configured, the +/// median portfolio at retirement and the p10–p90 range follow, +/// computed from the longest-horizon percentile bands. +fn renderAccumulationBlock(out: *std.Io.Writer, color: bool, va: std.mem.Allocator, ctx: view.ProjectionContext) !void { + try out.print("\n", .{}); + try cli.printBold(out, color, "Accumulation phase:\n", .{}); + + var line_buf: [128]u8 = undefined; + const parts = view.splitRetirementLine(&line_buf, ctx.retirement, &ctx.config); + // Label stays neutral; only the value (date / "not feasible" / + // "none") carries a style. This keeps "not feasible" loud + // without making the entire row scream red. + try out.print(" {s}", .{parts.label_text}); + try cli.printIntent(out, color, parts.value_style, "{s}\n", .{parts.value_text}); + + // Contribution line — suppressed when both contribution and + // accumulation are zero. + if (try view.fmtContributionLine(va, ctx.config.annual_contribution, ctx.config.contribution_inflation_adjusted, ctx.retirement.accumulation_years)) |contrib| { + try out.print(" {s}\n", .{contrib}); + } + + // Accumulation-phase stats: median + p10-p90 range at the + // retirement boundary. + if (ctx.accumulation) |acc| { + var median_buf: [24]u8 = undefined; + var p10_buf: [24]u8 = undefined; + var p90_buf: [24]u8 = undefined; + const median_str = fmt.fmtMoneyAbsTrim(&median_buf, acc.median_at_retirement); + const p10_str = fmt.fmtMoneyAbsTrim(&p10_buf, acc.p10_at_retirement); + const p90_str = fmt.fmtMoneyAbsTrim(&p90_buf, acc.p90_at_retirement); + try out.print(" Median portfolio at retirement: {s}\n", .{median_str}); + try out.print(" Range (10th\u{2013}90th percentile): {s} to {s}\n", .{ p10_str, p90_str }); + } +} + +/// Render the "Earliest retirement" block (driven by the user's +/// target spending — `target_spending`). +/// +/// Renders a grid of (confidence × horizon) cells, each showing the +/// earliest retirement date that sustains the target spending at that +/// confidence over that distribution horizon, or "—" when not feasible +/// within `max_accumulation_years`. +fn renderEarliestBlock(out: *std.Io.Writer, color: bool, va: std.mem.Allocator, ctx: view.ProjectionContext, as_of: zfin.Date) !void { + const earliest = ctx.earliest orelse return; + const target = ctx.config.target_spending orelse return; + + try out.print("\n", .{}); + var amt_buf: [24]u8 = undefined; + const amt_str = fmt.fmtMoneyAbsTrim(&amt_buf, target); + const adj: []const u8 = if (ctx.config.target_spending_inflation_adjusted) "CPI-adjusted" else "nominal"; + try cli.printBold(out, color, "Earliest retirement (target spending: {s}/yr {s})\n", .{ amt_str, adj }); + + const horizons = ctx.config.getHorizons(); + const confs = ctx.config.getConfidenceLevels(); + + // Header row: blank label space + horizon column headers. + const cell_width: usize = 14; + const label_width: usize = 25; + { + var hdr: std.ArrayListUnmanaged(u8) = .empty; + try hdr.appendNTimes(va, ' ', label_width); + for (horizons) |h| { + var hbuf: [16]u8 = undefined; + const hlabel = view.fmtHorizonLabel(&hbuf, h); + try hdr.appendNTimes(va, ' ', cell_width -| hlabel.len); + try hdr.appendSlice(va, hlabel); + } + try cli.printFg(out, color, cli.CLR_MUTED, "{s}\n", .{hdr.items}); + } + + for (confs, 0..) |conf, ci| { + const row = try view.buildEarliestRow(va, conf, horizons, earliest, ci, as_of); + // Label + try out.print(" {s}", .{row.label_text}); + // Pad label to label_width (we wrote 2 leading spaces + label, so pad to label_width - 2). + const pad = if (label_width > 2 + row.label_text.len) label_width - 2 - row.label_text.len else 0; + for (0..pad) |_| try out.print(" ", .{}); + // Cells + for (row.cells) |cell| { + const cellpad = if (cell_width > cell.text.len) cell_width - cell.text.len else 0; + for (0..cellpad) |_| try out.print(" ", .{}); + try cli.printIntent(out, color, cell.style, "{s}", .{cell.text}); + } + try out.print("\n", .{}); + } +} + // ── Tests ──────────────────────────────────────────────────── // // The projections simulation and rendering are covered by the diff --git a/src/format.zig b/src/format.zig index 9cd2e3b..f15f26d 100644 --- a/src/format.zig +++ b/src/format.zig @@ -210,6 +210,60 @@ pub fn fmtMoneyAbs(buf: []u8, amount: f64) []const u8 { return buf[0..len]; } +/// Format a dollar amount with commas, rounded to whole dollars +/// (no `.00` tail): $1,234. Always returns the absolute value — +/// callers handle sign display. +/// +/// Use when the cents are noise (chart-axis labels, projection +/// summaries where the numbers are already noisy estimates, etc.). +/// For exact accounting where cents matter, use `fmtMoneyAbs`. +pub fn fmtMoneyAbsWhole(buf: []u8, amount: f64) []const u8 { + const dollars_signed = @as(i64, @intFromFloat(@round(amount))); + const dollars: u64 = if (dollars_signed < 0) @intCast(-dollars_signed) else @intCast(dollars_signed); + + var tmp: [24]u8 = undefined; + var pos: usize = tmp.len; + + var d = dollars; + var digit_count: usize = 0; + if (d == 0) { + pos -= 1; + tmp[pos] = '0'; + } else { + while (d > 0) { + if (digit_count > 0 and digit_count % 3 == 0) { + pos -= 1; + tmp[pos] = ','; + } + pos -= 1; + tmp[pos] = '0' + @as(u8, @intCast(d % 10)); + d /= 10; + digit_count += 1; + } + } + pos -= 1; + tmp[pos] = '$'; + + const len = tmp.len - pos; + if (len > buf.len) return "$?"; + @memcpy(buf[0..len], tmp[pos..]); + return buf[0..len]; +} + +/// Format a dollar amount like `fmtMoneyAbs`, but elide the +/// `.00` tail when the value is a whole number of dollars: shows +/// `$1,234` for 1234.0 but keeps `$1,234.56` for non-zero cents. +/// +/// Distinct from `fmtMoneyAbsWhole`, which always rounds to dollars +/// and never shows cents. Use this for cosmetic displays where +/// cents-when-present are still informative (projection +/// summaries, status messages) but `.00` is just noise. +pub fn fmtMoneyAbsTrim(buf: []u8, amount: f64) []const u8 { + const s = fmtMoneyAbs(buf, amount); + if (std.mem.endsWith(u8, s, ".00")) return s[0 .. s.len - 3]; + return s; +} + /// Format an integer with commas (e.g. 1234567 -> "1,234,567"). pub fn fmtIntCommas(buf: []u8, value: u64) []const u8 { var tmp: [32]u8 = undefined; @@ -1092,6 +1146,46 @@ test "fmtMoneyAbs negative" { try std.testing.expectEqualStrings("$1,234.56", result); } +test "fmtMoneyAbsWhole" { + var buf: [24]u8 = undefined; + try std.testing.expectEqualStrings("$0", fmtMoneyAbsWhole(&buf, 0)); + try std.testing.expectEqualStrings("$1", fmtMoneyAbsWhole(&buf, 1)); + try std.testing.expectEqualStrings("$1,234", fmtMoneyAbsWhole(&buf, 1234)); + try std.testing.expectEqualStrings("$1,234,567", fmtMoneyAbsWhole(&buf, 1234567)); + + // Rounds half-away-from-zero (matches @round behavior for f64). + try std.testing.expectEqualStrings("$2", fmtMoneyAbsWhole(&buf, 1.5)); + try std.testing.expectEqualStrings("$1", fmtMoneyAbsWhole(&buf, 1.49)); + // Sub-dollar amount rounds to 0. + try std.testing.expectEqualStrings("$0", fmtMoneyAbsWhole(&buf, 0.4)); + + // Returns absolute value, like fmtMoneyAbs. Negative inputs + // get their magnitude rounded — `|-1234.56| = 1234.56` rounds + // to `$1,235`. + try std.testing.expectEqualStrings("$1,235", fmtMoneyAbsWhole(&buf, -1234.56)); +} + +test "fmtMoneyAbsTrim" { + var buf: [24]u8 = undefined; + // Whole dollars elide the .00 tail. + try std.testing.expectEqualStrings("$0", fmtMoneyAbsTrim(&buf, 0)); + try std.testing.expectEqualStrings("$1,234", fmtMoneyAbsTrim(&buf, 1234)); + try std.testing.expectEqualStrings("$1,234,567", fmtMoneyAbsTrim(&buf, 1234567)); + + // Non-zero cents are preserved (distinct from fmtMoneyAbsWhole + // which would round these). + try std.testing.expectEqualStrings("$1,234.56", fmtMoneyAbsTrim(&buf, 1234.56)); + try std.testing.expectEqualStrings("$0.01", fmtMoneyAbsTrim(&buf, 0.01)); + try std.testing.expectEqualStrings("$0.50", fmtMoneyAbsTrim(&buf, 0.5)); + + // Sub-cent rounds-to-zero-cents elides too. + try std.testing.expectEqualStrings("$10", fmtMoneyAbsTrim(&buf, 10.001)); + + // Returns absolute value, like fmtMoneyAbs. + try std.testing.expectEqualStrings("$1,234", fmtMoneyAbsTrim(&buf, -1234)); + try std.testing.expectEqualStrings("$1,234.56", fmtMoneyAbsTrim(&buf, -1234.56)); +} + test "fmtIntCommas" { var buf: [32]u8 = undefined; try std.testing.expectEqualStrings("0", fmtIntCommas(&buf, 0)); diff --git a/src/models/date.zig b/src/models/date.zig index 8a97182..dea0b61 100644 --- a/src/models/date.zig +++ b/src/models/date.zig @@ -98,6 +98,17 @@ pub const Date = struct { return .{ .days = ymdToEpochDays(new_year, ymd.month, new_day) }; } + /// Add N calendar years. Clamps Feb 29 -> Feb 28 if target is not + /// a leap year. Mirror of `subtractYears` — used by callers that + /// need "what date will it be when this person turns N", i.e. + /// `birthdate.addYears(target_age)`. + pub fn addYears(self: Date, n: u16) Date { + const ymd = epochDaysToYmd(self.days); + const new_year: i16 = ymd.year + @as(i16, @intCast(n)); + const new_day: u8 = if (ymd.month == 2 and ymd.day == 29 and !isLeapYear(new_year)) 28 else ymd.day; + return .{ .days = ymdToEpochDays(new_year, ymd.month, new_day) }; + } + /// Subtract N calendar months. Clamps day to end of month if needed (e.g. Mar 31 - 1M = Feb 28). pub fn subtractMonths(self: Date, n: u16) Date { const ymd = epochDaysToYmd(self.days); @@ -155,6 +166,43 @@ pub const Date = struct { return @as(f64, @floatFromInt(to.days - from.days)) / 365.25; } + /// Whole years between two dates, floored to a non-negative + /// `u16`. Returns 0 when `to` is at or before `from`. Built on + /// `yearsBetween` (365.25-day approximation) — sufficient for + /// "how many full years until X" displays where the displayed + /// date itself is the precision-bearing value. + /// + /// Distinct from `ageOn`, which is calendar-precise — use that + /// when the answer must match calendar-anniversary intuition + /// (e.g. "what age will I be on this exact date"). + pub fn wholeYearsBetween(from: Date, to: Date) u16 { + if (to.days <= from.days) return 0; + const years = yearsBetween(from, to); + const floored: i32 = @intFromFloat(@floor(years)); + if (floored < 0) return 0; + return @intCast(floored); + } + + /// Calendar-year age of a person born on `self` evaluated on + /// `on`. Whole-year integer math: subtract years, then drop one + /// if the birthday hasn't occurred yet that calendar year. + /// Returns 0 when `on` is at or before `self`. + /// + /// Distinct from `wholeYearsBetween`, which uses a 365.25-day + /// approximation that floors-down the exact-anniversary case to + /// `age − 1`. For "what age will I be on date X" displays where + /// the answer must match the calendar (e.g. you turn 65 ON + /// your 65th birthday, not the day after), use `ageOn`. + pub fn ageOn(self: Date, on: Date) u16 { + if (on.days <= self.days) return 0; + var years: i16 = on.year() - self.year(); + const before_birthday = (on.month() < self.month()) or + (on.month() == self.month() and on.day() < self.day()); + if (before_birthday) years -= 1; + if (years < 0) return 0; + return @intCast(years); + } + fn isLeapYear(y: i16) bool { const yu: u16 = @bitCast(y); return (yu % 4 == 0 and yu % 100 != 0) or (yu % 400 == 0); @@ -233,6 +281,35 @@ test "subtractYears" { try std.testing.expectEqual(@as(u8, 28), non_leap.day()); } +test "addYears" { + // Symmetric with subtractYears — same algorithm, opposite direction. + const d = Date.fromYmd(2026, 2, 24); + const d1 = d.addYears(1); + try std.testing.expectEqual(@as(i16, 2027), d1.year()); + try std.testing.expectEqual(@as(u8, 2), d1.month()); + try std.testing.expectEqual(@as(u8, 24), d1.day()); + + const d3 = d.addYears(3); + try std.testing.expectEqual(@as(i16, 2029), d3.year()); + + // Leap year edge case: Feb 29 2024 + 1 year = Feb 28 2025 (target year is non-leap) + const leap = Date.fromYmd(2024, 2, 29); + const non_leap = leap.addYears(1); + try std.testing.expectEqual(@as(i16, 2025), non_leap.year()); + try std.testing.expectEqual(@as(u8, 2), non_leap.month()); + try std.testing.expectEqual(@as(u8, 28), non_leap.day()); + + // Leap year edge case: Feb 29 2024 + 4 years = Feb 29 2028 (target year IS leap) + const leap_to_leap = leap.addYears(4); + try std.testing.expectEqual(@as(i16, 2028), leap_to_leap.year()); + try std.testing.expectEqual(@as(u8, 2), leap_to_leap.month()); + try std.testing.expectEqual(@as(u8, 29), leap_to_leap.day()); + + // Zero years is a no-op + const same = d.addYears(0); + try std.testing.expect(same.eql(d)); +} + test "lastDayOfPriorMonth" { // Feb 24 -> Jan 31 const d1 = Date.fromYmd(2026, 2, 24).lastDayOfPriorMonth(); @@ -340,6 +417,58 @@ test "yearsBetween" { try std.testing.expectApproxEqAbs(@as(f64, 0.0), Date.yearsBetween(a, a), 0.001); } +test "wholeYearsBetween" { + const a = Date.fromYmd(2024, 1, 1); + // 2024-01-01 → 2025-01-01 is 366 days (2024 is a leap year). + // 366 / 365.25 ≈ 1.002 → floor = 1. + const b = Date.fromYmd(2025, 1, 1); + try std.testing.expectEqual(@as(u16, 1), Date.wholeYearsBetween(a, b)); + + // 2025-01-01 → 2026-01-01 is 365 days (2025 is not a leap year). + // 365 / 365.25 ≈ 0.9993 → floor = 0. Caveat of the 365.25-day + // approximation: spans of exactly one non-leap year underflow. + // For calendar-precise age math, use Date.ageOn. + const c = Date.fromYmd(2026, 1, 1); + try std.testing.expectEqual(@as(u16, 0), Date.wholeYearsBetween(b, c)); + + // Multi-year spans average out the leap-year noise. + const ten_years = Date.fromYmd(2034, 1, 5); + try std.testing.expectEqual(@as(u16, 10), Date.wholeYearsBetween(a, ten_years)); + + // Same date returns 0. + try std.testing.expectEqual(@as(u16, 0), Date.wholeYearsBetween(a, a)); + + // `to` before `from` returns 0 (clamps the negative case). + const earlier = Date.fromYmd(2020, 1, 1); + try std.testing.expectEqual(@as(u16, 0), Date.wholeYearsBetween(a, earlier)); +} + +test "ageOn: exact anniversary returns full year (not approximation)" { + // Born 1981-04-12, evaluated on 2046-04-12: exactly 65, not 64. + // Distinguishes from wholeYearsBetween which uses 365.25-day + // approximation and floors down on exact anniversaries. + try std.testing.expectEqual(@as(u16, 65), Date.fromYmd(1981, 4, 12).ageOn(Date.fromYmd(2046, 4, 12))); +} + +test "ageOn: before birthday this year drops by one" { + // Born June 1, evaluated April 12 — birthday hasn't occurred yet. + try std.testing.expectEqual(@as(u16, 64), Date.fromYmd(1981, 6, 1).ageOn(Date.fromYmd(2046, 4, 12))); +} + +test "ageOn: birthday month, before day drops by one" { + // Same month, day not yet reached. + try std.testing.expectEqual(@as(u16, 64), Date.fromYmd(1981, 4, 15).ageOn(Date.fromYmd(2046, 4, 12))); +} + +test "ageOn: same date returns 0" { + try std.testing.expectEqual(@as(u16, 0), Date.fromYmd(2026, 5, 12).ageOn(Date.fromYmd(2026, 5, 12))); +} + +test "ageOn: birthdate after `on` returns 0" { + // Defensive: future birthdate evaluated against past date. + try std.testing.expectEqual(@as(u16, 0), Date.fromYmd(2030, 1, 1).ageOn(Date.fromYmd(2026, 5, 12))); +} + test "parse error cases" { try std.testing.expectError(error.InvalidDateFormat, Date.parse("not-a-date")); try std.testing.expectError(error.InvalidDateFormat, Date.parse("20240115")); // no dashes diff --git a/src/tui/projections_tab.zig b/src/tui/projections_tab.zig index b1b0bf5..65a0892 100644 --- a/src/tui/projections_tab.zig +++ b/src/tui/projections_tab.zig @@ -188,6 +188,7 @@ pub fn freeLoaded(app: *App) void { if (b) |slice| app.allocator.free(slice); } app.allocator.free(ctx.data.bands); + if (ctx.earliest) |er| app.allocator.free(er); } app.projections_ctx = null; // Mark projection chart as dirty so it re-renders on next draw @@ -398,11 +399,7 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, // Format as whole dollars (no decimals) var lbl_buf: [16]u8 = undefined; - const lbl_full = fmt.fmtMoneyAbs(&lbl_buf, @round(val)); - const lbl = if (std.mem.lastIndexOfScalar(u8, lbl_full, '.')) |dot| - lbl_full[0..dot] - else - lbl_full; + const lbl = fmt.fmtMoneyAbsWhole(&lbl_buf, val); const start_idx = row * @as(usize, width) + label_col; for (lbl, 0..) |ch, ci| { @@ -533,6 +530,13 @@ fn buildHeaderSection(app: *App, arena: std.mem.Allocator, lines: *std.ArrayList }); } } + + // Accumulation phase / Earliest retirement blocks. Use the + // historical snapshot date when one is configured so the + // promoted date and earliest-retirement grid anchor on the + // same reference point as the rest of the as-of-mode display. + const ref_date = app.projections_as_of orelse app.today; + try appendAccumulationBlocks(lines, arena, th, pctx, ref_date); } /// Build the footer section (terminal values + safe withdrawal table). @@ -613,6 +617,189 @@ fn appendEventSummary(lines: *std.ArrayListUnmanaged(StyledLine), as_of: zfin.Da } } +/// Append the "Accumulation phase" + "Earliest retirement" blocks +/// (driven by the user's target retirement date and target spending +/// inputs) to a styled-lines list. Always emits the retirement +/// line; the contribution row is suppressed when both contribution +/// and accumulation are zero. Earliest-retirement grid only renders +/// when `target_spending` is configured. +fn appendAccumulationBlocks( + lines: *std.ArrayListUnmanaged(StyledLine), + arena: std.mem.Allocator, + th: theme.Theme, + pctx: view.ProjectionContext, + as_of: zfin.Date, +) !void { + // Accumulation phase block. + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + try lines.append(arena, .{ .text = " Accumulation phase:", .style = th.headerStyle() }); + + var line_buf: [128]u8 = undefined; + const parts = view.splitRetirementLine(&line_buf, pctx.retirement, &pctx.config); + if (parts.value_style == .negative) { + // Per-cell styled line: 4-space indent + neutral label + + // red value. Other forms (none/at_date/at_age/promoted) all + // render with the value style matching the label, so the + // single-style fast path below is fine for them. + const indent = " "; + const total_len = indent.len + parts.label_text.len + parts.value_text.len; + const graphemes = try arena.alloc([]const u8, total_len); + const cell_styles = try arena.alloc(vaxis.Style, total_len); + const neutral = th.contentStyle(); + const negative = th.styleFor(.negative); + var gp: usize = 0; + for (indent) |ch| { + graphemes[gp] = tui.glyph(ch); + cell_styles[gp] = neutral; + gp += 1; + } + for (parts.label_text) |ch| { + graphemes[gp] = tui.glyph(ch); + cell_styles[gp] = neutral; + gp += 1; + } + for (parts.value_text) |ch| { + graphemes[gp] = tui.glyph(ch); + cell_styles[gp] = negative; + gp += 1; + } + try lines.append(arena, .{ + .text = "", + .style = neutral, + .graphemes = graphemes[0..gp], + .cell_styles = cell_styles[0..gp], + }); + } else { + try lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, " {s}{s}", .{ parts.label_text, parts.value_text }), + .style = th.contentStyle(), + }); + } + + if (try view.fmtContributionLine(arena, pctx.config.annual_contribution, pctx.config.contribution_inflation_adjusted, pctx.retirement.accumulation_years)) |contrib| { + try lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, " {s}", .{contrib}), + .style = th.contentStyle(), + }); + } + + if (pctx.accumulation) |acc| { + var median_buf: [24]u8 = undefined; + var p10_buf: [24]u8 = undefined; + var p90_buf: [24]u8 = undefined; + const median_str = fmt.fmtMoneyAbsTrim(&median_buf, acc.median_at_retirement); + const p10_str = fmt.fmtMoneyAbsTrim(&p10_buf, acc.p10_at_retirement); + const p90_str = fmt.fmtMoneyAbsTrim(&p90_buf, acc.p90_at_retirement); + try lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, " Median portfolio at retirement: {s}", .{median_str}), + .style = th.contentStyle(), + }); + try lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, " Range (10th\u{2013}90th percentile): {s} to {s}", .{ p10_str, p90_str }), + .style = th.mutedStyle(), + }); + } + + // Earliest retirement block (target-spending input). + if (pctx.earliest) |earliest| { + const target = pctx.config.target_spending orelse return; + var amt_buf: [24]u8 = undefined; + const amt_str = fmt.fmtMoneyAbsTrim(&amt_buf, target); + const adj: []const u8 = if (pctx.config.target_spending_inflation_adjusted) "CPI-adjusted" else "nominal"; + + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + try lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, " Earliest retirement (target spending: {s}/yr {s})", .{ amt_str, adj }), + .style = th.headerStyle(), + }); + + const horizons = pctx.config.getHorizons(); + const confs = pctx.config.getConfidenceLevels(); + const cell_width: usize = 14; + const label_width: usize = 25; + + // Header row. + { + var hdr: std.ArrayListUnmanaged(u8) = .empty; + try hdr.appendNTimes(arena, ' ', label_width); + for (horizons) |h| { + var hbuf: [16]u8 = undefined; + const hlabel = view.fmtHorizonLabel(&hbuf, h); + try hdr.appendNTimes(arena, ' ', cell_width -| hlabel.len); + try hdr.appendSlice(arena, hlabel); + } + try lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, " {s}", .{hdr.items}), + .style = th.mutedStyle(), + }); + } + + for (confs, 0..) |conf, ci| { + const row = try view.buildEarliestRow(arena, conf, horizons, earliest, ci, as_of); + + // Per-cell styled row so individual "infeasible" cells + // can render in `.negative` (red) while feasible date + // cells render in the default content color. A single + // `style` on the StyledLine would force every cell to + // the same color and bury the bad-news cells. + // + // Layout: " " + label + label-pad + (cell-pad + cell-text)* + const indent = " "; + var total: usize = indent.len + row.label_text.len; + const label_pad = if (label_width > row.label_text.len) label_width - row.label_text.len else 0; + total += label_pad; + for (row.cells) |cell| { + const cellpad = if (cell_width > cell.text.len) cell_width - cell.text.len else 0; + total += cellpad + cell.text.len; + } + + const graphemes = try arena.alloc([]const u8, total); + const cell_styles = try arena.alloc(vaxis.Style, total); + const neutral = th.contentStyle(); + var gp: usize = 0; + + for (indent) |ch| { + graphemes[gp] = tui.glyph(ch); + cell_styles[gp] = neutral; + gp += 1; + } + for (row.label_text) |ch| { + graphemes[gp] = tui.glyph(ch); + cell_styles[gp] = neutral; + gp += 1; + } + var pad_i: usize = 0; + while (pad_i < label_pad) : (pad_i += 1) { + graphemes[gp] = " "; + cell_styles[gp] = neutral; + gp += 1; + } + for (row.cells) |cell| { + const cellpad = if (cell_width > cell.text.len) cell_width - cell.text.len else 0; + var cp: usize = 0; + while (cp < cellpad) : (cp += 1) { + graphemes[gp] = " "; + cell_styles[gp] = neutral; + gp += 1; + } + const cell_style = th.styleFor(cell.style); + for (cell.text) |ch| { + graphemes[gp] = tui.glyph(ch); + cell_styles[gp] = cell_style; + gp += 1; + } + } + + try lines.append(arena, .{ + .text = "", + .style = neutral, + .graphemes = graphemes[0..gp], + .cell_styles = cell_styles[0..gp], + }); + } + } +} + pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine { const th = app.theme; var lines: std.ArrayListUnmanaged(StyledLine) = .empty; @@ -722,6 +909,13 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine } } + // Accumulation phase / Earliest retirement blocks. Use the + // historical snapshot date when one is configured so the + // promoted date and earliest-retirement grid anchor on the + // same reference point as the rest of the as-of-mode display. + const ref_date = app.projections_as_of orelse app.today; + try appendAccumulationBlocks(&lines, arena, th, ctx, ref_date); + // Braille chart: median portfolio value over the longest horizon try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); const horizons = config.getHorizons(); diff --git a/src/views/projections.zig b/src/views/projections.zig index e6a8d0d..12610a4 100644 --- a/src/views/projections.zig +++ b/src/views/projections.zig @@ -147,43 +147,64 @@ pub const ProjectionContext = struct { stock_pct: f64, bond_pct: f64, total_value: f64, + /// Resolved retirement boundary against the projection's reference + /// date (today for live mode, `as_of` for historical mode). + retirement: projections.ResolvedRetirement = .{ .accumulation_years = 0, .date = null, .source = .none }, + /// Statistics from the simulation's accumulation phase: portfolio + /// value at the retirement boundary, computed from the configured + /// median/p10/p90 percentile bands. `null` when the user has not + /// configured any retirement date (so the simulation runs + /// distribution-only and there's no boundary year to evaluate at). + accumulation: ?AccumulationStats = null, + /// "Earliest retirement" grid results: one entry per (horizon × + /// confidence) pair when the user configured `target_spending`, + /// or `null` otherwise. + earliest: ?[]projections.EarliestRetirement = null, + /// Which retirement-planning inputs the user configured. Drives + /// which display blocks render. + inputs: ProjectionInputs = .distribution_only, }; -pub const ProjectionData = struct { - withdrawals: []projections.WithdrawalResult, - bands: []?[]projections.YearPercentiles, - ci_99: usize, +/// Statistics extracted from the bands at the retirement-boundary +/// year. Used to render the median portfolio at retirement and the +/// p10–p90 range under the "Accumulation phase" display block. +pub const AccumulationStats = struct { + median_at_retirement: f64, + p10_at_retirement: f64, + p90_at_retirement: f64, + annual_contribution: f64, + contribution_inflation_adjusted: bool, }; -pub fn computeProjectionData( - alloc: std.mem.Allocator, - horizons: []const u16, - confidence_levels: []const f64, - total_value: f64, - stock_pct: f64, - events: []const projections.ResolvedEvent, -) !ProjectionData { - const num_results = horizons.len * confidence_levels.len; - const withdrawals = try alloc.alloc(projections.WithdrawalResult, num_results); - for (confidence_levels, 0..) |conf, ci| { - for (horizons, 0..) |h, hi| { - withdrawals[ci * horizons.len + hi] = projections.findSafeWithdrawal(h, total_value, stock_pct, conf, events); - } - } - const ci_99 = confidence_levels.len - 1; - const bands = try alloc.alloc(?[]projections.YearPercentiles, horizons.len); - for (horizons, 0..) |h, hi| { - bands[hi] = projections.computePercentileBands( - alloc, - h, - total_value, - withdrawals[ci_99 * horizons.len + hi].annual_amount, - stock_pct, - events, - ) catch null; - } - return .{ .withdrawals = withdrawals, .bands = bands, .ci_99 = ci_99 }; -} +/// Which retirement-planning inputs the user has configured. +/// +/// The simulation always runs the same two-phase model +/// (accumulation followed by distribution); these variants describe +/// only which output blocks the display layer renders. +/// +/// - `.distribution_only` — neither a target retirement date +/// (`retirement_age` / `retirement_at`) nor a target spending +/// (`target_spending`) is set. Already-retired users; the +/// accumulation phase has zero years. +/// - `.target_retirement_date` — `retirement_age` or +/// `retirement_at` is set. The display reports the spending the +/// accumulated portfolio supports. +/// - `.target_spending` — `target_spending` is set. The display +/// reports the date(s) at which that spending becomes +/// sustainable, and promotes one cell from the resulting grid +/// into the headline retirement line. +/// - `.both_targets` — both are set. Both blocks render +/// back-to-back; the configured date wins for the headline. +pub const ProjectionInputs = enum { + distribution_only, + target_retirement_date, + target_spending, + both_targets, +}; + +pub const ProjectionData = projections.ProjectionData; + +pub const runProjectionGrid = projections.runProjectionGrid; pub fn buildProjectionContext( alloc: std.mem.Allocator, @@ -193,9 +214,143 @@ pub fn buildProjectionContext( bond_pct: f64, total_value: f64, events: []const projections.ResolvedEvent, + as_of: Date, ) !ProjectionContext { const sim_stock_pct = if (config.target_stock_pct) |t| t / 100.0 else stock_pct; - const data = try computeProjectionData(alloc, config.getHorizons(), config.getConfidenceLevels(), total_value, sim_stock_pct, events); + + // Resolve the retirement boundary from the user's target + // retirement date (`retirement_age` / `retirement_at`). Returns + // `.none` when neither is configured. + var retirement = config.resolveRetirement(as_of); + const accumulation_years: u16 = retirement.accumulation_years; + + const data = try runProjectionGrid( + alloc, + config.getHorizons(), + config.getConfidenceLevels(), + total_value, + sim_stock_pct, + events, + accumulation_years, + config.annual_contribution, + config.contribution_inflation_adjusted, + ); + + // Accumulation-phase stats: extract portfolio value at the + // retirement boundary from the longest-horizon band (most data + // available; same boundary year for all horizons). + var accumulation_stats: ?AccumulationStats = null; + if (accumulation_years > 0) { + const horizons = config.getHorizons(); + if (horizons.len > 0) { + const last_band = data.bands[horizons.len - 1]; + if (last_band) |b| { + if (b.len > @as(usize, accumulation_years)) { + const yp = b[@as(usize, accumulation_years)]; + accumulation_stats = .{ + .median_at_retirement = yp.p50, + .p10_at_retirement = yp.p10, + .p90_at_retirement = yp.p90, + .annual_contribution = config.annual_contribution, + .contribution_inflation_adjusted = config.contribution_inflation_adjusted, + }; + } + } + } + } + + // Earliest retirement grid: when `target_spending` is set, + // search for the earliest retirement year per (horizon × + // confidence) pair. + var earliest: ?[]projections.EarliestRetirement = null; + if (config.target_spending) |target| { + const horizons = config.getHorizons(); + const confs = config.getConfidenceLevels(); + const cells = try alloc.alloc(projections.EarliestRetirement, horizons.len * confs.len); + for (confs, 0..) |conf, ci| { + for (horizons, 0..) |h, hi| { + cells[ci * horizons.len + hi] = try projections.findEarliestRetirement( + alloc, + total_value, + sim_stock_pct, + config.annual_contribution, + config.contribution_inflation_adjusted, + target, + config.target_spending_inflation_adjusted, + h, + conf, + events, + projections.max_accumulation_years, + ); + } + } + earliest = cells; + } + + const has_target_date = retirement.source != .none; + const has_target_spend = config.target_spending != null; + const inputs: ProjectionInputs = if (has_target_spend and has_target_date) + .both_targets + else if (has_target_spend) + .target_spending + else if (has_target_date) + .target_retirement_date + else + .distribution_only; + + // Promotion: when target_spending is configured but no explicit + // retirement date is, pick a cell from the Earliest retirement + // grid and promote it into `retirement` + `accumulation_stats`. + // This keeps the Accumulation phase block coherent with the + // target-spending answer below it. + if (inputs == .target_spending) { + if (earliest) |grid| { + const horizons = config.getHorizons(); + const confs = config.getConfidenceLevels(); + if (projections.pickPromotedCell(&config, as_of, confs)) |pc| { + const cell = grid[pc.confidence_index * horizons.len + pc.horizon_index]; + if (cell.accumulation_years) |n| { + // Promoted cell is feasible: synthesize a + // retirement date by walking N years out from + // `as_of` (preserves the reference date's m/d, + // matching the calendar-precise treatment of + // `retirement_at`). + const ret_date = Date.fromYmd( + as_of.year() + @as(i16, @intCast(n)), + as_of.month(), + as_of.day(), + ); + retirement = .{ + .accumulation_years = n, + .date = ret_date, + .source = .promoted, + }; + // Recompute accumulation_stats using the + // promoted N — the target-retirement-date path + // computes these from the percentile bands, but + // the target-spending cell already carries + // median/p10/p90 at retirement, so reuse them. + accumulation_stats = .{ + .median_at_retirement = cell.median_at_retirement, + .p10_at_retirement = cell.p10_at_retirement, + .p90_at_retirement = cell.p90_at_retirement, + .annual_contribution = config.annual_contribution, + .contribution_inflation_adjusted = config.contribution_inflation_adjusted, + }; + } else { + // Promoted cell is infeasible: render the line + // as "not feasible" and skip the stats lines. + retirement = .{ + .accumulation_years = 0, + .date = null, + .source = .promoted_infeasible, + }; + accumulation_stats = null; + } + } + } + } + return .{ .comparison = comparison, .config = config, @@ -203,6 +358,10 @@ pub fn buildProjectionContext( .stock_pct = stock_pct, .bond_pct = bond_pct, .total_value = total_value, + .retirement = retirement, + .accumulation = accumulation_stats, + .earliest = earliest, + .inputs = inputs, }; } @@ -433,9 +592,239 @@ fn buildContextFromParts( split.bond_pct, total_value, resolved_events, + as_of, ); } +// ── Accumulation phase / earliest retirement display blocks ──── + +/// Format the "Years until possible retirement: …" line. The line is +/// always present in projections output for transparency, including +/// the `none` and infeasible cases. +/// +/// When `config` has at least one birthdate AND the retirement is +/// resolved to a date, the configured persons' ages on the +/// retirement date are appended in birthdate-record order, separated +/// by '/'. Ages use whole-year integer math (`yearsBetween` floored). +/// +/// Output forms: +/// - `.none` → "Years until possible retirement: none" +/// - `.at_date` / `.at_age` / `.promoted` (with date) → +/// "Years until possible retirement: 10 (2036-07-01, ages 65/62)" +/// - `.promoted_infeasible` → "Years until possible retirement: not feasible" +/// +/// Buffer should be at least 128 bytes; the worst-case 4-person line +/// fits comfortably. +/// +/// For renderers that want to color just the value portion (e.g. +/// "not feasible" in red while keeping the label neutral), use +/// `splitRetirementLine` instead — this function returns the full +/// concatenated line for callers that don't care about styling. +pub fn fmtRetirementLine(buf: []u8, resolved: projections.ResolvedRetirement, config: *const projections.UserConfig) []const u8 { + const parts = splitRetirementLine(buf, resolved, config); + // Re-concatenate when the caller doesn't want the split. + // Both halves already point into `buf`, in order, so we only + // need to return the contiguous slice spanning both. + if (parts.value_text.len == 0) return parts.label_text; + const start = @intFromPtr(parts.label_text.ptr) - @intFromPtr(buf.ptr); + const end = (@intFromPtr(parts.value_text.ptr) - @intFromPtr(buf.ptr)) + parts.value_text.len; + return buf[start..end]; +} + +/// Pre-formatted retirement line split into a neutral label and a +/// value portion that the caller may want to render in a different +/// style. The value carries the StyleIntent; the label is always +/// rendered in the default content color. +/// +/// Concatenation rule: `` produces the full +/// line. Both slices point into `buf` and are contiguous. +pub const RetirementLineParts = struct { + /// Always-neutral label, e.g. "Years until possible retirement: ". + label_text: []const u8, + /// Value portion the caller may style. Empty when the resolved + /// state has no separate value (currently never — even the + /// `none` case puts "none" here). + value_text: []const u8, + /// Suggested style for the value portion. `.normal` for dates, + /// `.negative` for "not feasible", `.muted` for "none". + value_style: StyleIntent, +}; + +pub fn splitRetirementLine(buf: []u8, resolved: projections.ResolvedRetirement, config: *const projections.UserConfig) RetirementLineParts { + const label = "Years until possible retirement: "; + + if (resolved.source == .none) { + // "Years until possible retirement: " + "none" + const written = std.fmt.bufPrint(buf, "{s}none", .{label}) catch { + return .{ .label_text = "Years until possible retirement: ", .value_text = "none", .value_style = .muted }; + }; + return .{ + .label_text = written[0..label.len], + .value_text = written[label.len..], + .value_style = .muted, + }; + } + + if (resolved.source == .promoted_infeasible) { + const written = std.fmt.bufPrint(buf, "{s}not feasible", .{label}) catch { + return .{ .label_text = "Years until possible retirement: ", .value_text = "not feasible", .value_style = .negative }; + }; + return .{ + .label_text = written[0..label.len], + .value_text = written[label.len..], + .value_style = .negative, + }; + } + + var date_buf: [10]u8 = undefined; + const date_str = if (resolved.date) |d| d.format(&date_buf) else "????-??-??"; + + // Build the optional ", ages A/B/..." suffix when birthdates are + // configured and we have a retirement date. Skipping is safe and + // makes the function tolerant of zero-birthdate configs. + var ages_buf: [64]u8 = undefined; + var ages_len: usize = 0; + if (resolved.date) |d| { + if (config.birthdate_count > 0) { + // ", age " (single) or ", ages " (multiple) — singular + // form when only one person is configured matches normal + // English usage. + const prefix: []const u8 = if (config.birthdate_count == 1) ", age " else ", ages "; + if (ages_len + prefix.len > ages_buf.len) return shortParts(buf, resolved.accumulation_years, date_str); + @memcpy(ages_buf[ages_len .. ages_len + prefix.len], prefix); + ages_len += prefix.len; + + var i: u8 = 0; + while (i < config.birthdate_count) : (i += 1) { + if (i > 0) { + if (ages_len + 1 > ages_buf.len) return shortParts(buf, resolved.accumulation_years, date_str); + ages_buf[ages_len] = '/'; + ages_len += 1; + } + const age = config.birthdates[i].ageOn(d); + const age_str = std.fmt.bufPrint(ages_buf[ages_len..], "{d}", .{age}) catch + return shortParts(buf, resolved.accumulation_years, date_str); + ages_len += age_str.len; + } + } + } + + if (ages_len == 0) return shortParts(buf, resolved.accumulation_years, date_str); + + const written = std.fmt.bufPrint(buf, "{s}{d} ({s}{s})", .{ + label, + resolved.accumulation_years, + date_str, + ages_buf[0..ages_len], + }) catch return shortParts(buf, resolved.accumulation_years, date_str); + return .{ + .label_text = written[0..label.len], + .value_text = written[label.len..], + .value_style = .normal, + }; +} + +/// Fallback used when the ages suffix can't be rendered (no +/// birthdates, ages buffer overflow, or format failure). Keeps the +/// "Years until possible retirement: N (DATE)" form intact, with +/// the value portion styled `.normal`. +fn shortParts(buf: []u8, years: u16, date_str: []const u8) RetirementLineParts { + const label = "Years until possible retirement: "; + const written = std.fmt.bufPrint(buf, "{s}{d} ({s})", .{ label, years, date_str }) catch { + return .{ + .label_text = "Years until possible retirement: ", + .value_text = "?", + .value_style = .normal, + }; + }; + return .{ + .label_text = written[0..label.len], + .value_text = written[label.len..], + .value_style = .normal, + }; +} + +/// Format the "Annual contributions: $X (CPI-adjusted)" line. +/// +/// Returns null when the contribution is zero AND the caller has no +/// accumulation phase configured — the line would be pure noise. The +/// caller should still render the retirement line in that case; +/// suppressing the contribution row alone keeps the block tidy. +pub fn fmtContributionLine(arena: std.mem.Allocator, amount: f64, inflation_adjusted: bool, accumulation_years: u16) !?[]const u8 { + if (amount == 0 and accumulation_years == 0) return null; + var amt_buf: [24]u8 = undefined; + const amt_str = fmt.fmtMoneyAbs(&amt_buf, amount); + const amt_nodec = if (std.mem.endsWith(u8, amt_str, ".00")) + amt_str[0 .. amt_str.len - 3] + else + amt_str; + const adj_note: []const u8 = if (inflation_adjusted) " (CPI-adjusted)" else " (nominal)"; + return try std.fmt.allocPrint(arena, "Annual contributions: {s}{s}", .{ amt_nodec, adj_note }); +} + +/// A single cell in the "Earliest retirement" grid: either a formatted +/// date string or "not feasible" muted text. +pub const EarliestCell = struct { + text: []const u8, + style: StyleIntent, +}; + +/// Format an `EarliestRetirement` cell as a date string anchored +/// against `as_of` (the projection's reference date — pass today +/// for live mode, or a historical date for back-dated runs). +/// Reports the date the user reaches the `accumulation_years` +/// threshold, using `as_of`'s m/d for the calendar display. +/// +/// Cells where no value of `accumulation_years` ≤ `max_accumulation_years` +/// sustains the target spending render "infeasible" with the +/// `.negative` style — this is critical information for the user +/// (you can't retire under these conditions) and must NOT be +/// muted. Single word so it fits in the standard 14-char grid +/// column. The retirement line above the grid uses the longer +/// "not feasible" form when the promoted cell falls in this state +/// — both forms mean the same thing; the asymmetry is layout-driven. +pub fn fmtEarliestCell(arena: std.mem.Allocator, er: projections.EarliestRetirement, as_of: Date) !EarliestCell { + const n = er.accumulation_years orelse { + return .{ .text = "infeasible", .style = .negative }; + }; + if (n == 0) { + return .{ .text = try std.fmt.allocPrint(arena, "now", .{}), .style = .normal }; + } + // Anchor against the reference date's year/month/day. The + // earliest-retirement search produces an integer year offset; + // rendering a calendar-precise date requires picking some + // month/day. Use `as_of`'s m/d so the displayed year matches + // "N years from the reference date". + const ret_date = Date.fromYmd(as_of.year() + @as(i16, @intCast(n)), as_of.month(), as_of.day()); + var dbuf: [10]u8 = undefined; + const dstr = ret_date.format(&dbuf); + return .{ .text = try arena.dupe(u8, dstr), .style = .normal }; +} + +/// Build a row in the "Earliest retirement" grid for a single +/// confidence level. The row label is the confidence percentage; one +/// column per horizon, each rendering a date or "—". +pub const EarliestRow = struct { + label_text: []const u8, + cells: []EarliestCell, +}; + +pub fn buildEarliestRow( + arena: std.mem.Allocator, + confidence: f64, + horizons: []const u16, + earliest: []const projections.EarliestRetirement, + confidence_idx: usize, + as_of: Date, +) !EarliestRow { + const label = try std.fmt.allocPrint(arena, "{d:.0}% confidence", .{confidence * 100}); + const cells = try arena.alloc(EarliestCell, horizons.len); + for (horizons, 0..) |_, hi| { + cells[hi] = try fmtEarliestCell(arena, earliest[confidence_idx * horizons.len + hi], as_of); + } + return .{ .label_text = label, .cells = cells }; +} + // ── Table row builders (shared by CLI and TUI) ───────────────── /// A pre-formatted table row: label + right-aligned columns. @@ -755,33 +1144,6 @@ test "buildPercentileRow handles null bands" { try std.testing.expect(std.mem.indexOf(u8, row.text, "--") != null); } -test "computeProjectionData produces correct structure" { - const allocator = std.testing.allocator; - const horizons = [_]u16{ 20, 30 }; - const conf = [_]f64{ 0.95, 0.99 }; - - const data = try computeProjectionData(allocator, &horizons, &conf, 1000000, 0.75, &.{}); - defer { - allocator.free(data.withdrawals); - for (data.bands) |b| { - if (b) |slice| allocator.free(slice); - } - allocator.free(data.bands); - } - - // 2 horizons × 2 confidence levels = 4 withdrawal results - try std.testing.expectEqual(@as(usize, 4), data.withdrawals.len); - // 2 bands (one per horizon) - try std.testing.expectEqual(@as(usize, 2), data.bands.len); - // 99% is the last confidence level - try std.testing.expectEqual(@as(usize, 1), data.ci_99); - - // Withdrawal at 95% should be >= withdrawal at 99% (for same horizon) - try std.testing.expect(data.withdrawals[0].annual_amount >= data.withdrawals[2].annual_amount); - // Withdrawal at 20yr should be >= withdrawal at 30yr (for same confidence) - try std.testing.expect(data.withdrawals[0].annual_amount >= data.withdrawals[1].annual_amount); -} - test "fmtConfidenceLabel" { var buf: [25]u8 = undefined; const label = fmtConfidenceLabel(&buf, 0.99); @@ -794,3 +1156,342 @@ test "fmtHorizonLabel" { const label = fmtHorizonLabel(&buf, 30); try std.testing.expectEqualStrings("30 Year", label); } + +// ── Accumulation phase / earliest retirement view tests ──────── + +test "fmtRetirementLine: none" { + var buf: [128]u8 = undefined; + const config = projections.UserConfig{}; + const line = fmtRetirementLine(&buf, .{ + .accumulation_years = 0, + .date = null, + .source = .none, + }, &config); + try std.testing.expectEqualStrings("Years until possible retirement: none", line); +} + +test "fmtRetirementLine: at_date with no birthdates omits ages suffix" { + var buf: [128]u8 = undefined; + const config = projections.UserConfig{}; + const line = fmtRetirementLine(&buf, .{ + .accumulation_years = 10, + .date = Date.fromYmd(2036, 7, 1), + .source = .at_date, + }, &config); + try std.testing.expectEqualStrings("Years until possible retirement: 10 (2036-07-01)", line); +} + +test "fmtRetirementLine: at_age with no birthdates omits ages suffix" { + var buf: [128]u8 = undefined; + const config = projections.UserConfig{}; + const line = fmtRetirementLine(&buf, .{ + .accumulation_years = 14, + .date = Date.fromYmd(2040, 3, 15), + .source = .at_age, + }, &config); + try std.testing.expectEqualStrings("Years until possible retirement: 14 (2040-03-15)", line); +} + +test "fmtRetirementLine: at_age with one birthdate appends singular age suffix" { + var buf: [128]u8 = undefined; + var config = projections.UserConfig{}; + config.birthdate_count = 1; + config.birthdates[0] = Date.fromYmd(1975, 3, 15); // age 65 on 2040-03-15 + const line = fmtRetirementLine(&buf, .{ + .accumulation_years = 14, + .date = Date.fromYmd(2040, 3, 15), + .source = .at_age, + }, &config); + try std.testing.expectEqualStrings("Years until possible retirement: 14 (2040-03-15, age 65)", line); +} + +test "fmtRetirementLine: at_age with two birthdates uses plural ages" { + var buf: [128]u8 = undefined; + var config = projections.UserConfig{}; + config.birthdate_count = 2; + config.birthdates[0] = Date.fromYmd(1981, 4, 12); // age 65 on 2046-04-12 + config.birthdates[1] = Date.fromYmd(1983, 9, 8); // age 62 on 2046-04-12 + const line = fmtRetirementLine(&buf, .{ + .accumulation_years = 19, + .date = Date.fromYmd(2046, 4, 12), + .source = .at_age, + }, &config); + try std.testing.expectEqualStrings("Years until possible retirement: 19 (2046-04-12, ages 65/62)", line); +} + +test "fmtRetirementLine: ages are floored to whole years" { + // Born 1981-06-01; retirement on 2046-04-12 — birthday hasn't + // occurred yet that year, so age is 64 (not 65). + var buf: [128]u8 = undefined; + var config = projections.UserConfig{}; + config.birthdate_count = 1; + config.birthdates[0] = Date.fromYmd(1981, 6, 1); + const line = fmtRetirementLine(&buf, .{ + .accumulation_years = 19, + .date = Date.fromYmd(2046, 4, 12), + .source = .at_age, + }, &config); + try std.testing.expectEqualStrings("Years until possible retirement: 19 (2046-04-12, age 64)", line); +} + +test "fmtRetirementLine: promoted source renders dated form like at_date" { + var buf: [128]u8 = undefined; + var config = projections.UserConfig{}; + config.birthdate_count = 2; + config.birthdates[0] = Date.fromYmd(1981, 4, 12); + config.birthdates[1] = Date.fromYmd(1983, 9, 8); + const line = fmtRetirementLine(&buf, .{ + .accumulation_years = 19, + .date = Date.fromYmd(2046, 4, 12), + .source = .promoted, + }, &config); + try std.testing.expectEqualStrings("Years until possible retirement: 19 (2046-04-12, ages 65/62)", line); +} + +test "fmtRetirementLine: promoted_infeasible renders 'not feasible'" { + var buf: [128]u8 = undefined; + const config = projections.UserConfig{}; + const line = fmtRetirementLine(&buf, .{ + .accumulation_years = 0, + .date = null, + .source = .promoted_infeasible, + }, &config); + try std.testing.expectEqualStrings("Years until possible retirement: not feasible", line); +} + +test "fmtContributionLine: zero contribution and zero accumulation -> null" { + const allocator = std.testing.allocator; + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + const result = try fmtContributionLine(arena.allocator(), 0, true, 0); + try std.testing.expect(result == null); +} + +test "fmtContributionLine: nonzero contribution renders" { + const allocator = std.testing.allocator; + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + const result = try fmtContributionLine(arena.allocator(), 100_000, true, 10); + try std.testing.expect(result != null); + try std.testing.expect(std.mem.indexOf(u8, result.?, "100,000") != null); + try std.testing.expect(std.mem.indexOf(u8, result.?, "CPI-adjusted") != null); +} + +test "fmtContributionLine: nominal flag changes label" { + const allocator = std.testing.allocator; + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + const result = try fmtContributionLine(arena.allocator(), 50_000, false, 5); + try std.testing.expect(result != null); + try std.testing.expect(std.mem.indexOf(u8, result.?, "nominal") != null); + try std.testing.expect(std.mem.indexOf(u8, result.?, "CPI-adjusted") == null); +} + +test "fmtContributionLine: zero contribution but with accumulation still renders" { + // User pauses contributions but isn't retired yet — legitimate + // case; the line should appear so the user sees that the model + // is treating contributions as zero. + const allocator = std.testing.allocator; + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + const result = try fmtContributionLine(arena.allocator(), 0, true, 5); + try std.testing.expect(result != null); +} + +test "fmtEarliestCell: feasible at N=0 -> 'now'" { + const allocator = std.testing.allocator; + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + const cell = try fmtEarliestCell(arena.allocator(), .{ + .horizon = 30, + .confidence = 0.95, + .accumulation_years = 0, + .median_at_retirement = 0, + .p10_at_retirement = 0, + .p90_at_retirement = 0, + }, Date.fromYmd(2026, 5, 12)); + try std.testing.expectEqualStrings("now", cell.text); +} + +test "fmtEarliestCell: infeasible -> 'infeasible' label, negative style" { + const allocator = std.testing.allocator; + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + const cell = try fmtEarliestCell(arena.allocator(), .{ + .horizon = 30, + .confidence = 0.99, + .accumulation_years = null, + .median_at_retirement = 0, + .p10_at_retirement = 0, + .p90_at_retirement = 0, + }, Date.fromYmd(2026, 5, 12)); + try std.testing.expectEqualStrings("infeasible", cell.text); + // .negative — NOT .muted. "You can't retire under these + // conditions" is critical info; muting it would bury the + // headline. Style matches CLR_NEGATIVE convention used for + // losses elsewhere in the UI. + try std.testing.expectEqual(@as(StyleIntent, .negative), cell.style); +} + +test "fmtEarliestCell: N=10 produces date 10 years from today" { + const allocator = std.testing.allocator; + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + const cell = try fmtEarliestCell(arena.allocator(), .{ + .horizon = 30, + .confidence = 0.95, + .accumulation_years = 10, + .median_at_retirement = 0, + .p10_at_retirement = 0, + .p90_at_retirement = 0, + }, Date.fromYmd(2026, 5, 12)); + try std.testing.expectEqualStrings("2036-05-12", cell.text); +} + +test "buildProjectionContext: distribution-only inputs when no accumulation fields" { + const allocator = std.testing.allocator; + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + + const config = projections.UserConfig{}; + const comparison: benchmark.BenchmarkComparison = .{ + .stock_returns = .{}, + .bond_returns = .{}, + .benchmark_returns = .{}, + .portfolio_returns = .{}, + .conservative_return = 0.07, + .stock_pct = 0.75, + .bond_pct = 0.25, + }; + + var ctx = try buildProjectionContext( + arena.allocator(), + config, + comparison, + 0.75, + 0.25, + 1_000_000, + &.{}, + Date.fromYmd(2026, 5, 12), + ); + _ = &ctx; + + try std.testing.expectEqual(ProjectionInputs.distribution_only, ctx.inputs); + try std.testing.expectEqual(@as(usize, 0), ctx.retirement.accumulation_years); + try std.testing.expect(ctx.accumulation == null); + try std.testing.expect(ctx.earliest == null); +} + +test "buildProjectionContext: target_retirement_date inputs with retirement_at" { + const allocator = std.testing.allocator; + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + + var config = projections.UserConfig{}; + config.retirement_at = Date.fromYmd(2036, 7, 1); + config.annual_contribution = 50_000; + + const comparison: benchmark.BenchmarkComparison = .{ + .stock_returns = .{}, + .bond_returns = .{}, + .benchmark_returns = .{}, + .portfolio_returns = .{}, + .conservative_return = 0.07, + .stock_pct = 0.75, + .bond_pct = 0.25, + }; + + const ctx = try buildProjectionContext( + arena.allocator(), + config, + comparison, + 0.75, + 0.25, + 1_000_000, + &.{}, + Date.fromYmd(2026, 7, 1), + ); + + try std.testing.expectEqual(ProjectionInputs.target_retirement_date, ctx.inputs); + try std.testing.expectEqual(@as(u16, 10), ctx.retirement.accumulation_years); + try std.testing.expect(ctx.accumulation != null); + // Median at retirement should exceed starting value (we + // accumulate 10 years of $50k contributions on top). + try std.testing.expect(ctx.accumulation.?.median_at_retirement > 1_000_000); + try std.testing.expect(ctx.earliest == null); +} + +test "buildProjectionContext: target_spending inputs promote a cell into retirement + accumulation" { + const allocator = std.testing.allocator; + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + + var config = projections.UserConfig{}; + config.target_spending = 40_000; + + const comparison: benchmark.BenchmarkComparison = .{ + .stock_returns = .{}, + .bond_returns = .{}, + .benchmark_returns = .{}, + .portfolio_returns = .{}, + .conservative_return = 0.07, + .stock_pct = 0.75, + .bond_pct = 0.25, + }; + + const ctx = try buildProjectionContext( + arena.allocator(), + config, + comparison, + 0.75, + 0.25, + 1_000_000, + &.{}, + Date.fromYmd(2026, 5, 12), + ); + + try std.testing.expectEqual(ProjectionInputs.target_spending, ctx.inputs); + try std.testing.expect(ctx.earliest != null); + // 3 horizons × 3 confidence = 9 cells. + try std.testing.expectEqual(@as(usize, 9), ctx.earliest.?.len); + // Promotion runs: with $1M and $40k target, this is feasible + // immediately or very early. Either way, retirement.source + // should be .promoted and accumulation_stats should be present. + try std.testing.expect(ctx.retirement.source == .promoted); + try std.testing.expect(ctx.accumulation != null); +} + +test "buildProjectionContext: both_targets inputs when both fields configured" { + const allocator = std.testing.allocator; + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + + var config = projections.UserConfig{}; + config.retirement_at = Date.fromYmd(2036, 7, 1); + config.target_spending = 40_000; + + const comparison: benchmark.BenchmarkComparison = .{ + .stock_returns = .{}, + .bond_returns = .{}, + .benchmark_returns = .{}, + .portfolio_returns = .{}, + .conservative_return = 0.07, + .stock_pct = 0.75, + .bond_pct = 0.25, + }; + + const ctx = try buildProjectionContext( + arena.allocator(), + config, + comparison, + 0.75, + 0.25, + 1_000_000, + &.{}, + Date.fromYmd(2026, 7, 1), + ); + + try std.testing.expectEqual(ProjectionInputs.both_targets, ctx.inputs); + try std.testing.expect(ctx.accumulation != null); + try std.testing.expect(ctx.earliest != null); +}