/// CLI `projections` command: retirement projections and benchmark comparison. /// /// Produces: /// - Benchmark comparison table (SPY/AGG vs portfolio weighted returns) /// - Conservative weighted return estimate /// - Safe withdrawal amounts at multiple horizons and confidence levels /// /// When `as_of` is non-null, the same output is produced against a /// historical snapshot instead of the live portfolio. See /// `src/views/projections.zig:loadProjectionContextAsOf`. const std = @import("std"); const zfin = @import("../root.zig"); const cli = @import("common.zig"); const fmt = cli.fmt; const Date = zfin.Date; const Money = @import("../Money.zig"); const performance = @import("../analytics/performance.zig"); const projections = @import("../analytics/projections.zig"); const benchmark = @import("../analytics/benchmark.zig"); const valuation = @import("../analytics/valuation.zig"); const view = @import("../views/projections.zig"); const history = @import("../history.zig"); const timeline = @import("../analytics/timeline.zig"); /// Hardcoded benchmark symbols (configurable in a future version). const stock_benchmark = "SPY"; const bond_benchmark = "AGG"; /// How an as-of date resolved against the history directory. The CLI /// uses this to render a single header that tells the user what /// actually got loaded (exact hit, nearest-earlier, or straight-up /// "no snapshot available"). const AsOfResolution = struct { /// The requested date, as parsed by the caller. requested: Date, /// The date that was actually loaded. Differs from `requested` /// when we auto-snapped to the nearest-earlier data point. actual: Date, /// Whether the resolution landed on a native snapshot or an /// `imported_values.srf` row. Snapshot wins when both are /// available within the at-or-before window. source: history.AsOfSourceKind = .snapshot, /// Liquid total at `actual`. Filled when `source == .imported` /// (read directly from the imported_values row); zero otherwise /// — the snapshot path reads its totals from the loaded snapshot. liquid: f64 = 0, }; /// Run projections. /// /// `as_of` is the reference date for ages, horizons, and snapshot /// windows. `from_snapshot` selects the data source: /// - `false`: live mode. Load `file_path` directly. Caller passes /// today as `as_of`. /// - `true`: historical mode. Load the snapshot at-or-before /// `as_of` from the history dir. pub fn run( io: std.Io, allocator: std.mem.Allocator, svc: *zfin.DataService, file_path: []const u8, events_enabled: bool, as_of: Date, from_snapshot: bool, today: Date, overlay_actuals: bool, color: bool, out: *std.Io.Writer, ) !void { // Single arena for all view/render allocations. Same lifetime // regardless of live vs. as-of path. var arena_state = std.heap.ArenaAllocator.init(allocator); defer arena_state.deinit(); const va = arena_state.allocator(); // portfolio_dir is the directory component of file_path, ending // in a separator (for the downstream `{s}projections.srf` join). const dir_end = if (std.mem.lastIndexOfScalar(u8, file_path, std.fs.path.sep)) |idx| idx + 1 else 0; const portfolio_dir = file_path[0..dir_end]; // Build the context via either the live or as-of pipeline. Both // produce a `ProjectionContext`; from that point on rendering is // identical. var ctx: view.ProjectionContext = undefined; var resolution: ?AsOfResolution = null; // Snapshot must outlive the context when on the as-of path because // `ctx.allocations` borrow their symbol strings from the snapshot's // backing buffer. Keep this declared at the outer scope so the // defer runs at the end of `run`. var snap_bundle: ?history.LoadedSnapshot = null; defer if (snap_bundle) |*s| s.deinit(allocator); // Live-portfolio bundle. Loaded for the live (no-as-of) path // AND for the imported-only as-of path (which uses today's // composition scaled to the imported liquid total). var live_loaded: ?cli.LoadedPortfolio = null; defer if (live_loaded) |*l| l.deinit(allocator); var live_pf_data: ?cli.PortfolioData = null; defer if (live_pf_data) |*p| p.deinit(allocator); if (from_snapshot) { resolution = resolveAsOfSnapshot(io, va, file_path, as_of) catch |err| switch (err) { error.NoSnapshot => return, else => return err, }; if (resolution.?.source == .snapshot) { const hist_dir = try history.deriveHistoryDir(va, file_path); snap_bundle = try history.loadSnapshotAt(io, allocator, hist_dir, resolution.?.actual); ctx = try view.loadProjectionContextAsOf( io, va, portfolio_dir, &snap_bundle.?.snap, resolution.?.actual, svc, events_enabled, ); } else { // Imported-only as-of: need today's portfolio composition // (allocations + cash/CD totals) to scale to the // imported liquid value. // // Today's `as_of` parameter is being repurposed here as // the historical reference date for the simulation; for // the composition we ALWAYS want today's mix (the only // composition we know). Pass `today` for the cash/CD // computation. live_loaded = cli.loadPortfolio(io, allocator, file_path, today) orelse return; const lp = &live_loaded.?; var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); for (lp.positions) |pos| { if (pos.shares <= 0) continue; if (svc.getCachedCandles(pos.symbol)) |cs| { defer cs.deinit(); if (cs.data.len > 0) { try prices.put(pos.symbol, cs.data[cs.data.len - 1].close); } } } live_pf_data = cli.buildPortfolioData(allocator, lp.portfolio, lp.positions, lp.syms, &prices, svc, today) catch |err| switch (err) { error.NoAllocations, error.SummaryFailed => { try cli.stderrPrint(io, "Error computing portfolio summary for imported-only as-of.\n"); return; }, else => return err, }; ctx = try view.loadProjectionContextFromImported( io, va, portfolio_dir, live_pf_data.?.summary.allocations, live_pf_data.?.summary.total_value, lp.portfolio.totalCash(today), lp.portfolio.totalCdFaceValue(today), resolution.?.liquid, resolution.?.actual, svc, events_enabled, ); } } else { live_loaded = cli.loadPortfolio(io, allocator, file_path, as_of) orelse return; const lp = &live_loaded.?; // Prices from cache — matches pre-as-of behavior exactly. var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); for (lp.positions) |pos| { if (pos.shares <= 0) continue; if (svc.getCachedCandles(pos.symbol)) |cs| { defer cs.deinit(); if (cs.data.len > 0) { try prices.put(pos.symbol, cs.data[cs.data.len - 1].close); } } } live_pf_data = cli.buildPortfolioData(allocator, lp.portfolio, lp.positions, lp.syms, &prices, svc, as_of) catch |err| switch (err) { error.NoAllocations, error.SummaryFailed => { try cli.stderrPrint(io, "Error computing portfolio summary.\n"); return; }, else => return err, }; ctx = try view.loadProjectionContext( io, va, portfolio_dir, live_pf_data.?.summary.allocations, live_pf_data.?.summary.total_value, lp.portfolio.totalCash(as_of), lp.portfolio.totalCdFaceValue(as_of), svc, events_enabled, as_of, ); } // ── Optional actuals overlay ──────────────────────────────── // // Requires `--as-of` (an as-of-resolved snapshot date). When the // user passes `--overlay-actuals` without `--as-of`, warn and // continue without the overlay; the overlay against today-as-now // is meaningless because the future hasn't happened yet. if (overlay_actuals) { if (!from_snapshot) { try cli.stderrPrint(io, "Note: --overlay-actuals requires --as-of; ignoring.\n"); } else if (resolution) |r| { ctx.overlay_actuals = loadOverlayActuals(io, va, file_path, r.actual, today) catch |err| blk: { // Non-fatal — the projection still renders without // the overlay. Surface the error so the user can fix // their history dir but don't block the report. var buf: [256]u8 = undefined; const msg = std.fmt.bufPrint(&buf, "Note: could not load actuals overlay ({s}); rendering without it.\n", .{@errorName(err)}) catch "Note: could not load actuals overlay; rendering without it.\n"; try cli.stderrPrint(io, msg); break :blk null; }; } } const horizons = ctx.config.getHorizons(); const confidence_levels = ctx.config.getConfidenceLevels(); const comparison = ctx.comparison; try out.print("\n", .{}); if (resolution) |r| { const source_label: []const u8 = switch (r.source) { .snapshot => "snapshot", .imported => "imported value", }; try cli.printBold(out, color, "Projections (as of {f}, {s})\n", .{ r.actual, source_label }); } else { try cli.printBold(out, color, "Projections ({s})\n", .{file_path}); } try out.print("========================================\n", .{}); // If auto-snapped, print a muted note so the user knows the // requested date wasn't an exact hit. The wording reflects the // resolution source — "nearest snapshot" vs "nearest imported // value" — so the user knows which file to update for finer // granularity. if (resolution) |r| { if (r.actual.days != r.requested.days) { const diff = r.requested.days - r.actual.days; const nearest_label: []const u8 = switch (r.source) { .snapshot => "nearest snapshot", .imported => "nearest imported value", }; try cli.printFg(out, color, cli.CLR_MUTED, "(requested {f}; {s}: {f}, {d} day{s} earlier)\n", .{ r.requested, nearest_label, r.actual, diff, fmt.dayPlural(diff), }); } if (r.source == .imported) { try cli.printFg(out, color, cli.CLR_MUTED, "(bands use today's allocation scaled to the imported liquid total)\n", .{}); } } try out.print("\n", .{}); // Header row try cli.printFg(out, color, cli.CLR_MUTED, "{s: <32}{s: >8}{s: >9}{s: >9}{s: >10}{s: >9}\n", .{ "", "1 Year", "3 Year", "5 Year", "10 Year", "Week", }); // Build return rows via view model var spy_bufs: [5][16]u8 = undefined; var spy_label_buf: [32]u8 = undefined; const spy_row = view.buildReturnRow( view.fmtBenchmarkLabel(&spy_label_buf, stock_benchmark, ctx.stock_pct * 100), comparison.stock_returns, &spy_bufs, false, ); var agg_bufs: [5][16]u8 = undefined; var agg_label_buf: [32]u8 = undefined; const agg_row = view.buildReturnRow( view.fmtBenchmarkLabel(&agg_label_buf, bond_benchmark, ctx.bond_pct * 100), comparison.bond_returns, &agg_bufs, false, ); var bench_bufs: [5][16]u8 = undefined; const bench_row = view.buildReturnRow("Benchmark", comparison.benchmark_returns, &bench_bufs, true); var port_bufs: [5][16]u8 = undefined; const port_row = view.buildReturnRow("Your Portfolio", comparison.portfolio_returns, &port_bufs, true); const rows = [_]view.ReturnRow{ spy_row, agg_row, bench_row }; for (rows) |row| { if (row.bold) try cli.setBold(out, color); try writeReturnRow(out, color, row); if (row.bold) try cli.reset(out, color); } try out.print("\n", .{}); try cli.setBold(out, color); try writeReturnRow(out, color, port_row); try cli.reset(out, color); // Projected return (conservative estimate from benchmark analytics) { var buf: [16]u8 = undefined; const cell = view.fmtReturnCell(&buf, comparison.conservative_return); try cli.printFg(out, color, cli.CLR_MUTED, "{s: <32}{s: >8}\n", .{ "Projected return", cell.text }); } // Target allocation note { var note_buf: [128]u8 = undefined; if (view.fmtAllocationNote(¬e_buf, ctx.config.target_stock_pct, ctx.stock_pct)) |note| { try out.print("\n", .{}); try cli.printIntent(out, color, note.style, "{s}\n", .{note.text}); } } // ── Accumulation phase / Earliest retirement blocks ────────── try renderAccumulationBlock(out, color, va, ctx); try renderEarliestBlock(out, color, va, ctx, as_of); // ── Braille chart: median portfolio value ───────────────────── if (horizons.len > 0) { const last_idx = horizons.len - 1; if (ctx.data.bands[last_idx]) |b| { if (b.len >= 2) { try out.print("\n", .{}); try cli.printBold(out, color, "Median Portfolio Value ({d}-Year, 99% withdrawal)\n\n", .{horizons[last_idx]}); // Synthesize candles from median values const candles = try va.alloc(zfin.Candle, b.len); for (b, 0..) |bp, i| { const v: f32 = @floatCast(bp.p50); candles[i] = .{ .date = zfin.Date.fromYmd(2025, 1, 1).addDays(@intCast(i * 365)), .open = v, .high = v, .low = v, .close = v, .adj_close = v, .volume = 0, }; } var br = fmt.computeBrailleChart(va, candles, 80, 12, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch null; if (br) |*chart| { try fmt.writeBrailleAnsi(out, chart, color, cli.CLR_MUTED, true); // Year axis instead of date axis try cli.setFg(out, color, cli.CLR_MUTED); try out.print(" Now", .{}); const end_label_buf = try std.fmt.allocPrint(va, "{d}yr", .{horizons[last_idx]}); const pad = if (chart.n_cols > 3 + end_label_buf.len) chart.n_cols - 3 - end_label_buf.len else 0; for (0..pad) |_| try out.print(" ", .{}); try out.print("{s}\n", .{end_label_buf}); try cli.reset(out, color); } } } } // Overlay-actuals tip: the CLI's braille chart is single-series, // so the actuals overlay only renders in the TUI. Print a short // pointer so the user knows where to find it. (We do NOT gate on // ctx.overlay_actuals being non-null — even when the overlay was // requested but had no data, the user benefits from the tip.) if (overlay_actuals and from_snapshot) { try cli.printFg(out, color, cli.CLR_MUTED, " (Overlay rendered in TUI only — run `zfin interactive`, set as-of with `d`, then press `o`.)\n", .{}); try cli.printFg(out, color, cli.CLR_MUTED, " Caveat: overlay tracks trajectory, not SWR validity.\n", .{}); } // ── Terminal portfolio value ───────────────────────────────── try out.print("\n", .{}); try cli.printBold(out, color, "Terminal Portfolio Value (nominal, at 99% withdrawal rate)\n", .{}); try out.print("{s}\n", .{try view.buildHeaderRow(va, horizons, view.terminal_col_width)}); const p_labels = [_][]const u8{ "Pessimistic (p10)", "Median (p50)", "Optimistic (p90)" }; const p_styles = [_]view.StyleIntent{ .muted, .normal, .muted }; for (p_labels, p_styles, 0..) |plabel, pstyle, pi| { const row = try view.buildPercentileRow(va, plabel, pi, ctx.data.bands, pstyle); try cli.printIntent(out, color, row.style, "{s}\n", .{row.text}); } // ── Safe withdrawal table ────────────────────────────────── try out.print("\n", .{}); try cli.printBold(out, color, "Safe Withdrawal (FIRECalc historical simulation)\n", .{}); // Header row try out.print("{s}\n", .{try view.buildHeaderRow(va, horizons, view.withdrawal_col_width)}); // Withdrawal rows for (confidence_levels, 0..) |conf, ci| { const wr_rows = try view.buildWithdrawalRows(va, conf, horizons, ctx.data.withdrawals, ci); try out.print("{s}\n", .{wr_rows.amount.text}); try cli.printFg(out, color, cli.CLR_MUTED, "{s}\n", .{wr_rows.rate.text}); } // Life events summary — both as-of and live modes resolve ages // against the reference date (`resolution.actual` if a snapshot // was loaded, otherwise `as_of` directly). { const events = ctx.config.getEvents(); if (events.len > 0) { const ages_ref_date = if (resolution) |r| r.actual else as_of; const ages = ctx.config.currentAges(ages_ref_date); try out.print("\n", .{}); try cli.printBold(out, color, "Life Events\n", .{}); for (events) |*ev| { const line = try view.fmtEventLine(va, ev, &ages); try cli.printIntent(out, color, line.style, "{s}\n", .{line.text}); } } } try out.print("\n", .{}); } /// Key numbers extracted from a fully-built `ProjectionContext` for /// email-header headline comparison. Bundles what `zfin compare`'s /// attribution line needs alongside what the weekly review email's /// "Projected Return" and "1st Year Withdrawal" rows cite. pub const KeyMetrics = struct { /// The "conservative" trailing-returns estimate (MIN 3Y/5Y/10Y /// per position, weighted). Rendered under the label /// "Projected return" — matches the email's column header. projected_return: f64, /// Safe withdrawal amount at longest horizon × 99% confidence. /// This is the "retirement now, 1st year withdrawal" number the /// email cites. swr_99: f64, /// `swr_99 / total_value`. Rendered as a percent alongside the /// dollar amount for comparison sanity. swr_99_rate: f64, }; fn extractKeyMetrics(ctx: view.ProjectionContext) KeyMetrics { const horizons = ctx.config.getHorizons(); const longest = horizons.len - 1; const swr = ctx.data.withdrawals[ctx.data.ci_99 * horizons.len + longest]; const rate = if (ctx.total_value > 0) swr.annual_amount / ctx.total_value else 0.0; return .{ .projected_return = ctx.comparison.conservative_return, .swr_99 = swr.annual_amount, .swr_99_rate = rate, }; } /// Build a `ProjectionContext` against a historical snapshot date. /// /// Caller owns `snap_bundle_out.*` on success — it must outlive the /// returned context because allocations borrow symbol strings from /// the snapshot's backing buffer. fn loadAsOfContext( io: std.Io, allocator: std.mem.Allocator, va: std.mem.Allocator, svc: *zfin.DataService, file_path: []const u8, portfolio_dir: []const u8, events_enabled: bool, requested_date: Date, resolution_out: *AsOfResolution, snap_bundle_out: *history.LoadedSnapshot, ) !view.ProjectionContext { resolution_out.* = resolveAsOfSnapshot(io, va, file_path, requested_date) catch |err| return err; const hist_dir = try history.deriveHistoryDir(va, file_path); snap_bundle_out.* = try history.loadSnapshotAt(io, allocator, hist_dir, resolution_out.actual); return try view.loadProjectionContextAsOf( io, va, portfolio_dir, &snap_bundle_out.snap, resolution_out.actual, svc, events_enabled, ); } /// `--vs ` entry point: compare two projections side-by-side /// with deltas. The "then" side is always a historical snapshot at /// `vs_date`; the "now" side is either another historical snapshot /// (when `now_from_snapshot` is true) or the live portfolio at /// `now_date`. /// /// Target audience is the weekly review email's header — the /// "Projected Return" and "1st Year Withdrawal" rows with Δ columns. /// For the full benchmark table / SWR grid / percentile bands, run /// `zfin projections` and `zfin projections --as-of ` separately. pub fn runCompare( io: std.Io, allocator: std.mem.Allocator, svc: *zfin.DataService, file_path: []const u8, events_enabled: bool, vs_date: Date, now_date: Date, now_from_snapshot: bool, color: bool, out: *std.Io.Writer, ) !void { var arena_state = std.heap.ArenaAllocator.init(allocator); defer arena_state.deinit(); const va = arena_state.allocator(); const result = computeKeyComparison(io, allocator, va, svc, file_path, events_enabled, vs_date, now_date, now_from_snapshot) catch |err| switch (err) { error.NoSnapshot, error.PortfolioLoadFailed => return, else => return err, }; defer result.cleanup(); try out.print("\n", .{}); var then_buf: [10]u8 = undefined; var now_buf: [10]u8 = undefined; const then_str = std.fmt.bufPrint(&then_buf, "{f}", .{result.resolution.actual}) catch "????-??-??"; const now_str = if (result.now_resolution) |nr| (std.fmt.bufPrint(&now_buf, "{f}", .{nr.actual}) catch "????-??-??") else "today"; const days_between = if (result.now_resolution) |nr| nr.actual.days - result.resolution.actual.days else now_date.days - result.resolution.actual.days; try cli.printBold(out, color, "Projections comparison: {s} → {s} ({d} day{s})\n", .{ then_str, now_str, days_between, if (days_between == 1) "" else "s", }); // Snap notes for either endpoint, if applicable. if (result.resolution.actual.days != result.resolution.requested.days) { const diff = result.resolution.requested.days - result.resolution.actual.days; try cli.printFg(out, color, cli.CLR_MUTED, "(requested {f} for then; nearest snapshot: {s}, {d} day{s} earlier)\n", .{ result.resolution.requested, then_str, diff, if (diff == 1) "" else "s", }); } if (result.now_resolution) |nr| { if (nr.actual.days != nr.requested.days) { const diff = nr.requested.days - nr.actual.days; try cli.printFg(out, color, cli.CLR_MUTED, "(requested {f} for now; nearest snapshot: {s}, {d} day{s} earlier)\n", .{ nr.requested, now_str, diff, if (diff == 1) "" else "s", }); } } try out.print("\n", .{}); try renderKeyComparisonRows(out, color, result.then, result.now); try cli.printFg(out, color, cli.CLR_MUTED, "\nFor the full benchmark + SWR tables run `zfin projections --as-of {s}` and `zfin projections{s}`.\n", .{ then_str, if (result.now_resolution) |_| (try std.fmt.allocPrint(va, " --as-of {s}", .{now_str})) else "", }); try out.print("\n", .{}); } /// Shared key-metrics comparison used by both `projections --vs` and /// `compare --projections`. Returns `then`/`now` metrics ready for /// rendering, plus the snapshot resolutions for header rendering. /// Caller must invoke `cleanup()` to release retained snapshots. /// /// When `now_from_snapshot` is false (live mode), only `retained_then` /// is populated. When true, both snapshots are retained and must be /// cleaned up via `cleanup()`. pub const KeyComparisonResult = struct { then: KeyMetrics, now: KeyMetrics, /// Resolution of the "then" snapshot. Always present. resolution: AsOfResolution, /// Resolution of the "now" snapshot. Null when now is live. now_resolution: ?AsOfResolution, retained_then: history.LoadedSnapshot, retained_now: ?history.LoadedSnapshot, retained_allocator: std.mem.Allocator, pub fn cleanup(self: KeyComparisonResult) void { var mut = self; mut.retained_then.deinit(self.retained_allocator); if (mut.retained_now) |*s| s.deinit(self.retained_allocator); } }; pub fn computeKeyComparison( io: std.Io, allocator: std.mem.Allocator, va: std.mem.Allocator, svc: *zfin.DataService, file_path: []const u8, events_enabled: bool, vs_date: Date, now_date: Date, now_from_snapshot: bool, ) !KeyComparisonResult { const dir_end = if (std.mem.lastIndexOfScalar(u8, file_path, std.fs.path.sep)) |idx| idx + 1 else 0; const portfolio_dir = file_path[0..dir_end]; // Load "then" snapshot first. If it doesn't exist we bail before // doing the (more expensive) "now" side. var then_resolution: AsOfResolution = undefined; var then_snap: history.LoadedSnapshot = undefined; const then_ctx = try loadAsOfContext( io, allocator, va, svc, file_path, portfolio_dir, events_enabled, vs_date, &then_resolution, &then_snap, ); // Now side — either another snapshot or the live portfolio. if (now_from_snapshot) { var now_resolution: AsOfResolution = undefined; var now_snap: history.LoadedSnapshot = undefined; const now_ctx = loadAsOfContext( io, allocator, va, svc, file_path, portfolio_dir, events_enabled, now_date, &now_resolution, &now_snap, ) catch |err| { then_snap.deinit(allocator); return err; }; return .{ .then = extractKeyMetrics(then_ctx), .now = extractKeyMetrics(now_ctx), .resolution = then_resolution, .now_resolution = now_resolution, .retained_then = then_snap, .retained_now = now_snap, .retained_allocator = allocator, }; } // Live "now" side — mirrors `run()`'s live path. var loaded = cli.loadPortfolio(io, allocator, file_path, now_date) orelse { then_snap.deinit(allocator); return error.PortfolioLoadFailed; }; defer loaded.deinit(allocator); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); for (loaded.positions) |pos| { if (pos.shares <= 0) continue; if (svc.getCachedCandles(pos.symbol)) |cs| { defer cs.deinit(); if (cs.data.len > 0) { try prices.put(pos.symbol, cs.data[cs.data.len - 1].close); } } } var pf_data = cli.buildPortfolioData(allocator, loaded.portfolio, loaded.positions, loaded.syms, &prices, svc, now_date) catch |err| switch (err) { error.NoAllocations, error.SummaryFailed => { then_snap.deinit(allocator); try cli.stderrPrint(io, "Error computing portfolio summary.\n"); return error.PortfolioLoadFailed; }, else => { then_snap.deinit(allocator); return err; }, }; defer pf_data.deinit(allocator); const now_ctx = try view.loadProjectionContext( io, va, portfolio_dir, pf_data.summary.allocations, pf_data.summary.total_value, loaded.portfolio.totalCash(now_date), loaded.portfolio.totalCdFaceValue(now_date), svc, events_enabled, now_date, ); return .{ .then = extractKeyMetrics(then_ctx), .now = extractKeyMetrics(now_ctx), .resolution = then_resolution, .now_resolution = null, .retained_then = then_snap, .retained_now = null, .retained_allocator = allocator, }; } /// Render the three comparison rows (projected return, SWR @99%, SWR /// rate). Shared between `projections --vs` and any other caller that /// wants to embed the same block (e.g. `compare --projections`). pub fn renderKeyComparisonRows( out: *std.Io.Writer, color: bool, then: KeyMetrics, now: KeyMetrics, ) !void { try renderCompareRowPct(out, color, "Projected return:", then.projected_return, now.projected_return); try renderCompareRowMoney(out, color, "Safe withdrawal @99%:", then.swr_99, now.swr_99); try renderCompareRowPct(out, color, " (as % of total)", then.swr_99_rate, now.swr_99_rate); } /// Render a "label: then → now Δ" row for percentage values. fn renderCompareRowPct(out: *std.Io.Writer, color: bool, label: []const u8, then_val: f64, now_val: f64) !void { const delta = now_val - then_val; var then_buf: [16]u8 = undefined; var now_buf: [16]u8 = undefined; var delta_buf: [16]u8 = undefined; const then_str = std.fmt.bufPrint(&then_buf, "{d:.2}%", .{then_val * 100.0}) catch "?"; const now_str = std.fmt.bufPrint(&now_buf, "{d:.2}%", .{now_val * 100.0}) catch "?"; const delta_str = std.fmt.bufPrint(&delta_buf, "{s}{d:.2}%", .{ if (delta >= 0) "+" else "", delta * 100.0 }) catch "?"; try cli.printFg(out, color, cli.CLR_MUTED, " {s:<22} ", .{label}); try cli.printFg(out, color, cli.CLR_MUTED, "{s: >10} → {s: >10} ", .{ then_str, now_str }); try cli.printGainLoss(out, color, delta, "{s: >10}\n", .{delta_str}); } /// Render a "label: then → now Δ" row for money values. fn renderCompareRowMoney(out: *std.Io.Writer, color: bool, label: []const u8, then_val: f64, now_val: f64) !void { const delta = now_val - then_val; try cli.printFg(out, color, cli.CLR_MUTED, " {s:<22} ", .{label}); try cli.printFg(out, color, cli.CLR_MUTED, "{f} → {f} ", .{ Money.from(then_val).padRight(10), Money.from(now_val).padRight(10), }); try cli.printGainLoss(out, color, delta, "{f}\n", .{Money.from(delta).signed().padRight(12)}); } /// Resolve the user's requested as-of date against the history /// directory, accepting either a native snapshot or an /// `imported_values.srf` row. /// /// Thin adapter over `cli.resolveAsOfOrExplain` — the shared CLI /// helper owns the exact-then-fallback resolution and the stderr /// messaging. This wrapper just maps the error set to /// `error.NoSnapshot` (projections-specific) and packs the source + /// liquid fields into the local `AsOfResolution` shape. /// /// Arena-allocates the intermediate `hist_dir` + filename strings; /// pass a short-lived arena as `va`. fn resolveAsOfSnapshot( io: std.Io, va: std.mem.Allocator, file_path: []const u8, requested: Date, ) !AsOfResolution { const hist_dir = try history.deriveHistoryDir(va, file_path); const resolved = cli.resolveAsOfOrExplain(io, va, hist_dir, requested) catch |err| switch (err) { error.NoDataAtOrBefore => return error.NoSnapshot, else => |e| { try cli.stderrPrint(io, "Error resolving as-of: "); try cli.stderrPrint(io, @errorName(e)); try cli.stderrPrint(io, "\n"); return error.NoSnapshot; }, }; return .{ .requested = resolved.requested, .actual = resolved.actual, .source = resolved.source, .liquid = resolved.liquid, }; } /// Load the merged history timeline (snapshots + imported_values), /// filter to the [`as_of`, `today`] range, and produce an overlay /// section. Caller passes an arena allocator so all intermediate /// allocations are freed at the end of the request. /// /// Returns null on a missing/empty history dir — that's a soft /// failure (no overlay rendered, projection still works). fn loadOverlayActuals( io: std.Io, arena: std.mem.Allocator, file_path: []const u8, as_of: Date, today: Date, ) !?view.OverlayActualsSection { var loaded = history.loadTimeline(io, arena, file_path) catch |err| switch (err) { // Missing/unreadable history dir → no overlay, no error. error.FileNotFound, error.NotDir, error.AccessDenied => return null, else => return err, }; defer loaded.deinit(); if (loaded.series.points.len == 0) return null; return try view.buildOverlayActuals(arena, loaded.series.points, as_of, today); } /// Write a return row using the view model, applying StyleIntent colors. fn writeReturnRow(out: *std.Io.Writer, color: bool, row: view.ReturnRow) !void { try out.print("{s: <32}", .{row.label}); try writeCell(out, color, row.one_year, 8); try writeCell(out, color, row.three_year, 9); try writeCell(out, color, row.five_year, 9); try writeCell(out, color, row.ten_year, 10); try writeCell(out, color, row.week, 9); try out.print("\n", .{}); } fn writeCell(out: *std.Io.Writer, color: bool, cell: view.ReturnCell, width: usize) !void { switch (width) { 8 => try cli.printIntent(out, color, cell.style, "{s: >8}", .{cell.text}), 9 => try cli.printIntent(out, color, cell.style, "{s: >9}", .{cell.text}), 10 => try cli.printIntent(out, color, cell.style, "{s: >10}", .{cell.text}), else => try cli.printIntent(out, color, cell.style, "{s}", .{cell.text}), } } /// Render the "Accumulation phase" block (driven by the user's /// target retirement date — `retirement_age` / `retirement_at` — /// or by the promoted cell from the earliest-retirement search when /// only `target_spending` is configured). /// /// Always emits the "Years until possible retirement" line — including /// `none` for the already-retired case, where the entire block reduces /// to that single line. When a retirement date is configured, the /// median portfolio at retirement and the p10–p90 range follow, /// computed from the longest-horizon percentile bands. fn renderAccumulationBlock(out: *std.Io.Writer, color: bool, va: std.mem.Allocator, ctx: view.ProjectionContext) !void { try out.print("\n", .{}); try cli.printBold(out, color, "Accumulation phase:\n", .{}); var line_buf: [128]u8 = undefined; const parts = view.splitRetirementLine(&line_buf, ctx.retirement, &ctx.config); // Label stays neutral; only the value (date / "not feasible" / // "none") carries a style. This keeps "not feasible" loud // without making the entire row scream red. try out.print(" {s}", .{parts.label_text}); try cli.printIntent(out, color, parts.value_style, "{s}\n", .{parts.value_text}); // Contribution line — suppressed when both contribution and // accumulation are zero. if (try view.fmtContributionLine(va, ctx.config.annual_contribution, ctx.config.contribution_inflation_adjusted, ctx.retirement.accumulation_years)) |contrib| { try out.print(" {s}\n", .{contrib}); } // Accumulation-phase stats: median + p10-p90 range at the // retirement boundary. if (ctx.accumulation) |acc| { try out.print(" Median portfolio at retirement: {f}\n", .{Money.from(acc.median_at_retirement).trim()}); try out.print(" Range (10th\u{2013}90th percentile): {f} to {f}\n", .{ Money.from(acc.p10_at_retirement).trim(), Money.from(acc.p90_at_retirement).trim(), }); } } /// Render the "Earliest retirement" block (driven by the user's /// target spending — `target_spending`). /// /// Renders a grid of (confidence × horizon) cells, each showing the /// earliest retirement date that sustains the target spending at that /// confidence over that distribution horizon, or "—" when not feasible /// within `max_accumulation_years`. fn renderEarliestBlock(out: *std.Io.Writer, color: bool, va: std.mem.Allocator, ctx: view.ProjectionContext, as_of: zfin.Date) !void { const earliest = ctx.earliest orelse return; const target = ctx.config.target_spending orelse return; try out.print("\n", .{}); const adj: []const u8 = if (ctx.config.target_spending_inflation_adjusted) "CPI-adjusted" else "nominal"; try cli.printBold(out, color, "Earliest retirement (target spending: {f}/yr {s})\n", .{ Money.from(target).trim(), adj }); const horizons = ctx.config.getHorizons(); const confs = ctx.config.getConfidenceLevels(); // Header row: blank label space + horizon column headers. const cell_width: usize = 14; const label_width: usize = 25; { var hdr: std.ArrayListUnmanaged(u8) = .empty; try hdr.appendNTimes(va, ' ', label_width); for (horizons) |h| { var hbuf: [16]u8 = undefined; const hlabel = view.fmtHorizonLabel(&hbuf, h); try hdr.appendNTimes(va, ' ', cell_width -| hlabel.len); try hdr.appendSlice(va, hlabel); } try cli.printFg(out, color, cli.CLR_MUTED, "{s}\n", .{hdr.items}); } for (confs, 0..) |conf, ci| { const row = try view.buildEarliestRow(va, conf, horizons, earliest, ci, as_of); // Label try out.print(" {s}", .{row.label_text}); // Pad label to label_width (we wrote 2 leading spaces + label, so pad to label_width - 2). const pad = if (label_width > 2 + row.label_text.len) label_width - 2 - row.label_text.len else 0; for (0..pad) |_| try out.print(" ", .{}); // Cells for (row.cells) |cell| { const cellpad = if (cell_width > cell.text.len) cell_width - cell.text.len else 0; for (0..cellpad) |_| try out.print(" ", .{}); try cli.printIntent(out, color, cell.style, "{s}", .{cell.text}); } try out.print("\n", .{}); } } // ── Tests ──────────────────────────────────────────────────── // // The projections simulation and rendering are covered by the // view-model tests in `src/views/projections.zig` and the analytics // tests in `src/analytics/`. These tests focus on the CLI-surface // behaviour that `run` is responsible for: as-of snapshot resolution, // exact/nearest/miss branching, and error reporting. const testing = std.testing; const snapshot_model = @import("../models/snapshot.zig"); const snapshot = @import("snapshot.zig"); fn makeTestSvc() zfin.DataService { const config = zfin.Config{ .cache_dir = "/tmp" }; return zfin.DataService.init(std.testing.io, testing.allocator, config); } fn writeFixtureSnapshot( io: std.Io, dir: std.Io.Dir, allocator: std.mem.Allocator, filename: []const u8, as_of: Date, liquid: f64, ) !void { const lots = [_]snapshot_model.LotRow{ .{ .kind = "lot", .symbol = "VTI", .lot_symbol = "VTI", .account = "Roth", .security_type = "Stock", .shares = 100, .open_price = 200, .cost_basis = 20_000, .value = liquid, .price = liquid / 100, .quote_date = as_of, }, }; const totals = [_]snapshot_model.TotalRow{ .{ .kind = "total", .scope = "net_worth", .value = liquid }, .{ .kind = "total", .scope = "liquid", .value = liquid }, .{ .kind = "total", .scope = "illiquid", .value = 0 }, }; const snap: snapshot_model.Snapshot = .{ .meta = .{ .kind = "meta", .snapshot_version = 1, .as_of_date = as_of, .captured_at = 1_745_222_400, .zfin_version = "test", .stale_count = 0, }, .totals = @constCast(&totals), .tax_types = &.{}, .accounts = &.{}, .lots = @constCast(&lots), }; const rendered = try snapshot.renderSnapshot(allocator, snap); defer allocator.free(rendered); try dir.writeFile(io, .{ .sub_path = filename, .data = rendered }); } /// Build a portfolio path inside `tmp` and return the joined string. /// Caller owns the returned buffer. fn makeTestPortfolioPath(io: std.Io, tmp: *std.testing.TmpDir, allocator: std.mem.Allocator) ![]u8 { const dir_path = try tmp.dir.realPathFileAlloc(io, ".", allocator); defer allocator.free(dir_path); return std.fs.path.join(allocator, &.{ dir_path, "portfolio.srf" }); } test "resolveAsOfSnapshot: exact match returns actual == requested" { const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.createDirPath(io, "history"); var hist_dir = try tmp.dir.openDir(io, "history", .{}); defer hist_dir.close(io); const d = Date.fromYmd(2026, 3, 13); try writeFixtureSnapshot(io, hist_dir, testing.allocator, "2026-03-13-portfolio.srf", d, 1_000_000); const pf = try makeTestPortfolioPath(io, &tmp, testing.allocator); defer testing.allocator.free(pf); var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); const res = try resolveAsOfSnapshot(std.testing.io, arena.allocator(), pf, d); try testing.expect(res.actual.eql(d)); try testing.expect(res.requested.eql(d)); } test "resolveAsOfSnapshot: no exact match snaps to earlier" { const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.createDirPath(io, "history"); var hist_dir = try tmp.dir.openDir(io, "history", .{}); defer hist_dir.close(io); const earlier = Date.fromYmd(2026, 3, 12); try writeFixtureSnapshot(io, hist_dir, testing.allocator, "2026-03-12-portfolio.srf", earlier, 1_000_000); const pf = try makeTestPortfolioPath(io, &tmp, testing.allocator); defer testing.allocator.free(pf); var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); const requested = Date.fromYmd(2026, 3, 13); const res = try resolveAsOfSnapshot(std.testing.io, arena.allocator(), pf, requested); try testing.expect(res.actual.eql(earlier)); try testing.expect(res.requested.eql(requested)); try testing.expect(!res.actual.eql(res.requested)); } test "resolveAsOfSnapshot: no earlier snapshot returns NoSnapshot" { const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.createDirPath(io, "history"); var hist_dir = try tmp.dir.openDir(io, "history", .{}); defer hist_dir.close(io); // Only a later snapshot exists — can't satisfy an earlier request. const later = Date.fromYmd(2026, 4, 1); try writeFixtureSnapshot(io, hist_dir, testing.allocator, "2026-04-01-portfolio.srf", later, 1_000_000); const pf = try makeTestPortfolioPath(io, &tmp, testing.allocator); defer testing.allocator.free(pf); var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); const requested = Date.fromYmd(2026, 3, 13); const result = resolveAsOfSnapshot(std.testing.io, arena.allocator(), pf, requested); try testing.expectError(error.NoSnapshot, result); } test "resolveAsOfSnapshot: empty history dir returns NoSnapshot" { const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.createDirPath(io, "history"); const pf = try makeTestPortfolioPath(io, &tmp, testing.allocator); defer testing.allocator.free(pf); var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); const requested = Date.fromYmd(2026, 3, 13); const result = resolveAsOfSnapshot(std.testing.io, arena.allocator(), pf, requested); try testing.expectError(error.NoSnapshot, result); } test "run: as_of with no snapshots returns without error (stderr-only)" { const io = std.testing.io; // No history dir at all. `run` prints a stderr hint via // `resolveAsOfSnapshot` and returns — should NOT propagate the // error to the caller (exit code stays 0 from the CLI dispatch). var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var svc = makeTestSvc(); defer svc.deinit(); const pf = try makeTestPortfolioPath(io, &tmp, testing.allocator); defer testing.allocator.free(pf); var buf: [4096]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); const d = Date.fromYmd(2026, 3, 13); try run(io, testing.allocator, &svc, pf, false, d, true, d, false, false, &stream); // No body output because the resolution failed — the stderr // message is swallowed by `cli.stderrPrint` and doesn't land in // `stream`. This guarantees the error-path returns cleanly. const out = stream.buffered(); try testing.expectEqual(@as(usize, 0), out.len); } test "run: as_of with matching snapshot produces body output" { const io = std.testing.io; // End-to-end smoke test. With no cached candles, benchmark rows // will be `--` and portfolio returns will be empty, but the // rendering pipeline should still produce a complete header + // tables without panicking. var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var svc = makeTestSvc(); defer svc.deinit(); try tmp.dir.createDirPath(io, "history"); var hist_dir = try tmp.dir.openDir(io, "history", .{}); defer hist_dir.close(io); const d = Date.fromYmd(2026, 3, 13); try writeFixtureSnapshot(io, hist_dir, testing.allocator, "2026-03-13-portfolio.srf", d, 1_000_000); const pf = try makeTestPortfolioPath(io, &tmp, testing.allocator); defer testing.allocator.free(pf); var buf: [32_768]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); try run(io, testing.allocator, &svc, pf, false, d, true, d, false, false, &stream); const out = stream.buffered(); // Header should call out the as-of date explicitly. try testing.expect(std.mem.indexOf(u8, out, "as of 2026-03-13") != null); // Benchmark + withdrawal tables still render even with missing candles. try testing.expect(std.mem.indexOf(u8, out, "Safe Withdrawal") != null); try testing.expect(std.mem.indexOf(u8, out, "Terminal Portfolio Value") != null); } test "run: as_of auto-snap surfaces muted 'nearest' note" { const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var svc = makeTestSvc(); defer svc.deinit(); try tmp.dir.createDirPath(io, "history"); var hist_dir = try tmp.dir.openDir(io, "history", .{}); defer hist_dir.close(io); const actual = Date.fromYmd(2026, 3, 12); try writeFixtureSnapshot(io, hist_dir, testing.allocator, "2026-03-12-portfolio.srf", actual, 1_000_000); const pf = try makeTestPortfolioPath(io, &tmp, testing.allocator); defer testing.allocator.free(pf); var buf: [32_768]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); const requested = Date.fromYmd(2026, 3, 13); try run(io, testing.allocator, &svc, pf, false, requested, true, requested, false, false, &stream); const out = stream.buffered(); try testing.expect(std.mem.indexOf(u8, out, "as of 2026-03-12") != null); try testing.expect(std.mem.indexOf(u8, out, "(requested 2026-03-13") != null); try testing.expect(std.mem.indexOf(u8, out, "nearest snapshot: 2026-03-12") != null); // 1 day earlier → singular "day", not "days" try testing.expect(std.mem.indexOf(u8, out, "1 day earlier") != null); } test "renderCompareRowPct: positive delta renders with + sign" { var buf: [256]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); try renderCompareRowPct(&w, false, "Stocks", 0.50, 0.65); const out = w.buffered(); try testing.expect(std.mem.indexOf(u8, out, "Stocks") != null); try testing.expect(std.mem.indexOf(u8, out, "50.00%") != null); try testing.expect(std.mem.indexOf(u8, out, "65.00%") != null); try testing.expect(std.mem.indexOf(u8, out, "+15.00%") != null); } test "renderCompareRowPct: negative delta has no + sign" { var buf: [256]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); try renderCompareRowPct(&w, false, "Bonds", 0.40, 0.30); const out = w.buffered(); try testing.expect(std.mem.indexOf(u8, out, "40.00%") != null); try testing.expect(std.mem.indexOf(u8, out, "30.00%") != null); try testing.expect(std.mem.indexOf(u8, out, "-10.00%") != null); } test "renderCompareRowMoney: positive delta" { var buf: [256]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); try renderCompareRowMoney(&w, false, "Net Worth", 100_000, 110_000); const out = w.buffered(); try testing.expect(std.mem.indexOf(u8, out, "Net Worth") != null); try testing.expect(std.mem.indexOf(u8, out, "$100,000") != null); try testing.expect(std.mem.indexOf(u8, out, "$110,000") != null); } test "renderCompareRowMoney: zero delta" { var buf: [256]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); try renderCompareRowMoney(&w, false, "Cash", 50_000, 50_000); const out = w.buffered(); try testing.expect(std.mem.indexOf(u8, out, "Cash") != null); try testing.expect(std.mem.indexOf(u8, out, "$50,000") != null); } test "renderCompareRowPct: no ANSI when color=false" { var buf: [256]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); try renderCompareRowPct(&w, false, "X", 0.1, 0.2); try testing.expect(std.mem.indexOf(u8, w.buffered(), "\x1b[") == null); }