zfin/src/views/projections.zig

1483 lines
58 KiB
Zig
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/// Renderer-agnostic view model for the projections display.
///
/// Produces pre-formatted text and `StyleIntent` values that both CLI
/// and TUI renderers can consume through thin style-mapping adapters.
const std = @import("std");
const fmt = @import("../format.zig");
const Money = @import("../Money.zig");
const performance = @import("../analytics/performance.zig");
const benchmark = @import("../analytics/benchmark.zig");
const projections = @import("../analytics/projections.zig");
const valuation = @import("../analytics/valuation.zig");
const zfin = @import("../root.zig");
const snapshot_model = @import("../models/snapshot.zig");
const history = @import("../history.zig");
const Date = @import("../models/date.zig").Date;
pub const StyleIntent = fmt.StyleIntent;
// ── Layout constants (shared by CLI and TUI) ──────────────────
pub const label_width = 32;
pub const col_1y = 8;
pub const col_3y = 9;
pub const col_5y = 9;
pub const col_10y = 10;
pub const col_week = 9;
pub const withdrawal_label_width = 25;
pub const withdrawal_col_width = 12;
pub const terminal_col_width = 18;
// ── Return row formatting ──────────────────────────────────────
/// A single cell in the returns table: formatted text + style.
pub const ReturnCell = struct {
text: []const u8,
style: StyleIntent,
};
/// Format a return value into a buffer, returning the styled cell.
pub fn fmtReturnCell(buf: []u8, value: ?f64) ReturnCell {
if (value) |v| {
return .{
.text = performance.formatReturn(buf, v),
.style = if (v >= 0) .positive else .negative,
};
}
return .{ .text = "--", .style = .muted };
}
/// A complete row in the benchmark comparison table.
pub const ReturnRow = struct {
label: []const u8,
one_year: ReturnCell,
three_year: ReturnCell,
five_year: ReturnCell,
ten_year: ReturnCell,
week: ReturnCell,
bold: bool = false,
};
/// Build a return row from a ReturnsByPeriod and a label.
pub fn buildReturnRow(label: []const u8, returns: benchmark.ReturnsByPeriod, bufs: *[5][16]u8, bold: bool) ReturnRow {
return .{
.label = label,
.one_year = fmtReturnCell(&bufs[0], returns.one_year),
.three_year = fmtReturnCell(&bufs[1], returns.three_year),
.five_year = fmtReturnCell(&bufs[2], returns.five_year),
.ten_year = fmtReturnCell(&bufs[3], returns.ten_year),
.week = fmtReturnCell(&bufs[4], returns.week),
.bold = bold,
};
}
// ── Safe withdrawal formatting ─────────────────────────────────
/// A single cell in the withdrawal table.
pub const WithdrawalCell = struct {
amount_text: []const u8,
rate_text: []const u8,
};
/// Format a safe withdrawal result into display strings.
/// Caller owns both buffers (at least 24 bytes each).
/// Strips trailing ".00" from whole-dollar amounts for clean display.
pub fn fmtWithdrawalCell(amount_buf: []u8, rate_buf: []u8, result: projections.WithdrawalResult) WithdrawalCell {
const clean_amount = std.fmt.bufPrint(amount_buf, "{f}", .{Money.from(result.annual_amount).trim()}) catch "$?";
const rate_str = std.fmt.bufPrint(rate_buf, "{d:.2}%", .{result.withdrawal_rate * 100}) catch "??%";
return .{ .amount_text = clean_amount, .rate_text = rate_str };
}
/// Format a confidence level label (e.g. "99% safe withdrawal").
pub fn fmtConfidenceLabel(buf: []u8, confidence: f64) []const u8 {
return std.fmt.bufPrint(buf, "{d:.0}% safe withdrawal", .{confidence * 100}) catch "??";
}
/// Format a horizon column header (e.g. "30 Year").
pub fn fmtHorizonLabel(buf: []u8, horizon: u16) []const u8 {
return std.fmt.bufPrint(buf, "{d} Year", .{horizon}) catch "??";
}
// ── Allocation summary ─────────────────────────────────────────
/// Result of formatting the allocation note.
pub const AllocationNote = struct {
text: []const u8,
style: StyleIntent,
};
/// Format the target allocation note line with drift-aware styling.
/// Returns null if no target is configured.
///
/// Drift thresholds:
/// - Within 5%: "on target" (muted)
/// - 510% off: warning
/// - Over 10% off: negative
pub fn fmtAllocationNote(buf: []u8, target_stock_pct: ?f64, current_stock_pct: f64) ?AllocationNote {
const target = target_stock_pct orelse return null;
const current = current_stock_pct * 100;
const drift = @abs(current - target);
const style: StyleIntent = if (drift < 5.0) .muted else if (drift < 10.0) .warning else .negative;
const text = if (drift < 5.0)
std.fmt.bufPrint(buf, "Target allocation: {d:.0}% stocks / {d:.0}% bonds (current: {d:.1}% \u{2014} on target)", .{
target, 100.0 - target, current,
}) catch return null
else
std.fmt.bufPrint(buf, "Target allocation: {d:.0}% stocks / {d:.0}% bonds (current: {d:.1}%)", .{
target, 100.0 - target, current,
}) catch return null;
return .{ .text = text, .style = style };
}
/// Format the stock benchmark label with weight.
pub fn fmtBenchmarkLabel(buf: []u8, symbol: []const u8, weight_pct: f64) []const u8 {
return std.fmt.bufPrint(buf, "{s} ({d:.1}% weight)", .{ symbol, weight_pct }) catch symbol;
}
// ── Precomputed projection data (shared by CLI and TUI) ────────
pub const ProjectionContext = struct {
comparison: benchmark.BenchmarkComparison,
config: projections.UserConfig,
data: ProjectionData,
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,
};
/// 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,
};
/// 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,
config: projections.UserConfig,
comparison: benchmark.BenchmarkComparison,
stock_pct: f64,
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;
// 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,
.data = data,
.stock_pct = stock_pct,
.bond_pct = bond_pct,
.total_value = total_value,
.retirement = retirement,
.accumulation = accumulation_stats,
.earliest = earliest,
.inputs = inputs,
};
}
/// Load and compute a complete ProjectionContext from a portfolio path and service.
///
/// This is the single entry point for both CLI and TUI. It handles:
/// - Loading projections.srf and metadata.srf from the portfolio directory
/// - Deriving stock/bond allocation from classification metadata
/// - Computing benchmark trailing returns (SPY + AGG)
/// - Building per-position weighted trailing returns
/// - Running the FIRECalc simulation for all horizons and confidence levels
///
/// The caller provides the portfolio summary (allocations, total value, cash/CD)
/// and a DataService for candle access. All intermediate allocations use `alloc`.
pub fn loadProjectionContext(
io: std.Io,
alloc: std.mem.Allocator,
portfolio_dir: []const u8,
allocations: []const valuation.Allocation,
total_value: f64,
cash_value: f64,
cd_value: f64,
svc: *zfin.DataService,
events_enabled: bool,
as_of: Date,
) !ProjectionContext {
return buildContextFromParts(
io,
alloc,
portfolio_dir,
allocations,
total_value,
cash_value,
cd_value,
svc,
events_enabled,
as_of,
);
}
// ── As-of (historical) projection context ──────────────────────
//
// Retrospective projections. Builds a ProjectionContext as if it were
// a past `as_of` date, using a stored snapshot for portfolio
// composition and truncating benchmark candle history at `as_of` so
// trailing returns reflect what was knowable at that moment.
//
// The snapshot→allocations aggregation (`SnapshotAllocations` and
// `aggregateSnapshotAllocations`) lives in `src/history.zig` next to
// the other snapshot-domain helpers. This module orchestrates the
// full projection pipeline through `buildContextFromParts`.
//
// Known limitation: `projections.srf` and `metadata.srf` are still
// loaded from the current working copy (git-tracked). This means
// retirement ages, target allocation, confidence levels, and symbol
// classifications are all "as of now", not as of the requested date.
// Documented edge case; see TODO.md.
/// Build a complete `ProjectionContext` as of a historical `as_of_date`.
///
/// Mirrors `loadProjectionContext` but sources portfolio composition
/// from a snapshot instead of the live portfolio file:
/// - Allocations derived from snapshot's lot rows
/// - Total value / cash / CD taken from snapshot
/// - Benchmark candles truncated to <= as_of_date
/// - Per-symbol trailing returns truncated to <= as_of_date
/// - Life events resolved against ages-as-of-as_of via
/// `UserConfig.currentAges`
///
/// Known as-of limitations (documented):
/// - `metadata.srf` classifications are current, not historical.
/// Symbols reclassified since the snapshot use the new class.
/// - `projections.srf` config (retirement ages, horizons, target
/// allocation) is current, not historical.
/// - Per-symbol candles older than ~10yr may be missing from cache,
/// causing null trailing returns for older windows.
///
/// Caller owns the returned context. `snap` must outlive the context
/// (allocation symbol strings borrow from the snapshot's backing
/// buffer — see `history.aggregateSnapshotAllocations`).
pub fn loadProjectionContextAsOf(
io: std.Io,
alloc: std.mem.Allocator,
portfolio_dir: []const u8,
snap: *const snapshot_model.Snapshot,
as_of_date: Date,
svc: *zfin.DataService,
events_enabled: bool,
) !ProjectionContext {
var snap_allocs = try history.aggregateSnapshotAllocations(alloc, snap);
defer snap_allocs.deinit(alloc);
return buildContextFromParts(
io,
alloc,
portfolio_dir,
snap_allocs.allocations,
snap_allocs.total_value,
snap_allocs.cash_value,
snap_allocs.cd_value,
svc,
events_enabled,
as_of_date,
);
}
/// Shared core: build a `ProjectionContext` from pre-computed
/// allocations and totals. Both `loadProjectionContext` (live) and
/// `loadProjectionContextAsOf` (historical) delegate here.
///
/// `as_of` gates two behaviors:
/// - `null` → live mode. Benchmark + per-symbol candles used as-is;
/// events resolved against current ages (`resolveEvents()`).
/// - `|d|` → historical mode. Benchmark + per-symbol candles sliced
/// to `<= d`; events resolved against ages-as-of-d
/// (`resolveEventsWithAges(currentAgesAsOf(d))`).
fn buildContextFromParts(
io: std.Io,
alloc: std.mem.Allocator,
portfolio_dir: []const u8,
allocations: []const valuation.Allocation,
total_value: f64,
cash_value: f64,
cd_value: f64,
svc: *zfin.DataService,
events_enabled: bool,
as_of: Date,
) !ProjectionContext {
// Load projections.srf
const proj_path = try std.fmt.allocPrint(alloc, "{s}projections.srf", .{portfolio_dir});
defer alloc.free(proj_path);
const proj_data = std.Io.Dir.cwd().readFileAlloc(io, proj_path, alloc, .limited(64 * 1024)) catch null;
defer if (proj_data) |d| alloc.free(d);
var config = projections.parseProjectionsConfig(proj_data);
if (!events_enabled) config.event_count = 0;
// Resolve age-based horizons (if any) against `as_of`. The caller
// chooses whether `as_of` is today (live mode) or a historical
// backfill date. This turns
// `horizon_age:num:N` records into concrete year counts appended to
// `config.horizons` — see `UserConfig.resolveHorizonAges`.
const horizon_anchor = as_of;
try config.resolveHorizonAges(horizon_anchor);
// Load metadata for classification
const meta_path = try std.fmt.allocPrint(alloc, "{s}metadata.srf", .{portfolio_dir});
defer alloc.free(meta_path);
const meta_data = std.Io.Dir.cwd().readFileAlloc(io, meta_path, alloc, .limited(1024 * 1024)) catch null;
defer if (meta_data) |d| alloc.free(d);
var cm_opt: ?zfin.classification.ClassificationMap = if (meta_data) |d|
zfin.classification.parseClassificationFile(alloc, d) catch null
else
null;
defer if (cm_opt) |*cm| cm.deinit();
// Derive stock/bond split
const split = benchmark.deriveAllocationSplit(
allocations,
if (cm_opt) |cm| cm.entries else &.{},
total_value,
cash_value,
cd_value,
);
// Fetch benchmark candles (checks cache first). In historical
// mode we slice to `<= as_of` — `performance.trailingReturns`
// anchors on the last candle's date, so trimming the tail gives
// returns "as of" that date for free.
const spy_result = svc.getCandles("SPY") catch null;
defer if (spy_result) |r| r.deinit();
const spy_candles = history.sliceCandlesAsOf(
if (spy_result) |r| r.data else &.{},
as_of,
);
const agg_result = svc.getCandles("AGG") catch null;
defer if (agg_result) |r| r.deinit();
const agg_candles = history.sliceCandlesAsOf(
if (agg_result) |r| r.data else &.{},
as_of,
);
const spy_trailing = performance.trailingReturns(spy_candles);
const agg_trailing = performance.trailingReturns(agg_candles);
const spy_week = performance.weekReturn(spy_candles);
const agg_week = performance.weekReturn(agg_candles);
// Build per-position trailing returns from cached candles, each
// optionally truncated to the as-of date.
var pos_returns: std.ArrayListUnmanaged(benchmark.PositionReturn) = .empty;
defer pos_returns.deinit(alloc);
for (allocations) |a| {
const candles_res = svc.getCachedCandles(a.symbol) orelse continue;
defer candles_res.deinit();
const candles = history.sliceCandlesAsOf(candles_res.data, as_of);
if (candles.len > 0) {
try pos_returns.append(alloc, .{
.symbol = a.symbol,
.weight = a.weight,
.returns = performance.trailingReturns(candles),
});
}
}
const comparison = benchmark.buildComparison(
spy_trailing,
agg_trailing,
split.stock_pct,
split.bond_pct,
pos_returns.items,
spy_week,
agg_week,
);
// Resolve events against ages-as-of the reference date. The
// caller chooses whether `as_of` is today (live mode) or a
// historical backfill date — the math is the same either way.
const resolved_events = blk: {
const resolved = config.resolveEvents(as_of);
break :blk resolved[0..config.event_count];
};
return buildProjectionContext(
alloc,
config,
comparison,
split.stock_pct,
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;
const adj_note: []const u8 = if (inflation_adjusted) " (CPI-adjusted)" else " (nominal)";
return try std.fmt.allocPrint(arena, "Annual contributions: {f}{s}", .{ Money.from(amount).trim(), 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.
pub const TableRow = struct {
text: []const u8,
style: StyleIntent,
};
/// Build a column header row for a given set of horizons and column width.
pub fn buildHeaderRow(arena: std.mem.Allocator, horizons: []const u16, col_width: usize) ![]const u8 {
var row: std.ArrayListUnmanaged(u8) = .empty;
try row.appendNTimes(arena, ' ', withdrawal_label_width);
for (horizons) |h| {
var hbuf: [16]u8 = undefined;
const hlabel = fmtHorizonLabel(&hbuf, h);
try row.appendNTimes(arena, ' ', col_width -| hlabel.len);
try row.appendSlice(arena, hlabel);
}
return row.toOwnedSlice(arena);
}
/// Build withdrawal rows for one confidence level: amount row + rate row.
pub fn buildWithdrawalRows(
arena: std.mem.Allocator,
confidence: f64,
horizons: []const u16,
cached_results: []const projections.WithdrawalResult,
confidence_idx: usize,
) !struct { amount: TableRow, rate: TableRow } {
// Amount row
var amount_row: std.ArrayListUnmanaged(u8) = .empty;
var lbuf: [25]u8 = undefined;
const clabel = fmtConfidenceLabel(&lbuf, confidence);
try amount_row.appendSlice(arena, clabel);
try amount_row.appendNTimes(arena, ' ', withdrawal_label_width -| clabel.len);
for (horizons, 0..) |_, hi| {
const result = cached_results[confidence_idx * horizons.len + hi];
var abuf: [24]u8 = undefined;
var rbuf: [16]u8 = undefined;
const cell = fmtWithdrawalCell(&abuf, &rbuf, result);
try amount_row.appendNTimes(arena, ' ', withdrawal_col_width -| cell.amount_text.len);
try amount_row.appendSlice(arena, cell.amount_text);
}
// Rate row
var rate_row: std.ArrayListUnmanaged(u8) = .empty;
try rate_row.appendNTimes(arena, ' ', withdrawal_label_width);
for (horizons, 0..) |_, hi| {
const result = cached_results[confidence_idx * horizons.len + hi];
var abuf: [24]u8 = undefined;
var rbuf: [16]u8 = undefined;
const cell = fmtWithdrawalCell(&abuf, &rbuf, result);
try rate_row.appendNTimes(arena, ' ', withdrawal_col_width -| cell.rate_text.len);
try rate_row.appendSlice(arena, cell.rate_text);
}
return .{
.amount = .{ .text = try amount_row.toOwnedSlice(arena), .style = .normal },
.rate = .{ .text = try rate_row.toOwnedSlice(arena), .style = .muted },
};
}
/// Build a percentile row (p10/p50/p90) across horizons.
pub fn buildPercentileRow(
arena: std.mem.Allocator,
label: []const u8,
percentile_idx: usize,
all_bands: []const ?[]const projections.YearPercentiles,
style: StyleIntent,
) !TableRow {
var row: std.ArrayListUnmanaged(u8) = .empty;
try row.appendSlice(arena, label);
try row.appendNTimes(arena, ' ', withdrawal_label_width -| label.len);
for (all_bands) |bands_opt| {
if (bands_opt) |bands| {
if (bands.len > 0) {
const last = bands[bands.len - 1];
const val = switch (percentile_idx) {
0 => last.p10,
1 => last.p50,
2 => last.p90,
else => 0,
};
var mbuf: [24]u8 = undefined;
const txt = std.fmt.bufPrint(&mbuf, "{f}", .{Money.from(val)}) catch "$?";
try row.appendNTimes(arena, ' ', terminal_col_width -| txt.len);
try row.appendSlice(arena, txt);
} else {
try row.appendNTimes(arena, ' ', terminal_col_width - 2);
try row.appendSlice(arena, "--");
}
} else {
try row.appendNTimes(arena, ' ', terminal_col_width - 2);
try row.appendSlice(arena, "--");
}
}
return .{ .text = try row.toOwnedSlice(arena), .style = style };
}
// ── Event summary (shared by CLI and TUI) ──────────────────────
pub const EventLine = struct {
text: []const u8,
style: StyleIntent,
};
/// Format a single event line for display.
/// Output: " Social Security (Emil) +$38,400/yr age 67 (in 17yr)"
pub fn fmtEventLine(arena: std.mem.Allocator, ev: *const projections.LifeEvent, current_ages: []const u16) !EventLine {
const name = ev.getName();
const amount = ev.annual_amount;
const is_income = amount >= 0;
const style: StyleIntent = if (is_income) .positive else .negative;
var amt_buf: [24]u8 = undefined;
const sign: []const u8 = if (is_income) "+" else "-";
const abs_amount = @abs(amount);
// Whole-dollar form (no decimals) for compact event-line display.
const amt_nodec = std.fmt.bufPrint(&amt_buf, "{f}", .{Money.from(abs_amount).whole()}) catch "$?";
const start_yr = ev.startYear(current_ages);
const timing = if (start_yr) |sy| blk: {
if (sy == 0)
break :blk try std.fmt.allocPrint(arena, "age {d} (now)", .{ev.start_age})
else
break :blk try std.fmt.allocPrint(arena, "age {d} (in {d}yr)", .{ ev.start_age, sy });
} else try std.fmt.allocPrint(arena, "age {d}", .{ev.start_age});
const dur_str = if (ev.duration > 0)
try std.fmt.allocPrint(arena, ", {d}yr", .{ev.duration})
else
"";
const nominal_str: []const u8 = if (!ev.inflation_adjusted) ", nominal" else "";
const text = try std.fmt.allocPrint(arena, " {s: <28} {s}{s}/yr {s}{s}{s}", .{
name, sign, amt_nodec, timing, dur_str, nominal_str,
});
return .{ .text = text, .style = style };
}
// ── Tests ──────────────────────────────────────────────────────
test "fmtReturnCell positive" {
var buf: [16]u8 = undefined;
const cell = fmtReturnCell(&buf, 0.1234);
try std.testing.expect(cell.style == .positive);
try std.testing.expect(cell.text.len > 0);
}
test "fmtReturnCell negative" {
var buf: [16]u8 = undefined;
const cell = fmtReturnCell(&buf, -0.05);
try std.testing.expect(cell.style == .negative);
}
test "fmtReturnCell null" {
var buf: [16]u8 = undefined;
const cell = fmtReturnCell(&buf, null);
try std.testing.expect(cell.style == .muted);
try std.testing.expectEqualStrings("--", cell.text);
}
test "fmtWithdrawalCell strips .00" {
var abuf: [24]u8 = undefined;
var rbuf: [16]u8 = undefined;
const cell = fmtWithdrawalCell(&abuf, &rbuf, .{
.confidence = 0.99,
.annual_amount = 305000,
.withdrawal_rate = 0.0366,
});
try std.testing.expect(!std.mem.endsWith(u8, cell.amount_text, ".00"));
try std.testing.expect(std.mem.indexOf(u8, cell.rate_text, "3.66") != null);
}
test "fmtAllocationNote on target" {
var buf: [128]u8 = undefined;
const note = fmtAllocationNote(&buf, 77, 0.768);
try std.testing.expect(note != null);
try std.testing.expect(note.?.style == .muted);
try std.testing.expect(std.mem.indexOf(u8, note.?.text, "on target") != null);
}
test "fmtAllocationNote off target" {
var buf: [128]u8 = undefined;
const note = fmtAllocationNote(&buf, 77, 0.90);
try std.testing.expect(note != null);
try std.testing.expect(note.?.style == .negative); // 13% drift, >10%
try std.testing.expect(std.mem.indexOf(u8, note.?.text, "on target") == null);
}
test "fmtAllocationNote warning range" {
var buf: [128]u8 = undefined;
const note = fmtAllocationNote(&buf, 77, 0.84);
try std.testing.expect(note != null);
try std.testing.expect(note.?.style == .warning); // 7% drift, in 5-10% range
}
test "fmtAllocationNote no target" {
var buf: [128]u8 = undefined;
try std.testing.expect(fmtAllocationNote(&buf, null, 0.75) == null);
}
test "fmtBenchmarkLabel" {
var buf: [32]u8 = undefined;
const label = fmtBenchmarkLabel(&buf, "SPY", 83.8);
try std.testing.expect(std.mem.indexOf(u8, label, "SPY") != null);
try std.testing.expect(std.mem.indexOf(u8, label, "83.8") != null);
}
test "buildReturnRow" {
var bufs: [5][16]u8 = undefined;
const returns = benchmark.ReturnsByPeriod{
.one_year = 0.15,
.three_year = -0.02,
.five_year = null,
};
const row = buildReturnRow("Test", returns, &bufs, false);
try std.testing.expectEqualStrings("Test", row.label);
try std.testing.expect(row.one_year.style == .positive);
try std.testing.expect(row.three_year.style == .negative);
try std.testing.expect(row.five_year.style == .muted);
try std.testing.expect(row.bold == false);
}
test "buildHeaderRow formats horizons" {
const allocator = std.testing.allocator;
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
const a = arena.allocator();
const horizons = [_]u16{ 30, 45 };
const result = try buildHeaderRow(a, &horizons, withdrawal_col_width);
try std.testing.expect(std.mem.indexOf(u8, result, "30 Year") != null);
try std.testing.expect(std.mem.indexOf(u8, result, "45 Year") != null);
}
test "buildHeaderRow uses terminal column width" {
const allocator = std.testing.allocator;
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
const a = arena.allocator();
const horizons = [_]u16{20};
const narrow = try buildHeaderRow(a, &horizons, withdrawal_col_width);
const wide = try buildHeaderRow(a, &horizons, terminal_col_width);
try std.testing.expect(wide.len > narrow.len);
}
test "buildWithdrawalRows produces amount and rate" {
const allocator = std.testing.allocator;
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
const a = arena.allocator();
const horizons = [_]u16{ 30, 45 };
const results = [_]projections.WithdrawalResult{
.{ .confidence = 0.95, .annual_amount = 350000, .withdrawal_rate = 0.042 },
.{ .confidence = 0.95, .annual_amount = 310000, .withdrawal_rate = 0.037 },
};
const rows = try buildWithdrawalRows(a, 0.95, &horizons, &results, 0);
// Amount row should contain the dollar amounts
try std.testing.expect(std.mem.indexOf(u8, rows.amount.text, "350,000") != null);
try std.testing.expect(std.mem.indexOf(u8, rows.amount.text, "310,000") != null);
try std.testing.expect(rows.amount.style == .normal);
// Rate row should contain percentages
try std.testing.expect(std.mem.indexOf(u8, rows.rate.text, "4.20%") != null);
try std.testing.expect(std.mem.indexOf(u8, rows.rate.text, "3.70%") != null);
try std.testing.expect(rows.rate.style == .muted);
}
test "buildPercentileRow extracts correct percentile" {
const allocator = std.testing.allocator;
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
const a = arena.allocator();
const bands = [_]projections.YearPercentiles{
.{ .year = 0, .p10 = 1000000, .p25 = 2000000, .p50 = 3000000, .p75 = 4000000, .p90 = 5000000 },
.{ .year = 30, .p10 = 5000000, .p25 = 10000000, .p50 = 20000000, .p75 = 30000000, .p90 = 50000000 },
};
const band_slice: []const projections.YearPercentiles = &bands;
const all_bands = [_]?[]const projections.YearPercentiles{band_slice};
// p10 (index 0)
const row_p10 = try buildPercentileRow(a, "Pessimistic", 0, &all_bands, .muted);
try std.testing.expect(std.mem.indexOf(u8, row_p10.text, "5,000,000") != null);
try std.testing.expect(row_p10.style == .muted);
// p50 (index 1)
const row_p50 = try buildPercentileRow(a, "Median", 1, &all_bands, .normal);
try std.testing.expect(std.mem.indexOf(u8, row_p50.text, "20,000,000") != null);
try std.testing.expect(row_p50.style == .normal);
// p90 (index 2)
const row_p90 = try buildPercentileRow(a, "Optimistic", 2, &all_bands, .muted);
try std.testing.expect(std.mem.indexOf(u8, row_p90.text, "50,000,000") != null);
}
test "buildPercentileRow handles null bands" {
const allocator = std.testing.allocator;
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
const a = arena.allocator();
const all_bands = [_]?[]const projections.YearPercentiles{null};
const row = try buildPercentileRow(a, "Pessimistic", 0, &all_bands, .muted);
try std.testing.expect(std.mem.indexOf(u8, row.text, "--") != null);
}
test "fmtConfidenceLabel" {
var buf: [25]u8 = undefined;
const label = fmtConfidenceLabel(&buf, 0.99);
try std.testing.expect(std.mem.indexOf(u8, label, "99%") != null);
try std.testing.expect(std.mem.indexOf(u8, label, "withdrawal") != null);
}
test "fmtHorizonLabel" {
var buf: [16]u8 = undefined;
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);
}