initial implementation of pre-retirement projections/accumulation phase
This commit is contained in:
parent
f9d2148c23
commit
bdd827734d
8 changed files with 3335 additions and 205 deletions
66
AGENTS.md
66
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
|
||||
|
|
|
|||
160
README.md
160
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
|
||||
|
||||
```
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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: `<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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue