1483 lines
58 KiB
Zig
1483 lines
58 KiB
Zig
/// 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)
|
||
/// - 5–10% 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
|
||
/// 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,
|
||
};
|
||
|
||
/// 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);
|
||
}
|