zfin/src/views/projections.zig

2503 lines
97 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 forecast = @import("../analytics/forecast_evaluation.zig");
const timeline = @import("../analytics/timeline.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("../Date.zig");
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,
/// Realized trajectory overlay (snapshots + imported_values).
/// Populated only when the user passed `--overlay-actuals` (CLI)
/// or toggled `o` in the TUI projections tab. Requires `as_of`
/// to be set; meaningless in live mode.
overlay_actuals: ?OverlayActualsSection = null,
/// Where the as-of context came from. `.live` for normal "now"
/// mode (no `--as-of`); `.snapshot` when the as-of date had a
/// native `*-portfolio.srf` snapshot; `.imported` when only an
/// `imported_values.srf` row was available and today's
/// allocations had to be scaled to the imported liquid total.
/// Drives the header note ("As-of: ... (snapshot)" vs "(imported)")
/// so users know how literal the bands are.
as_of_source: AsOfSource = .live,
};
pub const AsOfSource = enum { live, snapshot, imported };
/// 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,
};
/// One realized (date, total liquid) point on the actuals overlay,
/// expressed in years from the as-of date so the chart can place it
/// on the same x-axis as the projected percentile bands.
pub const ActualsPoint = struct {
/// Years since `as_of` (as_of itself = 0.0). Uses 365.25 to
/// match the rest of the codebase's year math (`Date.yearsBetween`).
years_from_as_of: f64,
/// Total liquid value on that date, in nominal dollars.
liquid: f64,
};
/// View-model section produced when `--overlay-actuals` is active.
/// Carries the realized-trajectory points to render on top of the
/// percentile-band chart, plus the "today" position for the
/// vertical reference line.
///
/// Caveat (must be surfaced in any UI consuming this section):
/// this overlay shows whether the model was directionally honest,
/// not whether the SWR claim was accurate. The SWR claim is a
/// 30-year claim; we have at most ~12 years of weekly history.
pub const OverlayActualsSection = struct {
points: []ActualsPoint,
/// Years from `as_of` to today. The actuals line ends here;
/// beyond this x-position only bands are visible.
today_years: f64,
/// The as-of date the overlay is anchored to; surfaced for
/// status-line / footnote display ("actuals from YYYY-MM-DD").
as_of: Date,
allocator: std.mem.Allocator,
pub fn deinit(self: *OverlayActualsSection) void {
self.allocator.free(self.points);
}
};
/// Build an `OverlayActualsSection` from a merged `TimelineSeries`
/// (snapshots + imported_values). Filters to `as_of..today`,
/// converts each point's date to fractional years from `as_of`, and
/// pulls the liquid total. Snapshot precedence is already handled
/// upstream by `timeline.buildMergedSeries`.
///
/// Returns an empty section (no error) when the timeline yields no
/// points in range — the caller still toggles the overlay on, the
/// chart simply has no actuals line to draw.
pub fn buildOverlayActuals(
allocator: std.mem.Allocator,
timeline_points: []const timeline.TimelinePoint,
as_of: Date,
today: Date,
) !OverlayActualsSection {
const days_per_year: f64 = 365.25;
// Count first so we can size the slice exactly.
var keep: usize = 0;
for (timeline_points) |p| {
if (p.as_of_date.days < as_of.days) continue;
if (p.as_of_date.days > today.days) continue;
keep += 1;
}
const out = try allocator.alloc(ActualsPoint, keep);
errdefer allocator.free(out);
var i: usize = 0;
for (timeline_points) |p| {
if (p.as_of_date.days < as_of.days) continue;
if (p.as_of_date.days > today.days) continue;
const days_since: f64 = @floatFromInt(p.as_of_date.days - as_of.days);
out[i] = .{
.years_from_as_of = days_since / days_per_year,
.liquid = p.liquid,
};
i += 1;
}
const today_days: f64 = @floatFromInt(today.days - as_of.days);
return .{
.points = out,
.today_years = today_days / days_per_year,
.as_of = as_of,
.allocator = allocator,
};
}
/// 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);
var ctx = try 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,
);
ctx.as_of_source = .snapshot;
return ctx;
}
/// Build a `ProjectionContext` for an as-of date that has only an
/// `imported_values.srf` row — no native `*-portfolio.srf` snapshot.
///
/// We can't reconstruct the historical lot composition from just a
/// `liquid` total, so we use **today's allocations** scaled to the
/// imported liquid value. The simulation then runs with:
/// - Today's stock/bond split (ratio-preserving),
/// - Per-position trailing returns truncated to <= as_of,
/// - `total_value = imported_liquid` (the simulation starting point),
/// - Cash/CD totals scaled by the same factor.
///
/// This is the best approximation available for back-dated runs that
/// predate native snapshots — useful for users with weekly imported
/// history going back years. Limitations are documented in the
/// caveat surfaced by the calling display layer.
///
/// Caller-provided `live_allocations` is from the current portfolio
/// (live `portfolioSummary().allocations`), `live_total_value` is the
/// matching `summary.total_value`, etc. The function builds a
/// freshly-allocated, scaled copy of the allocations slice and frees
/// it before returning — `buildContextFromParts` only borrows
/// `allocations` during the build (to derive the stock/bond split
/// and per-position trailing returns) and doesn't store it on the
/// context. Same lifetime convention as
/// `loadProjectionContextAsOf`, which similarly frees its
/// snapshot-derived allocations slice via `defer snap_allocs.deinit`.
pub fn loadProjectionContextFromImported(
io: std.Io,
alloc: std.mem.Allocator,
portfolio_dir: []const u8,
live_allocations: []const valuation.Allocation,
live_total_value: f64,
live_cash_value: f64,
live_cd_value: f64,
imported_liquid: f64,
as_of_date: Date,
svc: *zfin.DataService,
events_enabled: bool,
) !ProjectionContext {
// Defensive: degenerate live total. Skip scaling and pass through;
// simulation will produce flat bands anchored at imported_liquid
// either way.
const scale: f64 = if (live_total_value > 0) imported_liquid / live_total_value else 1.0;
// Scale a copy of the allocations. The slice is consumed by
// `buildContextFromParts` during the build (split derivation,
// per-position trailing returns) but not retained on the
// returned context — so we free it here, mirroring the snapshot
// path's `defer snap_allocs.deinit(alloc)`.
const scaled = try alloc.alloc(valuation.Allocation, live_allocations.len);
defer alloc.free(scaled);
for (live_allocations, 0..) |a, i| {
scaled[i] = a;
scaled[i].market_value = a.market_value * scale;
// Weight is preserved (it's a ratio); shares/cost stay as
// today's because the simulation only consumes weight +
// total_value. Carrying real share counts at imported scale
// would be misleading — they don't reflect history.
}
var ctx = try buildContextFromParts(
io,
alloc,
portfolio_dir,
scaled,
imported_liquid,
live_cash_value * scale,
live_cd_value * scale,
svc,
events_enabled,
as_of_date,
);
ctx.as_of_source = .imported;
return ctx;
}
/// 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);
// Build per-position trailing returns from cached candles, each
// optionally truncated to the as-of date. `trailingReturns`
// populates `.week` per position; `portfolioWeightedReturns`
// aggregates the week the same way as the longer periods.
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,
);
// 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| (std.fmt.bufPrint(&date_buf, "{f}", .{d}) catch "????-??-??") 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 = std.fmt.bufPrint(&dbuf, "{f}", .{ret_date}) catch "????-??-??";
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 };
}
// ── Forecast-vs-actual sub-views ──────────────────────────────
//
// `convergenceLines` and `backtestLines` produce renderer-agnostic
// pre-formatted lines for the two forecast-evaluation sub-views.
// The CLI command (`zfin projections --convergence` / `--return-backtest`)
// emits each line with ANSI per `intent`; the TUI scroll fallback
// emits each line with `theme.styleFor(intent)`. Same source of
// truth for column widths, header text, and stride logic — the
// previous implementation drift produced overflow in the TUI
// fallback, which is why this module exists at all.
/// One line in the forecast-evaluation sub-view output.
///
/// `bold` is honored by renderers that support bold (CLI via
/// `setBold`, TUI via the `vaxis.Style.bold` flag in the theme's
/// `headerStyle`). Renderers that don't support it ignore the
/// flag.
pub const ForecastLine = struct {
text: []const u8,
intent: StyleIntent,
bold: bool = false,
};
/// Column widths for the convergence table. Header underlines
/// are derived from these at emit time so the dashes can never
/// drift from the data widths.
const conv_col_observed = 12; // "YYYY-MM-DD"
const conv_col_projected = 12; // "YYYY-MM-DD" or "reached"
const conv_col_years = 14; // "Years until" → "12.34"
/// Format strings derived from the column widths above. Built
/// at comptime so widths and dash counts stay in sync.
const conv_header_fmt = std.fmt.comptimePrint(
" {{s:<{d}}} {{s:<{d}}} {{s:>{d}}}",
.{ conv_col_observed, conv_col_projected, conv_col_years },
);
const conv_sep_fmt = std.fmt.comptimePrint(
" {{s:-<{d}}} {{s:-<{d}}} {{s:->{d}}}",
.{ conv_col_observed, conv_col_projected, conv_col_years },
);
const conv_row_fmt = std.fmt.comptimePrint(
" {{f}} {{s:<{d}}} {{s:>{d}}}",
.{ conv_col_projected, conv_col_years },
);
/// Build the renderer-agnostic line list for the convergence
/// sub-view. Stride logic mirrors the CLI: show first, last,
/// and every Nth observation in between for ~quarterly cadence
/// on weekly imported data.
///
/// Caller owns nothing — all output strings are allocated in
/// `arena`. Output is the concatenation of `convergenceHeaderLines`
/// and `convergenceTableLines`; the two halves are exposed
/// separately so chart renderers can reuse just the header.
pub fn convergenceLines(
arena: std.mem.Allocator,
points: []const forecast.ConvergencePoint,
) ![]const ForecastLine {
var lines: std.ArrayList(ForecastLine) = .empty;
const header = try convergenceHeaderLines(arena, points);
try lines.appendSlice(arena, header);
if (points.len > 0) {
const table = try convergenceTableLines(arena, points);
try lines.appendSlice(arena, table);
}
return lines.toOwnedSlice(arena);
}
/// Header section for the convergence sub-view: title, range,
/// caveat. Chart renderers use this directly; table renderers
/// use it via `convergenceLines`.
pub fn convergenceHeaderLines(
arena: std.mem.Allocator,
points: []const forecast.ConvergencePoint,
) ![]const ForecastLine {
var lines: std.ArrayList(ForecastLine) = .empty;
try lines.append(arena, .{
.text = "Projection convergence (spreadsheet-projected retirement date over time)",
.intent = .accent,
.bold = true,
});
if (points.len == 0) {
try lines.append(arena, .{
.text = " No convergence data available (imported_values.srf empty or missing projected_retirement fields).",
.intent = .muted,
});
return lines.toOwnedSlice(arena);
}
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " {d} observations from {f} → {f}", .{
points.len, points[0].observation_date, points[points.len - 1].observation_date,
}),
.intent = .muted,
});
try lines.append(arena, .{
.text = " Caveat: tracks the model's directional honesty, not SWR validity.",
.intent = .muted,
});
try lines.append(arena, .{ .text = "", .intent = .normal });
return lines.toOwnedSlice(arena);
}
/// Table section for the convergence sub-view: column header +
/// separator + sampled body rows + optional stride caption.
/// Caller-provided `points` must be non-empty (the header
/// already handles the empty case).
pub fn convergenceTableLines(
arena: std.mem.Allocator,
points: []const forecast.ConvergencePoint,
) ![]const ForecastLine {
var lines: std.ArrayList(ForecastLine) = .empty;
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, conv_header_fmt, .{ "Observed", "Projected", "Years until" }),
.intent = .muted,
});
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, conv_sep_fmt, .{ "", "", "" }),
.intent = .muted,
});
const stride: usize = if (points.len > 26) (points.len + 25) / 26 else 1;
for (points, 0..) |p, i| {
if (i != 0 and i != points.len - 1 and (i % stride) != 0) continue;
var proj_buf: [16]u8 = undefined;
const proj_str: []const u8 = if (p.reached)
"reached"
else
std.fmt.bufPrint(&proj_buf, "{f}", .{p.projected_date}) catch "??????????";
var years_buf: [16]u8 = undefined;
const years_str: []const u8 = if (p.reached)
"0.00"
else
std.fmt.bufPrint(&years_buf, "{d:.2}", .{p.years_until_retirement}) catch "??";
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, conv_row_fmt, .{ p.observation_date.padLeft(conv_col_observed), proj_str, years_str }),
.intent = .normal,
});
}
if (stride > 1) {
try lines.append(arena, .{ .text = "", .intent = .normal });
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " (Showing every {d}th observation — full chart on TUI projections tab.)", .{stride}),
.intent = .muted,
});
}
return lines.toOwnedSlice(arena);
}
/// Column widths for the back-test table. Same source of truth
/// for CLI and TUI fallback. The numeric column is 11 cols wide
/// so the em-dash sentinel can sit dead center (5 leading + dash
/// + 5 trailing); 6-char numeric values like `12.34%` end up
/// right-aligned with 5 leading spaces.
const bt_col_anchor = 12; // "YYYY-MM-DD"
const bt_col_value = 11; // "12.34%" right-aligned / "—" centered
const bt_header_fmt = std.fmt.comptimePrint(
" {{s:<{d}}} {{s}} {{s}} {{s}} {{s}}",
.{bt_col_anchor},
);
const bt_sep_fmt = std.fmt.comptimePrint(
" {{s:-<{d}}} {{s:->{d}}} {{s:->{d}}} {{s:->{d}}} {{s:->{d}}}",
.{ bt_col_anchor, bt_col_value, bt_col_value, bt_col_value, bt_col_value },
);
// Data-row format: numeric cells right-aligned via `{s:>11}`
// (safe — they're pure ASCII). Missing cells use the hard-coded
// `dash_cell` literal which is already 11 display cols wide; it
// passes through `{s:>11}` unchanged because the format spec
// doesn't truncate when content meets/exceeds the width.
const bt_row_fmt = std.fmt.comptimePrint(
" {{f}} {{s:>{d}}} {{s:>{d}}} {{s:>{d}}} {{s:>{d}}}",
.{ bt_col_value, bt_col_value, bt_col_value, bt_col_value },
);
/// Pre-centered column headers for the back-test table. Each is
/// exactly `bt_col_value` (11) display columns wide so they line
/// up with the data rows below. Hard-coded because the labels
/// are fixed at compile time — no need for a runtime centering
/// helper.
const bt_hdr_expected = " Expected "; // 1 lead + 8 chars + 2 trail = 11
const bt_hdr_1y = " 1y "; // 4 lead + 2 chars + 5 trail = 11
const bt_hdr_3y = " 3y "; // 4 lead + 2 chars + 5 trail = 11
const bt_hdr_5y = " 5y "; // 4 lead + 2 chars + 5 trail = 11
/// Build the renderer-agnostic line list for the return-backtest
/// sub-view. `real_mode` toggles a methodology caption (whether
/// realized values are inflation-deflated). Stride logic shows
/// at most ~30 anchors.
///
/// Caller owns nothing — all output strings are allocated in
/// `arena`. Output is the concatenation of `backtestHeaderLines`
/// and `backtestTableLines`.
pub fn backtestLines(
arena: std.mem.Allocator,
anchors: []const forecast.BacktestAnchor,
real_mode: bool,
) ![]const ForecastLine {
var lines: std.ArrayList(ForecastLine) = .empty;
const header = try backtestHeaderLines(arena, anchors, real_mode);
try lines.appendSlice(arena, header);
if (anchors.len > 0) {
const table = try backtestTableLines(arena, anchors);
try lines.appendSlice(arena, table);
}
return lines.toOwnedSlice(arena);
}
/// Header section for the back-test sub-view: title, range,
/// color-coded legend, methodology caption, caveat. Chart
/// renderers use this directly; table renderers use it via
/// `backtestLines`.
pub fn backtestHeaderLines(
arena: std.mem.Allocator,
anchors: []const forecast.BacktestAnchor,
real_mode: bool,
) ![]const ForecastLine {
var lines: std.ArrayList(ForecastLine) = .empty;
try lines.append(arena, .{
.text = "Expected vs realized return back-test",
.intent = .accent,
.bold = true,
});
if (anchors.len == 0) {
try lines.append(arena, .{
.text = " No back-test data available (imported_values.srf empty or missing expected_return fields).",
.intent = .muted,
});
return lines.toOwnedSlice(arena);
}
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " {d} anchors from {f} → {f}", .{
anchors.len, anchors[0].anchor_date, anchors[anchors.len - 1].anchor_date,
}),
.intent = .muted,
});
// Color-coded legend. Each line is rendered in the matching
// chart series color (purple/cyan/yellow/green) so the user
// can map line → series at a glance. Line styles
// (solid/dashed/dotted) reinforce the distinction for
// color-blind users.
try lines.append(arena, .{
.text = " Expected (solid) — projected return at each anchor date",
.intent = .accent,
});
try lines.append(arena, .{
.text = " Realized 1y (dotted)",
.intent = .info,
});
try lines.append(arena, .{
.text = " Realized 3y (dashed)",
.intent = .warning,
});
try lines.append(arena, .{
.text = " Realized 5y (solid)",
.intent = .positive,
});
if (real_mode) {
try lines.append(arena, .{
.text = " realized = inflation-deflated forward CAGR (Shiller CPI). expected is left nominal.",
.intent = .muted,
});
}
try lines.append(arena, .{
.text = " Caveat: tracks the model's expected-return honesty, not SWR validity.",
.intent = .muted,
});
try lines.append(arena, .{ .text = "", .intent = .normal });
return lines.toOwnedSlice(arena);
}
/// Hard-coded em-dash sentinel cell for missing back-test
/// values. 5 leading spaces + `—` + 5 trailing spaces = 11
/// display columns. Sits dead center within the 11-col numeric
/// column.
const dash_cell = "";
/// Table section for the back-test sub-view. Caller-provided
/// `anchors` must be non-empty.
pub fn backtestTableLines(
arena: std.mem.Allocator,
anchors: []const forecast.BacktestAnchor,
) ![]const ForecastLine {
var lines: std.ArrayList(ForecastLine) = .empty;
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, bt_header_fmt, .{ " Anchor", bt_hdr_expected, bt_hdr_1y, bt_hdr_3y, bt_hdr_5y }),
.intent = .muted,
});
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, bt_sep_fmt, .{ "", "", "", "", "" }),
.intent = .muted,
});
const stride: usize = if (anchors.len > 30) (anchors.len + 29) / 30 else 1;
for (anchors, 0..) |a, idx| {
if (idx != 0 and idx != anchors.len - 1 and (idx % stride) != 0) continue;
var ebuf: [16]u8 = undefined;
var r1_buf: [16]u8 = undefined;
var r3_buf: [16]u8 = undefined;
var r5_buf: [16]u8 = undefined;
const e_cell = std.fmt.bufPrint(&ebuf, "{d:.2}% ", .{a.expected * 100}) catch "??";
// Numeric cells are pure ASCII so the format string's
// `{s:>10}` byte-padding lines them up correctly. Missing
// cells use the hard-coded `dash_cell` literal which is
// already shaped to 10 display columns (Zig's byte-padding
// would under-pad the multibyte em-dash by 2 cols).
const r1_cell: []const u8 = if (a.realized_1y) |v|
std.fmt.bufPrint(&r1_buf, "{d:.2}% ", .{v * 100}) catch "??"
else
dash_cell;
const r3_cell: []const u8 = if (a.realized_3y) |v|
std.fmt.bufPrint(&r3_buf, "{d:.2}% ", .{v * 100}) catch "??"
else
dash_cell;
const r5_cell: []const u8 = if (a.realized_5y) |v|
std.fmt.bufPrint(&r5_buf, "{d:.2}% ", .{v * 100}) catch "??"
else
dash_cell;
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, bt_row_fmt, .{ a.anchor_date.padLeft(bt_col_anchor), e_cell, r1_cell, r3_cell, r5_cell }),
.intent = .normal,
});
}
if (stride > 1) {
try lines.append(arena, .{ .text = "", .intent = .normal });
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " (Showing every {d}th anchor — full chart on TUI projections tab.)", .{stride}),
.intent = .muted,
});
}
return lines.toOwnedSlice(arena);
}
// ── 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);
}
// ── Overlay-actuals tests ─────────────────────────────────────
/// Build a TimelinePoint with just the date and liquid value
/// — the overlay only reads those two fields. Empty accounts /
/// tax_types are fine; we never deinit these synthetic points.
fn makeTp(date: Date, liquid: f64) timeline.TimelinePoint {
return .{
.as_of_date = date,
.net_worth = liquid,
.liquid = liquid,
.illiquid = 0,
.accounts = &.{},
.tax_types = &.{},
.source = .snapshot,
};
}
test "buildOverlayActuals: empty input produces empty section" {
var section = try buildOverlayActuals(
std.testing.allocator,
&.{},
Date.fromYmd(2024, 1, 1),
Date.fromYmd(2025, 1, 1),
);
defer section.deinit();
try std.testing.expectEqual(@as(usize, 0), section.points.len);
try std.testing.expectApproxEqAbs(@as(f64, 1.0), section.today_years, 0.01);
}
test "buildOverlayActuals: single point at as_of has years=0" {
const points = [_]timeline.TimelinePoint{
makeTp(Date.fromYmd(2024, 1, 1), 1_000_000),
};
var section = try buildOverlayActuals(
std.testing.allocator,
&points,
Date.fromYmd(2024, 1, 1),
Date.fromYmd(2024, 1, 1),
);
defer section.deinit();
try std.testing.expectEqual(@as(usize, 1), section.points.len);
try std.testing.expectEqual(@as(f64, 0.0), section.points[0].years_from_as_of);
try std.testing.expectEqual(@as(f64, 1_000_000), section.points[0].liquid);
try std.testing.expectEqual(@as(f64, 0.0), section.today_years);
}
test "buildOverlayActuals: filters out points before as_of and after today" {
const points = [_]timeline.TimelinePoint{
makeTp(Date.fromYmd(2023, 6, 1), 800_000), // before as_of - excluded
makeTp(Date.fromYmd(2024, 1, 1), 1_000_000),
makeTp(Date.fromYmd(2024, 7, 1), 1_100_000),
makeTp(Date.fromYmd(2025, 1, 1), 1_200_000),
makeTp(Date.fromYmd(2025, 6, 1), 1_300_000), // after today - excluded
};
var section = try buildOverlayActuals(
std.testing.allocator,
&points,
Date.fromYmd(2024, 1, 1),
Date.fromYmd(2025, 1, 1),
);
defer section.deinit();
try std.testing.expectEqual(@as(usize, 3), section.points.len);
// First point is at as_of itself, last point is at today.
try std.testing.expectEqual(@as(f64, 0.0), section.points[0].years_from_as_of);
try std.testing.expectApproxEqAbs(@as(f64, 1.0), section.points[2].years_from_as_of, 0.01);
}
test "buildOverlayActuals: years_from_as_of math is monotonic" {
const points = [_]timeline.TimelinePoint{
makeTp(Date.fromYmd(2024, 1, 1), 1_000_000),
makeTp(Date.fromYmd(2024, 7, 1), 1_100_000),
makeTp(Date.fromYmd(2025, 1, 1), 1_200_000),
};
var section = try buildOverlayActuals(
std.testing.allocator,
&points,
Date.fromYmd(2024, 1, 1),
Date.fromYmd(2025, 1, 1),
);
defer section.deinit();
var prev: f64 = -1.0;
for (section.points) |p| {
try std.testing.expect(p.years_from_as_of > prev);
prev = p.years_from_as_of;
}
}
test "buildOverlayActuals: today_years matches today - as_of" {
var section = try buildOverlayActuals(
std.testing.allocator,
&.{},
Date.fromYmd(2024, 1, 1),
Date.fromYmd(2026, 1, 1),
);
defer section.deinit();
// Two calendar years = 731 days / 365.25 ≈ 2.001 years.
try std.testing.expectApproxEqAbs(@as(f64, 2.0), section.today_years, 0.01);
}
test "buildOverlayActuals: liquid value is preserved verbatim" {
const points = [_]timeline.TimelinePoint{
makeTp(Date.fromYmd(2024, 6, 15), 1_234_567.89),
};
var section = try buildOverlayActuals(
std.testing.allocator,
&points,
Date.fromYmd(2024, 1, 1),
Date.fromYmd(2025, 1, 1),
);
defer section.deinit();
try std.testing.expectEqual(@as(f64, 1_234_567.89), section.points[0].liquid);
}
test "buildOverlayActuals: empty range (today < as_of) produces empty points" {
const points = [_]timeline.TimelinePoint{
makeTp(Date.fromYmd(2024, 6, 1), 1_000_000),
};
var section = try buildOverlayActuals(
std.testing.allocator,
&points,
Date.fromYmd(2025, 1, 1),
Date.fromYmd(2024, 1, 1), // today before as_of (degenerate)
);
defer section.deinit();
// Filter is `>= as_of AND <= today`. With today < as_of, no points
// can satisfy both — section is empty.
try std.testing.expectEqual(@as(usize, 0), section.points.len);
}
// ── Forecast-vs-actual view-model tests ───────────────────────
test "convergenceLines: empty input yields title + 'no data' message" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const lines = try convergenceLines(arena.allocator(), &.{});
try std.testing.expect(lines.len >= 2);
try std.testing.expect(lines[0].bold);
try std.testing.expectEqual(StyleIntent.accent, lines[0].intent);
// Second line is the muted "no data" caption.
try std.testing.expectEqual(StyleIntent.muted, lines[1].intent);
}
test "convergenceLines: header constants drive aligned widths" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const points = [_]forecast.ConvergencePoint{
.{
.observation_date = Date.fromYmd(2020, 1, 1),
.projected_date = Date.fromYmd(2030, 6, 15),
.years_until_retirement = 10.45,
.reached = false,
},
.{
.observation_date = Date.fromYmd(2025, 1, 1),
.projected_date = Date.fromYmd(2025, 1, 1),
.years_until_retirement = 0.0,
.reached = true,
},
};
const lines = try convergenceLines(arena.allocator(), &points);
// Find the column-header line (look for "Observed").
var header_idx: ?usize = null;
for (lines, 0..) |ln, i| {
if (std.mem.indexOf(u8, ln.text, "Observed") != null) header_idx = i;
}
try std.testing.expect(header_idx != null);
const sep = lines[header_idx.? + 1].text;
// Separator is built from the same comptime widths as the header,
// so dash count must match the header's column extent.
try std.testing.expect(std.mem.indexOf(u8, sep, "------------") != null);
}
test "backtestLines: emits color-coded legend for the chart series" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const anchors = [_]forecast.BacktestAnchor{
.{
.anchor_date = Date.fromYmd(2020, 1, 1),
.expected = 0.08,
.realized_1y = 0.10,
.realized_3y = 0.12,
.realized_5y = null,
},
};
const lines = try backtestLines(arena.allocator(), &anchors, false);
// Legend lines: each in a distinct intent so renderers can map
// line → series color. Counting matters because the chart
// renderer reads colors by intent and a missing legend line
// would silently break the user's "what is each color?"
// mental model.
var saw_accent = false;
var saw_info = false;
var saw_warning = false;
var saw_positive = false;
for (lines) |ln| {
if (ln.bold) continue; // skip title
if (std.mem.indexOf(u8, ln.text, "Expected (solid)") != null) {
try std.testing.expectEqual(StyleIntent.accent, ln.intent);
saw_accent = true;
}
if (std.mem.indexOf(u8, ln.text, "Realized 1y (dotted)") != null) {
try std.testing.expectEqual(StyleIntent.info, ln.intent);
saw_info = true;
}
if (std.mem.indexOf(u8, ln.text, "Realized 3y (dashed)") != null) {
try std.testing.expectEqual(StyleIntent.warning, ln.intent);
saw_warning = true;
}
if (std.mem.indexOf(u8, ln.text, "Realized 5y (solid)") != null) {
try std.testing.expectEqual(StyleIntent.positive, ln.intent);
saw_positive = true;
}
}
try std.testing.expect(saw_accent);
try std.testing.expect(saw_info);
try std.testing.expect(saw_warning);
try std.testing.expect(saw_positive);
}
test "backtestLines: real_mode emits inflation-deflated caption" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const anchors = [_]forecast.BacktestAnchor{
.{
.anchor_date = Date.fromYmd(2020, 1, 1),
.expected = 0.08,
.realized_1y = 0.10,
.realized_3y = null,
.realized_5y = null,
},
};
const nominal = try backtestLines(arena.allocator(), &anchors, false);
var saw_nominal = false;
for (nominal) |ln| {
if (std.mem.indexOf(u8, ln.text, "inflation-deflated") != null) saw_nominal = true;
}
try std.testing.expect(!saw_nominal);
const real = try backtestLines(arena.allocator(), &anchors, true);
var saw_real = false;
for (real) |ln| {
if (std.mem.indexOf(u8, ln.text, "inflation-deflated") != null) saw_real = true;
}
try std.testing.expect(saw_real);
}
test "backtestLines: missing horizons render as em-dash" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const anchors = [_]forecast.BacktestAnchor{
.{
.anchor_date = Date.fromYmd(2024, 6, 1), // recent → realized_5y missing
.expected = 0.08,
.realized_1y = 0.10,
.realized_3y = null,
.realized_5y = null,
},
};
const lines = try backtestLines(arena.allocator(), &anchors, false);
// The data row contains the anchor date AND the formatted
// expected percentage. Use the latter to disambiguate from
// the range-header line which also carries the date.
for (lines) |ln| {
if (std.mem.indexOf(u8, ln.text, "8.00%") != null and
std.mem.indexOf(u8, ln.text, "2024-06-01") != null)
{
try std.testing.expect(std.mem.indexOf(u8, ln.text, "") != null);
return;
}
}
try std.testing.expect(false); // data row not found
}
test "backtestLines: data rows align across mixed numeric/em-dash cells" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
// Two anchors: one fully populated, one with most horizons
// missing. Em-dashes are 3 bytes / 1 display col; missing
// cells use the hard-coded `dash_cell` literal pre-shaped to
// 10 display columns. Numeric cells are pure ASCII so byte-
// padding via `{s:>10}` is safe. Both rows must produce
// identical display widths.
const anchors = [_]forecast.BacktestAnchor{
.{
.anchor_date = Date.fromYmd(2014, 1, 1),
.expected = 0.12,
.realized_1y = 0.10,
.realized_3y = 0.09,
.realized_5y = 0.08,
},
.{
.anchor_date = Date.fromYmd(2025, 6, 1),
.expected = 0.07,
.realized_1y = null,
.realized_3y = null,
.realized_5y = null,
},
};
const lines = try backtestLines(arena.allocator(), &anchors, false);
var full_row: ?[]const u8 = null;
var sparse_row: ?[]const u8 = null;
for (lines) |ln| {
// Look for the data row signature: leading two spaces,
// anchor date, no header / legend keywords.
if (std.mem.indexOf(u8, ln.text, "2014-01-01") != null and
std.mem.indexOf(u8, ln.text, "from") == null)
{
full_row = ln.text;
}
if (std.mem.indexOf(u8, ln.text, "2025-06-01") != null and
std.mem.indexOf(u8, ln.text, "from") == null)
{
sparse_row = ln.text;
}
}
try std.testing.expect(full_row != null);
try std.testing.expect(sparse_row != null);
// Both rows must occupy identical display-column widths
// even though their byte lengths differ (each em-dash is 2
// extra bytes vs a 1-byte ASCII space).
try std.testing.expectEqual(fmt.displayCols(full_row.?), fmt.displayCols(sparse_row.?));
}
// ── Regression locks: back-test layout constants ──────────────
//
// The back-test table layout was tuned by eye. These tests
// freeze the exact widths and string contents so an "innocent"
// edit to the format string or a header constant trips a test
// instead of silently re-misaligning the table.
test "backtest layout: column widths are 11 cols / 12 cols" {
try std.testing.expectEqual(@as(usize, 11), bt_col_value);
try std.testing.expectEqual(@as(usize, 12), bt_col_anchor);
}
test "backtest layout: dash_cell is exactly 11 display columns" {
try std.testing.expectEqual(@as(usize, 11), fmt.displayCols(dash_cell));
}
test "backtest layout: dash_cell exact byte content" {
// The dash position was tuned to read as visually centered
// next to the right-aligned numeric data (which ends with
// a trailing space — see `bufPrint("{d:.2}% ", ...)`). If
// someone changes the dash position, this test fires and
// forces a deliberate update of both the dash_cell literal
// and the matching alignment test below.
try std.testing.expectEqualStrings("", dash_cell);
}
test "backtest layout: all column headers are exactly 11 display cols" {
try std.testing.expectEqual(@as(usize, 11), fmt.displayCols(bt_hdr_expected));
try std.testing.expectEqual(@as(usize, 11), fmt.displayCols(bt_hdr_1y));
try std.testing.expectEqual(@as(usize, 11), fmt.displayCols(bt_hdr_3y));
try std.testing.expectEqual(@as(usize, 11), fmt.displayCols(bt_hdr_5y));
}
test "backtest layout: header strings exact byte content" {
// Frozen against the user-tuned alignment. Edit these
// strings only with a deliberate visual recheck of the
// table output.
try std.testing.expectEqualStrings(" Expected ", bt_hdr_expected);
try std.testing.expectEqualStrings(" 1y ", bt_hdr_1y);
try std.testing.expectEqualStrings(" 3y ", bt_hdr_3y);
try std.testing.expectEqualStrings(" 5y ", bt_hdr_5y);
}
test "backtest layout: data row em-dash sits under header centerline" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
// Single anchor with all realized values missing — every
// numeric cell renders as `dash_cell`. Find the data row
// and the header row, then verify the em-dash byte position
// is consistent across all four numeric columns AND lines
// up with the column header strings beneath the dashes.
const anchors = [_]forecast.BacktestAnchor{
.{
.anchor_date = Date.fromYmd(2025, 6, 1),
.expected = 0.07,
.realized_1y = null,
.realized_3y = null,
.realized_5y = null,
},
};
const lines = try backtestLines(arena.allocator(), &anchors, false);
var data_row: ?[]const u8 = null;
for (lines) |ln| {
if (std.mem.indexOf(u8, ln.text, "2025-06-01") != null and
std.mem.indexOf(u8, ln.text, "from") == null)
{
data_row = ln.text;
}
}
try std.testing.expect(data_row != null);
// Count em-dash occurrences in the data row. Each missing
// realized horizon contributes exactly one — three total
// (1y, 3y, 5y; expected is always populated).
var dash_count: usize = 0;
var i: usize = 0;
while (i + 2 < data_row.?.len) : (i += 1) {
if (std.mem.eql(u8, data_row.?[i .. i + 3], "")) dash_count += 1;
}
try std.testing.expectEqual(@as(usize, 3), dash_count);
}
test "backtest layout: full-row width matches header-row width" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const anchors = [_]forecast.BacktestAnchor{
.{
.anchor_date = Date.fromYmd(2014, 1, 1),
.expected = 0.12,
.realized_1y = 0.10,
.realized_3y = 0.09,
.realized_5y = 0.08,
},
};
const lines = try backtestLines(arena.allocator(), &anchors, false);
// Find the column header line ("Anchor … Expected … 1y …")
// and the data row, then verify their display widths match.
// A drift between header padding and data padding would
// show up here as a mismatch.
var header_row: ?[]const u8 = null;
var data_row: ?[]const u8 = null;
for (lines) |ln| {
if (std.mem.indexOf(u8, ln.text, "Anchor") != null and
std.mem.indexOf(u8, ln.text, "Expected") != null)
{
header_row = ln.text;
}
if (std.mem.indexOf(u8, ln.text, "2014-01-01") != null and
std.mem.indexOf(u8, ln.text, "from") == null)
{
data_row = ln.text;
}
}
try std.testing.expect(header_row != null);
try std.testing.expect(data_row != null);
try std.testing.expectEqual(fmt.displayCols(header_row.?), fmt.displayCols(data_row.?));
}
test "backtest layout: separator row width matches header width" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const anchors = [_]forecast.BacktestAnchor{
.{
.anchor_date = Date.fromYmd(2020, 1, 1),
.expected = 0.08,
.realized_1y = 0.10,
.realized_3y = null,
.realized_5y = null,
},
};
const lines = try backtestLines(arena.allocator(), &anchors, false);
var header_row: ?[]const u8 = null;
var sep_row: ?[]const u8 = null;
for (lines) |ln| {
if (std.mem.indexOf(u8, ln.text, "Anchor") != null and
std.mem.indexOf(u8, ln.text, "Expected") != null)
{
header_row = ln.text;
}
// Separator row is all dashes after the leading 2 spaces.
if (ln.text.len > 4 and
std.mem.eql(u8, ln.text[0..4], " --"))
{
sep_row = ln.text;
}
}
try std.testing.expect(header_row != null);
try std.testing.expect(sep_row != null);
try std.testing.expectEqual(fmt.displayCols(header_row.?), fmt.displayCols(sep_row.?));
}