2503 lines
97 KiB
Zig
2503 lines
97 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 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)
|
||
/// - 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,
|
||
/// 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
|
||
/// 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,
|
||
};
|
||
|
||
/// 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.?));
|
||
}
|