/// 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 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"); 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 money_str = fmt.fmtMoneyAbs(amount_buf, result.annual_amount); // Strip trailing ".00" for clean display const clean_amount = if (std.mem.endsWith(u8, money_str, ".00")) money_str[0 .. money_str.len - 3] else money_str; 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 2%: "on target" (muted) /// - 2–5% off: warning /// - Over 5% 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 < 2.0) .muted else if (drift < 5.0) .warning else .negative; const text = if (drift < 2.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, }; pub const ProjectionData = struct { withdrawals: []projections.WithdrawalResult, bands: []?[]projections.YearPercentiles, ci_99: usize, }; pub fn computeProjectionData( alloc: std.mem.Allocator, horizons: []const u16, confidence_levels: []const f64, total_value: f64, stock_pct: f64, ) !ProjectionData { const num_results = horizons.len * confidence_levels.len; const withdrawals = try alloc.alloc(projections.WithdrawalResult, num_results); for (confidence_levels, 0..) |conf, ci| { for (horizons, 0..) |h, hi| { withdrawals[ci * horizons.len + hi] = projections.findSafeWithdrawal(h, total_value, stock_pct, conf); } } const ci_99 = confidence_levels.len - 1; const bands = try alloc.alloc(?[]projections.YearPercentiles, horizons.len); for (horizons, 0..) |h, hi| { bands[hi] = projections.computePercentileBands( alloc, h, total_value, withdrawals[ci_99 * horizons.len + hi].annual_amount, stock_pct, ) catch null; } return .{ .withdrawals = withdrawals, .bands = bands, .ci_99 = ci_99 }; } pub fn buildProjectionContext( alloc: std.mem.Allocator, config: projections.UserConfig, comparison: benchmark.BenchmarkComparison, stock_pct: f64, bond_pct: f64, total_value: f64, ) !ProjectionContext { const sim_stock_pct = if (config.target_stock_pct) |t| t / 100.0 else stock_pct; const data = try computeProjectionData(alloc, config.getHorizons(), config.getConfidenceLevels(), total_value, sim_stock_pct); return .{ .comparison = comparison, .config = config, .data = data, .stock_pct = stock_pct, .bond_pct = bond_pct, .total_value = total_value, }; } /// 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( alloc: std.mem.Allocator, portfolio_dir: []const u8, allocations: []const valuation.Allocation, total_value: f64, cash_value: f64, cd_value: f64, svc: *zfin.DataService, ) !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.fs.cwd().readFileAlloc(alloc, proj_path, 64 * 1024) catch null; defer if (proj_data) |d| alloc.free(d); const config = projections.parseProjectionsConfig(proj_data); // 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.fs.cwd().readFileAlloc(alloc, meta_path, 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) const spy_result = svc.getCandles("SPY") catch null; const spy_candles = if (spy_result) |r| r.data else &.{}; defer if (spy_result) |r| alloc.free(r.data); const agg_result = svc.getCandles("AGG") catch null; const agg_candles = if (agg_result) |r| r.data else &.{}; defer if (agg_result) |r| alloc.free(r.data); 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 var pos_returns: std.ArrayListUnmanaged(benchmark.PositionReturn) = .empty; defer pos_returns.deinit(alloc); for (allocations) |a| { const candles = svc.getCachedCandles(a.symbol) orelse continue; defer alloc.free(candles); 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, ); return buildProjectionContext(alloc, config, comparison, split.stock_pct, split.bond_pct, total_value); } // ── 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 = fmt.fmtMoneyAbs(&mbuf, val); 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 }; } // ── 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.85); try std.testing.expect(note != null); try std.testing.expect(note.?.style == .negative); // >5% drift 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.80); try std.testing.expect(note != null); try std.testing.expect(note.?.style == .warning); // 3% drift, in 2-5% 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 "computeProjectionData produces correct structure" { const allocator = std.testing.allocator; const horizons = [_]u16{ 20, 30 }; const conf = [_]f64{ 0.95, 0.99 }; const data = try computeProjectionData(allocator, &horizons, &conf, 1000000, 0.75); defer { allocator.free(data.withdrawals); for (data.bands) |b| { if (b) |slice| allocator.free(slice); } allocator.free(data.bands); } // 2 horizons × 2 confidence levels = 4 withdrawal results try std.testing.expectEqual(@as(usize, 4), data.withdrawals.len); // 2 bands (one per horizon) try std.testing.expectEqual(@as(usize, 2), data.bands.len); // 99% is the last confidence level try std.testing.expectEqual(@as(usize, 1), data.ci_99); // Withdrawal at 95% should be >= withdrawal at 99% (for same horizon) try std.testing.expect(data.withdrawals[0].annual_amount >= data.withdrawals[2].annual_amount); // Withdrawal at 20yr should be >= withdrawal at 30yr (for same confidence) try std.testing.expect(data.withdrawals[0].annual_amount >= data.withdrawals[1].annual_amount); } 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); }