initial implementation of pre-retirement projections/accumulation phase

This commit is contained in:
Emil Lerch 2026-05-12 16:37:28 -07:00
parent f9d2148c23
commit bdd827734d
Signed by: lobo
GPG key ID: A7B62D657EF764F8
8 changed files with 3335 additions and 205 deletions

View file

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

160
README.md
View file

@ -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
(1871present). 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,
p10p90 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 (0100). 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
```

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

@ -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
/// p10p90 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: `<label_text><value_text>` 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);
}