zfin/src/tui/projections_tab.zig

1124 lines
46 KiB
Zig

//! 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).
//!
//! ## As-of mode
//!
//! When `app.projections_as_of` is non-null, the tab renders against a
//! historical snapshot instead of the live portfolio, using
//! `view.loadProjectionContextAsOf`. The user toggles this via the `d`
//! keybind (date popup) or `D` (return to live). Auto-snaps to the
//! nearest earlier snapshot when the exact date isn't available.
const std = @import("std");
const vaxis = @import("vaxis");
const zfin = @import("../root.zig");
const fmt = @import("../format.zig");
const Money = @import("../Money.zig");
const theme = @import("theme.zig");
const tui = @import("../tui.zig");
const chart = @import("chart.zig");
const projection_chart = @import("projection_chart.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 history = @import("../history.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 dir_end = if (std.mem.lastIndexOfScalar(u8, portfolio_path, std.fs.path.sep)) |idx| idx + 1 else 0;
const portfolio_dir = portfolio_path[0..dir_end];
// As-of mode — load historical snapshot + ctx. This path is
// independent of `app.portfolio_summary` / `app.portfolio` because
// the snapshot's own totals and lot composition are the source of
// truth for the projection.
//
// On any failure (no snapshot at/before requested date, unreadable
// file, compute error) we clear the as-of state, leave a status
// message explaining why, and fall through to the live path so
// the tab still shows something rather than going blank.
as_of: {
const requested_date = app.projections_as_of orelse break :as_of;
const actual_date = resolveSnapshotDate(app, portfolio_path, requested_date) orelse {
// `setStatus` already called by resolveSnapshotDate.
app.projections_as_of = null;
app.projections_as_of_requested = null;
break :as_of;
};
app.projections_as_of = actual_date;
// Preserve requested for the header note; clear if it matches actual.
if (actual_date.eql(requested_date)) {
app.projections_as_of_requested = null;
}
const hist_dir = history.deriveHistoryDir(app.allocator, portfolio_path) catch {
app.setStatus("Failed to derive history dir — showing live");
app.projections_as_of = null;
app.projections_as_of_requested = null;
break :as_of;
};
defer app.allocator.free(hist_dir);
var loaded = history.loadSnapshotAt(app.io, app.allocator, hist_dir, actual_date) catch {
app.setStatus("Failed to load snapshot — showing live");
app.projections_as_of = null;
app.projections_as_of_requested = null;
break :as_of;
};
defer loaded.deinit(app.allocator);
const ctx = view.loadProjectionContextAsOf(
app.io,
app.allocator,
portfolio_dir,
&loaded.snap,
actual_date,
app.svc,
app.projections_events_enabled,
) catch {
app.setStatus("Failed to compute as-of projections — showing live");
app.projections_as_of = null;
app.projections_as_of_requested = null;
break :as_of;
};
app.projections_ctx = ctx;
return;
}
// Live path. Reached either because no as-of was requested OR the
// as-of branch above bailed and fell through after clearing state.
const summary = app.portfolio_summary orelse {
app.setStatus("No portfolio summary — visit Portfolio tab first");
return;
};
const portfolio = app.portfolio orelse return;
const ctx = view.loadProjectionContext(
app.io,
app.allocator,
portfolio_dir,
summary.allocations,
summary.total_value,
portfolio.totalCash(app.today),
portfolio.totalCdFaceValue(app.today),
app.svc,
app.projections_events_enabled,
app.today,
) catch {
app.setStatus("Failed to compute projections");
return;
};
app.projections_ctx = ctx;
}
/// Resolve the user's requested as-of date against the portfolio's
/// history directory. Returns the actual date to load (exact match or
/// nearest-earlier snapshot), or null with a status-bar message if
/// no usable snapshot exists.
///
/// Thin adapter over `history.resolveSnapshotDate` — the shared pure
/// resolver owns the exact/snap logic; this wrapper maps its errors
/// to user-visible status-bar messages and handles the arena.
fn resolveSnapshotDate(app: *App, portfolio_path: []const u8, requested: zfin.Date) ?zfin.Date {
var arena_state = std.heap.ArenaAllocator.init(app.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const hist_dir = history.deriveHistoryDir(arena, portfolio_path) catch {
app.setStatus("Failed to derive history dir");
return null;
};
const resolved = history.resolveSnapshotDate(app.io, arena, hist_dir, requested) catch |err| switch (err) {
error.NoSnapshotAtOrBefore => {
var date_buf: [10]u8 = undefined;
var status_buf: [128]u8 = undefined;
const date_str = requested.format(&date_buf);
const msg = std.fmt.bufPrint(&status_buf, "No snapshot at or before {s}", .{date_str}) catch "No snapshot at or before requested date";
app.setStatus(msg);
return null;
},
error.OutOfMemory => {
app.setStatus("Out of memory resolving snapshot");
return null;
},
else => {
app.setStatus("Error accessing snapshot");
return null;
},
};
if (!resolved.exact) {
// Remember the original request for the muted header note.
app.projections_as_of_requested = requested;
}
return resolved.actual;
}
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);
if (ctx.earliest) |er| app.allocator.free(er);
}
app.projections_ctx = null;
// Mark projection chart as dirty so it re-renders on next draw
app.projections_chart_dirty = true;
}
// ── Rendering ─────────────────────────────────────────────────
pub fn drawContent(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, width: u16, height: u16) !void {
const arena = ctx.arena;
// Determine whether to use Kitty graphics
const use_kitty = switch (app.chart.config.mode) {
.braille => false,
.kitty => true,
.auto => if (app.vx_app) |va| va.vx.caps.kitty_graphics else false,
};
// Need bands data for the chart
const has_bands = if (app.projections_ctx) |pctx| blk: {
const horizons = pctx.config.getHorizons();
if (horizons.len == 0) break :blk false;
const last_idx = horizons.len - 1;
if (pctx.data.bands[last_idx]) |bands| {
break :blk bands.len >= 2;
}
break :blk false;
} else false;
if (use_kitty and has_bands and app.projections_chart_visible) {
drawWithKittyChart(app, ctx, buf, width, height) catch {
try drawWithScroll(app, arena, buf, width, height);
};
} else {
try drawWithScroll(app, arena, buf, width, height);
}
}
/// Render styled lines with scroll_offset applied.
fn drawWithScroll(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
const all_lines = try buildStyledLines(app, arena);
const start = @min(app.scroll_offset, if (all_lines.len > 0) all_lines.len - 1 else 0);
try app.drawStyledContent(arena, buf, width, height, all_lines[start..]);
}
/// Draw projections tab using Kitty graphics protocol for the percentile band chart.
fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, width: u16, height: u16) !void {
const arena = ctx.arena;
const th = app.theme;
const pctx = app.projections_ctx orelse return;
const config = pctx.config;
const horizons = config.getHorizons();
const last_idx = horizons.len - 1;
const bands = pctx.data.bands[last_idx] orelse return;
if (bands.len < 2) return;
// Build text header (benchmark comparison + allocation note)
var header_lines: std.ArrayListUnmanaged(StyledLine) = .empty;
try buildHeaderSection(app, arena, &header_lines, pctx);
// Chart title
try header_lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try header_lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " Portfolio Projection ({d}-Year, percentile bands at 99% withdrawal)", .{horizons[last_idx]}),
.style = th.headerStyle(),
});
try header_lines.append(arena, .{ .text = "", .style = th.contentStyle() });
// Pre-build footer to compute its line count for adaptive chart sizing
var footer_lines: std.ArrayListUnmanaged(StyledLine) = .empty;
try buildFooterSection(app, arena, &footer_lines, pctx);
const footer_line_count: u16 = @intCast(@min(footer_lines.items.len, height));
// Draw header into buffer
const header_slice = try header_lines.toOwnedSlice(arena);
try app.drawStyledContent(arena, buf, width, height, header_slice);
// Calculate chart area — adaptive: leave room for footer + 1 row for year axis
const header_rows: u16 = @intCast(@min(header_slice.len, height));
const footer_reserve = footer_line_count + 1; // +1 for year axis row
const chart_rows = height -| header_rows -| footer_reserve;
if (chart_rows < 6) {
// Not enough space for chart — fall back to text-only with scroll
try drawWithScroll(app, arena, buf, width, height);
return;
}
// Compute pixel dimensions
const cell_w: u32 = if (ctx.cell_size.width > 0) ctx.cell_size.width else 8;
const cell_h: u32 = if (ctx.cell_size.height > 0) ctx.cell_size.height else 16;
const label_cols: u16 = 12; // columns for axis labels on the right
const chart_cols = width -| 2 -| label_cols;
if (chart_cols == 0) return;
const px_w: u32 = @as(u32, chart_cols) * cell_w;
const px_h: u32 = @as(u32, chart_rows) * cell_h;
if (px_w < 100 or px_h < 100) return;
const capped_w = @min(px_w, app.chart.config.max_width);
const capped_h = @min(px_h, app.chart.config.max_height);
// Render or reuse cached image
if (app.projections_chart_dirty) {
// Free old image
if (app.projections_image_id) |old_id| {
if (app.vx_app) |va| {
va.vx.freeImage(va.tty.writer(), old_id);
}
app.projections_image_id = null;
}
if (app.vx_app) |va| {
const chart_result = projection_chart.renderProjectionChart(
app.io,
app.allocator,
bands,
capped_w,
capped_h,
th,
) catch {
app.projections_chart_dirty = false;
return;
};
defer app.allocator.free(chart_result.rgb_data);
// Base64-encode and transmit
const base64_enc = std.base64.standard.Encoder;
const b64_buf = app.allocator.alloc(u8, base64_enc.calcSize(chart_result.rgb_data.len)) catch {
app.projections_chart_dirty = false;
return;
};
defer app.allocator.free(b64_buf);
const encoded = base64_enc.encode(b64_buf, chart_result.rgb_data);
const img = va.vx.transmitPreEncodedImage(
va.tty.writer(),
encoded,
chart_result.width,
chart_result.height,
.rgb,
) catch {
app.projections_chart_dirty = false;
return;
};
app.projections_image_id = img.id;
app.projections_image_width = @intCast(chart_cols);
app.projections_image_height = chart_rows;
app.projections_value_min = chart_result.value_min;
app.projections_value_max = chart_result.value_max;
app.projections_chart_dirty = false;
}
}
// Place the image in the cell buffer
if (app.projections_image_id) |img_id| {
const chart_row_start: usize = header_rows;
const chart_col_start: usize = 1;
const buf_idx = chart_row_start * @as(usize, width) + chart_col_start;
if (buf_idx < buf.len) {
buf[buf_idx] = .{
.char = .{ .grapheme = " " },
.style = th.contentStyle(),
.image = .{
.img_id = img_id,
.options = .{
.size = .{
.rows = app.projections_image_height,
.cols = app.projections_image_width,
},
.scale = .contain,
},
},
};
}
// Axis labels (dollar values on the right side)
const img_rows = app.projections_image_height;
const label_col: usize = @as(usize, chart_col_start) + @as(usize, app.projections_image_width) + 1;
const label_style = th.mutedStyle();
if (label_col + 10 <= width and img_rows >= 4 and app.projections_value_max > app.projections_value_min) {
// Label band boundaries at the right edge, in priority order:
// p10 and p90 (extremes, always kept), then p50 (median), then p25/p75 (lowest priority).
const last_band = bands[bands.len - 1];
const label_values = [_]f64{ last_band.p10, last_band.p90, last_band.p50, last_band.p25, last_band.p75 };
const val_range = app.projections_value_max - app.projections_value_min;
const rows_f = @as(f64, @floatFromInt(img_rows -| 1));
var placed_rows: [5]usize = undefined;
var placed_count: usize = 0;
for (label_values) |val| {
const norm = (val - app.projections_value_min) / val_range;
const row_f = @as(f64, @floatFromInt(chart_row_start)) + (1.0 - norm) * rows_f;
const row: usize = @intFromFloat(@round(row_f));
if (row >= height) continue;
// Skip if this label would overlap any already-placed label
var overlaps = false;
for (placed_rows[0..placed_count]) |prev_row| {
const diff = if (row >= prev_row) row - prev_row else prev_row - row;
if (diff <= 1) {
overlaps = true;
break;
}
}
if (overlaps) continue;
// Format as whole dollars (no decimals)
var lbl_buf: [16]u8 = undefined;
const lbl = std.fmt.bufPrint(&lbl_buf, "{f}", .{Money.from(val).whole()}) catch "$?";
const start_idx = row * @as(usize, width) + label_col;
for (lbl, 0..) |ch, ci| {
const idx = start_idx + ci;
if (idx < buf.len and label_col + ci < width) {
buf[idx] = .{
.char = .{ .grapheme = tui.glyph(ch) },
.style = label_style,
};
}
}
placed_rows[placed_count] = row;
placed_count += 1;
}
// Year axis: "Now" on left edge, "{horizon}yr" on right edge of chart
const axis_row: usize = chart_row_start + @as(usize, img_rows);
if (axis_row < height) {
const axis_base = axis_row * @as(usize, width);
// "Now" at left
const now_label = "Now";
for (now_label, 0..) |ch, ci| {
const idx = axis_base + chart_col_start + ci;
if (idx < buf.len) {
buf[idx] = .{
.char = .{ .grapheme = tui.glyph(ch) },
.style = label_style,
};
}
}
// "{horizon}yr" at right edge of chart area
var yr_buf: [8]u8 = undefined;
const yr_label = std.fmt.bufPrint(&yr_buf, "{d}yr", .{horizons[last_idx]}) catch "??yr";
const yr_start = chart_col_start + @as(usize, chart_cols) -| yr_label.len;
for (yr_label, 0..) |ch, ci| {
const idx = axis_base + yr_start + ci;
if (idx < buf.len) {
buf[idx] = .{
.char = .{ .grapheme = tui.glyph(ch) },
.style = label_style,
};
}
}
}
}
// Render footer (terminal values + withdrawal table) below the chart
const footer_start_row = header_rows + app.projections_image_height + 1; // +1 for axis row
if (footer_start_row + 4 < height) {
const footer_slice = try footer_lines.toOwnedSlice(arena);
const footer_buf_start = footer_start_row * @as(usize, width);
const remaining_height = height - @as(u16, @intCast(footer_start_row));
if (footer_buf_start < buf.len) {
try app.drawStyledContent(arena, buf[footer_buf_start..], width, remaining_height, footer_slice);
}
}
}
}
/// Build the header section (benchmark comparison table + allocation note).
fn buildHeaderSection(app: *App, arena: std.mem.Allocator, lines: *std.ArrayListUnmanaged(StyledLine), pctx: view.ProjectionContext) !void {
const th = app.theme;
const comparison = pctx.comparison;
const config = pctx.config;
const stock_pct = pctx.stock_pct;
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = " Benchmark Comparison", .style = th.headerStyle() });
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
// Column headers
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", pctx.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);
// Projected return (conservative estimate from benchmark analytics)
{
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}", .{ "Projected return", cell.text }),
.style = th.mutedStyle(),
});
}
// Target allocation note
{
var note_buf: [128]u8 = undefined;
if (view.fmtAllocationNote(&note_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),
});
}
}
// Accumulation phase / Earliest retirement blocks. Use the
// historical snapshot date when one is configured so the
// promoted date and earliest-retirement grid anchor on the
// same reference point as the rest of the as-of-mode display.
const ref_date = app.projections_as_of orelse app.today;
try appendAccumulationBlocks(lines, arena, th, pctx, ref_date);
}
/// Build the footer section (terminal values + safe withdrawal table).
fn buildFooterSection(app: *App, arena: std.mem.Allocator, lines: *std.ArrayListUnmanaged(StyledLine), pctx: view.ProjectionContext) !void {
const th = app.theme;
const config = pctx.config;
const horizons = config.getHorizons();
// Terminal portfolio value
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() });
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " {s}", .{try view.buildHeaderRow(arena, horizons, view.terminal_col_width)}),
.style = th.headerStyle(),
});
{
const all_bands = pctx.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 = pctx.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(),
});
}
// Life events summary
try appendEventSummary(lines, app.today, arena, th, pctx);
}
fn appendEventSummary(lines: *std.ArrayListUnmanaged(StyledLine), as_of: zfin.Date, arena: std.mem.Allocator, th: theme.Theme, pctx: view.ProjectionContext) !void {
const events = pctx.config.getEvents();
if (events.len == 0) return;
const ages = pctx.config.currentAges(as_of);
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = " Life Events", .style = th.headerStyle() });
for (events) |*ev| {
const line = try view.fmtEventLine(arena, ev, &ages);
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " {s}", .{line.text}),
.style = th.styleFor(line.style),
});
}
}
/// Append the "Accumulation phase" + "Earliest retirement" blocks
/// (driven by the user's target retirement date and target spending
/// inputs) to a styled-lines list. Always emits the retirement
/// line; the contribution row is suppressed when both contribution
/// and accumulation are zero. Earliest-retirement grid only renders
/// when `target_spending` is configured.
fn appendAccumulationBlocks(
lines: *std.ArrayListUnmanaged(StyledLine),
arena: std.mem.Allocator,
th: theme.Theme,
pctx: view.ProjectionContext,
as_of: zfin.Date,
) !void {
// Accumulation phase block.
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = " Accumulation phase:", .style = th.headerStyle() });
var line_buf: [128]u8 = undefined;
const parts = view.splitRetirementLine(&line_buf, pctx.retirement, &pctx.config);
if (parts.value_style == .negative) {
// Per-cell styled line: 4-space indent + neutral label +
// red value. Other forms (none/at_date/at_age/promoted) all
// render with the value style matching the label, so the
// single-style fast path below is fine for them.
const indent = " ";
const total_len = indent.len + parts.label_text.len + parts.value_text.len;
const graphemes = try arena.alloc([]const u8, total_len);
const cell_styles = try arena.alloc(vaxis.Style, total_len);
const neutral = th.contentStyle();
const negative = th.styleFor(.negative);
var gp: usize = 0;
for (indent) |ch| {
graphemes[gp] = tui.glyph(ch);
cell_styles[gp] = neutral;
gp += 1;
}
for (parts.label_text) |ch| {
graphemes[gp] = tui.glyph(ch);
cell_styles[gp] = neutral;
gp += 1;
}
for (parts.value_text) |ch| {
graphemes[gp] = tui.glyph(ch);
cell_styles[gp] = negative;
gp += 1;
}
try lines.append(arena, .{
.text = "",
.style = neutral,
.graphemes = graphemes[0..gp],
.cell_styles = cell_styles[0..gp],
});
} else {
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " {s}{s}", .{ parts.label_text, parts.value_text }),
.style = th.contentStyle(),
});
}
if (try view.fmtContributionLine(arena, pctx.config.annual_contribution, pctx.config.contribution_inflation_adjusted, pctx.retirement.accumulation_years)) |contrib| {
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " {s}", .{contrib}),
.style = th.contentStyle(),
});
}
if (pctx.accumulation) |acc| {
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " Median portfolio at retirement: {f}", .{Money.from(acc.median_at_retirement).trim()}),
.style = th.contentStyle(),
});
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " Range (10th\u{2013}90th percentile): {f} to {f}", .{
Money.from(acc.p10_at_retirement).trim(),
Money.from(acc.p90_at_retirement).trim(),
}),
.style = th.mutedStyle(),
});
}
// Earliest retirement block (target-spending input).
if (pctx.earliest) |earliest| {
const target = pctx.config.target_spending orelse return;
const adj: []const u8 = if (pctx.config.target_spending_inflation_adjusted) "CPI-adjusted" else "nominal";
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " Earliest retirement (target spending: {f}/yr {s})", .{ Money.from(target).trim(), adj }),
.style = th.headerStyle(),
});
const horizons = pctx.config.getHorizons();
const confs = pctx.config.getConfidenceLevels();
const cell_width: usize = 14;
const label_width: usize = 25;
// Header row.
{
var hdr: std.ArrayListUnmanaged(u8) = .empty;
try hdr.appendNTimes(arena, ' ', label_width);
for (horizons) |h| {
var hbuf: [16]u8 = undefined;
const hlabel = view.fmtHorizonLabel(&hbuf, h);
try hdr.appendNTimes(arena, ' ', cell_width -| hlabel.len);
try hdr.appendSlice(arena, hlabel);
}
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " {s}", .{hdr.items}),
.style = th.mutedStyle(),
});
}
for (confs, 0..) |conf, ci| {
const row = try view.buildEarliestRow(arena, conf, horizons, earliest, ci, as_of);
// Per-cell styled row so individual "infeasible" cells
// can render in `.negative` (red) while feasible date
// cells render in the default content color. A single
// `style` on the StyledLine would force every cell to
// the same color and bury the bad-news cells.
//
// Layout: " " + label + label-pad + (cell-pad + cell-text)*
const indent = " ";
var total: usize = indent.len + row.label_text.len;
const label_pad = if (label_width > row.label_text.len) label_width - row.label_text.len else 0;
total += label_pad;
for (row.cells) |cell| {
const cellpad = if (cell_width > cell.text.len) cell_width - cell.text.len else 0;
total += cellpad + cell.text.len;
}
const graphemes = try arena.alloc([]const u8, total);
const cell_styles = try arena.alloc(vaxis.Style, total);
const neutral = th.contentStyle();
var gp: usize = 0;
for (indent) |ch| {
graphemes[gp] = tui.glyph(ch);
cell_styles[gp] = neutral;
gp += 1;
}
for (row.label_text) |ch| {
graphemes[gp] = tui.glyph(ch);
cell_styles[gp] = neutral;
gp += 1;
}
var pad_i: usize = 0;
while (pad_i < label_pad) : (pad_i += 1) {
graphemes[gp] = " ";
cell_styles[gp] = neutral;
gp += 1;
}
for (row.cells) |cell| {
const cellpad = if (cell_width > cell.text.len) cell_width - cell.text.len else 0;
var cp: usize = 0;
while (cp < cellpad) : (cp += 1) {
graphemes[gp] = " ";
cell_styles[gp] = neutral;
gp += 1;
}
const cell_style = th.styleFor(cell.style);
for (cell.text) |ch| {
graphemes[gp] = tui.glyph(ch);
cell_styles[gp] = cell_style;
gp += 1;
}
}
try lines.append(arena, .{
.text = "",
.style = neutral,
.graphemes = graphemes[0..gp],
.cell_styles = cell_styles[0..gp],
});
}
}
}
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;
// As-of indicator — only shown when the tab is displaying a
// historical snapshot. Muted header note so it doesn't compete
// with the main content. If the user asked for a date that had no
// exact snapshot, a second muted line explains the auto-snap.
if (app.projections_as_of) |actual| {
var actual_buf: [10]u8 = undefined;
const actual_str = actual.format(&actual_buf);
const header = try std.fmt.allocPrint(arena, " As-of: {s} (snapshot)", .{actual_str});
try lines.append(arena, .{ .text = header, .style = th.mutedStyle() });
if (app.projections_as_of_requested) |requested| {
if (!requested.eql(actual)) {
var req_buf: [10]u8 = undefined;
const req_str = requested.format(&req_buf);
const diff = requested.days - actual.days;
const note = try std.fmt.allocPrint(
arena,
" (requested {s}; snapped back {d} day{s})",
.{ req_str, diff, fmt.dayPlural(diff) },
);
try lines.append(arena, .{ .text = note, .style = th.mutedStyle() });
}
}
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
}
// 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);
// Projected return (conservative estimate from benchmark analytics)
{
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}", .{ "Projected return", cell.text }),
.style = th.mutedStyle(),
});
}
// Target allocation note
{
var note_buf: [128]u8 = undefined;
if (view.fmtAllocationNote(&note_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),
});
}
}
// Accumulation phase / Earliest retirement blocks. Use the
// historical snapshot date when one is configured so the
// promoted date and earliest-retirement grid anchor on the
// same reference point as the rest of the as-of-mode display.
const ref_date = app.projections_as_of orelse app.today;
try appendAccumulationBlocks(&lines, arena, th, ctx, ref_date);
// 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) |*br_chart| {
const bg = th.bg;
const muted_fg = theme.Theme.vcolor(th.text_muted);
const bg_v = theme.Theme.vcolor(bg);
for (0..br_chart.chart_height) |row| {
const graphemes = try arena.alloc([]const u8, br_chart.n_cols + 20);
const styles = try arena.alloc(vaxis.Style, br_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..br_chart.n_cols) |col| {
const pat = br_chart.pattern(row, col);
graphemes[gpos] = fmt.brailleGlyph(pat);
if (pat != 0) {
styles[gpos] = .{ .fg = theme.Theme.vcolor(br_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 == br_chart.chart_height - 1) {
const lbl = if (row == 0) br_chart.maxLabel() else br_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, br_chart.n_cols + 20);
const axis_styles = try arena.alloc(vaxis.Style, br_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 n_pad = if (br_chart.n_cols + 2 > 3 + end_label.len) br_chart.n_cols + 2 - 3 - end_label.len else 0;
for (0..n_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(),
});
}
// Life events summary (at the bottom)
try appendEventSummary(&lines, app.today, arena, th, ctx);
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,
});
}