1584 lines
66 KiB
Zig
1584 lines
66 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 `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 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 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,
|
|
};
|
|
|
|
// ── 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,
|
|
|
|
/// 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,
|
|
};
|
|
|
|
/// 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 tab = struct {
|
|
pub const ActionT = Action;
|
|
pub const StateT = State;
|
|
|
|
/// Display name for the tab bar.
|
|
pub const label: []const u8 = "Projections";
|
|
|
|
pub const default_bindings: []const framework.TabBinding(Action) = &.{
|
|
.{ .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 } },
|
|
};
|
|
|
|
pub const 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",
|
|
});
|
|
|
|
pub const status_hints: []const Action = &.{
|
|
.toggle_chart,
|
|
.toggle_events,
|
|
.as_of_input,
|
|
};
|
|
|
|
pub fn init(state: *State, app: *App) !void {
|
|
_ = app;
|
|
state.* = .{};
|
|
}
|
|
|
|
pub fn deinit(state: *State, app: *App) void {
|
|
freeLoaded(state, app);
|
|
state.* = .{};
|
|
}
|
|
|
|
pub fn activate(state: *State, app: *App) !void {
|
|
if (state.loaded) return;
|
|
// Projections reads `app.portfolio.summary` and
|
|
// `.file`. Ensure they're populated even when the user
|
|
// jumps straight here without visiting portfolio first.
|
|
app.ensurePortfolioDataLoaded();
|
|
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 {};
|
|
app.setStatus("As-of cleared — showing live");
|
|
},
|
|
}
|
|
}
|
|
|
|
/// 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;
|
|
}
|
|
};
|
|
|
|
/// 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.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 = 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;
|
|
// Mark projection chart as dirty so it re-renders on next draw
|
|
state.chart_dirty = true;
|
|
}
|
|
|
|
// ── 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,
|
|
};
|
|
|
|
// 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 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(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,
|
|
) 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 = @as(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;
|
|
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 + 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, "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(¬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 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],
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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, "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(¬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 = 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,
|
|
});
|
|
}
|
|
|
|
/// 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 {};
|
|
|
|
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);
|
|
}
|