//! 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 `state.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 projection_chart = @import("../charts/projection_chart.zig"); const forecast_chart = @import("../charts/forecast_chart.zig"); const braille = @import("../charts/braille.zig"); const forecast = @import("../analytics/forecast_evaluation.zig"); const imported = @import("../data/imported_values.zig"); const milestones = @import("../analytics/milestones.zig"); const shiller = @import("../data/shiller.zig"); const view = @import("../views/projections.zig"); const history = @import("../history.zig"); const cli = @import("../commands/common.zig"); const framework = @import("tab_framework.zig"); const input_buffer = @import("input_buffer.zig"); const App = tui.App; const StyledLine = tui.StyledLine; // ── Tab-local action enum ───────────────────────────────────── // // Projections tab keybinds: // - `o` : toggle the actuals overlay on the projection chart // - `v` : show/hide the percentile-band chart // - `e` : enable/disable simulated lifecycle events // - `d` : open the as-of date input popup // - Esc : clear the active as-of date (back to live view) pub const Action = enum { /// Toggle the as-of-anchored actuals overlay on the projection /// chart. No-op (with status hint) when not in as-of mode. overlay_actuals, /// Show / hide the percentile-band chart. Toggles /// `state.chart_visible`; doesn't reload data. toggle_chart, /// Enable / disable simulated lifecycle events (RMDs, lump-sum /// withdrawals). Forces a reload because the simulation engine /// re-runs with the new flag. toggle_events, /// Open the as-of date input mini-popup. Mode transition is /// handled by the App; the popup commit / clear path lives in /// tui.zig and calls `loadData` on this tab. as_of_input, /// Clear the active as-of date and return to the live view. /// No-op when no as-of date is set. Bound to Esc. clear_as_of, /// Toggle the convergence sub-view: spreadsheet-projected /// retirement date over time. Reads `imported_values.srf`. /// When active, replaces the main bands chart. toggle_convergence, /// Toggle the return-backtest sub-view: spreadsheet expected /// return vs realized 1y/3y/5y forward CAGR. Reads /// `imported_values.srf`. When active, replaces the main bands /// chart. toggle_return_backtest, /// Toggle auto-zoom on the actuals overlay. When the overlay is /// active and zoom is on (default), the chart's x-axis is /// clamped to roughly `[as_of, today + N years]` where N is the /// actuals span - without that clamp, a 10-year actuals line is /// squashed into the first 20% of a 50-year horizon. Pressing /// `z` flips back to the full horizon. No-op (with status hint) /// when the overlay is off. toggle_overlay_zoom, }; // ── Tab-private state ───────────────────────────────────────── pub const State = struct { /// Whether `activate` has populated `ctx` (or set up /// disabled state). Distinct from `ctx != null` because failed /// loads still mark loaded. loaded: bool = false, /// User-tunable inputs to the projection engine (annual /// contribution, target spending, retirement target percentile, /// etc.). Driven by annotations on the portfolio file. config: @import("../analytics/projections.zig").UserConfig = .{}, /// Loaded projection context: bands, withdrawal tables, /// horizon configs, optional overlay actuals. Owned by State; /// freed via `freeLoaded`. ctx: ?@import("../views/projections.zig").ProjectionContext = null, /// Currently-focused horizon row in the terminal-value table. /// (Reserved for future expansion; not consumed today.) horizon_idx: usize = 0, /// Kitty graphics image id for the percentile-band chart, when /// using the Kitty path. Null when no image is currently /// transmitted to the terminal. image_id: ?u32 = null, /// Pixel dimensions of the most-recently-transmitted image /// (used to detect resize and re-render). image_width: u16 = 0, image_height: u16 = 0, /// True when the chart needs re-rendering on the next draw. /// Set by `freeLoaded`, by toggle_chart, by overlay_actuals /// changes, etc. chart_dirty: bool = true, /// Whether the percentile-band chart is shown. Toggled by /// `toggle_chart`. When false, the tab renders text-only with /// scroll (the full report). chart_visible: bool = true, /// Whether simulated lifecycle events (RMDs, lump-sum /// withdrawals) are included in the projection. Toggled by /// `toggle_events`; flipping forces a reload. events_enabled: bool = true, /// Y-axis bounds last used for the chart - informational, not /// load-bearing in dispatch. value_min: f64 = 0, value_max: f64 = 0, /// When non-null, the projections tab renders against a historical /// snapshot instead of the live portfolio. Set via the `d` popup /// (parsed by `cli.parseAsOfDate`) and auto-snapped to the nearest /// earlier available snapshot. Cleared by Esc on the projections /// tab when set, or by committing an empty / "live" input. as_of: ?zfin.Date = null, /// When auto-snap kicked in, `as_of` is the resolved snapshot /// date but `as_of_requested` remembers what the user actually /// typed - surfaced in the tab header as a muted "(requested X; /// snapped to Y, N days earlier)" note. as_of_requested: ?zfin.Date = null, /// When true, the projections chart overlays the realized /// portfolio trajectory (snapshots + imported_values) on top of /// the percentile bands. Toggled by `overlay_actuals`. Only /// meaningful when `as_of` is set; the action flashes a status /// message and leaves this off otherwise. overlay_actuals: bool = false, /// When true (default), and the actuals overlay is active, the /// chart x-axis is zoomed to roughly `[as_of, today + N years]` /// where N is the actuals span. Without this, a 10-year actuals /// line gets squashed into the first 20% of a 50-year horizon /// chart and the comparison-against-bands story is hard to read. /// Toggled by `toggle_overlay_zoom` (`z`); a no-op (with status /// hint) when the overlay is off because there's nothing /// short-history to zoom into. zoom_overlay: bool = true, /// Active sub-view replacing the main bands chart. The default /// view (`.bands`) renders the standard percentile-band chart /// + projection report. `.convergence` and `.return_backtest` /// pull data from `imported_values.srf` and render /// forecast-evaluation charts via `charts/forecast_chart.zig`. /// Toggled by the `c` and `r` keybinds; toggling either /// clears the other (mutually exclusive). sub_view: SubView = .bands, /// Cached convergence-chart points, populated lazily on first /// activation of `.convergence`. Owned by State; freed via /// `freeLoaded`. convergence_points: ?[]forecast.ConvergencePoint = null, /// Cached back-test anchors (one per anchor row, with three /// horizons pivoted into a single record). Populated lazily on /// first activation of `.return_backtest`. Owned by State; /// freed via `freeLoaded`. backtest_anchors: ?[]forecast_chart.BacktestAnchor = null, /// Tab-internal modal sub-state. The framework treats the /// tab as normal; projections' own `handleKey` / /// `statusOverride` hooks branch on this and route input /// to the modal handler. App.Mode does NOT carry the /// `date_input` variant. modal: Modal = .none, }; /// Active chart sub-view on the projections tab. Mutually /// exclusive - only one view replaces the main bands chart at a /// time. pub const SubView = enum { /// Default percentile-band chart + projection report. bands, /// Forecast convergence: spreadsheet-projected retirement /// date over time. Sources `imported_values.srf`. convergence, /// Return back-test: spreadsheet expected return vs realized /// 1y/3y/5y forward CAGR. Sources `imported_values.srf`. return_backtest, }; /// Tab-internal modal sub-state. Today only one modal: the /// as-of date input prompt (`d` keybind). Add variants here /// if/when projections grows more modals. pub const Modal = enum { /// No modal active. none, /// Date-input prompt is open. Reads from App's shared /// `input_buf` / `input_len`; commits via /// `cli.parseAsOfDate`. Same scaffolding as `symbol_input`. date_input, }; // ── Tab framework contract ──────────────────────────────────── pub const meta: framework.TabMeta(Action) = .{ .label = "Projections", .default_bindings = &.{ .{ .action = .overlay_actuals, .key = .{ .codepoint = 'o' } }, .{ .action = .toggle_chart, .key = .{ .codepoint = 'v' } }, .{ .action = .toggle_events, .key = .{ .codepoint = 'e' } }, .{ .action = .as_of_input, .key = .{ .codepoint = 'd' } }, .{ .action = .clear_as_of, .key = .{ .codepoint = vaxis.Key.escape } }, .{ .action = .toggle_convergence, .key = .{ .codepoint = 'c' } }, .{ .action = .toggle_return_backtest, .key = .{ .codepoint = 'b' } }, .{ .action = .toggle_overlay_zoom, .key = .{ .codepoint = 'z' } }, }, .action_labels = std.enums.EnumArray(Action, []const u8).init(.{ .overlay_actuals = "Toggle actuals overlay", .toggle_chart = "Toggle chart visibility", .toggle_events = "Toggle lifecycle events", .as_of_input = "Set as-of date", .clear_as_of = "Clear as-of date", .toggle_convergence = "Toggle convergence sub-view", .toggle_return_backtest = "Toggle return back-test sub-view", .toggle_overlay_zoom = "Toggle overlay zoom", }), .status_hints = &.{ .toggle_chart, .toggle_events, .as_of_input, .toggle_convergence, .toggle_return_backtest, }, }; pub const tab = struct { pub const ActionT = Action; pub const StateT = State; pub fn init(state: *State, app: *App) !void { _ = app; state.* = .{}; } pub fn deinit(state: *State, app: *App) void { freeLoaded(state, app); state.* = .{}; } /// Release the transmitted Kitty projection-chart image before /// vaxis is torn down. Called across all tabs at App teardown while /// `app.vx_app` is still valid (distinct from `deinit`, which runs /// after vaxis is gone). pub fn releaseGraphics(state: *State, app: *App) void { if (state.image_id) |id| { if (app.vx_app) |va| va.vx.freeImage(va.tty.writer(), id); state.image_id = null; } } pub fn activate(state: *State, app: *App) !void { if (state.loaded) return; // Projections reads `app.portfolio.summary` and `.file`, // both populated synchronously by pd.load at App init. // No further data fetching needed here. loadData(state, app); } pub const deactivate = framework.noopDeactivate(State); pub fn reload(state: *State, app: *App) !void { state.loaded = false; freeLoaded(state, app); loadData(state, app); } pub const tick = framework.noopTick(State); /// Pre-empt key handler. When the date-input modal is open /// (`state.modal == .date_input`), every key goes through /// here - global keymap matching is bypassed so typing `r` /// during input doesn't fire the refresh action. Returns /// `false` when no modal is active so dispatch falls through /// to the normal global -> tab-local path. pub fn handleKey(state: *State, app: *App, key: vaxis.Key) bool { return switch (state.modal) { .none => false, .date_input => handleDateInputKey(state, app, key), }; } /// Status-bar override. The date-input modal renders an /// interactive prompt with the live input buffer + cursor; /// otherwise the App-level default status applies. pub fn statusOverride(state: *State, app: *App) ?framework.StatusOverride { _ = app; return switch (state.modal) { .none => null, .date_input => .{ .input_prompt = .{ .prompt = "As-of: ", .hint = " YYYY-MM-DD | 1M | live Enter=confirm ", } }, }; } pub fn handleAction(state: *State, app: *App, action: Action) void { switch (action) { .overlay_actuals => { if (state.as_of == null) { var arena_state = std.heap.ArenaAllocator.init(app.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const d_keys = app.keysForTabAction(arena, "projections", "as_of_input") catch return; var buf: [128]u8 = undefined; const msg = formatOverlayUnavailable(&buf, d_keys[0]) catch return; app.setStatus(msg); return; } state.overlay_actuals = !state.overlay_actuals; // Re-run loadData so the overlay section gets built // (or freed). The timeline load is the expensive // bit but it's rare - humans toggle this maybe a // few times per session. freeLoaded(state, app); state.loaded = false; loadData(state, app); state.chart_dirty = true; if (state.overlay_actuals) { app.setStatus("Overlay: ON - tracks trajectory, not SWR validity"); } else { app.setStatus("Overlay: OFF"); } }, .toggle_chart => { state.chart_visible = !state.chart_visible; state.chart_dirty = true; app.scroll_offset = 0; }, .toggle_events => { state.events_enabled = !state.events_enabled; freeLoaded(state, app); state.loaded = false; loadData(state, app); const status_msg = if (state.events_enabled) "Events enabled" else "Events disabled"; app.setStatus(status_msg); }, .as_of_input => { state.modal = .date_input; app.input_len = 0; // No setStatus - `statusOverride` returns the // input prompt while `state.modal == .date_input`. }, .clear_as_of => { // No-op when no as-of date is set. Returns to the // live view by clearing as-of state and reloading. if (state.as_of == null) return; state.as_of = null; state.as_of_requested = null; state.overlay_actuals = false; tab.reload(state, app) catch |err| std.log.debug("projections reload failed: {t}", .{err}); app.setStatus("As-of cleared - showing live"); }, .toggle_convergence => { if (state.sub_view == .convergence) { // Toggle off - return to default bands view. // Clear the status override so the default // contextual help (status_hints) reappears. state.sub_view = .bands; app.status_len = 0; } else { state.sub_view = .convergence; ensureConvergenceLoaded(state, app); app.setStatus("Sub-view: convergence - model's directional honesty, not SWR validity"); } state.chart_dirty = true; app.scroll_offset = 0; }, .toggle_return_backtest => { if (state.sub_view == .return_backtest) { state.sub_view = .bands; app.status_len = 0; } else { state.sub_view = .return_backtest; ensureBacktestLoaded(state, app); app.setStatus("Sub-view: return back-test - model's expected-return honesty, not SWR validity"); } state.chart_dirty = true; app.scroll_offset = 0; }, .toggle_overlay_zoom => { // No-op when the overlay isn't active. Without an // overlay there's no short-history actuals line to // give visual priority to, so zoom would just // truncate the band envelope for no reason. if (!state.overlay_actuals) { app.setStatus("Zoom only applies when the actuals overlay is on"); return; } state.zoom_overlay = !state.zoom_overlay; state.chart_dirty = true; if (state.zoom_overlay) { app.setStatus("Zoom: ON - x-axis clamped to overlay span"); } else { app.setStatus("Zoom: OFF - full horizon"); } }, } } /// Projections requires a loaded portfolio (the simulation /// engine reads lots / allocations from `app.portfolio`). Same /// predicate as analysis_tab and history_tab. pub fn isDisabled(app: *App) bool { return app.portfolio.file == null; } /// Drop cached projection data on portfolio reload. The /// projection result holds pointers into the previous /// portfolio's memory; invalidate before the underlying data /// is freed. /// /// Deliberately does NOT eager-rebuild, even when this tab /// is active. The reload broadcast fires BEFORE pd.reload /// resets the portfolio arena; rebuilding now would store /// borrowed pointers that get freed seconds later when the /// arena resets, producing use-after-free crashes on the /// next draw. The orchestrator /// (portfolio_tab.reloadPortfolioFile) calls /// `app.loadTabData()` AFTER pd.reload completes, which /// re-activates the active tab against fresh data. pub fn onPortfolioReload(state: *State, app: *App) void { freeLoaded(state, app); state.loaded = false; } }; /// Format the "overlay unavailable" status hint shown when the user /// presses the overlay-toggle key while no as-of date is set. Pure /// function over the as-of-input key string. pub fn formatOverlayUnavailable(buf: []u8, as_of_input_key: []const u8) std.fmt.BufPrintError![]const u8 { return std.fmt.bufPrint(buf, "Overlay only available with --as-of (press {s} to set)", .{as_of_input_key}); } // ── Data loading ────────────────────────────────────────────── pub fn loadData(state: *State, app: *App) void { state.loaded = true; freeLoaded(state, app); const portfolio_path = app.anchorPath() 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 = state.as_of orelse break :as_of; const resolution = resolveAsOf(state, app, portfolio_path, requested_date) orelse { // `setStatus` already called by resolveAsOf. state.as_of = null; state.as_of_requested = null; break :as_of; }; const actual_date = resolution.actual; state.as_of = actual_date; // Preserve requested for the header note; clear if it matches actual. if (actual_date.eql(requested_date)) { state.as_of_requested = null; } const hist_dir = history.deriveHistoryDir(app.allocator, portfolio_path) catch { app.setStatus("Failed to derive history dir - showing live"); state.as_of = null; state.as_of_requested = null; break :as_of; }; defer app.allocator.free(hist_dir); const ctx = switch (resolution.source) { .snapshot => snap: { var loaded = history.loadSnapshotAt(app.io, app.allocator, hist_dir, actual_date) catch { app.setStatus("Failed to load snapshot - showing live"); state.as_of = null; state.as_of_requested = null; break :as_of; }; defer loaded.deinit(app.allocator); break :snap view.loadProjectionContextAsOf( app.io, app.allocator, portfolio_dir, &loaded.snap, actual_date, app.svc, state.events_enabled, ) catch { app.setStatus("Failed to compute as-of projections - showing live"); state.as_of = null; state.as_of_requested = null; break :as_of; }; }, .imported => imp: { // Imported-only as-of: scale today's allocations to // the imported liquid total. Requires the live // portfolio summary, which the portfolio tab loads // up-front into `app.portfolio.summary`. const summary = app.portfolio.summary orelse { app.setStatus("Imported as-of needs live portfolio - visit Portfolio tab first"); state.as_of = null; state.as_of_requested = null; break :as_of; }; const portfolio = app.portfolio.file orelse { app.setStatus("Imported as-of needs live portfolio - visit Portfolio tab first"); state.as_of = null; state.as_of_requested = null; break :as_of; }; break :imp view.loadProjectionContextFromImported( app.io, app.allocator, portfolio_dir, summary.allocations, summary.total_value, portfolio.totalCash(app.today), portfolio.totalCdFaceValue(app.today), resolution.liquid, actual_date, app.svc, state.events_enabled, ) catch { app.setStatus("Failed to compute as-of projections - showing live"); state.as_of = null; state.as_of_requested = null; break :as_of; }; }, }; var ctx_with_overlay = ctx; // Attach the actuals overlay if the toggle is on. Failures // here are non-fatal - the chart still renders without the // overlay; the toggle stays on so the user knows the intent. if (state.overlay_actuals) { if (loadOverlayActuals(app, portfolio_path, actual_date)) |ov| { ctx_with_overlay.overlay_actuals = ov; } else |_| { // Silent - the chart-render path will simply not // draw an overlay layer. Status would be noisy on // every redraw. } } state.ctx = ctx_with_overlay; 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.file 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, state.events_enabled, app.today, ) catch { app.setStatus("Failed to compute projections"); return; }; state.ctx = ctx; } /// Resolve the user's requested as-of date against the portfolio's /// history directory, accepting either a native snapshot OR an /// `imported_values.srf` row. Returns the resolved record, or null /// with a status-bar message if no usable data exists. /// /// Thin adapter over `history.resolveAsOfDate` - the shared pure /// resolver owns exact-then-fallback logic; this wrapper maps its /// errors to user-visible status-bar messages and handles the arena. fn resolveAsOf(state: *State, app: *App, portfolio_path: []const u8, requested: zfin.Date) ?history.ResolvedAsOf { 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.resolveAsOfDate(app.io, arena, hist_dir, requested) catch |err| switch (err) { error.NoDataAtOrBefore => { var status_buf: [128]u8 = undefined; const msg = std.fmt.bufPrint(&status_buf, "No snapshot or imported value at or before {f}", .{requested}) catch "No data at or before requested date"; app.setStatus(msg); return null; }, error.OutOfMemory => { app.setStatus("Out of memory resolving as-of"); return null; }, else => { app.setStatus("Error accessing as-of data"); return null; }, }; if (!resolved.exact) { // Remember the original request for the muted header note. state.as_of_requested = requested; } return resolved; } /// Load the merged history timeline (snapshots + imported_values) /// and produce an overlay section for the projections chart. /// Allocates the resulting `OverlayActualsSection.points` slice /// from `app.allocator` so it survives until `freeLoaded` runs. fn loadOverlayActuals(app: *App, portfolio_path: []const u8, as_of: zfin.Date) !view.OverlayActualsSection { var loaded = try history.loadTimeline(app.io, app.allocator, portfolio_path); defer loaded.deinit(); return try view.buildOverlayActuals(app.allocator, loaded.series.points, as_of, app.today); } pub fn freeLoaded(state: *State, app: *App) void { if (state.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); if (ctx.overlay_actuals) |*ov| ov.deinit(); } state.ctx = null; // Sub-view caches are reset alongside the main projection // context. They reload lazily on next sub-view activation. if (state.convergence_points) |pts| app.allocator.free(pts); state.convergence_points = null; if (state.backtest_anchors) |an| app.allocator.free(an); state.backtest_anchors = null; // Mark projection chart as dirty so it re-renders on next draw state.chart_dirty = true; } /// Lazy-load the convergence points from `imported_values.srf`. /// No-op when already loaded. Errors are surfaced to status - /// the sub-view's own render will fall back to a "no data" line. fn ensureConvergenceLoaded(state: *State, app: *App) void { if (state.convergence_points != null) return; const path = importedValuesPath(app) orelse { app.setStatus("imported_values.srf not found"); return; }; defer app.allocator.free(path); var iv = imported.loadImportedValues(app.io, app.allocator, path) catch { app.setStatus("Failed to load imported_values.srf"); return; }; defer iv.deinit(); state.convergence_points = forecast.convergencePoints(app.allocator, iv.points) catch null; } /// Lazy-load the back-test anchors from `imported_values.srf`, /// running `forecast.returnBacktest` and pivoting (anchor, /// horizon) rows into per-anchor records suitable for the chart /// renderer. No-op when already loaded. fn ensureBacktestLoaded(state: *State, app: *App) void { if (state.backtest_anchors != null) return; const path = importedValuesPath(app) orelse { app.setStatus("imported_values.srf not found"); return; }; defer app.allocator.free(path); var iv = imported.loadImportedValues(app.io, app.allocator, path) catch { app.setStatus("Failed to load imported_values.srf"); return; }; defer iv.deinit(); // Build CPI list from Shiller annual data. Real-mode is OFF // by default in the TUI; the toggle is a future enhancement. var cpi_list: std.ArrayList(milestones.YearCpi) = .empty; defer cpi_list.deinit(app.allocator); for (shiller.annual_returns) |yr| { cpi_list.append(app.allocator, .{ .year = yr.year, .cpi = yr.cpi_inflation }) catch return; } const horizons = [_]u16{ 1, 3, 5 }; const rows = forecast.returnBacktest(app.allocator, iv.points, &horizons, false, cpi_list.items) catch return; defer app.allocator.free(rows); // Pivot (anchor, horizon) rows into per-anchor records. var anchors: std.ArrayList(forecast_chart.BacktestAnchor) = .empty; errdefer anchors.deinit(app.allocator); var i: usize = 0; while (i < rows.len) { const anchor_date = rows[i].anchor_date; const expected = rows[i].expected_return; var r1: ?f64 = null; var r3: ?f64 = null; var r5: ?f64 = null; while (i < rows.len and rows[i].anchor_date.eql(anchor_date)) : (i += 1) { switch (rows[i].horizon_years) { 1 => r1 = rows[i].realized_cagr, 3 => r3 = rows[i].realized_cagr, 5 => r5 = rows[i].realized_cagr, else => {}, } } anchors.append(app.allocator, .{ .anchor_date = anchor_date, .expected = expected, .realized_1y = r1, .realized_3y = r3, .realized_5y = r5, }) catch return; } state.backtest_anchors = anchors.toOwnedSlice(app.allocator) catch null; } /// Resolve the path to `/history/imported_values.srf` /// for the current portfolio, returning null when no portfolio is /// loaded. Caller owns the returned slice. fn importedValuesPath(app: *App) ?[]u8 { const ppath = app.anchorPath() orelse return null; const hist_dir = history.deriveHistoryDir(app.allocator, ppath) catch return null; defer app.allocator.free(hist_dir); return std.fs.path.join(app.allocator, &.{ hist_dir, "imported_values.srf" }) catch null; } // ── Rendering ───────────────────────────────────────────────── pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void { // 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, }; // Sub-view dispatch: convergence and return-backtest replace // the main bands chart entirely. They have their own // chart-mode (Kitty graphics) and text-mode (line-list) // renderers; pick by the same `use_kitty` heuristic. switch (state.sub_view) { .bands => {}, // fall through to default rendering below .convergence => { if (use_kitty) { drawConvergenceWithKitty(state, app, arena, buf, width, height) catch { try drawConvergenceWithScroll(state, app, arena, buf, width, height); }; } else { try drawConvergenceWithScroll(state, app, arena, buf, width, height); } return; }, .return_backtest => { if (use_kitty) { drawBacktestWithKitty(state, app, arena, buf, width, height) catch { try drawBacktestWithScroll(state, app, arena, buf, width, height); }; } else { try drawBacktestWithScroll(state, app, arena, buf, width, height); } return; }, } // Need bands data for the chart const has_bands = if (state.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 state.chart_visible) { drawWithKittyChart(state, app, arena, buf, width, height) catch { try drawWithScroll(state, app, arena, buf, width, height); }; } else { try drawWithScroll(state, app, arena, buf, width, height); } } /// Render styled lines with scroll_offset applied. fn drawWithScroll(state: *State, app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void { const all_lines = try buildLines(state, 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(state: *State, app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void { const th = app.theme; const pctx = state.ctx orelse return; const config = pctx.config; const horizons = config.getHorizons(); const last_idx = horizons.len - 1; const full_bands = pctx.data.bands[last_idx] orelse return; if (full_bands.len < 2) return; // Effective bands slice for both the chart render AND the // right-edge axis labels. When the overlay is on and zoom is // enabled, the chart is clamped to roughly `[year 0, year // 2 * today_years]` so a short actuals history isn't squashed // into the start of a 50-year horizon. The label code below // reads `bands[bands.len - 1]` to position p10/p50/p90 etc. // against the rendered y-range - those two views MUST agree // on which slice was rendered, otherwise the label `val` is // outside the chart's `[value_min, value_max]` window and the // resulting `row_f` underflows when cast to usize. const overlay_today_years: ?f64 = blk: { if (!state.overlay_actuals) break :blk null; const ctx_data = state.ctx orelse break :blk null; const ov = ctx_data.overlay_actuals orelse break :blk null; break :blk ov.today_years; }; const bands = if (state.zoom_overlay) view.overlayZoomBands(full_bands, overlay_today_years) else full_bands; // Build text header (benchmark comparison + allocation note) var header_lines: std.ArrayListUnmanaged(StyledLine) = .empty; try buildHeaderSection(state, 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(state, app, arena, buf, width, height); return; } // Compute pixel dimensions const cell_size = app.cellPixelSize(); const cell_w: u32 = cell_size.width; const cell_h: u32 = cell_size.height; 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 (state.chart_dirty) { // Free old image if (state.image_id) |old_id| { if (app.vx_app) |va| { va.vx.freeImage(va.tty.writer(), old_id); } state.image_id = null; } if (app.vx_app) |va| { // Build the actuals overlay only when overlay is on AND // we're in as-of mode. The overlay is meaningless without // an as-of anchor (no projected future to overlay onto). // // Copy the view's ActualsPoint slice into the chart's // ActualsPoint slice - same field shape, but distinct // types so the chart module stays leaf-level (no view // dependency). Render-scoped allocation; fine to do per // dirty redraw because the overlay is at most ~12 years // of weekly data (~600 points). const overlay_input: ?projection_chart.ActualsOverlay = blk: { if (!state.overlay_actuals) break :blk null; const ctx_data = state.ctx orelse break :blk null; const ov = ctx_data.overlay_actuals orelse break :blk null; const ov_buf = app.allocator.alloc(projection_chart.ActualsPoint, ov.points.len) catch break :blk null; for (ov.points, 0..) |p, idx| { ov_buf[idx] = .{ .years_from_as_of = p.years_from_as_of, .liquid = p.liquid }; } break :blk .{ .points = ov_buf, .today_years = ov.today_years }; }; defer if (overlay_input) |ov| app.allocator.free(@constCast(ov.points)); const chart_result = projection_chart.renderProjectionChart( app.io, app.allocator, bands, capped_w, capped_h, th, overlay_input, pctx.retirement.boundaryYear(), ) catch { state.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 { state.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 { state.chart_dirty = false; return; }; state.image_id = img.id; state.image_width = @intCast(chart_cols); state.image_height = chart_rows; state.value_min = chart_result.value_min; state.value_max = chart_result.value_max; state.chart_dirty = false; } } // Place the image in the cell buffer if (state.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 = state.image_height, .cols = state.image_width, }, .scale = .contain, }, }, }; } // Axis labels (dollar values on the right side) const img_rows = state.image_height; const label_col: usize = @as(usize, chart_col_start) + @as(usize, state.image_width) + 1; const label_style = th.mutedStyle(); if (label_col + 10 <= width and img_rows >= 4 and state.value_max > state.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 = state.value_max - state.value_min; const rows_f: f64 = @floatFromInt(img_rows -| 1); var placed_rows: [5]usize = undefined; var placed_count: usize = 0; for (label_values) |val| { const norm = (val - state.value_min) / val_range; const row_f = @as(f64, @floatFromInt(chart_row_start)) + (1.0 - norm) * rows_f; // Defensive: a label value outside [value_min, value_max] // produces a row_f outside the chart strip, which would // panic on @intFromFloat for usize. Skip silently - the // label just doesn't render, which is the right thing // when the band value is off-chart. (This used to fire // when the bands slice and the chart-render slice // disagreed; both now read from the same `bands` local, // but the guard is cheap insurance.) if (!std.math.isFinite(row_f)) continue; if (row_f < 0) continue; 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, }; } } // "{years}yr" at right edge of chart area - the last // rendered band's year, so a zoomed overlay window // labels its true span (not the full horizon). var yr_buf: [8]u8 = undefined; const yr_label = std.fmt.bufPrint(&yr_buf, "{d}yr", .{bands[bands.len - 1].year}) 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 + state.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(state: *State, 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 (price-only weighted return)", .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, config.benchmark_stock, 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, config.benchmark_bond, 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(¬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), }); } } // 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 = state.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 appendSwrTable(lines, arena, th, pctx); // Life events summary try appendEventSummary(lines, app.today, arena, th, pctx); } /// Append the "Safe Withdrawal" table (header + per-confidence /// amount/rate rows) to a styled-lines list. Shared by the chart and /// no-chart render paths so they can't drift. /// /// During an accumulation phase the per-row % rate is suppressed and /// replaced with a single footnote: dividing today's-dollars /// retirement spending by today's portfolio yields a misleading rate. /// See `view.swrRateNote`. fn appendSwrTable( lines: *std.ArrayListUnmanaged(StyledLine), arena: std.mem.Allocator, th: theme.Theme, pctx: view.ProjectionContext, ) !void { const horizons = pctx.config.getHorizons(); 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 = pctx.config.getConfidenceLevels(); const rate_note = view.swrRateNote(pctx.retirement.accumulation_years); 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(), }); if (rate_note == null) { try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s}", .{rows.rate.text}), .style = th.mutedStyle(), }); } } if (rate_note) |note| { try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s}", .{note}), .style = th.mutedStyle(), }); } // Spending trough callout (declining/rising spending models only). if (pctx.spending_trough) |trough| { try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Lowest spending: {f} in year {d} ({d}), today's dollars", .{ Money.from(trough.amount).whole(), trough.years_from_now, trough.date.year(), }), .style = th.mutedStyle(), }); } } 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], }); } } } // ── Sub-view renderers ──────────────────────────────────────── /// Convert renderer-agnostic `view.ForecastLine`s into the /// TUI's `StyledLine` shape. Maps `intent` -> theme style and /// honors the `bold` flag (rendered as `headerStyle` - /// purple+bold). Bridges the view-model and the TUI's draw /// path so the convergence/back-test fallbacks share their /// formatting with the CLI. fn forecastLinesToStyled( arena: std.mem.Allocator, th: theme.Theme, lines: []const view.ForecastLine, ) ![]StyledLine { const out = try arena.alloc(StyledLine, lines.len); for (lines, 0..) |ln, i| { const style: vaxis.Style = if (ln.bold) th.headerStyle() else th.styleFor(ln.intent); out[i] = .{ .text = ln.text, .style = style }; } return out; } /// Render the convergence sub-view as a Kitty-graphics chart. /// Falls back to scroll-mode on render failure. fn drawConvergenceWithKitty(state: *State, app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void { const th = app.theme; const points = state.convergence_points orelse return error.NoData; if (points.len < 2) return error.NoData; // Header: pulled from the shared view-model so the chart // path matches the CLI / scroll-fallback exactly. Prepend a // blank to give the title some breathing room. const view_header = try view.convergenceHeaderLines(arena, points); var header_lines: std.ArrayListUnmanaged(StyledLine) = .empty; try header_lines.append(arena, .{ .text = "", .style = th.contentStyle() }); const styled_header = try forecastLinesToStyled(arena, th, view_header); try header_lines.appendSlice(arena, styled_header); // Footer: keybind hints var footer_lines: std.ArrayListUnmanaged(StyledLine) = .empty; try footer_lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try footer_lines.append(arena, .{ .text = " Press 'c' to return to bands view, 'b' for return back-test.", .style = th.mutedStyle(), }); const header_slice = try header_lines.toOwnedSlice(arena); try app.drawStyledContent(arena, buf, width, height, header_slice); const header_rows: u16 = @intCast(@min(header_slice.len, height)); const footer_reserve: u16 = @intCast(footer_lines.items.len); const chart_rows = height -| header_rows -| footer_reserve; if (chart_rows < 6) { try drawConvergenceWithScroll(state, app, arena, buf, width, height); return; } const cell_size = app.cellPixelSize(); const px_w: u32 = @as(u32, width -| 2) * cell_size.width; const px_h: u32 = @as(u32, chart_rows) * cell_size.height; if (px_w < 100 or px_h < 100) return error.TooSmall; const capped_w = @min(px_w, app.chart_config.max_width); const capped_h = @min(px_h, app.chart_config.max_height); if (state.chart_dirty) { if (state.image_id) |old_id| { if (app.vx_app) |va| va.vx.freeImage(va.tty.writer(), old_id); state.image_id = null; } if (app.vx_app) |va| { const result = forecast_chart.renderConvergenceChart(app.io, app.allocator, points, capped_w, capped_h, th) catch { state.chart_dirty = false; return; }; defer app.allocator.free(result.rgb_data); const base64_enc = std.base64.standard.Encoder; const b64_buf = app.allocator.alloc(u8, base64_enc.calcSize(result.rgb_data.len)) catch { state.chart_dirty = false; return; }; defer app.allocator.free(b64_buf); const encoded = base64_enc.encode(b64_buf, result.rgb_data); const img = va.vx.transmitPreEncodedImage(va.tty.writer(), encoded, result.width, result.height, .rgb) catch { state.chart_dirty = false; return; }; state.image_id = img.id; state.image_width = width -| 2; state.image_height = chart_rows; state.chart_dirty = false; } } if (state.image_id) |img_id| { const buf_idx = @as(usize, header_rows) * @as(usize, width) + 1; if (buf_idx < buf.len) { buf[buf_idx] = .{ .char = .{ .grapheme = " " }, .style = th.contentStyle(), .image = .{ .img_id = img_id, .options = .{ .size = .{ .rows = state.image_height, .cols = state.image_width }, .scale = .contain, }, }, }; } } // Draw footer below the chart const footer_start_row: usize = @as(usize, header_rows) + @as(usize, chart_rows); if (footer_start_row < height) { const footer_buf_offset = footer_start_row * @as(usize, width); const remaining_buf_len = buf.len -| footer_buf_offset; const footer_height: u16 = @intCast(height -| footer_start_row); if (remaining_buf_len > 0) { try app.drawStyledContent(arena, buf[footer_buf_offset..], width, footer_height, footer_lines.items); } } } /// Render the convergence sub-view as scrollable styled lines /// (no Kitty graphics). Used when the terminal lacks Kitty /// support or the chart-mode render failed. fn drawConvergenceWithScroll(state: *State, app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void { const lines = try buildConvergenceLines(state, app, arena); const start = @min(app.scroll_offset, if (lines.len > 0) lines.len - 1 else 0); try app.drawStyledContent(arena, buf, width, height, lines[start..]); } /// Build the styled lines for the convergence sub-view's text /// fallback. Sampled rows for scannability - full data lives in /// the chart-mode rendering. fn buildConvergenceLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const StyledLine { const th = app.theme; const points = state.convergence_points orelse return try buildEmptyConvergenceLines(arena, th, " No imported_values.srf data available."); var lines: std.ArrayListUnmanaged(StyledLine) = .empty; try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); // Pull from the shared view-model so widths/headings match // the CLI exactly. Empty `points` is handled by the view // (emits a "no data" line + nothing else); we still want the // closing keybind hint, so handle that case below. const view_lines = try view.convergenceLines(arena, points); const styled = try forecastLinesToStyled(arena, th, view_lines); try lines.appendSlice(arena, styled); if (points.len > 0) { try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try lines.append(arena, .{ .text = " Press 'c' to return to bands view, 'b' for return back-test.", .style = th.mutedStyle(), }); } return lines.toOwnedSlice(arena); } /// "No data" fallback shared by `buildConvergenceLines` and /// `buildBacktestLines` when the underlying anchor slice is null /// (load failed or imported_values.srf doesn't exist). fn buildEmptyConvergenceLines(arena: std.mem.Allocator, th: theme.Theme, msg: []const u8) ![]const StyledLine { var lines: std.ArrayListUnmanaged(StyledLine) = .empty; try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try lines.append(arena, .{ .text = " Forecast convergence (spreadsheet's predicted retirement date over time)", .style = th.headerStyle(), }); try lines.append(arena, .{ .text = msg, .style = th.mutedStyle() }); return lines.toOwnedSlice(arena); } /// Render the return-backtest sub-view as a Kitty-graphics chart. fn drawBacktestWithKitty(state: *State, app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void { const th = app.theme; const anchors = state.backtest_anchors orelse return error.NoData; if (anchors.len < 2) return error.NoData; // Header: pulled from the shared view-model so the chart // path matches the CLI / scroll-fallback exactly. Includes // the color-coded legend (purple/cyan/yellow/green). const view_header = try view.backtestHeaderLines(arena, anchors, false); var header_lines: std.ArrayListUnmanaged(StyledLine) = .empty; try header_lines.append(arena, .{ .text = "", .style = th.contentStyle() }); const styled_header = try forecastLinesToStyled(arena, th, view_header); try header_lines.appendSlice(arena, styled_header); var footer_lines: std.ArrayListUnmanaged(StyledLine) = .empty; try footer_lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try footer_lines.append(arena, .{ .text = " Press 'b' to return to bands view, 'c' for convergence.", .style = th.mutedStyle(), }); const header_slice = try header_lines.toOwnedSlice(arena); try app.drawStyledContent(arena, buf, width, height, header_slice); const header_rows: u16 = @intCast(@min(header_slice.len, height)); const footer_reserve: u16 = @intCast(footer_lines.items.len); const chart_rows = height -| header_rows -| footer_reserve; if (chart_rows < 6) { try drawBacktestWithScroll(state, app, arena, buf, width, height); return; } const cell_size = app.cellPixelSize(); const px_w: u32 = @as(u32, width -| 2) * cell_size.width; const px_h: u32 = @as(u32, chart_rows) * cell_size.height; if (px_w < 100 or px_h < 100) return error.TooSmall; const capped_w = @min(px_w, app.chart_config.max_width); const capped_h = @min(px_h, app.chart_config.max_height); if (state.chart_dirty) { if (state.image_id) |old_id| { if (app.vx_app) |va| va.vx.freeImage(va.tty.writer(), old_id); state.image_id = null; } if (app.vx_app) |va| { const result = forecast_chart.renderBacktestChart(app.io, app.allocator, anchors, capped_w, capped_h, th) catch { state.chart_dirty = false; return; }; defer app.allocator.free(result.rgb_data); const base64_enc = std.base64.standard.Encoder; const b64_buf = app.allocator.alloc(u8, base64_enc.calcSize(result.rgb_data.len)) catch { state.chart_dirty = false; return; }; defer app.allocator.free(b64_buf); const encoded = base64_enc.encode(b64_buf, result.rgb_data); const img = va.vx.transmitPreEncodedImage(va.tty.writer(), encoded, result.width, result.height, .rgb) catch { state.chart_dirty = false; return; }; state.image_id = img.id; state.image_width = width -| 2; state.image_height = chart_rows; state.chart_dirty = false; } } if (state.image_id) |img_id| { const buf_idx = @as(usize, header_rows) * @as(usize, width) + 1; if (buf_idx < buf.len) { buf[buf_idx] = .{ .char = .{ .grapheme = " " }, .style = th.contentStyle(), .image = .{ .img_id = img_id, .options = .{ .size = .{ .rows = state.image_height, .cols = state.image_width }, .scale = .contain, }, }, }; } } const footer_start_row: usize = @as(usize, header_rows) + @as(usize, chart_rows); if (footer_start_row < height) { const footer_buf_offset = footer_start_row * @as(usize, width); const remaining_buf_len = buf.len -| footer_buf_offset; const footer_height: u16 = @intCast(height -| footer_start_row); if (remaining_buf_len > 0) { try app.drawStyledContent(arena, buf[footer_buf_offset..], width, footer_height, footer_lines.items); } } } /// Render the return-backtest sub-view as scrollable styled lines. fn drawBacktestWithScroll(state: *State, app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void { const lines = try buildBacktestLines(state, app, arena); const start = @min(app.scroll_offset, if (lines.len > 0) lines.len - 1 else 0); try app.drawStyledContent(arena, buf, width, height, lines[start..]); } fn buildBacktestLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const StyledLine { const th = app.theme; const anchors = state.backtest_anchors orelse return try buildEmptyBacktestLines(arena, th, " No imported_values.srf data available."); var lines: std.ArrayListUnmanaged(StyledLine) = .empty; try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); const view_lines = try view.backtestLines(arena, anchors, false); const styled = try forecastLinesToStyled(arena, th, view_lines); try lines.appendSlice(arena, styled); if (anchors.len > 0) { try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try lines.append(arena, .{ .text = " Press 'b' to return to bands view, 'c' for convergence.", .style = th.mutedStyle(), }); } return lines.toOwnedSlice(arena); } /// "No data" fallback for the back-test sub-view. fn buildEmptyBacktestLines(arena: std.mem.Allocator, th: theme.Theme, msg: []const u8) ![]const StyledLine { var lines: std.ArrayListUnmanaged(StyledLine) = .empty; try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try lines.append(arena, .{ .text = " Expected vs realized return back-test", .style = th.headerStyle(), }); try lines.append(arena, .{ .text = msg, .style = th.mutedStyle() }); return lines.toOwnedSlice(arena); } /// Build the styled-line representation of the projections /// view (text-only fallback when the chart is hidden, and the /// scroll body when the chart is visible). File-private - the /// framework draw hook is `drawContent`, which composes this /// internally. fn buildLines(state: *State, 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 = state.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 (state.as_of) |actual| { const source_label: []const u8 = if (state.ctx) |c| switch (c.as_of_source) { .snapshot => "snapshot", .imported => "imported", .live => "live", } else "snapshot"; const header = try std.fmt.allocPrint(arena, " As-of: {f} ({s})", .{ actual, source_label }); try lines.append(arena, .{ .text = header, .style = th.mutedStyle() }); if (state.as_of_requested) |requested| { if (!requested.eql(actual)) { const diff = requested.days - actual.days; const note = try std.fmt.allocPrint( arena, " (requested {f}; snapped back {d} day{s})", .{ requested, diff, fmt.dayPlural(diff) }, ); try lines.append(arena, .{ .text = note, .style = th.mutedStyle() }); } } if (state.ctx) |c| { if (c.as_of_source == .imported) { try lines.append(arena, .{ .text = " (bands use today's allocation scaled to the imported liquid total)", .style = th.mutedStyle(), }); } } if (state.overlay_actuals) { const ov_note = try std.fmt.allocPrint( arena, " Overlay: actuals from {f} · tracks trajectory, not SWR validity", .{actual}, ); try lines.append(arena, .{ .text = ov_note, .style = th.infoStyle() }); } try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); } // Header try lines.append(arena, .{ .text = " Benchmark Comparison (price-only weighted return)", .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, config.benchmark_stock, 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, config.benchmark_bond, 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(¬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), }); } } // 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 = state.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 = braille.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); // Cell budget per row: 2 leading spaces + n_cols // chart cells + 1 separator space + up to // `money_label_max_bytes` for the price label. // Sized via a named constant so the projection // chart doesn't silently truncate labels for // portfolios that grow past $1M; see // `braille.money_label_max_bytes`. const proj_label_cells: usize = 1 + braille.money_label_max_bytes; const proj_row_cells: usize = 2 + br_chart.n_cols + proj_label_cells; for (0..br_chart.chart_height) |row| { const graphemes = try arena.alloc([]const u8, proj_row_cells); const styles = try arena.alloc(vaxis.Style, proj_row_cells); 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] = braille.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, proj_row_cells); const axis_styles = try arena.alloc(vaxis.Style, proj_row_cells); 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 appendSwrTable(&lines, arena, th, ctx); // 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, }); } /// Key handler for the date-input modal (`d` keybind on /// projections). Accepts the same input as the CLI `--as-of` /// flag - `YYYY-MM-DD`, relative shortcuts (`1W`, `1M`, `3M`, /// `1Q`, `1Y`, `3Y`, `5Y`), or `live` / empty for live state. /// Commit via Enter, cancel via Esc. /// /// Returns `true` for any consumed key. Always consumes: /// modal contract - keys can't leak through to global keymap /// matching while the prompt is open. Cleanup of /// `state.modal` and `app.input_len` happens here on /// cancel/commit; the shared `handleInputBuffer` no longer /// touches mode/modal state (its callers do). fn handleDateInputKey(state: *State, app: *App, key: vaxis.Key) bool { switch (input_buffer.handleKey(&app.input_buf, &app.input_len, key)) { .cancelled => { state.modal = .none; app.setStatus("Cancelled"); return true; }, .edited => return true, .ignored => return true, .committed => { const input = app.input_buf[0..app.input_len]; const parsed = cli.parseAsOfDate(input, app.today) catch |err| { var buf: [256]u8 = undefined; const msg = cli.fmtAsOfParseError(&buf, input, err); app.setStatus(msg); state.modal = .none; app.input_len = 0; return true; }; if (parsed) |d| { // Guard against future dates. if (d.days > app.today.days) { app.setStatus("As-of date is in the future"); state.modal = .none; app.input_len = 0; return true; } state.as_of = d; state.as_of_requested = null; var status_buf: [64]u8 = undefined; const msg = std.fmt.bufPrint(&status_buf, "As-of: {f}", .{d}) catch "As-of set"; app.setStatus(msg); } else { // `null` parse result = live. state.as_of = null; state.as_of_requested = null; app.setStatus("As-of cleared - showing live"); } tab.reload(state, app) catch |err| std.log.debug("projections reload failed: {t}", .{err}); state.modal = .none; app.input_len = 0; return true; }, } } // ── Tests ───────────────────────────────────────────────────── const testing = std.testing; test "formatOverlayUnavailable: includes resolved as-of-input key" { var buf: [128]u8 = undefined; const msg = try formatOverlayUnavailable(&buf, "d"); try testing.expectEqualStrings("Overlay only available with --as-of (press d to set)", msg); } test "formatOverlayUnavailable: respects rebound as-of-input key" { var buf: [128]u8 = undefined; const msg = try formatOverlayUnavailable(&buf, "ctrl+d"); try testing.expectEqualStrings("Overlay only available with --as-of (press ctrl+d to set)", msg); } /// Build a `ProjectionContext` for the SWR-table tests. Runs the real /// (network-free) historical simulation via `view.buildProjectionContext` /// so `data.withdrawals` is populated. `retirement_at == null` yields a /// distribution-only context (accumulation_years == 0); a future date /// yields an accumulation phase. fn buildSwrTestCtx( arena: std.mem.Allocator, retirement_at: ?zfin.Date, annual_contribution: f64, as_of: zfin.Date, ) !view.ProjectionContext { const benchmark = @import("../analytics/benchmark.zig"); const projections = @import("../analytics/projections.zig"); var config: projections.UserConfig = .{}; config.retirement_at = retirement_at; config.annual_contribution = annual_contribution; const comparison: benchmark.BenchmarkComparison = .{ .stock_returns = .{}, .bond_returns = .{}, .benchmark_returns = .{}, .portfolio_returns = .{}, .conservative_return = 0.07, .stock_pct = 0.75, .bond_pct = 0.25, }; return view.buildProjectionContext(arena, config, comparison, 0.75, 0.25, 1_000_000, &.{}, as_of); } /// Tallies of the line kinds `appendSwrTable` emits, used by the SWR /// table tests. Each emitted line is exactly one kind: /// - `header`: the "Safe Withdrawal (...)" title line. /// - `amount`: a "NN% safe withdrawal $X $Y ..." row. /// - `rate`: a bare numeric percentage row (rendered only when /// there is no accumulation phase). /// - `footnote`: the "% rate omitted during accumulation ..." note. const SwrLineCounts = struct { headers: usize = 0, amounts: usize = 0, rates: usize = 0, footnotes: usize = 0, }; fn classifySwrLines(lines: []const StyledLine) SwrLineCounts { var c: SwrLineCounts = .{}; for (lines) |line| { if (std.mem.indexOf(u8, line.text, "Safe Withdrawal") != null) { // Title line ("Safe Withdrawal (FIRECalc ...)"). c.headers += 1; } else if (std.mem.indexOf(u8, line.text, "omitted") != null) { // Suppression footnote. c.footnotes += 1; } else if (std.mem.indexOf(u8, line.text, "safe withdrawal") != null) { // Amount row: the "NN% safe withdrawal" label carries a // '%', so it must be matched before the bare-'%' rate case. c.amounts += 1; } else if (std.mem.indexOfScalar(u8, line.text, '%') != null) { // Numeric percentage row. c.rates += 1; } } return c; } test "appendSwrTable: distribution-only renders a rate row per confidence, no footnote" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const a = arena.allocator(); const ctx = try buildSwrTestCtx(a, null, 0, zfin.Date.fromYmd(2026, 5, 12)); try testing.expectEqual(@as(u16, 0), ctx.retirement.accumulation_years); var lines: std.ArrayListUnmanaged(StyledLine) = .empty; try appendSwrTable(&lines, a, theme.default_theme, ctx); const n_conf = ctx.config.getConfidenceLevels().len; const counts = classifySwrLines(lines.items); try testing.expectEqual(@as(usize, 1), counts.headers); try testing.expectEqual(@as(usize, 0), counts.footnotes); // One amount row and one rate row per confidence level. try testing.expectEqual(n_conf, counts.amounts); try testing.expectEqual(n_conf, counts.rates); } test "appendSwrTable: accumulation suppresses rate rows and adds one footnote" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const a = arena.allocator(); // Retire 10 years out with contributions -> non-zero accumulation. const ctx = try buildSwrTestCtx(a, zfin.Date.fromYmd(2036, 7, 1), 50_000, zfin.Date.fromYmd(2026, 7, 1)); try testing.expect(ctx.retirement.accumulation_years > 0); var lines: std.ArrayListUnmanaged(StyledLine) = .empty; try appendSwrTable(&lines, a, theme.default_theme, ctx); const n_conf = ctx.config.getConfidenceLevels().len; const counts = classifySwrLines(lines.items); // Amounts still render; the per-row rate is gone, replaced by a // single footnote. try testing.expectEqual(n_conf, counts.amounts); try testing.expectEqual(@as(usize, 0), counts.rates); try testing.expectEqual(@as(usize, 1), counts.footnotes); }