diff --git a/TODO.md b/TODO.md index 616212a..250aad2 100644 --- a/TODO.md +++ b/TODO.md @@ -1,5 +1,18 @@ # Future Work +## Projections: future enhancements + +- Configurable return cap per position (default: none; cap outliers like NVDA) +- Configurable MIN period selection (currently 3Y/5Y/10Y, exclude 1Y) +- Life events in `projections.srf`: Social Security income, college costs, pensions +- "Not Retired Yet" mode: contributions until retirement date +- Multiple spending models (Bernicke, percentage-of-remaining) +- Configurable benchmark symbols (currently hardcoded SPY + AGG) +- Age-based horizons from birthdates in projections.srf +- Weekly tracking history (portfolio value + retirement date time series) +- Kitty graphics percentile band chart (currently braille only) +- Unclassified position handling in allocation split (warn user) + ## Analysis account/asset-class total mismatch The "By Account" and "By Tax Type" sections in the analysis command sum to slightly diff --git a/src/analytics/performance.zig b/src/analytics/performance.zig index 5f8458e..e7e7ca0 100644 --- a/src/analytics/performance.zig +++ b/src/analytics/performance.zig @@ -285,6 +285,72 @@ pub fn formatReturn(buf: []u8, value: f64) []const u8 { return std.fmt.bufPrint(buf, "{d:.2}%", .{value * 100.0}) catch "??%"; } +/// Compute 1-week return from candle data: (latest_close / close_7_days_ago) - 1. +/// Candles must be sorted by date ascending. +pub fn weekReturn(candles: []const Candle) ?f64 { + if (candles.len < 2) return null; + const latest = candles[candles.len - 1]; + const target_date = latest.date.addDays(-7); + + // Linear scan backward (at most ~10 steps for daily candles) + var i: usize = candles.len - 2; + while (true) { + if (candles[i].date.days <= target_date.days) { + if (candles[i].close == 0) return null; + return (latest.close / candles[i].close) - 1.0; + } + if (i == 0) break; + i -= 1; + } + return null; +} + +test "weekReturn less than 2 candles" { + const c = [_]Candle{.{ .date = Date.fromYmd(2024, 1, 10), .open = 100, .high = 100, .low = 100, .close = 100, .adj_close = 100, .volume = 0 }}; + try std.testing.expect(weekReturn(&c) == null); + try std.testing.expect(weekReturn(&[_]Candle{}) == null); +} + +test "weekReturn simple positive" { + const candles = [_]Candle{ + .{ .date = Date.fromYmd(2024, 1, 1), .open = 100, .high = 100, .low = 100, .close = 100, .adj_close = 100, .volume = 0 }, + .{ .date = Date.fromYmd(2024, 1, 8), .open = 110, .high = 110, .low = 110, .close = 110, .adj_close = 110, .volume = 0 }, + }; + const r = weekReturn(&candles).?; + try std.testing.expectApproxEqAbs(@as(f64, 0.10), r, 0.001); +} + +test "weekReturn negative" { + const candles = [_]Candle{ + .{ .date = Date.fromYmd(2024, 1, 1), .open = 100, .high = 100, .low = 100, .close = 100, .adj_close = 100, .volume = 0 }, + .{ .date = Date.fromYmd(2024, 1, 8), .open = 95, .high = 95, .low = 95, .close = 95, .adj_close = 95, .volume = 0 }, + }; + const r = weekReturn(&candles).?; + try std.testing.expectApproxEqAbs(@as(f64, -0.05), r, 0.001); +} + +test "weekReturn snaps to nearest trading day" { + // Latest is Wednesday Jan 10. 7 days back is Wednesday Jan 3. + // But candles only have Mon Jan 1 and Fri Jan 5. Should snap to Fri Jan 5. + const candles = [_]Candle{ + .{ .date = Date.fromYmd(2024, 1, 1), .open = 90, .high = 90, .low = 90, .close = 90, .adj_close = 90, .volume = 0 }, + .{ .date = Date.fromYmd(2024, 1, 5), .open = 100, .high = 100, .low = 100, .close = 100, .adj_close = 100, .volume = 0 }, + .{ .date = Date.fromYmd(2024, 1, 10), .open = 105, .high = 105, .low = 105, .close = 105, .adj_close = 105, .volume = 0 }, + }; + // Target: Jan 10 - 7 = Jan 3. Nearest at-or-before is Jan 1 (not Jan 5 which is after Jan 3) + const r = weekReturn(&candles).?; + // 105/90 - 1 = 0.1667 + try std.testing.expectApproxEqAbs(@as(f64, 0.1667), r, 0.001); +} + +test "weekReturn zero close returns null" { + const candles = [_]Candle{ + .{ .date = Date.fromYmd(2024, 1, 1), .open = 0, .high = 0, .low = 0, .close = 0, .adj_close = 0, .volume = 0 }, + .{ .date = Date.fromYmd(2024, 1, 8), .open = 100, .high = 100, .low = 100, .close = 100, .adj_close = 100, .volume = 0 }, + }; + try std.testing.expect(weekReturn(&candles) == null); +} + test "total return simple" { const candles = [_]Candle{ .{ .date = Date.fromYmd(2024, 1, 2), .open = 100, .high = 101, .low = 99, .close = 100, .adj_close = 100, .volume = 1000 }, diff --git a/src/analytics/projections.zig b/src/analytics/projections.zig index c46c043..79861a7 100644 --- a/src/analytics/projections.zig +++ b/src/analytics/projections.zig @@ -10,6 +10,7 @@ /// - Success rate for a given spending level /// - Percentile bands of portfolio value at each year (for charting) const std = @import("std"); +const log = std.log.scoped(.projections); const shiller = @import("../data/shiller.zig"); const srf = @import("srf"); @@ -297,16 +298,35 @@ fn successRate( /// the portfolio survives `horizon` years in at least `confidence` fraction /// of all historical cycles. /// -/// Uses binary search with $1 precision. +/// Uses binary search with $1 precision, seeded with a 4%-rule estimate +/// to narrow the search band (~10 iterations instead of ~23). pub fn findSafeWithdrawal( horizon: u16, initial_value: f64, stock_pct: f64, confidence: f64, ) WithdrawalResult { - // Binary search bounds: $0 to the full portfolio value - var lo: f64 = 0; - var hi: f64 = initial_value; + // Seed from the 4% rule, adjusted for horizon and confidence. + // Base ~4% for 30yr/95%. Shorter horizons allow more; longer less. + // Higher confidence requires less. + const base_rate = 0.04; + const horizon_adj = 30.0 / @as(f64, @floatFromInt(horizon)); // >1 for short, <1 for long + const conf_adj = (1.0 - confidence) / 0.05; // 1.0 at 95%, 0.2 at 99%, 2.0 at 90% + const estimate = initial_value * base_rate * @sqrt(horizon_adj) * @sqrt(conf_adj); + + // Search band: ±50% of estimate, clamped to [0, initial_value] + var lo: f64 = @max(estimate * 0.5, 0); + var hi: f64 = @min(estimate * 1.5, initial_value); + + // Verify bounds bracket the answer; widen if not + if (successRate(horizon, initial_value, lo, stock_pct) < confidence) { + log.debug("findSafeWithdrawal: estimate too high, widening lo to 0 (horizon={d}, conf={d:.2})", .{ horizon, confidence }); + lo = 0; + } + if (successRate(horizon, initial_value, hi, stock_pct) >= confidence) { + log.debug("findSafeWithdrawal: estimate too low, widening hi to portfolio value (horizon={d}, conf={d:.2})", .{ horizon, confidence }); + hi = initial_value; + } // Binary search to $1 precision while (hi - lo > 1.0) { diff --git a/src/commands/history.zig b/src/commands/history.zig index dd0f505..c2b8b45 100644 --- a/src/commands/history.zig +++ b/src/commands/history.zig @@ -393,7 +393,7 @@ fn renderBrailleChart( var chart = fmt.computeBrailleChart(allocator, candles, 60, 10, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch return; defer chart.deinit(allocator); - try fmt.writeBrailleAnsi(out, &chart, color, cli.CLR_MUTED); + try fmt.writeBrailleAnsi(out, &chart, color, cli.CLR_MUTED, false); } fn renderTable( diff --git a/src/commands/projections.zig b/src/commands/projections.zig index 332faa2..b16ebe5 100644 --- a/src/commands/projections.zig +++ b/src/commands/projections.zig @@ -49,83 +49,24 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, file_path: []co }; defer pf_data.deinit(allocator); - // Load projections.srf config (sibling to portfolio file) + // Build projection context (loads config, metadata, computes everything) const dir_end = if (std.mem.lastIndexOfScalar(u8, file_path, std.fs.path.sep)) |idx| idx + 1 else 0; - const proj_path = std.fmt.allocPrint(allocator, "{s}projections.srf", .{file_path[0..dir_end]}) catch null; - defer if (proj_path) |p| allocator.free(p); + var arena_state = std.heap.ArenaAllocator.init(allocator); + defer arena_state.deinit(); + const va = arena_state.allocator(); - const proj_data = if (proj_path) |p| std.fs.cwd().readFileAlloc(allocator, p, 64 * 1024) catch null else null; - defer if (proj_data) |d| allocator.free(d); - - const user_config = projections.parseProjectionsConfig(proj_data); - - // Derive stock/bond allocation from portfolio using classification metadata. - const meta_path = std.fmt.allocPrint(allocator, "{s}metadata.srf", .{file_path[0..dir_end]}) catch null; - defer if (meta_path) |p| allocator.free(p); - const meta_data = if (meta_path) |p| std.fs.cwd().readFileAlloc(allocator, p, 1024 * 1024) catch null else null; - defer if (meta_data) |d| allocator.free(d); - var cm_opt: ?zfin.classification.ClassificationMap = if (meta_data) |d| - zfin.classification.parseClassificationFile(allocator, d) catch null - else - null; - defer if (cm_opt) |*cm| cm.deinit(); - - const allocs = pf_data.summary.allocations; - const total_value = pf_data.summary.total_value; - - // Derive stock/bond split from classification metadata - const split = benchmark.deriveAllocationSplit( - allocs, - if (cm_opt) |cm| cm.entries else &.{}, - total_value, + const ctx = try view.loadProjectionContext( + va, + file_path[0..dir_end], + pf_data.summary.allocations, + pf_data.summary.total_value, portfolio.totalCash(), portfolio.totalCdFaceValue(), + svc, ); - - const stock_pct = split.stock_pct; - const bond_pct = split.bond_pct; - - const sim_stock_pct = if (user_config.target_stock_pct) |t| t / 100.0 else stock_pct; - - // Fetch benchmark candles (ensure they're cached) - _ = svc.getCandles(stock_benchmark) catch null; - _ = svc.getCandles(bond_benchmark) catch null; - const spy_candles = svc.getCachedCandles(stock_benchmark) orelse &.{}; - defer if (spy_candles.len > 0) allocator.free(spy_candles); - const agg_candles = svc.getCachedCandles(bond_benchmark) orelse &.{}; - defer if (agg_candles.len > 0) allocator.free(agg_candles); - - // Compute benchmark trailing returns + week returns - const spy_trailing = performance.trailingReturns(spy_candles); - const agg_trailing = performance.trailingReturns(agg_candles); - const spy_week = weekReturn(spy_candles); - const agg_week = weekReturn(agg_candles); - - // Build per-position trailing returns for portfolio weighted average - var pos_returns = std.ArrayList(benchmark.PositionReturn).empty; - defer pos_returns.deinit(allocator); - for (allocs) |a| { - const candles = pf_data.candle_map.get(a.symbol) orelse continue; - if (candles.len == 0) continue; - try pos_returns.append(allocator, .{ - .symbol = a.symbol, - .weight = a.weight, - .returns = performance.trailingReturns(candles), - }); - } - - // Build benchmark comparison - const comparison = benchmark.buildComparison( - spy_trailing, - agg_trailing, - stock_pct, - bond_pct, - pos_returns.items, - spy_week, - agg_week, - ); - - // ── Render via view model ────────────────────────────────── + const horizons = ctx.config.getHorizons(); + const confidence_levels = ctx.config.getConfidenceLevels(); + const comparison = ctx.comparison; try out.print("\n", .{}); try cli.setBold(out, color); try out.print("Projections ({s})\n", .{file_path}); @@ -143,7 +84,7 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, file_path: []co 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, stock_pct * 100), + view.fmtBenchmarkLabel(&spy_label_buf, stock_benchmark, ctx.stock_pct * 100), comparison.stock_returns, &spy_bufs, false, @@ -152,7 +93,7 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, file_path: []co 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, bond_pct * 100), + view.fmtBenchmarkLabel(&agg_label_buf, bond_benchmark, ctx.bond_pct * 100), comparison.bond_returns, &agg_bufs, false, @@ -189,7 +130,7 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, file_path: []co // Target allocation note { var note_buf: [128]u8 = undefined; - if (view.fmtAllocationNote(¬e_buf, user_config.target_stock_pct, stock_pct)) |note| { + if (view.fmtAllocationNote(¬e_buf, ctx.config.target_stock_pct, ctx.stock_pct)) |note| { try out.print("\n", .{}); try cli.setStyleIntent(out, color, note.style); try out.print("{s}\n", .{note.text}); @@ -197,49 +138,80 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, file_path: []co } } + // ── 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.setBold(out, color); + try out.print("Median Portfolio Value ({d}-Year, 99% withdrawal)\n\n", .{horizons[last_idx]}); + try cli.reset(out, color); + + // 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); + } + } + } + } + + // ── Terminal portfolio value ───────────────────────────────── + try out.print("\n", .{}); + try cli.setBold(out, color); + try out.print("Terminal Portfolio Value (nominal, at 99% withdrawal rate)\n", .{}); + try cli.reset(out, color); + + 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.setStyleIntent(out, color, row.style); + try out.print("{s}\n", .{row.text}); + try cli.reset(out, color); + } + // ── Safe withdrawal table ────────────────────────────────── try out.print("\n", .{}); try cli.setBold(out, color); try out.print("Safe Withdrawal (FIRECalc historical simulation)\n", .{}); try cli.reset(out, color); - const horizons = user_config.getHorizons(); - const confidence_levels = user_config.getConfidenceLevels(); - // Header row - try out.print("{s: <25}", .{""}); - for (horizons) |h| { - var hbuf: [16]u8 = undefined; - try out.print("{s: >12}", .{view.fmtHorizonLabel(&hbuf, h)}); - } - try out.print("\n", .{}); + try out.print("{s}\n", .{try view.buildHeaderRow(va, horizons, view.withdrawal_col_width)}); - // One row per confidence level - for (confidence_levels) |conf| { - var lbuf: [25]u8 = undefined; - try out.print("{s: <25}", .{view.fmtConfidenceLabel(&lbuf, conf)}); - - for (horizons) |h| { - const result = projections.findSafeWithdrawal(h, total_value, sim_stock_pct, conf); - var abuf: [24]u8 = undefined; - var rbuf: [16]u8 = undefined; - const cell = view.fmtWithdrawalCell(&abuf, &rbuf, result); - try out.print("{s: >12}", .{cell.amount_text}); - } - try out.print("\n", .{}); - - // Rate row + // 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.setFg(out, color, cli.CLR_MUTED); - try out.print("{s: <25}", .{""}); - for (horizons) |h| { - const result = projections.findSafeWithdrawal(h, total_value, sim_stock_pct, conf); - var abuf: [24]u8 = undefined; - var rbuf: [16]u8 = undefined; - const cell = view.fmtWithdrawalCell(&abuf, &rbuf, result); - try out.print("{s: >12}", .{cell.rate_text}); - } + try out.print("{s}\n", .{wr_rows.rate.text}); try cli.reset(out, color); - try out.print("\n", .{}); } try out.print("\n", .{}); @@ -266,18 +238,3 @@ fn writeCell(out: *std.Io.Writer, color: bool, cell: view.ReturnCell, width: usi } try cli.reset(out, color); } - -fn candleDate(c: zfin.Candle) zfin.Date { - return c.date; -} - -/// Compute 1-week return from candle data. -fn weekReturn(candles: []const zfin.Candle) ?f64 { - if (candles.len < 2) return null; - const latest = candles[candles.len - 1]; - const target_date = latest.date.addDays(-7); - const idx = valuation.indexAtOrBefore(zfin.Candle, candles, target_date, candleDate) orelse return null; - const start = candles[idx]; - if (start.close == 0) return null; - return (latest.close / start.close) - 1.0; -} diff --git a/src/commands/quote.zig b/src/commands/quote.zig index f1dffac..36540df 100644 --- a/src/commands/quote.zig +++ b/src/commands/quote.zig @@ -100,7 +100,7 @@ pub fn display(allocator: std.mem.Allocator, candles: []const zfin.Candle, quote var chart = fmt.computeBrailleChart(allocator, chart_data, 60, 10, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch null; if (chart) |*ch| { defer ch.deinit(allocator); - try fmt.writeBrailleAnsi(out, ch, color, cli.CLR_MUTED); + try fmt.writeBrailleAnsi(out, ch, color, cli.CLR_MUTED, false); } } diff --git a/src/format.zig b/src/format.zig index b82ef64..e38f7d3 100644 --- a/src/format.zig +++ b/src/format.zig @@ -765,9 +765,11 @@ pub fn computeBrailleChart( // Price labels var result: BrailleChart = undefined; - const max_str = std.fmt.bufPrint(&result.max_label, "${d:.0}", .{max_price}) catch ""; + var max_tmp: [24]u8 = undefined; + var min_tmp: [24]u8 = undefined; + const max_str = std.fmt.bufPrint(&result.max_label, "{s}", .{fmtMoneyAbs(&max_tmp, max_price)}) catch ""; result.max_label_len = max_str.len; - const min_str = std.fmt.bufPrint(&result.min_label, "${d:.0}", .{min_price}) catch ""; + const min_str = std.fmt.bufPrint(&result.min_label, "{s}", .{fmtMoneyAbs(&min_tmp, min_price)}) catch ""; result.min_label_len = min_str.len; const n_cols = @min(data.len, chart_width); @@ -826,12 +828,14 @@ pub fn computeBrailleChart( } /// Write a braille chart to a writer with ANSI color escapes. -/// Used by the CLI for terminal output. +/// Used by the CLI for terminal output. Set `skip_date_axis` to +/// provide a custom x-axis (e.g. year labels instead of dates). pub fn writeBrailleAnsi( out: *std.Io.Writer, chart: *const BrailleChart, use_color: bool, muted_color: [3]u8, + skip_date_axis: bool, ) !void { var last_r: u8 = 0; var last_g: u8 = 0; @@ -879,23 +883,24 @@ pub fn writeBrailleAnsi( } // Date axis below chart - var start_buf: [7]u8 = undefined; - var end_buf: [7]u8 = undefined; - const start_label = BrailleChart.fmtShortDate(chart.start_date, &start_buf); - const end_label = BrailleChart.fmtShortDate(chart.end_date, &end_buf); + if (!skip_date_axis) { + var start_buf: [7]u8 = undefined; + var end_buf: [7]u8 = undefined; + const start_label = BrailleChart.fmtShortDate(chart.start_date, &start_buf); + const end_label = BrailleChart.fmtShortDate(chart.end_date, &end_buf); - if (use_color) try out.print("\x1b[38;2;{d};{d};{d}m", .{ muted_color[0], muted_color[1], muted_color[2] }); - try out.writeAll(" "); // match leading indent - try out.writeAll(start_label); - // Fill gap between start and end labels - const total_width = chart.n_cols; - if (total_width > start_label.len + end_label.len) { - const gap = total_width - start_label.len - end_label.len; - for (0..gap) |_| try out.writeAll(" "); + if (use_color) try out.print("\x1b[38;2;{d};{d};{d}m", .{ muted_color[0], muted_color[1], muted_color[2] }); + try out.writeAll(" "); // match leading indent + try out.writeAll(start_label); + const total_width = chart.n_cols; + if (total_width > start_label.len + end_label.len) { + const gap = total_width - start_label.len - end_label.len; + for (0..gap) |_| try out.writeAll(" "); + } + try out.writeAll(end_label); + if (use_color) try out.writeAll("\x1b[0m"); + try out.writeAll("\n"); } - try out.writeAll(end_label); - if (use_color) try out.writeAll("\x1b[0m"); - try out.writeAll("\n"); } // ── ANSI color helpers (for CLI) ───────────────────────────── diff --git a/src/tui.zig b/src/tui.zig index 86accc3..1b11bf7 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -14,6 +14,7 @@ const options_tab = @import("tui/options_tab.zig"); const earnings_tab = @import("tui/earnings_tab.zig"); const analysis_tab = @import("tui/analysis_tab.zig"); const history_tab = @import("tui/history_tab.zig"); +const projections_tab = @import("tui/projections_tab.zig"); const history_io = @import("history.zig"); const timeline = @import("analytics/timeline.zig"); @@ -79,6 +80,7 @@ pub const Tab = enum { earnings, analysis, history, + projections, fn label(self: Tab) []const u8 { return switch (self) { @@ -89,11 +91,12 @@ pub const Tab = enum { .earnings => " 5:Earnings ", .analysis => " 6:Analysis ", .history => " 7:History ", + .projections => " 8:Projections ", }; } }; -const tabs = [_]Tab{ .portfolio, .quote, .performance, .options, .earnings, .analysis, .history }; +const tabs = [_]Tab{ .portfolio, .quote, .performance, .options, .earnings, .analysis, .history, .projections }; pub const InputMode = enum { normal, @@ -378,6 +381,13 @@ pub const App = struct { history_loaded: bool = false, history_disabled: bool = false, // true when no portfolio path (history requires it) history_timeline: ?history_io.LoadedTimeline = null, + + // Projections tab state + projections_loaded: bool = false, + projections_disabled: bool = false, + projections_config: @import("analytics/projections.zig").UserConfig = .{}, + projections_ctx: ?@import("views/projections.zig").ProjectionContext = null, + projections_horizon_idx: usize = 0, // Default to `.liquid` — that's the metric most worth watching // day-to-day. Illiquid barely changes, net_worth is dominated by // liquid anyway, so "show me liquid" is the headline view. @@ -925,7 +935,7 @@ pub const App = struct { ctx.queueRefresh() catch {}; return ctx.consumeAndRedraw(); }, - .tab_1, .tab_2, .tab_3, .tab_4, .tab_5, .tab_6, .tab_7 => { + .tab_1, .tab_2, .tab_3, .tab_4, .tab_5, .tab_6, .tab_7, .tab_8 => { const idx = @intFromEnum(action) - @intFromEnum(keybinds.Action.tab_1); if (idx < tabs.len) { const target = tabs[idx]; @@ -1364,7 +1374,7 @@ pub const App = struct { .options => { self.svc.invalidate(self.symbol, .options); }, - .portfolio, .analysis, .history => {}, + .portfolio, .analysis, .history, .projections => {}, } } switch (self.active_tab) { @@ -1400,6 +1410,10 @@ pub const App = struct { self.history_loaded = false; history_tab.freeLoaded(self); }, + .projections => { + self.projections_loaded = false; + projections_tab.freeLoaded(self); + }, } self.loadTabData(); @@ -1444,6 +1458,10 @@ pub const App = struct { if (self.history_disabled) return; if (!self.history_loaded) history_tab.loadData(self); }, + .projections => { + if (self.projections_disabled) return; + if (!self.projections_loaded) projections_tab.loadData(self); + }, } } @@ -1548,6 +1566,7 @@ pub const App = struct { if (self.classification_map) |*cm| cm.deinit(); if (self.account_map) |*am| am.deinit(); history_tab.freeLoaded(self); + projections_tab.freeLoaded(self); self.chart.freeCache(self.allocator); // Free cached indicators } @@ -1626,7 +1645,8 @@ pub const App = struct { fn isTabDisabled(self: *App, t: Tab) bool { return (t == .earnings and self.earnings_disabled) or (t == .analysis and self.analysis_disabled) or - (t == .history and self.history_disabled); + (t == .history and self.history_disabled) or + (t == .projections and self.projections_disabled); } fn isSymbolSelected(self: *App) bool { @@ -1657,6 +1677,7 @@ pub const App = struct { .earnings => try self.drawStyledContent(ctx.arena, buf, width, height, try self.buildEarningsStyledLines(ctx.arena)), .analysis => try self.drawStyledContent(ctx.arena, buf, width, height, try self.buildAnalysisStyledLines(ctx.arena)), .history => try self.drawStyledContent(ctx.arena, buf, width, height, try self.buildHistoryStyledLines(ctx.arena)), + .projections => try self.drawStyledContent(ctx.arena, buf, width, height, try projections_tab.buildStyledLines(self, ctx.arena)), } } @@ -2038,6 +2059,7 @@ comptime { _ = earnings_tab; _ = analysis_tab; _ = history_tab; + _ = projections_tab; } /// Entry point for the interactive TUI. diff --git a/src/tui/keybinds.zig b/src/tui/keybinds.zig index 03dc46f..d1c4542 100644 --- a/src/tui/keybinds.zig +++ b/src/tui/keybinds.zig @@ -14,6 +14,7 @@ pub const Action = enum { tab_5, tab_6, tab_7, + tab_8, scroll_down, scroll_up, scroll_top, @@ -98,6 +99,7 @@ const default_bindings = [_]Binding{ .{ .action = .tab_5, .key = .{ .codepoint = '5' } }, .{ .action = .tab_6, .key = .{ .codepoint = '6' } }, .{ .action = .tab_7, .key = .{ .codepoint = '7' } }, + .{ .action = .tab_8, .key = .{ .codepoint = '8' } }, .{ .action = .scroll_down, .key = .{ .codepoint = 'd', .mods = .{ .ctrl = true } } }, .{ .action = .scroll_up, .key = .{ .codepoint = 'u', .mods = .{ .ctrl = true } } }, .{ .action = .scroll_top, .key = .{ .codepoint = 'g' } }, diff --git a/src/tui/projections_tab.zig b/src/tui/projections_tab.zig new file mode 100644 index 0000000..93defd7 --- /dev/null +++ b/src/tui/projections_tab.zig @@ -0,0 +1,365 @@ +//! TUI projections tab — retirement projections and benchmark comparison. +//! +//! Layout (top-to-bottom): +//! 1. Benchmark comparison table (SPY/AGG/Benchmark/Your Portfolio) +//! 2. Conservative estimate + target allocation note +//! 3. Braille chart of portfolio value percentile bands (median line) +//! 4. Terminal portfolio value table (p10/p50/p90) +//! 5. Safe withdrawal table at multiple confidence levels +//! +//! Consumes `src/analytics/projections.zig` (simulation engine), +//! `src/analytics/benchmark.zig` (weighted returns), and +//! `src/views/projections.zig` (view model). + +const std = @import("std"); +const vaxis = @import("vaxis"); +const zfin = @import("../root.zig"); +const fmt = @import("../format.zig"); +const theme = @import("theme.zig"); +const tui = @import("../tui.zig"); +const projections = @import("../analytics/projections.zig"); +const benchmark = @import("../analytics/benchmark.zig"); +const performance = @import("../analytics/performance.zig"); +const valuation = @import("../analytics/valuation.zig"); +const view = @import("../views/projections.zig"); +const App = tui.App; +const StyledLine = tui.StyledLine; + +// ── Data loading ────────────────────────────────────────────── + +pub fn loadData(app: *App) void { + app.projections_loaded = true; + freeLoaded(app); + + const portfolio_path = app.portfolio_path orelse { + app.setStatus("Projections tab requires a loaded portfolio"); + return; + }; + + const summary = app.portfolio_summary orelse { + app.setStatus("No portfolio summary — visit Portfolio tab first"); + return; + }; + + const portfolio = app.portfolio orelse return; + const dir_end = if (std.mem.lastIndexOfScalar(u8, portfolio_path, std.fs.path.sep)) |idx| idx + 1 else 0; + + const ctx = view.loadProjectionContext( + app.allocator, + portfolio_path[0..dir_end], + summary.allocations, + summary.total_value, + portfolio.totalCash(), + portfolio.totalCdFaceValue(), + app.svc, + ) catch { + app.setStatus("Failed to compute projections"); + return; + }; + + app.projections_ctx = ctx; +} + +pub fn freeLoaded(app: *App) void { + if (app.projections_ctx) |ctx| { + app.allocator.free(ctx.data.withdrawals); + for (ctx.data.bands) |b| { + if (b) |slice| app.allocator.free(slice); + } + app.allocator.free(ctx.data.bands); + } + app.projections_ctx = null; +} + +// ── Rendering ───────────────────────────────────────────────── + +pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine { + const th = app.theme; + var lines: std.ArrayListUnmanaged(StyledLine) = .empty; + + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + + const ctx = app.projections_ctx orelse { + try lines.append(arena, .{ .text = " No projection data. Ensure portfolio is loaded.", .style = th.mutedStyle() }); + return lines.toOwnedSlice(arena); + }; + + const comparison = ctx.comparison; + const config = ctx.config; + const stock_pct = ctx.stock_pct; + + // Header + try lines.append(arena, .{ + .text = " Benchmark Comparison", + .style = th.headerStyle(), + }); + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + + // Column headers (accent color to match other tabs) + try lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, " {s: <30}{s: >8}{s: >9}{s: >9}{s: >10}{s: >9}", .{ + "", "1 Year", "3 Year", "5 Year", "10 Year", "Week", + }), + .style = th.headerStyle(), + }); + + // Return rows + var spy_bufs: [5][16]u8 = undefined; + var spy_label_buf: [32]u8 = undefined; + const spy_row = view.buildReturnRow( + view.fmtBenchmarkLabel(&spy_label_buf, "SPY", stock_pct * 100), + comparison.stock_returns, + &spy_bufs, + false, + ); + try appendReturnRow(&lines, arena, th, spy_row); + + var agg_bufs: [5][16]u8 = undefined; + var agg_label_buf: [32]u8 = undefined; + const agg_row = view.buildReturnRow( + view.fmtBenchmarkLabel(&agg_label_buf, "AGG", ctx.bond_pct * 100), + comparison.bond_returns, + &agg_bufs, + false, + ); + try appendReturnRow(&lines, arena, th, agg_row); + + var bench_bufs: [5][16]u8 = undefined; + const bench_row = view.buildReturnRow("Benchmark", comparison.benchmark_returns, &bench_bufs, true); + try appendReturnRow(&lines, arena, th, bench_row); + + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + + var port_bufs: [5][16]u8 = undefined; + const port_row = view.buildReturnRow("Your Portfolio", comparison.portfolio_returns, &port_bufs, true); + try appendReturnRow(&lines, arena, th, port_row); + + // Conservative estimate + { + var buf: [16]u8 = undefined; + const cell = view.fmtReturnCell(&buf, comparison.conservative_return); + try lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, " {s: <30}{s: >8}", .{ "Conservative estimate", cell.text }), + .style = th.mutedStyle(), + }); + } + + // Target allocation note + { + var note_buf: [128]u8 = undefined; + if (view.fmtAllocationNote(¬e_buf, config.target_stock_pct, stock_pct)) |note| { + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + try lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, " {s}", .{note.text}), + .style = th.styleFor(note.style), + }); + } + } + + // Braille chart: median portfolio value over the longest horizon + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + const horizons = config.getHorizons(); + if (horizons.len > 0) { + const last_idx = horizons.len - 1; + if (ctx.data.bands[last_idx]) |bands| { + if (bands.len >= 2) { + try lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, " Median Portfolio Value ({d}-Year, 99% withdrawal)", .{horizons[last_idx]}), + .style = th.headerStyle(), + }); + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + + // Synthesize candles from median values + const candles = try arena.alloc(zfin.Candle, bands.len); + for (bands, 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, + }; + } + + // Compute braille chart with wider dimensions + const chart_width: usize = 80; + const chart_height: usize = 12; + var br = fmt.computeBrailleChart(arena, candles, chart_width, chart_height, th.positive, th.negative) catch null; + + if (br) |*chart| { + const bg = th.bg; + const muted_fg = theme.Theme.vcolor(th.text_muted); + const bg_v = theme.Theme.vcolor(bg); + + for (0..chart.chart_height) |row| { + const graphemes = try arena.alloc([]const u8, chart.n_cols + 20); + const styles = try arena.alloc(vaxis.Style, chart.n_cols + 20); + var gpos: usize = 0; + + // 2 leading spaces + graphemes[gpos] = " "; + styles[gpos] = .{ .fg = muted_fg, .bg = bg_v }; + gpos += 1; + graphemes[gpos] = " "; + styles[gpos] = styles[0]; + gpos += 1; + + // Chart columns + for (0..chart.n_cols) |col| { + const pat = chart.pattern(row, col); + graphemes[gpos] = fmt.brailleGlyph(pat); + if (pat != 0) { + styles[gpos] = .{ .fg = theme.Theme.vcolor(chart.col_colors[col]), .bg = bg_v }; + } else { + styles[gpos] = .{ .fg = bg_v, .bg = bg_v }; + } + gpos += 1; + } + + // Right-side price labels + if (row == 0 or row == chart.chart_height - 1) { + const lbl = if (row == 0) chart.maxLabel() else chart.minLabel(); + const lbl_full = try std.fmt.allocPrint(arena, " {s}", .{lbl}); + for (lbl_full) |ch| { + if (gpos < graphemes.len) { + graphemes[gpos] = tui.glyph(ch); + styles[gpos] = .{ .fg = muted_fg, .bg = bg_v }; + gpos += 1; + } + } + } + + try lines.append(arena, .{ + .text = "", + .style = .{ .fg = theme.Theme.vcolor(th.text), .bg = bg_v }, + .graphemes = graphemes[0..gpos], + .cell_styles = styles[0..gpos], + }); + } + + // Year axis: "Now" on left, "{horizon}yr" on right + { + const axis_graphemes = try arena.alloc([]const u8, chart.n_cols + 20); + const axis_styles = try arena.alloc(vaxis.Style, chart.n_cols + 20); + const muted_style = vaxis.Style{ .fg = muted_fg, .bg = bg_v }; + var apos: usize = 0; + + // " Now" + for (" Now") |ch| { + axis_graphemes[apos] = tui.glyph(ch); + axis_styles[apos] = muted_style; + apos += 1; + } + + // Padding to right-align the end label + const end_label = try std.fmt.allocPrint(arena, "{d}yr", .{horizons[last_idx]}); + const pad = if (chart.n_cols + 2 > 3 + end_label.len) chart.n_cols + 2 - 3 - end_label.len else 0; + for (0..pad) |_| { + axis_graphemes[apos] = " "; + axis_styles[apos] = muted_style; + apos += 1; + } + + for (end_label) |ch| { + if (apos < axis_graphemes.len) { + axis_graphemes[apos] = tui.glyph(ch); + axis_styles[apos] = muted_style; + apos += 1; + } + } + + try lines.append(arena, .{ + .text = "", + .style = muted_style, + .graphemes = axis_graphemes[0..apos], + .cell_styles = axis_styles[0..apos], + }); + } + } + } + } + } + + // Portfolio value at end of horizon (nominal, using 99% withdrawal) + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + try lines.append(arena, .{ + .text = " Terminal Portfolio Value (nominal, at 99% withdrawal rate)", + .style = th.headerStyle(), + }); + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + + // Column header + try lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, " {s}", .{try view.buildHeaderRow(arena, horizons, view.terminal_col_width)}), + .style = th.headerStyle(), + }); + + // Percentile rows + { + const all_bands = ctx.data.bands; + 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(arena, plabel, pi, all_bands, pstyle); + try lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, " {s}", .{row.text}), + .style = th.styleFor(row.style), + }); + } + } + + // Safe withdrawal table + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + try lines.append(arena, .{ + .text = " Safe Withdrawal (FIRECalc historical simulation)", + .style = th.headerStyle(), + }); + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + + try lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, " {s}", .{try view.buildHeaderRow(arena, horizons, view.withdrawal_col_width)}), + .style = th.headerStyle(), + }); + + const cached_wr = ctx.data.withdrawals; + const confidence_levels = config.getConfidenceLevels(); + for (confidence_levels, 0..) |conf, ci| { + const rows = try view.buildWithdrawalRows(arena, conf, horizons, cached_wr, ci); + try lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, " {s}", .{rows.amount.text}), + .style = th.contentStyle(), + }); + try lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, " {s}", .{rows.rate.text}), + .style = th.mutedStyle(), + }); + } + + return lines.toOwnedSlice(arena); +} + +// ── Helpers ─────────────────────────────────────────────────── + +fn appendReturnRow( + lines: *std.ArrayListUnmanaged(StyledLine), + arena: std.mem.Allocator, + th: theme.Theme, + row: view.ReturnRow, +) !void { + // SPY/AGG (not bold) in muted; Benchmark/Portfolio (bold) in content style. + const style = if (row.bold) th.contentStyle() else th.mutedStyle(); + try lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, " {s: <30}{s: >8}{s: >9}{s: >9}{s: >10}{s: >9}", .{ + row.label, + row.one_year.text, + row.three_year.text, + row.five_year.text, + row.ten_year.text, + row.week.text, + }), + .style = style, + }); +} diff --git a/src/views/projections.zig b/src/views/projections.zig index 92bcf5c..ddeb95e 100644 --- a/src/views/projections.zig +++ b/src/views/projections.zig @@ -7,6 +7,8 @@ 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; @@ -20,6 +22,7 @@ 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 ────────────────────────────────────── @@ -52,13 +55,7 @@ pub const ReturnRow = struct { }; /// Build a return row from a ReturnsByPeriod and a label. -/// Caller owns the buffers (5 buffers of at least 16 bytes each). -pub fn buildReturnRow( - label: []const u8, - returns: benchmark.ReturnsByPeriod, - bufs: *[5][16]u8, - bold: bool, -) ReturnRow { +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), @@ -80,6 +77,7 @@ pub const WithdrawalCell = struct { /// 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 @@ -87,13 +85,8 @@ pub fn fmtWithdrawalCell(amount_buf: []u8, rate_buf: []u8, result: projections.W 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, - }; + return .{ .amount_text = clean_amount, .rate_text = rate_str }; } /// Format a confidence level label (e.g. "99% safe withdrawal"). @@ -125,14 +118,7 @@ pub fn fmtAllocationNote(buf: []u8, target_stock_pct: ?f64, current_stock_pct: f 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 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, @@ -141,15 +127,270 @@ pub fn fmtAllocationNote(buf: []u8, target_stock_pct: ?f64, current_stock_pct: f 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 (e.g. "SPY (83.8% weight)"). +/// 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" { @@ -233,3 +474,129 @@ test "buildReturnRow" { 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); +}