/// 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: `` 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.?)); }