/// Renderer-agnostic view model for the projections display. /// /// Produces pre-formatted text and `StyleIntent` values that both CLI /// and TUI renderers can consume through thin style-mapping adapters. const std = @import("std"); const fmt = @import("../format.zig"); const Money = @import("../Money.zig"); const performance = @import("../analytics/performance.zig"); const benchmark = @import("../analytics/benchmark.zig"); const projections = @import("../analytics/projections.zig"); const valuation = @import("../analytics/valuation.zig"); const zfin = @import("../root.zig"); const snapshot_model = @import("../models/snapshot.zig"); const history = @import("../history.zig"); const Date = @import("../models/date.zig").Date; pub const StyleIntent = fmt.StyleIntent; // ── Layout constants (shared by CLI and TUI) ────────────────── pub const label_width = 32; pub const col_1y = 8; pub const col_3y = 9; pub const col_5y = 9; pub const col_10y = 10; pub const col_week = 9; pub const withdrawal_label_width = 25; pub const withdrawal_col_width = 12; pub const terminal_col_width = 18; // ── Return row formatting ────────────────────────────────────── /// A single cell in the returns table: formatted text + style. pub const ReturnCell = struct { text: []const u8, style: StyleIntent, }; /// Format a return value into a buffer, returning the styled cell. pub fn fmtReturnCell(buf: []u8, value: ?f64) ReturnCell { if (value) |v| { return .{ .text = performance.formatReturn(buf, v), .style = if (v >= 0) .positive else .negative, }; } return .{ .text = "--", .style = .muted }; } /// A complete row in the benchmark comparison table. pub const ReturnRow = struct { label: []const u8, one_year: ReturnCell, three_year: ReturnCell, five_year: ReturnCell, ten_year: ReturnCell, week: ReturnCell, bold: bool = false, }; /// Build a return row from a ReturnsByPeriod and a label. pub fn buildReturnRow(label: []const u8, returns: benchmark.ReturnsByPeriod, bufs: *[5][16]u8, bold: bool) ReturnRow { return .{ .label = label, .one_year = fmtReturnCell(&bufs[0], returns.one_year), .three_year = fmtReturnCell(&bufs[1], returns.three_year), .five_year = fmtReturnCell(&bufs[2], returns.five_year), .ten_year = fmtReturnCell(&bufs[3], returns.ten_year), .week = fmtReturnCell(&bufs[4], returns.week), .bold = bold, }; } // ── Safe withdrawal formatting ───────────────────────────────── /// A single cell in the withdrawal table. pub const WithdrawalCell = struct { amount_text: []const u8, rate_text: []const u8, }; /// Format a safe withdrawal result into display strings. /// Caller owns both buffers (at least 24 bytes each). /// Strips trailing ".00" from whole-dollar amounts for clean display. pub fn fmtWithdrawalCell(amount_buf: []u8, rate_buf: []u8, result: projections.WithdrawalResult) WithdrawalCell { const clean_amount = std.fmt.bufPrint(amount_buf, "{f}", .{Money.from(result.annual_amount).trim()}) catch "$?"; const rate_str = std.fmt.bufPrint(rate_buf, "{d:.2}%", .{result.withdrawal_rate * 100}) catch "??%"; return .{ .amount_text = clean_amount, .rate_text = rate_str }; } /// Format a confidence level label (e.g. "99% safe withdrawal"). pub fn fmtConfidenceLabel(buf: []u8, confidence: f64) []const u8 { return std.fmt.bufPrint(buf, "{d:.0}% safe withdrawal", .{confidence * 100}) catch "??"; } /// Format a horizon column header (e.g. "30 Year"). pub fn fmtHorizonLabel(buf: []u8, horizon: u16) []const u8 { return std.fmt.bufPrint(buf, "{d} Year", .{horizon}) catch "??"; } // ── Allocation summary ───────────────────────────────────────── /// Result of formatting the allocation note. pub const AllocationNote = struct { text: []const u8, style: StyleIntent, }; /// Format the target allocation note line with drift-aware styling. /// Returns null if no target is configured. /// /// Drift thresholds: /// - Within 5%: "on target" (muted) /// - 5–10% off: warning /// - Over 10% off: negative pub fn fmtAllocationNote(buf: []u8, target_stock_pct: ?f64, current_stock_pct: f64) ?AllocationNote { const target = target_stock_pct orelse return null; const current = current_stock_pct * 100; const drift = @abs(current - target); const style: StyleIntent = if (drift < 5.0) .muted else if (drift < 10.0) .warning else .negative; const text = if (drift < 5.0) std.fmt.bufPrint(buf, "Target allocation: {d:.0}% stocks / {d:.0}% bonds (current: {d:.1}% \u{2014} on target)", .{ target, 100.0 - target, current, }) catch return null else std.fmt.bufPrint(buf, "Target allocation: {d:.0}% stocks / {d:.0}% bonds (current: {d:.1}%)", .{ target, 100.0 - target, current, }) catch return null; return .{ .text = text, .style = style }; } /// Format the stock benchmark label with weight. pub fn fmtBenchmarkLabel(buf: []u8, symbol: []const u8, weight_pct: f64) []const u8 { return std.fmt.bufPrint(buf, "{s} ({d:.1}% weight)", .{ symbol, weight_pct }) catch symbol; } // ── Precomputed projection data (shared by CLI and TUI) ──────── pub const ProjectionContext = struct { comparison: benchmark.BenchmarkComparison, config: projections.UserConfig, data: ProjectionData, stock_pct: f64, bond_pct: f64, total_value: f64, /// Resolved retirement boundary against the projection's reference /// date (today for live mode, `as_of` for historical mode). retirement: projections.ResolvedRetirement = .{ .accumulation_years = 0, .date = null, .source = .none }, /// Statistics from the simulation's accumulation phase: portfolio /// value at the retirement boundary, computed from the configured /// median/p10/p90 percentile bands. `null` when the user has not /// configured any retirement date (so the simulation runs /// distribution-only and there's no boundary year to evaluate at). accumulation: ?AccumulationStats = null, /// "Earliest retirement" grid results: one entry per (horizon × /// confidence) pair when the user configured `target_spending`, /// or `null` otherwise. earliest: ?[]projections.EarliestRetirement = null, /// Which retirement-planning inputs the user configured. Drives /// which display blocks render. inputs: ProjectionInputs = .distribution_only, }; /// Statistics extracted from the bands at the retirement-boundary /// year. Used to render the median portfolio at retirement and the /// p10–p90 range under the "Accumulation phase" display block. pub const AccumulationStats = struct { median_at_retirement: f64, p10_at_retirement: f64, p90_at_retirement: f64, annual_contribution: f64, contribution_inflation_adjusted: bool, }; /// Which retirement-planning inputs the user has configured. /// /// The simulation always runs the same two-phase model /// (accumulation followed by distribution); these variants describe /// only which output blocks the display layer renders. /// /// - `.distribution_only` — neither a target retirement date /// (`retirement_age` / `retirement_at`) nor a target spending /// (`target_spending`) is set. Already-retired users; the /// accumulation phase has zero years. /// - `.target_retirement_date` — `retirement_age` or /// `retirement_at` is set. The display reports the spending the /// accumulated portfolio supports. /// - `.target_spending` — `target_spending` is set. The display /// reports the date(s) at which that spending becomes /// sustainable, and promotes one cell from the resulting grid /// into the headline retirement line. /// - `.both_targets` — both are set. Both blocks render /// back-to-back; the configured date wins for the headline. pub const ProjectionInputs = enum { distribution_only, target_retirement_date, target_spending, both_targets, }; pub const ProjectionData = projections.ProjectionData; pub const runProjectionGrid = projections.runProjectionGrid; pub fn buildProjectionContext( alloc: std.mem.Allocator, config: projections.UserConfig, comparison: benchmark.BenchmarkComparison, stock_pct: f64, bond_pct: f64, total_value: f64, events: []const projections.ResolvedEvent, as_of: Date, ) !ProjectionContext { const sim_stock_pct = if (config.target_stock_pct) |t| t / 100.0 else stock_pct; // Resolve the retirement boundary from the user's target // retirement date (`retirement_age` / `retirement_at`). Returns // `.none` when neither is configured. var retirement = config.resolveRetirement(as_of); const accumulation_years: u16 = retirement.accumulation_years; const data = try runProjectionGrid( alloc, config.getHorizons(), config.getConfidenceLevels(), total_value, sim_stock_pct, events, accumulation_years, config.annual_contribution, config.contribution_inflation_adjusted, ); // Accumulation-phase stats: extract portfolio value at the // retirement boundary from the longest-horizon band (most data // available; same boundary year for all horizons). var accumulation_stats: ?AccumulationStats = null; if (accumulation_years > 0) { const horizons = config.getHorizons(); if (horizons.len > 0) { const last_band = data.bands[horizons.len - 1]; if (last_band) |b| { if (b.len > @as(usize, accumulation_years)) { const yp = b[@as(usize, accumulation_years)]; accumulation_stats = .{ .median_at_retirement = yp.p50, .p10_at_retirement = yp.p10, .p90_at_retirement = yp.p90, .annual_contribution = config.annual_contribution, .contribution_inflation_adjusted = config.contribution_inflation_adjusted, }; } } } } // Earliest retirement grid: when `target_spending` is set, // search for the earliest retirement year per (horizon × // confidence) pair. var earliest: ?[]projections.EarliestRetirement = null; if (config.target_spending) |target| { const horizons = config.getHorizons(); const confs = config.getConfidenceLevels(); const cells = try alloc.alloc(projections.EarliestRetirement, horizons.len * confs.len); for (confs, 0..) |conf, ci| { for (horizons, 0..) |h, hi| { cells[ci * horizons.len + hi] = try projections.findEarliestRetirement( alloc, total_value, sim_stock_pct, config.annual_contribution, config.contribution_inflation_adjusted, target, config.target_spending_inflation_adjusted, h, conf, events, projections.max_accumulation_years, ); } } earliest = cells; } const has_target_date = retirement.source != .none; const has_target_spend = config.target_spending != null; const inputs: ProjectionInputs = if (has_target_spend and has_target_date) .both_targets else if (has_target_spend) .target_spending else if (has_target_date) .target_retirement_date else .distribution_only; // Promotion: when target_spending is configured but no explicit // retirement date is, pick a cell from the Earliest retirement // grid and promote it into `retirement` + `accumulation_stats`. // This keeps the Accumulation phase block coherent with the // target-spending answer below it. if (inputs == .target_spending) { if (earliest) |grid| { const horizons = config.getHorizons(); const confs = config.getConfidenceLevels(); if (projections.pickPromotedCell(&config, as_of, confs)) |pc| { const cell = grid[pc.confidence_index * horizons.len + pc.horizon_index]; if (cell.accumulation_years) |n| { // Promoted cell is feasible: synthesize a // retirement date by walking N years out from // `as_of` (preserves the reference date's m/d, // matching the calendar-precise treatment of // `retirement_at`). const ret_date = Date.fromYmd( as_of.year() + @as(i16, @intCast(n)), as_of.month(), as_of.day(), ); retirement = .{ .accumulation_years = n, .date = ret_date, .source = .promoted, }; // Recompute accumulation_stats using the // promoted N — the target-retirement-date path // computes these from the percentile bands, but // the target-spending cell already carries // median/p10/p90 at retirement, so reuse them. accumulation_stats = .{ .median_at_retirement = cell.median_at_retirement, .p10_at_retirement = cell.p10_at_retirement, .p90_at_retirement = cell.p90_at_retirement, .annual_contribution = config.annual_contribution, .contribution_inflation_adjusted = config.contribution_inflation_adjusted, }; } else { // Promoted cell is infeasible: render the line // as "not feasible" and skip the stats lines. retirement = .{ .accumulation_years = 0, .date = null, .source = .promoted_infeasible, }; accumulation_stats = null; } } } } return .{ .comparison = comparison, .config = config, .data = data, .stock_pct = stock_pct, .bond_pct = bond_pct, .total_value = total_value, .retirement = retirement, .accumulation = accumulation_stats, .earliest = earliest, .inputs = inputs, }; } /// Load and compute a complete ProjectionContext from a portfolio path and service. /// /// This is the single entry point for both CLI and TUI. It handles: /// - Loading projections.srf and metadata.srf from the portfolio directory /// - Deriving stock/bond allocation from classification metadata /// - Computing benchmark trailing returns (SPY + AGG) /// - Building per-position weighted trailing returns /// - Running the FIRECalc simulation for all horizons and confidence levels /// /// The caller provides the portfolio summary (allocations, total value, cash/CD) /// and a DataService for candle access. All intermediate allocations use `alloc`. pub fn loadProjectionContext( io: std.Io, alloc: std.mem.Allocator, portfolio_dir: []const u8, allocations: []const valuation.Allocation, total_value: f64, cash_value: f64, cd_value: f64, svc: *zfin.DataService, events_enabled: bool, as_of: Date, ) !ProjectionContext { return buildContextFromParts( io, alloc, portfolio_dir, allocations, total_value, cash_value, cd_value, svc, events_enabled, as_of, ); } // ── As-of (historical) projection context ────────────────────── // // Retrospective projections. Builds a ProjectionContext as if it were // a past `as_of` date, using a stored snapshot for portfolio // composition and truncating benchmark candle history at `as_of` so // trailing returns reflect what was knowable at that moment. // // The snapshot→allocations aggregation (`SnapshotAllocations` and // `aggregateSnapshotAllocations`) lives in `src/history.zig` next to // the other snapshot-domain helpers. This module orchestrates the // full projection pipeline through `buildContextFromParts`. // // Known limitation: `projections.srf` and `metadata.srf` are still // loaded from the current working copy (git-tracked). This means // retirement ages, target allocation, confidence levels, and symbol // classifications are all "as of now", not as of the requested date. // Documented edge case; see TODO.md. /// Build a complete `ProjectionContext` as of a historical `as_of_date`. /// /// Mirrors `loadProjectionContext` but sources portfolio composition /// from a snapshot instead of the live portfolio file: /// - Allocations derived from snapshot's lot rows /// - Total value / cash / CD taken from snapshot /// - Benchmark candles truncated to <= as_of_date /// - Per-symbol trailing returns truncated to <= as_of_date /// - Life events resolved against ages-as-of-as_of via /// `UserConfig.currentAges` /// /// Known as-of limitations (documented): /// - `metadata.srf` classifications are current, not historical. /// Symbols reclassified since the snapshot use the new class. /// - `projections.srf` config (retirement ages, horizons, target /// allocation) is current, not historical. /// - Per-symbol candles older than ~10yr may be missing from cache, /// causing null trailing returns for older windows. /// /// Caller owns the returned context. `snap` must outlive the context /// (allocation symbol strings borrow from the snapshot's backing /// buffer — see `history.aggregateSnapshotAllocations`). pub fn loadProjectionContextAsOf( io: std.Io, alloc: std.mem.Allocator, portfolio_dir: []const u8, snap: *const snapshot_model.Snapshot, as_of_date: Date, svc: *zfin.DataService, events_enabled: bool, ) !ProjectionContext { var snap_allocs = try history.aggregateSnapshotAllocations(alloc, snap); defer snap_allocs.deinit(alloc); return buildContextFromParts( io, alloc, portfolio_dir, snap_allocs.allocations, snap_allocs.total_value, snap_allocs.cash_value, snap_allocs.cd_value, svc, events_enabled, as_of_date, ); } /// Shared core: build a `ProjectionContext` from pre-computed /// allocations and totals. Both `loadProjectionContext` (live) and /// `loadProjectionContextAsOf` (historical) delegate here. /// /// `as_of` gates two behaviors: /// - `null` → live mode. Benchmark + per-symbol candles used as-is; /// events resolved against current ages (`resolveEvents()`). /// - `|d|` → historical mode. Benchmark + per-symbol candles sliced /// to `<= d`; events resolved against ages-as-of-d /// (`resolveEventsWithAges(currentAgesAsOf(d))`). fn buildContextFromParts( io: std.Io, alloc: std.mem.Allocator, portfolio_dir: []const u8, allocations: []const valuation.Allocation, total_value: f64, cash_value: f64, cd_value: f64, svc: *zfin.DataService, events_enabled: bool, as_of: Date, ) !ProjectionContext { // Load projections.srf const proj_path = try std.fmt.allocPrint(alloc, "{s}projections.srf", .{portfolio_dir}); defer alloc.free(proj_path); const proj_data = std.Io.Dir.cwd().readFileAlloc(io, proj_path, alloc, .limited(64 * 1024)) catch null; defer if (proj_data) |d| alloc.free(d); var config = projections.parseProjectionsConfig(proj_data); if (!events_enabled) config.event_count = 0; // Resolve age-based horizons (if any) against `as_of`. The caller // chooses whether `as_of` is today (live mode) or a historical // backfill date. This turns // `horizon_age:num:N` records into concrete year counts appended to // `config.horizons` — see `UserConfig.resolveHorizonAges`. const horizon_anchor = as_of; try config.resolveHorizonAges(horizon_anchor); // Load metadata for classification const meta_path = try std.fmt.allocPrint(alloc, "{s}metadata.srf", .{portfolio_dir}); defer alloc.free(meta_path); const meta_data = std.Io.Dir.cwd().readFileAlloc(io, meta_path, alloc, .limited(1024 * 1024)) catch null; defer if (meta_data) |d| alloc.free(d); var cm_opt: ?zfin.classification.ClassificationMap = if (meta_data) |d| zfin.classification.parseClassificationFile(alloc, d) catch null else null; defer if (cm_opt) |*cm| cm.deinit(); // Derive stock/bond split const split = benchmark.deriveAllocationSplit( allocations, if (cm_opt) |cm| cm.entries else &.{}, total_value, cash_value, cd_value, ); // Fetch benchmark candles (checks cache first). In historical // mode we slice to `<= as_of` — `performance.trailingReturns` // anchors on the last candle's date, so trimming the tail gives // returns "as of" that date for free. const spy_result = svc.getCandles("SPY") catch null; defer if (spy_result) |r| r.deinit(); const spy_candles = history.sliceCandlesAsOf( if (spy_result) |r| r.data else &.{}, as_of, ); const agg_result = svc.getCandles("AGG") catch null; defer if (agg_result) |r| r.deinit(); const agg_candles = history.sliceCandlesAsOf( if (agg_result) |r| r.data else &.{}, as_of, ); const spy_trailing = performance.trailingReturns(spy_candles); const agg_trailing = performance.trailingReturns(agg_candles); const spy_week = performance.weekReturn(spy_candles); const agg_week = performance.weekReturn(agg_candles); // Build per-position trailing returns from cached candles, each // optionally truncated to the as-of date. var pos_returns: std.ArrayListUnmanaged(benchmark.PositionReturn) = .empty; defer pos_returns.deinit(alloc); for (allocations) |a| { const candles_res = svc.getCachedCandles(a.symbol) orelse continue; defer candles_res.deinit(); const candles = history.sliceCandlesAsOf(candles_res.data, as_of); if (candles.len > 0) { try pos_returns.append(alloc, .{ .symbol = a.symbol, .weight = a.weight, .returns = performance.trailingReturns(candles), }); } } const comparison = benchmark.buildComparison( spy_trailing, agg_trailing, split.stock_pct, split.bond_pct, pos_returns.items, spy_week, agg_week, ); // Resolve events against ages-as-of the reference date. The // caller chooses whether `as_of` is today (live mode) or a // historical backfill date — the math is the same either way. const resolved_events = blk: { const resolved = config.resolveEvents(as_of); break :blk resolved[0..config.event_count]; }; return buildProjectionContext( alloc, config, comparison, split.stock_pct, split.bond_pct, total_value, resolved_events, as_of, ); } // ── Accumulation phase / earliest retirement display blocks ──── /// Format the "Years until possible retirement: …" line. The line is /// always present in projections output for transparency, including /// the `none` and infeasible cases. /// /// When `config` has at least one birthdate AND the retirement is /// resolved to a date, the configured persons' ages on the /// retirement date are appended in birthdate-record order, separated /// by '/'. Ages use whole-year integer math (`yearsBetween` floored). /// /// Output forms: /// - `.none` → "Years until possible retirement: none" /// - `.at_date` / `.at_age` / `.promoted` (with date) → /// "Years until possible retirement: 10 (2036-07-01, ages 65/62)" /// - `.promoted_infeasible` → "Years until possible retirement: not feasible" /// /// Buffer should be at least 128 bytes; the worst-case 4-person line /// fits comfortably. /// /// For renderers that want to color just the value portion (e.g. /// "not feasible" in red while keeping the label neutral), use /// `splitRetirementLine` instead — this function returns the full /// concatenated line for callers that don't care about styling. pub fn fmtRetirementLine(buf: []u8, resolved: projections.ResolvedRetirement, config: *const projections.UserConfig) []const u8 { const parts = splitRetirementLine(buf, resolved, config); // Re-concatenate when the caller doesn't want the split. // Both halves already point into `buf`, in order, so we only // need to return the contiguous slice spanning both. if (parts.value_text.len == 0) return parts.label_text; const start = @intFromPtr(parts.label_text.ptr) - @intFromPtr(buf.ptr); const end = (@intFromPtr(parts.value_text.ptr) - @intFromPtr(buf.ptr)) + parts.value_text.len; return buf[start..end]; } /// Pre-formatted retirement line split into a neutral label and a /// value portion that the caller may want to render in a different /// style. The value carries the StyleIntent; the label is always /// rendered in the default content color. /// /// Concatenation rule: `` produces the full /// line. Both slices point into `buf` and are contiguous. pub const RetirementLineParts = struct { /// Always-neutral label, e.g. "Years until possible retirement: ". label_text: []const u8, /// Value portion the caller may style. Empty when the resolved /// state has no separate value (currently never — even the /// `none` case puts "none" here). value_text: []const u8, /// Suggested style for the value portion. `.normal` for dates, /// `.negative` for "not feasible", `.muted` for "none". value_style: StyleIntent, }; pub fn splitRetirementLine(buf: []u8, resolved: projections.ResolvedRetirement, config: *const projections.UserConfig) RetirementLineParts { const label = "Years until possible retirement: "; if (resolved.source == .none) { // "Years until possible retirement: " + "none" const written = std.fmt.bufPrint(buf, "{s}none", .{label}) catch { return .{ .label_text = "Years until possible retirement: ", .value_text = "none", .value_style = .muted }; }; return .{ .label_text = written[0..label.len], .value_text = written[label.len..], .value_style = .muted, }; } if (resolved.source == .promoted_infeasible) { const written = std.fmt.bufPrint(buf, "{s}not feasible", .{label}) catch { return .{ .label_text = "Years until possible retirement: ", .value_text = "not feasible", .value_style = .negative }; }; return .{ .label_text = written[0..label.len], .value_text = written[label.len..], .value_style = .negative, }; } var date_buf: [10]u8 = undefined; const date_str = if (resolved.date) |d| d.format(&date_buf) else "????-??-??"; // Build the optional ", ages A/B/..." suffix when birthdates are // configured and we have a retirement date. Skipping is safe and // makes the function tolerant of zero-birthdate configs. var ages_buf: [64]u8 = undefined; var ages_len: usize = 0; if (resolved.date) |d| { if (config.birthdate_count > 0) { // ", age " (single) or ", ages " (multiple) — singular // form when only one person is configured matches normal // English usage. const prefix: []const u8 = if (config.birthdate_count == 1) ", age " else ", ages "; if (ages_len + prefix.len > ages_buf.len) return shortParts(buf, resolved.accumulation_years, date_str); @memcpy(ages_buf[ages_len .. ages_len + prefix.len], prefix); ages_len += prefix.len; var i: u8 = 0; while (i < config.birthdate_count) : (i += 1) { if (i > 0) { if (ages_len + 1 > ages_buf.len) return shortParts(buf, resolved.accumulation_years, date_str); ages_buf[ages_len] = '/'; ages_len += 1; } const age = config.birthdates[i].ageOn(d); const age_str = std.fmt.bufPrint(ages_buf[ages_len..], "{d}", .{age}) catch return shortParts(buf, resolved.accumulation_years, date_str); ages_len += age_str.len; } } } if (ages_len == 0) return shortParts(buf, resolved.accumulation_years, date_str); const written = std.fmt.bufPrint(buf, "{s}{d} ({s}{s})", .{ label, resolved.accumulation_years, date_str, ages_buf[0..ages_len], }) catch return shortParts(buf, resolved.accumulation_years, date_str); return .{ .label_text = written[0..label.len], .value_text = written[label.len..], .value_style = .normal, }; } /// Fallback used when the ages suffix can't be rendered (no /// birthdates, ages buffer overflow, or format failure). Keeps the /// "Years until possible retirement: N (DATE)" form intact, with /// the value portion styled `.normal`. fn shortParts(buf: []u8, years: u16, date_str: []const u8) RetirementLineParts { const label = "Years until possible retirement: "; const written = std.fmt.bufPrint(buf, "{s}{d} ({s})", .{ label, years, date_str }) catch { return .{ .label_text = "Years until possible retirement: ", .value_text = "?", .value_style = .normal, }; }; return .{ .label_text = written[0..label.len], .value_text = written[label.len..], .value_style = .normal, }; } /// Format the "Annual contributions: $X (CPI-adjusted)" line. /// /// Returns null when the contribution is zero AND the caller has no /// accumulation phase configured — the line would be pure noise. The /// caller should still render the retirement line in that case; /// suppressing the contribution row alone keeps the block tidy. pub fn fmtContributionLine(arena: std.mem.Allocator, amount: f64, inflation_adjusted: bool, accumulation_years: u16) !?[]const u8 { if (amount == 0 and accumulation_years == 0) return null; const adj_note: []const u8 = if (inflation_adjusted) " (CPI-adjusted)" else " (nominal)"; return try std.fmt.allocPrint(arena, "Annual contributions: {f}{s}", .{ Money.from(amount).trim(), adj_note }); } /// A single cell in the "Earliest retirement" grid: either a formatted /// date string or "not feasible" muted text. pub const EarliestCell = struct { text: []const u8, style: StyleIntent, }; /// Format an `EarliestRetirement` cell as a date string anchored /// against `as_of` (the projection's reference date — pass today /// for live mode, or a historical date for back-dated runs). /// Reports the date the user reaches the `accumulation_years` /// threshold, using `as_of`'s m/d for the calendar display. /// /// Cells where no value of `accumulation_years` ≤ `max_accumulation_years` /// sustains the target spending render "infeasible" with the /// `.negative` style — this is critical information for the user /// (you can't retire under these conditions) and must NOT be /// muted. Single word so it fits in the standard 14-char grid /// column. The retirement line above the grid uses the longer /// "not feasible" form when the promoted cell falls in this state /// — both forms mean the same thing; the asymmetry is layout-driven. pub fn fmtEarliestCell(arena: std.mem.Allocator, er: projections.EarliestRetirement, as_of: Date) !EarliestCell { const n = er.accumulation_years orelse { return .{ .text = "infeasible", .style = .negative }; }; if (n == 0) { return .{ .text = try std.fmt.allocPrint(arena, "now", .{}), .style = .normal }; } // Anchor against the reference date's year/month/day. The // earliest-retirement search produces an integer year offset; // rendering a calendar-precise date requires picking some // month/day. Use `as_of`'s m/d so the displayed year matches // "N years from the reference date". const ret_date = Date.fromYmd(as_of.year() + @as(i16, @intCast(n)), as_of.month(), as_of.day()); var dbuf: [10]u8 = undefined; const dstr = ret_date.format(&dbuf); return .{ .text = try arena.dupe(u8, dstr), .style = .normal }; } /// Build a row in the "Earliest retirement" grid for a single /// confidence level. The row label is the confidence percentage; one /// column per horizon, each rendering a date or "—". pub const EarliestRow = struct { label_text: []const u8, cells: []EarliestCell, }; pub fn buildEarliestRow( arena: std.mem.Allocator, confidence: f64, horizons: []const u16, earliest: []const projections.EarliestRetirement, confidence_idx: usize, as_of: Date, ) !EarliestRow { const label = try std.fmt.allocPrint(arena, "{d:.0}% confidence", .{confidence * 100}); const cells = try arena.alloc(EarliestCell, horizons.len); for (horizons, 0..) |_, hi| { cells[hi] = try fmtEarliestCell(arena, earliest[confidence_idx * horizons.len + hi], as_of); } return .{ .label_text = label, .cells = cells }; } // ── Table row builders (shared by CLI and TUI) ───────────────── /// A pre-formatted table row: label + right-aligned columns. pub const TableRow = struct { text: []const u8, style: StyleIntent, }; /// Build a column header row for a given set of horizons and column width. pub fn buildHeaderRow(arena: std.mem.Allocator, horizons: []const u16, col_width: usize) ![]const u8 { var row: std.ArrayListUnmanaged(u8) = .empty; try row.appendNTimes(arena, ' ', withdrawal_label_width); for (horizons) |h| { var hbuf: [16]u8 = undefined; const hlabel = fmtHorizonLabel(&hbuf, h); try row.appendNTimes(arena, ' ', col_width -| hlabel.len); try row.appendSlice(arena, hlabel); } return row.toOwnedSlice(arena); } /// Build withdrawal rows for one confidence level: amount row + rate row. pub fn buildWithdrawalRows( arena: std.mem.Allocator, confidence: f64, horizons: []const u16, cached_results: []const projections.WithdrawalResult, confidence_idx: usize, ) !struct { amount: TableRow, rate: TableRow } { // Amount row var amount_row: std.ArrayListUnmanaged(u8) = .empty; var lbuf: [25]u8 = undefined; const clabel = fmtConfidenceLabel(&lbuf, confidence); try amount_row.appendSlice(arena, clabel); try amount_row.appendNTimes(arena, ' ', withdrawal_label_width -| clabel.len); for (horizons, 0..) |_, hi| { const result = cached_results[confidence_idx * horizons.len + hi]; var abuf: [24]u8 = undefined; var rbuf: [16]u8 = undefined; const cell = fmtWithdrawalCell(&abuf, &rbuf, result); try amount_row.appendNTimes(arena, ' ', withdrawal_col_width -| cell.amount_text.len); try amount_row.appendSlice(arena, cell.amount_text); } // Rate row var rate_row: std.ArrayListUnmanaged(u8) = .empty; try rate_row.appendNTimes(arena, ' ', withdrawal_label_width); for (horizons, 0..) |_, hi| { const result = cached_results[confidence_idx * horizons.len + hi]; var abuf: [24]u8 = undefined; var rbuf: [16]u8 = undefined; const cell = fmtWithdrawalCell(&abuf, &rbuf, result); try rate_row.appendNTimes(arena, ' ', withdrawal_col_width -| cell.rate_text.len); try rate_row.appendSlice(arena, cell.rate_text); } return .{ .amount = .{ .text = try amount_row.toOwnedSlice(arena), .style = .normal }, .rate = .{ .text = try rate_row.toOwnedSlice(arena), .style = .muted }, }; } /// Build a percentile row (p10/p50/p90) across horizons. pub fn buildPercentileRow( arena: std.mem.Allocator, label: []const u8, percentile_idx: usize, all_bands: []const ?[]const projections.YearPercentiles, style: StyleIntent, ) !TableRow { var row: std.ArrayListUnmanaged(u8) = .empty; try row.appendSlice(arena, label); try row.appendNTimes(arena, ' ', withdrawal_label_width -| label.len); for (all_bands) |bands_opt| { if (bands_opt) |bands| { if (bands.len > 0) { const last = bands[bands.len - 1]; const val = switch (percentile_idx) { 0 => last.p10, 1 => last.p50, 2 => last.p90, else => 0, }; var mbuf: [24]u8 = undefined; const txt = std.fmt.bufPrint(&mbuf, "{f}", .{Money.from(val)}) catch "$?"; try row.appendNTimes(arena, ' ', terminal_col_width -| txt.len); try row.appendSlice(arena, txt); } else { try row.appendNTimes(arena, ' ', terminal_col_width - 2); try row.appendSlice(arena, "--"); } } else { try row.appendNTimes(arena, ' ', terminal_col_width - 2); try row.appendSlice(arena, "--"); } } return .{ .text = try row.toOwnedSlice(arena), .style = style }; } // ── Event summary (shared by CLI and TUI) ────────────────────── pub const EventLine = struct { text: []const u8, style: StyleIntent, }; /// Format a single event line for display. /// Output: " Social Security (Emil) +$38,400/yr age 67 (in 17yr)" pub fn fmtEventLine(arena: std.mem.Allocator, ev: *const projections.LifeEvent, current_ages: []const u16) !EventLine { const name = ev.getName(); const amount = ev.annual_amount; const is_income = amount >= 0; const style: StyleIntent = if (is_income) .positive else .negative; var amt_buf: [24]u8 = undefined; const sign: []const u8 = if (is_income) "+" else "-"; const abs_amount = @abs(amount); // Whole-dollar form (no decimals) for compact event-line display. const amt_nodec = std.fmt.bufPrint(&amt_buf, "{f}", .{Money.from(abs_amount).whole()}) catch "$?"; const start_yr = ev.startYear(current_ages); const timing = if (start_yr) |sy| blk: { if (sy == 0) break :blk try std.fmt.allocPrint(arena, "age {d} (now)", .{ev.start_age}) else break :blk try std.fmt.allocPrint(arena, "age {d} (in {d}yr)", .{ ev.start_age, sy }); } else try std.fmt.allocPrint(arena, "age {d}", .{ev.start_age}); const dur_str = if (ev.duration > 0) try std.fmt.allocPrint(arena, ", {d}yr", .{ev.duration}) else ""; const nominal_str: []const u8 = if (!ev.inflation_adjusted) ", nominal" else ""; const text = try std.fmt.allocPrint(arena, " {s: <28} {s}{s}/yr {s}{s}{s}", .{ name, sign, amt_nodec, timing, dur_str, nominal_str, }); return .{ .text = text, .style = style }; } // ── Tests ────────────────────────────────────────────────────── test "fmtReturnCell positive" { var buf: [16]u8 = undefined; const cell = fmtReturnCell(&buf, 0.1234); try std.testing.expect(cell.style == .positive); try std.testing.expect(cell.text.len > 0); } test "fmtReturnCell negative" { var buf: [16]u8 = undefined; const cell = fmtReturnCell(&buf, -0.05); try std.testing.expect(cell.style == .negative); } test "fmtReturnCell null" { var buf: [16]u8 = undefined; const cell = fmtReturnCell(&buf, null); try std.testing.expect(cell.style == .muted); try std.testing.expectEqualStrings("--", cell.text); } test "fmtWithdrawalCell strips .00" { var abuf: [24]u8 = undefined; var rbuf: [16]u8 = undefined; const cell = fmtWithdrawalCell(&abuf, &rbuf, .{ .confidence = 0.99, .annual_amount = 305000, .withdrawal_rate = 0.0366, }); try std.testing.expect(!std.mem.endsWith(u8, cell.amount_text, ".00")); try std.testing.expect(std.mem.indexOf(u8, cell.rate_text, "3.66") != null); } test "fmtAllocationNote on target" { var buf: [128]u8 = undefined; const note = fmtAllocationNote(&buf, 77, 0.768); try std.testing.expect(note != null); try std.testing.expect(note.?.style == .muted); try std.testing.expect(std.mem.indexOf(u8, note.?.text, "on target") != null); } test "fmtAllocationNote off target" { var buf: [128]u8 = undefined; const note = fmtAllocationNote(&buf, 77, 0.90); try std.testing.expect(note != null); try std.testing.expect(note.?.style == .negative); // 13% drift, >10% try std.testing.expect(std.mem.indexOf(u8, note.?.text, "on target") == null); } test "fmtAllocationNote warning range" { var buf: [128]u8 = undefined; const note = fmtAllocationNote(&buf, 77, 0.84); try std.testing.expect(note != null); try std.testing.expect(note.?.style == .warning); // 7% drift, in 5-10% range } test "fmtAllocationNote no target" { var buf: [128]u8 = undefined; try std.testing.expect(fmtAllocationNote(&buf, null, 0.75) == null); } test "fmtBenchmarkLabel" { var buf: [32]u8 = undefined; const label = fmtBenchmarkLabel(&buf, "SPY", 83.8); try std.testing.expect(std.mem.indexOf(u8, label, "SPY") != null); try std.testing.expect(std.mem.indexOf(u8, label, "83.8") != null); } test "buildReturnRow" { var bufs: [5][16]u8 = undefined; const returns = benchmark.ReturnsByPeriod{ .one_year = 0.15, .three_year = -0.02, .five_year = null, }; const row = buildReturnRow("Test", returns, &bufs, false); try std.testing.expectEqualStrings("Test", row.label); try std.testing.expect(row.one_year.style == .positive); try std.testing.expect(row.three_year.style == .negative); try std.testing.expect(row.five_year.style == .muted); try std.testing.expect(row.bold == false); } test "buildHeaderRow formats horizons" { const allocator = std.testing.allocator; var arena = std.heap.ArenaAllocator.init(allocator); defer arena.deinit(); const a = arena.allocator(); const horizons = [_]u16{ 30, 45 }; const result = try buildHeaderRow(a, &horizons, withdrawal_col_width); try std.testing.expect(std.mem.indexOf(u8, result, "30 Year") != null); try std.testing.expect(std.mem.indexOf(u8, result, "45 Year") != null); } test "buildHeaderRow uses terminal column width" { const allocator = std.testing.allocator; var arena = std.heap.ArenaAllocator.init(allocator); defer arena.deinit(); const a = arena.allocator(); const horizons = [_]u16{20}; const narrow = try buildHeaderRow(a, &horizons, withdrawal_col_width); const wide = try buildHeaderRow(a, &horizons, terminal_col_width); try std.testing.expect(wide.len > narrow.len); } test "buildWithdrawalRows produces amount and rate" { const allocator = std.testing.allocator; var arena = std.heap.ArenaAllocator.init(allocator); defer arena.deinit(); const a = arena.allocator(); const horizons = [_]u16{ 30, 45 }; const results = [_]projections.WithdrawalResult{ .{ .confidence = 0.95, .annual_amount = 350000, .withdrawal_rate = 0.042 }, .{ .confidence = 0.95, .annual_amount = 310000, .withdrawal_rate = 0.037 }, }; const rows = try buildWithdrawalRows(a, 0.95, &horizons, &results, 0); // Amount row should contain the dollar amounts try std.testing.expect(std.mem.indexOf(u8, rows.amount.text, "350,000") != null); try std.testing.expect(std.mem.indexOf(u8, rows.amount.text, "310,000") != null); try std.testing.expect(rows.amount.style == .normal); // Rate row should contain percentages try std.testing.expect(std.mem.indexOf(u8, rows.rate.text, "4.20%") != null); try std.testing.expect(std.mem.indexOf(u8, rows.rate.text, "3.70%") != null); try std.testing.expect(rows.rate.style == .muted); } test "buildPercentileRow extracts correct percentile" { const allocator = std.testing.allocator; var arena = std.heap.ArenaAllocator.init(allocator); defer arena.deinit(); const a = arena.allocator(); const bands = [_]projections.YearPercentiles{ .{ .year = 0, .p10 = 1000000, .p25 = 2000000, .p50 = 3000000, .p75 = 4000000, .p90 = 5000000 }, .{ .year = 30, .p10 = 5000000, .p25 = 10000000, .p50 = 20000000, .p75 = 30000000, .p90 = 50000000 }, }; const band_slice: []const projections.YearPercentiles = &bands; const all_bands = [_]?[]const projections.YearPercentiles{band_slice}; // p10 (index 0) const row_p10 = try buildPercentileRow(a, "Pessimistic", 0, &all_bands, .muted); try std.testing.expect(std.mem.indexOf(u8, row_p10.text, "5,000,000") != null); try std.testing.expect(row_p10.style == .muted); // p50 (index 1) const row_p50 = try buildPercentileRow(a, "Median", 1, &all_bands, .normal); try std.testing.expect(std.mem.indexOf(u8, row_p50.text, "20,000,000") != null); try std.testing.expect(row_p50.style == .normal); // p90 (index 2) const row_p90 = try buildPercentileRow(a, "Optimistic", 2, &all_bands, .muted); try std.testing.expect(std.mem.indexOf(u8, row_p90.text, "50,000,000") != null); } test "buildPercentileRow handles null bands" { const allocator = std.testing.allocator; var arena = std.heap.ArenaAllocator.init(allocator); defer arena.deinit(); const a = arena.allocator(); const all_bands = [_]?[]const projections.YearPercentiles{null}; const row = try buildPercentileRow(a, "Pessimistic", 0, &all_bands, .muted); try std.testing.expect(std.mem.indexOf(u8, row.text, "--") != null); } test "fmtConfidenceLabel" { var buf: [25]u8 = undefined; const label = fmtConfidenceLabel(&buf, 0.99); try std.testing.expect(std.mem.indexOf(u8, label, "99%") != null); try std.testing.expect(std.mem.indexOf(u8, label, "withdrawal") != null); } test "fmtHorizonLabel" { var buf: [16]u8 = undefined; const label = fmtHorizonLabel(&buf, 30); try std.testing.expectEqualStrings("30 Year", label); } // ── Accumulation phase / earliest retirement view tests ──────── test "fmtRetirementLine: none" { var buf: [128]u8 = undefined; const config = projections.UserConfig{}; const line = fmtRetirementLine(&buf, .{ .accumulation_years = 0, .date = null, .source = .none, }, &config); try std.testing.expectEqualStrings("Years until possible retirement: none", line); } test "fmtRetirementLine: at_date with no birthdates omits ages suffix" { var buf: [128]u8 = undefined; const config = projections.UserConfig{}; const line = fmtRetirementLine(&buf, .{ .accumulation_years = 10, .date = Date.fromYmd(2036, 7, 1), .source = .at_date, }, &config); try std.testing.expectEqualStrings("Years until possible retirement: 10 (2036-07-01)", line); } test "fmtRetirementLine: at_age with no birthdates omits ages suffix" { var buf: [128]u8 = undefined; const config = projections.UserConfig{}; const line = fmtRetirementLine(&buf, .{ .accumulation_years = 14, .date = Date.fromYmd(2040, 3, 15), .source = .at_age, }, &config); try std.testing.expectEqualStrings("Years until possible retirement: 14 (2040-03-15)", line); } test "fmtRetirementLine: at_age with one birthdate appends singular age suffix" { var buf: [128]u8 = undefined; var config = projections.UserConfig{}; config.birthdate_count = 1; config.birthdates[0] = Date.fromYmd(1975, 3, 15); // age 65 on 2040-03-15 const line = fmtRetirementLine(&buf, .{ .accumulation_years = 14, .date = Date.fromYmd(2040, 3, 15), .source = .at_age, }, &config); try std.testing.expectEqualStrings("Years until possible retirement: 14 (2040-03-15, age 65)", line); } test "fmtRetirementLine: at_age with two birthdates uses plural ages" { var buf: [128]u8 = undefined; var config = projections.UserConfig{}; config.birthdate_count = 2; config.birthdates[0] = Date.fromYmd(1981, 4, 12); // age 65 on 2046-04-12 config.birthdates[1] = Date.fromYmd(1983, 9, 8); // age 62 on 2046-04-12 const line = fmtRetirementLine(&buf, .{ .accumulation_years = 19, .date = Date.fromYmd(2046, 4, 12), .source = .at_age, }, &config); try std.testing.expectEqualStrings("Years until possible retirement: 19 (2046-04-12, ages 65/62)", line); } test "fmtRetirementLine: ages are floored to whole years" { // Born 1981-06-01; retirement on 2046-04-12 — birthday hasn't // occurred yet that year, so age is 64 (not 65). var buf: [128]u8 = undefined; var config = projections.UserConfig{}; config.birthdate_count = 1; config.birthdates[0] = Date.fromYmd(1981, 6, 1); const line = fmtRetirementLine(&buf, .{ .accumulation_years = 19, .date = Date.fromYmd(2046, 4, 12), .source = .at_age, }, &config); try std.testing.expectEqualStrings("Years until possible retirement: 19 (2046-04-12, age 64)", line); } test "fmtRetirementLine: promoted source renders dated form like at_date" { var buf: [128]u8 = undefined; var config = projections.UserConfig{}; config.birthdate_count = 2; config.birthdates[0] = Date.fromYmd(1981, 4, 12); config.birthdates[1] = Date.fromYmd(1983, 9, 8); const line = fmtRetirementLine(&buf, .{ .accumulation_years = 19, .date = Date.fromYmd(2046, 4, 12), .source = .promoted, }, &config); try std.testing.expectEqualStrings("Years until possible retirement: 19 (2046-04-12, ages 65/62)", line); } test "fmtRetirementLine: promoted_infeasible renders 'not feasible'" { var buf: [128]u8 = undefined; const config = projections.UserConfig{}; const line = fmtRetirementLine(&buf, .{ .accumulation_years = 0, .date = null, .source = .promoted_infeasible, }, &config); try std.testing.expectEqualStrings("Years until possible retirement: not feasible", line); } test "fmtContributionLine: zero contribution and zero accumulation -> null" { const allocator = std.testing.allocator; var arena = std.heap.ArenaAllocator.init(allocator); defer arena.deinit(); const result = try fmtContributionLine(arena.allocator(), 0, true, 0); try std.testing.expect(result == null); } test "fmtContributionLine: nonzero contribution renders" { const allocator = std.testing.allocator; var arena = std.heap.ArenaAllocator.init(allocator); defer arena.deinit(); const result = try fmtContributionLine(arena.allocator(), 100_000, true, 10); try std.testing.expect(result != null); try std.testing.expect(std.mem.indexOf(u8, result.?, "100,000") != null); try std.testing.expect(std.mem.indexOf(u8, result.?, "CPI-adjusted") != null); } test "fmtContributionLine: nominal flag changes label" { const allocator = std.testing.allocator; var arena = std.heap.ArenaAllocator.init(allocator); defer arena.deinit(); const result = try fmtContributionLine(arena.allocator(), 50_000, false, 5); try std.testing.expect(result != null); try std.testing.expect(std.mem.indexOf(u8, result.?, "nominal") != null); try std.testing.expect(std.mem.indexOf(u8, result.?, "CPI-adjusted") == null); } test "fmtContributionLine: zero contribution but with accumulation still renders" { // User pauses contributions but isn't retired yet — legitimate // case; the line should appear so the user sees that the model // is treating contributions as zero. const allocator = std.testing.allocator; var arena = std.heap.ArenaAllocator.init(allocator); defer arena.deinit(); const result = try fmtContributionLine(arena.allocator(), 0, true, 5); try std.testing.expect(result != null); } test "fmtEarliestCell: feasible at N=0 -> 'now'" { const allocator = std.testing.allocator; var arena = std.heap.ArenaAllocator.init(allocator); defer arena.deinit(); const cell = try fmtEarliestCell(arena.allocator(), .{ .horizon = 30, .confidence = 0.95, .accumulation_years = 0, .median_at_retirement = 0, .p10_at_retirement = 0, .p90_at_retirement = 0, }, Date.fromYmd(2026, 5, 12)); try std.testing.expectEqualStrings("now", cell.text); } test "fmtEarliestCell: infeasible -> 'infeasible' label, negative style" { const allocator = std.testing.allocator; var arena = std.heap.ArenaAllocator.init(allocator); defer arena.deinit(); const cell = try fmtEarliestCell(arena.allocator(), .{ .horizon = 30, .confidence = 0.99, .accumulation_years = null, .median_at_retirement = 0, .p10_at_retirement = 0, .p90_at_retirement = 0, }, Date.fromYmd(2026, 5, 12)); try std.testing.expectEqualStrings("infeasible", cell.text); // .negative — NOT .muted. "You can't retire under these // conditions" is critical info; muting it would bury the // headline. Style matches CLR_NEGATIVE convention used for // losses elsewhere in the UI. try std.testing.expectEqual(@as(StyleIntent, .negative), cell.style); } test "fmtEarliestCell: N=10 produces date 10 years from today" { const allocator = std.testing.allocator; var arena = std.heap.ArenaAllocator.init(allocator); defer arena.deinit(); const cell = try fmtEarliestCell(arena.allocator(), .{ .horizon = 30, .confidence = 0.95, .accumulation_years = 10, .median_at_retirement = 0, .p10_at_retirement = 0, .p90_at_retirement = 0, }, Date.fromYmd(2026, 5, 12)); try std.testing.expectEqualStrings("2036-05-12", cell.text); } test "buildProjectionContext: distribution-only inputs when no accumulation fields" { const allocator = std.testing.allocator; var arena = std.heap.ArenaAllocator.init(allocator); defer arena.deinit(); const config = projections.UserConfig{}; const comparison: benchmark.BenchmarkComparison = .{ .stock_returns = .{}, .bond_returns = .{}, .benchmark_returns = .{}, .portfolio_returns = .{}, .conservative_return = 0.07, .stock_pct = 0.75, .bond_pct = 0.25, }; var ctx = try buildProjectionContext( arena.allocator(), config, comparison, 0.75, 0.25, 1_000_000, &.{}, Date.fromYmd(2026, 5, 12), ); _ = &ctx; try std.testing.expectEqual(ProjectionInputs.distribution_only, ctx.inputs); try std.testing.expectEqual(@as(usize, 0), ctx.retirement.accumulation_years); try std.testing.expect(ctx.accumulation == null); try std.testing.expect(ctx.earliest == null); } test "buildProjectionContext: target_retirement_date inputs with retirement_at" { const allocator = std.testing.allocator; var arena = std.heap.ArenaAllocator.init(allocator); defer arena.deinit(); var config = projections.UserConfig{}; config.retirement_at = Date.fromYmd(2036, 7, 1); config.annual_contribution = 50_000; const comparison: benchmark.BenchmarkComparison = .{ .stock_returns = .{}, .bond_returns = .{}, .benchmark_returns = .{}, .portfolio_returns = .{}, .conservative_return = 0.07, .stock_pct = 0.75, .bond_pct = 0.25, }; const ctx = try buildProjectionContext( arena.allocator(), config, comparison, 0.75, 0.25, 1_000_000, &.{}, Date.fromYmd(2026, 7, 1), ); try std.testing.expectEqual(ProjectionInputs.target_retirement_date, ctx.inputs); try std.testing.expectEqual(@as(u16, 10), ctx.retirement.accumulation_years); try std.testing.expect(ctx.accumulation != null); // Median at retirement should exceed starting value (we // accumulate 10 years of $50k contributions on top). try std.testing.expect(ctx.accumulation.?.median_at_retirement > 1_000_000); try std.testing.expect(ctx.earliest == null); } test "buildProjectionContext: target_spending inputs promote a cell into retirement + accumulation" { const allocator = std.testing.allocator; var arena = std.heap.ArenaAllocator.init(allocator); defer arena.deinit(); var config = projections.UserConfig{}; config.target_spending = 40_000; const comparison: benchmark.BenchmarkComparison = .{ .stock_returns = .{}, .bond_returns = .{}, .benchmark_returns = .{}, .portfolio_returns = .{}, .conservative_return = 0.07, .stock_pct = 0.75, .bond_pct = 0.25, }; const ctx = try buildProjectionContext( arena.allocator(), config, comparison, 0.75, 0.25, 1_000_000, &.{}, Date.fromYmd(2026, 5, 12), ); try std.testing.expectEqual(ProjectionInputs.target_spending, ctx.inputs); try std.testing.expect(ctx.earliest != null); // 3 horizons × 3 confidence = 9 cells. try std.testing.expectEqual(@as(usize, 9), ctx.earliest.?.len); // Promotion runs: with $1M and $40k target, this is feasible // immediately or very early. Either way, retirement.source // should be .promoted and accumulation_stats should be present. try std.testing.expect(ctx.retirement.source == .promoted); try std.testing.expect(ctx.accumulation != null); } test "buildProjectionContext: both_targets inputs when both fields configured" { const allocator = std.testing.allocator; var arena = std.heap.ArenaAllocator.init(allocator); defer arena.deinit(); var config = projections.UserConfig{}; config.retirement_at = Date.fromYmd(2036, 7, 1); config.target_spending = 40_000; const comparison: benchmark.BenchmarkComparison = .{ .stock_returns = .{}, .bond_returns = .{}, .benchmark_returns = .{}, .portfolio_returns = .{}, .conservative_return = 0.07, .stock_pct = 0.75, .bond_pct = 0.25, }; const ctx = try buildProjectionContext( arena.allocator(), config, comparison, 0.75, 0.25, 1_000_000, &.{}, Date.fromYmd(2026, 7, 1), ); try std.testing.expectEqual(ProjectionInputs.both_targets, ctx.inputs); try std.testing.expect(ctx.accumulation != null); try std.testing.expect(ctx.earliest != null); }