From d7e7b7693343f0c5975ccd1ca2fd2dd43d2d6332 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Sun, 17 May 2026 10:37:19 -0700 Subject: [PATCH] add backtest and convergence to tui --- AGENTS.md | 41 ++ TODO.md | 48 ++ src/analytics/forecast_evaluation.zig | 104 ++++ src/commands/common.zig | 3 + src/commands/history.zig | 2 + src/commands/projections.zig | 171 +----- src/format.zig | 2 + src/tui/forecast_chart.zig | 630 +++++++++++++++++++++++ src/tui/projections_tab.zig | 520 +++++++++++++++++++ src/tui/theme.zig | 2 + src/views/projections.zig | 715 ++++++++++++++++++++++++++ 11 files changed, 2086 insertions(+), 152 deletions(-) create mode 100644 src/tui/forecast_chart.zig diff --git a/AGENTS.md b/AGENTS.md index 8e1d6b9..f1ebb44 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -321,6 +321,47 @@ Ask the user instead.** freely when asked; don't treat it as part of the repo surface. Don't mention it in commit messages for unrelated work. +### Em-dash usage — ASK FIRST + +If you're about to write an em-dash (`—`) anywhere (code, tests, doc +comments, commit messages, AGENTS.md prose), **stop and check whether a +regular ASCII hyphen (`-`) would do.** Most of the time it would. Em-dashes +look nice in prose but they create real problems: + +- **In tabular output / TUI cells**, `—` is 3 bytes / 1 display column. + Zig's `{s:>N}` formatter pads by byte count, so any column containing + em-dashes will be 2 visual columns short per em-dash. We've fixed this + bug at least twice; don't reintroduce it. If you genuinely need a + multibyte sentinel for "no data", use `fmt.padRightToCols` / + `fmt.centerDash` (display-column-aware) — or just hard-code the cell + as a literal const string when the cell width is fixed and the dash + position is static (no point computing what you already know). Add + an alignment test that compares the multibyte row's `displayCols` + against an ASCII row. +- **In code identifiers and string literals**, em-dashes look fine in your + editor and break grep on the user's machine when they're searching with + ASCII `-`. If a future grep for "expected-return" or "as-of" silently + misses your "expected—return" string, that's a bug surface. +- **In commit messages and prose docs**, em-dashes are an AI tell. The + user reads commit messages and won't appreciate the codebase looking + like it was written by ChatGPT. + +**Rule of thumb:** + +- Use `-` (ASCII hyphen) for: ranges (`1-5y`), compound modifiers + (`forecast-vs-actual`), CLI flag names (`--return-backtest`), code + identifiers, and string concatenation in tables. +- Use `—` (em-dash) only when you're displaying it to the user as a + meaningful sentinel (e.g. "no data" cell in a table), AND you've handled + the display-column padding correctly. +- If you find yourself reaching for `—` in prose for a parenthetical + aside, **switch to a regular dash, comma, or parens.** Em-dashes have + become a stylistic AI tic and the user has explicitly asked to keep + them out unless they earn their place. + +When in doubt, **ask**. A one-line "I'm about to use `—` here for X, OK?" +is much cheaper than reverting after the user notices. + --- ## Commands diff --git a/TODO.md b/TODO.md index 8fb6c88..d0c09f1 100644 --- a/TODO.md +++ b/TODO.md @@ -668,6 +668,54 @@ populate. This could be solved on the server by spawning a thread to fetch the data, then returning 202 Accepted, which could then be polled client side. Maybe this is a better long term approach? +## Audit: em-dash sentinel usage across all tables — priority LOW + +The codebase uses `—` (em-dash) as the canonical "no data" sentinel +in several table cells, but the rendering rules and alignment +choices are inconsistent. AGENTS.md now warns against em-dash +overuse generally; this audit is the second half — pick a +consistent treatment and apply it everywhere. + +Known em-dash sites: + +- `src/views/projections.zig` (back-test): hard-coded `dash_cell` + literal in 10-col cells — pre-shaped at compile time so no + helper is involved. Numeric cells use Zig's `{s:>10}` byte- + padding (safe since they're pure ASCII). +- `src/commands/history.zig` / `src/tui/history_tab.zig`: centered + via `fmt.centerDash` in 31-col cells (illiquid totals on + imported-only history rows). +- `src/commands/milestones.zig`: right-padded via + `fmt.padRightToCols` in the "days since prev" cell. Mixes + with ASCII cells like `"42 days"`. +- `src/commands/perf.zig` / `src/tui/performance_tab.zig`: + emitted via `{s:>13}` byte-padding — under-padded by 2 cols + per em-dash. Either hard-code a `dash_cell` literal (cell + width is static) or migrate to `fmt.centerDash` / + `fmt.padRightToCols`. + +Decisions to make: + +1. **Centered vs right-aligned in numeric columns.** Back-test + centers; perf right-aligns (or would, if it weren't broken). + Centering reads as a more deliberate sentinel; right-aligning + keeps the visual right-edge of the column smooth. Pick one. +2. **Should some tables drop the em-dash entirely** in favor of + ASCII `-`? Rule of thumb: if the column header makes the + meaning unambiguous AND no rows contain bare `-` for other + reasons (signed values use `-2.21%` which is multi-char, so + a lone `-` is unambiguous), `-` is fine. If the column also + carries dates or strings where a stray `-` could read as + part of the value, keep `—`. +3. **Helper vs literal.** When the cell width is fixed and the + dash position is static, a hard-coded literal const string + (like back-test's `dash_cell`) is simpler than calling a + helper at runtime. Use helpers when width or position varies. + +Once decisions are made, sweep all four sites + add a regression +alignment test per table that mixes a fully-populated row with +an em-dash-heavy row and verifies `displayCols` matches. + ## Low-priority items The following items are acknowledged but not prioritized. Listed here diff --git a/src/analytics/forecast_evaluation.zig b/src/analytics/forecast_evaluation.zig index 9658ad1..b1ce1f8 100644 --- a/src/analytics/forecast_evaluation.zig +++ b/src/analytics/forecast_evaluation.zig @@ -241,6 +241,59 @@ fn computeCagr( return std.math.pow(f64, ratio, exponent) - 1.0; } +/// Pivoted view of `BacktestPoint`s: one record per anchor with +/// realized CAGRs for the 1y/3y/5y horizons in dedicated optional +/// columns. The chart renderer plots all four series side-by-side; +/// the table renderers (CLI + TUI scroll fallback) emit one row +/// per anchor with four columns. Both consumers want the pivoted +/// shape, so the pivot lives here rather than in either renderer. +pub const BacktestAnchor = struct { + anchor_date: Date, + expected: f64, + realized_1y: ?f64, + realized_3y: ?f64, + realized_5y: ?f64, +}; + +/// Pivot a sorted `[]BacktestPoint` (output of `returnBacktest`, +/// ordered by `(anchor_index, horizon_index)`) into one +/// `BacktestAnchor` per distinct `anchor_date`. Rows for horizons +/// outside `{1, 3, 5}` are dropped — the wider chart isn't +/// designed to show them. Caller owns the returned slice. +pub fn pivotByAnchor( + allocator: std.mem.Allocator, + rows: []const BacktestPoint, +) ![]BacktestAnchor { + var out: std.ArrayList(BacktestAnchor) = .empty; + errdefer out.deinit(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 => {}, + } + } + try out.append(allocator, .{ + .anchor_date = anchor_date, + .expected = expected, + .realized_1y = r1, + .realized_3y = r3, + .realized_5y = r5, + }); + } + + return out.toOwnedSlice(allocator); +} + // ── Tests ──────────────────────────────────────────────────── const testing = std.testing; @@ -430,3 +483,54 @@ test "returnBacktest: zero or negative liquid yields null realized" { defer testing.allocator.free(out); try testing.expectEqual(@as(?f64, null), out[0].realized_cagr); } + +test "pivotByAnchor: empty input returns empty" { + const out = try pivotByAnchor(testing.allocator, &.{}); + defer testing.allocator.free(out); + try testing.expectEqual(@as(usize, 0), out.len); +} + +test "pivotByAnchor: groups three horizons into one anchor" { + const date = Date.fromYmd(2020, 1, 1); + const rows = [_]BacktestPoint{ + .{ .anchor_date = date, .horizon_years = 1, .expected_return = 0.10, .realized_cagr = 0.12 }, + .{ .anchor_date = date, .horizon_years = 3, .expected_return = 0.10, .realized_cagr = 0.08 }, + .{ .anchor_date = date, .horizon_years = 5, .expected_return = 0.10, .realized_cagr = null }, + }; + const out = try pivotByAnchor(testing.allocator, &rows); + defer testing.allocator.free(out); + try testing.expectEqual(@as(usize, 1), out.len); + try testing.expect(out[0].anchor_date.eql(date)); + try testing.expectEqual(@as(f64, 0.10), out[0].expected); + try testing.expectEqual(@as(?f64, 0.12), out[0].realized_1y); + try testing.expectEqual(@as(?f64, 0.08), out[0].realized_3y); + try testing.expectEqual(@as(?f64, null), out[0].realized_5y); +} + +test "pivotByAnchor: preserves anchor order" { + const d1 = Date.fromYmd(2020, 1, 1); + const d2 = Date.fromYmd(2021, 6, 15); + const rows = [_]BacktestPoint{ + .{ .anchor_date = d1, .horizon_years = 1, .expected_return = 0.10, .realized_cagr = 0.12 }, + .{ .anchor_date = d2, .horizon_years = 1, .expected_return = 0.08, .realized_cagr = 0.05 }, + }; + const out = try pivotByAnchor(testing.allocator, &rows); + defer testing.allocator.free(out); + try testing.expectEqual(@as(usize, 2), out.len); + try testing.expect(out[0].anchor_date.eql(d1)); + try testing.expect(out[1].anchor_date.eql(d2)); +} + +test "pivotByAnchor: drops horizons outside {1, 3, 5}" { + const date = Date.fromYmd(2020, 1, 1); + const rows = [_]BacktestPoint{ + .{ .anchor_date = date, .horizon_years = 1, .expected_return = 0.10, .realized_cagr = 0.12 }, + .{ .anchor_date = date, .horizon_years = 10, .expected_return = 0.10, .realized_cagr = 0.07 }, // dropped + }; + const out = try pivotByAnchor(testing.allocator, &rows); + defer testing.allocator.free(out); + try testing.expectEqual(@as(usize, 1), out.len); + try testing.expectEqual(@as(?f64, 0.12), out[0].realized_1y); + try testing.expectEqual(@as(?f64, null), out[0].realized_3y); + try testing.expectEqual(@as(?f64, null), out[0].realized_5y); +} diff --git a/src/commands/common.zig b/src/commands/common.zig index 0777568..17a78c7 100644 --- a/src/commands/common.zig +++ b/src/commands/common.zig @@ -13,6 +13,7 @@ pub const CLR_MUTED = [3]u8{ 0x80, 0x80, 0x80 }; // dim/secondary text (TUI .tex pub const CLR_HEADER = [3]u8{ 0x9d, 0x7c, 0xd8 }; // section headers (TUI .accent) pub const CLR_ACCENT = [3]u8{ 0x89, 0xb4, 0xfa }; // info highlights, bar fills (TUI .bar_fill) pub const CLR_WARNING = [3]u8{ 0xe5, 0xc0, 0x7b }; // stale/manual price indicator (TUI .warning) +pub const CLR_INFO = [3]u8{ 0x56, 0xb6, 0xc2 }; // cyan — secondary legend items (TUI .info) // ── ANSI color helpers ─────────────────────────────────────── @@ -46,6 +47,8 @@ pub fn setStyleIntent(out: *std.Io.Writer, c: bool, intent: fmt.StyleIntent) !vo .positive => try setFg(out, c, CLR_POSITIVE), .negative => try setFg(out, c, CLR_NEGATIVE), .warning => try setFg(out, c, CLR_WARNING), + .accent => try setFg(out, c, CLR_HEADER), + .info => try setFg(out, c, CLR_INFO), } } diff --git a/src/commands/history.zig b/src/commands/history.zig index f90e91a..c806726 100644 --- a/src/commands/history.zig +++ b/src/commands/history.zig @@ -391,6 +391,8 @@ fn renderWindowsBlock(out: *std.Io.Writer, color: bool, ws: timeline.WindowSet) .positive => try cli.setFg(out, color, cli.CLR_POSITIVE), .negative => try cli.setFg(out, color, cli.CLR_NEGATIVE), .muted, .warning => try cli.setFg(out, color, cli.CLR_MUTED), + .accent => try cli.setFg(out, color, cli.CLR_HEADER), + .info => try cli.setFg(out, color, cli.CLR_INFO), .normal => {}, } diff --git a/src/commands/projections.zig b/src/commands/projections.zig index d49c725..2b47f8d 100644 --- a/src/commands/projections.zig +++ b/src/commands/projections.zig @@ -632,64 +632,8 @@ pub fn runConvergence( defer iv.deinit(); const points = try forecast.convergencePoints(va, iv.points); - - try cli.setBold(out, color); - try out.print("Projection convergence (spreadsheet-projected retirement date over time)\n", .{}); - try cli.reset(out, color); - - if (points.len == 0) { - try cli.printFg(out, color, cli.CLR_MUTED, "No convergence data available (imported_values.srf empty or missing projected_retirement fields).\n", .{}); - return; - } - - // Range header — first/last observation dates. - try cli.printFg( - out, - color, - cli.CLR_MUTED, - " {d} observations from {f} → {f}\n", - .{ points.len, points[0].observation_date, points[points.len - 1].observation_date }, - ); - try cli.printFg( - out, - color, - cli.CLR_MUTED, - " Caveat: tracks the model's directional honesty, not SWR validity.\n", - .{}, - ); - try out.print("\n", .{}); - - // Table header - try cli.setFg(out, color, cli.CLR_MUTED); - try out.print(" {s:<12} {s:<12} {s:>14}\n", .{ "Observed", "Projected", "Years until" }); - try out.print(" {s:-<12} {s:-<12} {s:->14}\n", .{ "", "", "" }); - try cli.reset(out, color); - - // Body — show first, last, and every 13th in between for a - // ~quarterly cadence on weekly imported data. Reading the - // entire 11.5-year history line-by-line is rarely useful; - // the chart on the TUI is the high-fidelity surface. - const stride: usize = if (points.len > 26) (points.len + 25) / 26 else 1; - for (points, 0..) |p, i| { - if (i != 0 and i != points.len - 1 and (i % stride) != 0) continue; - - var proj_buf: [16]u8 = undefined; - const proj_str: []const u8 = if (p.reached) "reached" else std.fmt.bufPrint(&proj_buf, "{f}", .{p.projected_date}) catch "??????????"; - - var years_buf: [16]u8 = undefined; - const years_str: []const u8 = if (p.reached) - "0.00" - else - std.fmt.bufPrint(&years_buf, "{d:.2}", .{p.years_until_retirement}) catch "??"; - - try out.print(" {f} {s:<12} {s:>14}\n", .{ p.observation_date, proj_str, years_str }); - } - - if (stride > 1) { - try out.print("\n", .{}); - try cli.printFg(out, color, cli.CLR_MUTED, " (Showing every {d}th observation — full chart on TUI projections tab.)\n", .{stride}); - } - try out.print("\n", .{}); + const lines = try view.convergenceLines(va, points); + try renderForecastLines(out, color, lines); } /// `zfin projections --return-backtest [--real]` entry point. @@ -734,101 +678,24 @@ pub fn runReturnBacktest( } const rows = try forecast.returnBacktest(va, iv.points, backtest_horizons, real_mode, cpi_list.items); + const anchors = try forecast.pivotByAnchor(va, rows); + const lines = try view.backtestLines(va, anchors, real_mode); + try renderForecastLines(out, color, lines); +} - try cli.setBold(out, color); - try out.print("Expected vs realized return back-test\n", .{}); - try cli.reset(out, color); - - if (rows.len == 0) { - try cli.printFg(out, color, cli.CLR_MUTED, "No back-test data available (imported_values.srf empty or missing expected_return fields).\n", .{}); - return; - } - - // Header note explaining the methodology. - try cli.printFg( - out, - color, - cli.CLR_MUTED, - " expected_return = spreadsheet's min(1y,3y,5y,10y)-weighted claim at each anchor.\n", - .{}, - ); - if (real_mode) { - try cli.printFg( - out, - color, - cli.CLR_MUTED, - " realized = inflation-deflated forward CAGR (Shiller CPI). expected is left nominal.\n", - .{}, - ); - } else { - try cli.printFg( - out, - color, - cli.CLR_MUTED, - " realized = nominal forward CAGR. Pair with --real to deflate.\n", - .{}, - ); - } - try cli.printFg( - out, - color, - cli.CLR_MUTED, - " Caveat: tracks the model's expected-return honesty, not SWR validity.\n", - .{}, - ); - try out.print("\n", .{}); - - // Group rows by anchor: emit one line per anchor with three - // realized columns. Rows are emitted in (anchor, horizon) - // order from `forecast.returnBacktest`. - try cli.setFg(out, color, cli.CLR_MUTED); - try out.print(" {s:<12} {s:>10} {s:>10} {s:>10} {s:>10}\n", .{ "Anchor", "Expected", "1y", "3y", "5y" }); - try out.print(" {s:-<12} {s:->10} {s:->10} {s:->10} {s:->10}\n", .{ "", "", "", "", "" }); - try cli.reset(out, color); - - // Show at most ~30 evenly-spaced anchors so the table - // stays scannable. Anchors are in ascending order. - var anchor_rows = std.ArrayList(struct { date: Date, expected: f64, r1: ?f64, r3: ?f64, r5: ?f64 }).empty; - defer anchor_rows.deinit(va); - - 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 => {}, - } - } - try anchor_rows.append(va, .{ .date = anchor_date, .expected = expected, .r1 = r1, .r3 = r3, .r5 = r5 }); - } - - const stride: usize = if (anchor_rows.items.len > 30) (anchor_rows.items.len + 29) / 30 else 1; - for (anchor_rows.items, 0..) |a, idx| { - if (idx != 0 and idx != anchor_rows.items.len - 1 and (idx % stride) != 0) continue; - - var ebuf: [16]u8 = undefined; - const e_str = std.fmt.bufPrint(&ebuf, "{d:.2}%", .{a.expected * 100}) catch "??"; - - var r1_buf: [16]u8 = undefined; - var r3_buf: [16]u8 = undefined; - var r5_buf: [16]u8 = undefined; - const r1_str: []const u8 = if (a.r1) |v| std.fmt.bufPrint(&r1_buf, "{d:.2}%", .{v * 100}) catch "??" else "—"; - const r3_str: []const u8 = if (a.r3) |v| std.fmt.bufPrint(&r3_buf, "{d:.2}%", .{v * 100}) catch "??" else "—"; - const r5_str: []const u8 = if (a.r5) |v| std.fmt.bufPrint(&r5_buf, "{d:.2}%", .{v * 100}) catch "??" else "—"; - - try out.print(" {f} {s:>10} {s:>10} {s:>10} {s:>10}\n", .{ a.date, e_str, r1_str, r3_str, r5_str }); - } - - if (stride > 1) { - try out.print("\n", .{}); - try cli.printFg(out, color, cli.CLR_MUTED, " (Showing every {d}th anchor — full chart on TUI projections tab.)\n", .{stride}); +/// Emit `view.ForecastLine`s through the CLI's ANSI styling +/// helpers. Shared by `runConvergence` and `runReturnBacktest` so +/// the bold/intent → ANSI mapping lives in exactly one place. +fn renderForecastLines( + out: *std.Io.Writer, + color: bool, + lines: []const view.ForecastLine, +) !void { + for (lines) |ln| { + if (ln.bold) try cli.setBold(out, color); + try cli.setStyleIntent(out, color, ln.intent); + try out.print("{s}\n", .{ln.text}); + try cli.reset(out, color); } try out.print("\n", .{}); } diff --git a/src/format.zig b/src/format.zig index b1a3f48..e56c336 100644 --- a/src/format.zig +++ b/src/format.zig @@ -471,6 +471,8 @@ pub const StyleIntent = enum { positive, // green (gains, premium received) negative, // red (losses, premium paid) warning, // yellow (stale data, drift) + accent, // purple — section headers, primary series in legends + info, // cyan — informational/overlay content, secondary legend items }; /// Summary of DRIP (dividend reinvestment) lots for a single ST or LT bucket. diff --git a/src/tui/forecast_chart.zig b/src/tui/forecast_chart.zig new file mode 100644 index 0000000..65e64aa --- /dev/null +++ b/src/tui/forecast_chart.zig @@ -0,0 +1,630 @@ +//! Forecast-evaluation chart renderer using z2d. +//! +//! Sibling to `projection_chart.zig` for plain line-shaped charts +//! (no percentile bands). Used by the projections tab's +//! convergence and return-back-test sub-views. +//! +//! Two render entry points: +//! - `renderConvergenceChart`: single-series line of +//! years-until-retirement vs. observation date, with a dashed +//! `slope=-1` reference line for "perfect convergence" and +//! small markers on `reached` rows. +//! - `renderBacktestChart`: multi-series line chart showing +//! `expected_return` (primary, solid) alongside realized 1y/3y/5y +//! forward CAGR (faint, line styles vary by horizon). Y=0 +//! reference line for sanity. +//! +//! Both produce raw RGB pixel data for Kitty graphics protocol +//! transmission, mirroring `projection_chart.zig`'s output shape. +//! +//! The two functions share substantial scaffolding (margins, +//! axes, grid lines, value-range expansion). Helpers are +//! file-private; `renderProjectionChart`'s helpers are re-derived +//! locally to avoid leaking implementation details across the +//! module boundary. Sibling rather than shared because the chart +//! shapes are different enough that a shared core would be +//! awkwardly parameterized. + +const std = @import("std"); +const z2d = @import("z2d"); +const theme = @import("theme.zig"); +const forecast = @import("../analytics/forecast_evaluation.zig"); +const Date = @import("../Date.zig"); + +const Surface = z2d.Surface; +const Context = z2d.Context; +const Pixel = z2d.Pixel; + +const margin_left: f64 = 4; +const margin_right: f64 = 4; +const margin_top: f64 = 4; +const margin_bottom: f64 = 4; + +pub const ChartResult = struct { + rgb_data: []const u8, + width: u16, + height: u16, + /// Y-range used; renderers may want this for label rendering. + value_min: f64, + value_max: f64, +}; + +// ── View 1: Convergence chart ──────────────────────────────── + +/// Render the convergence chart. X-axis spans +/// `points[0].observation_date` to +/// `points[points.len-1].observation_date`. Y-axis is +/// years-until-retirement (Encoding B per the spec). +/// +/// Visual layers (bottom to top): +/// - Background +/// - Horizontal grid lines (at y values 0, 5, 10, ...) +/// - Dashed `slope=-1` reference line: at the leftmost x it +/// starts at `points[0].years_until_retirement` and decreases +/// by 1 year per year of x progression. This is "what the +/// line would look like if the model converged perfectly." +/// - Solid line through the convergence points +/// - Distinct markers on `reached` rows (small filled dots, +/// theme accent color) +pub fn renderConvergenceChart( + io: std.Io, + alloc: std.mem.Allocator, + points: []const forecast.ConvergencePoint, + width_px: u32, + height_px: u32, + th: theme.Theme, +) !ChartResult { + if (points.len < 2) return error.InsufficientData; + + const w: i32 = @intCast(width_px); + const h: i32 = @intCast(height_px); + var sfc = try Surface.init(.image_surface_rgb, alloc, w, h); + defer sfc.deinit(alloc); + + var ctx = Context.init(io, alloc, &sfc); + defer ctx.deinit(); + + ctx.setAntiAliasingMode(.none); + ctx.setOperator(.src); + + const bg = th.bg; + const fwidth: f64 = @floatFromInt(width_px); + const fheight: f64 = @floatFromInt(height_px); + + // Background + ctx.setSourceToPixel(opaqueColor(bg)); + ctx.resetPath(); + try ctx.moveTo(0, 0); + try ctx.lineTo(fwidth, 0); + try ctx.lineTo(fwidth, fheight); + try ctx.lineTo(0, fheight); + try ctx.closePath(); + try ctx.fill(); + + const chart_left = margin_left; + const chart_right = fwidth - margin_right; + const chart_w = chart_right - chart_left; + const chart_top = margin_top; + const chart_bottom = fheight - margin_bottom; + + // X-range: observation_date span + const x0_days: f64 = @floatFromInt(points[0].observation_date.days); + const x1_days: f64 = @floatFromInt(points[points.len - 1].observation_date.days); + const x_span: f64 = if (x1_days > x0_days) x1_days - x0_days else 1.0; + + // Y-range: years_until_retirement, padded + const y_min: f64 = 0; + var y_max: f64 = 0; + for (points) |p| { + if (p.years_until_retirement > y_max) y_max = p.years_until_retirement; + } + // The reference line ends at `points[0].years_until_retirement - + // (x1 - x0) / 365.25`, which can be negative. Clamp the y-range + // floor at 0 — negative years-until-retirement isn't a + // meaningful display value. + if (y_max < 1) y_max = 1; // ensure at least a 1-year scale + const y_pad = y_max * 0.1; + y_max += y_pad; + + // Grid lines + const grid_color = blendColor(th.text_muted, 40, bg); + try drawHorizontalGridLines(&ctx, chart_left, chart_right, chart_top, chart_bottom, 5, grid_color); + + // Reference line: slope = -1 year/year, starting at the leftmost + // anchor's years_until_retirement value. If a point converges + // perfectly it'd lie on this reference. + { + const ref_start_y = points[0].years_until_retirement; + const x_years_span = x_span / 365.25; + const ref_end_y = ref_start_y - x_years_span; + const ref_color = blendColor(th.text_muted, 100, bg); + ctx.setSourceToPixel(ref_color); + ctx.setLineWidth(1.0); + // Dashed: emit segment-pairs. + const dash_len: f64 = 6.0; + const gap_len: f64 = 4.0; + var dx: f64 = 0; + const total_pixels = chart_w; + while (dx < total_pixels) { + const dx_end = @min(dx + dash_len, total_pixels); + const f0 = dx / total_pixels; + const f1 = dx_end / total_pixels; + const y0 = mapY(ref_start_y + (ref_end_y - ref_start_y) * f0, y_min, y_max, chart_top, chart_bottom); + const y1 = mapY(ref_start_y + (ref_end_y - ref_start_y) * f1, y_min, y_max, chart_top, chart_bottom); + ctx.resetPath(); + try ctx.moveTo(chart_left + dx, y0); + try ctx.lineTo(chart_left + dx_end, y1); + try ctx.stroke(); + dx = dx_end + gap_len; + } + ctx.setLineWidth(2.0); + } + + // Main series: solid line through all points, theme accent. + { + ctx.setSourceToPixel(opaqueColor(th.accent)); + ctx.setLineWidth(2.0); + ctx.resetPath(); + for (points, 0..) |p, i| { + const dx_days: f64 = @floatFromInt(p.observation_date.days); + const x_frac = (dx_days - x0_days) / x_span; + const x = chart_left + x_frac * chart_w; + const y = mapY(p.years_until_retirement, y_min, y_max, chart_top, chart_bottom); + if (i == 0) try ctx.moveTo(x, y) else try ctx.lineTo(x, y); + } + try ctx.stroke(); + } + + // Reached markers (small filled dots). + { + ctx.setSourceToPixel(opaqueColor(th.positive)); + const dot_radius: f64 = 2.5; + for (points) |p| { + if (!p.reached) continue; + const dx_days: f64 = @floatFromInt(p.observation_date.days); + const x_frac = (dx_days - x0_days) / x_span; + const x = chart_left + x_frac * chart_w; + const y = mapY(p.years_until_retirement, y_min, y_max, chart_top, chart_bottom); + try fillCircle(&ctx, x, y, dot_radius); + } + } + + // Border + try drawRect(&ctx, chart_left, chart_top, chart_right, chart_bottom, blendColor(th.text_muted, 60, bg), 1.0); + + return .{ + .rgb_data = try extractRgb(alloc, &sfc), + .width = @intCast(width_px), + .height = @intCast(height_px), + .value_min = y_min, + .value_max = y_max, + }; +} + +// ── View 2: Return back-test chart ─────────────────────────── + +/// Pivot of `forecast.BacktestPoint` rows into a single anchor's +/// realized-by-horizon view. One per anchor; passed to +/// `renderBacktestChart` as the renderer-friendly shape. +pub const BacktestAnchor = forecast.BacktestAnchor; + +/// Render the return back-test chart. X-axis spans the anchor +/// dates; y-axis is decimal return rate. Renders four lines with +/// distinct hues so the legend is unambiguous; line styles +/// (dotted/dashed/solid) reinforce it for color-blind users: +/// - `expected` (solid, theme accent — purple) +/// - `realized_1y` (dotted, theme info — cyan) +/// - `realized_3y` (dashed, theme warning — yellow) +/// - `realized_5y` (solid, theme positive — green) +/// +/// Plus a y=0 reference line. +pub fn renderBacktestChart( + io: std.Io, + alloc: std.mem.Allocator, + anchors: []const BacktestAnchor, + width_px: u32, + height_px: u32, + th: theme.Theme, +) !ChartResult { + if (anchors.len < 2) return error.InsufficientData; + + const w: i32 = @intCast(width_px); + const h: i32 = @intCast(height_px); + var sfc = try Surface.init(.image_surface_rgb, alloc, w, h); + defer sfc.deinit(alloc); + + var ctx = Context.init(io, alloc, &sfc); + defer ctx.deinit(); + + ctx.setAntiAliasingMode(.none); + ctx.setOperator(.src); + + const bg = th.bg; + const fwidth: f64 = @floatFromInt(width_px); + const fheight: f64 = @floatFromInt(height_px); + + // Background + ctx.setSourceToPixel(opaqueColor(bg)); + ctx.resetPath(); + try ctx.moveTo(0, 0); + try ctx.lineTo(fwidth, 0); + try ctx.lineTo(fwidth, fheight); + try ctx.lineTo(0, fheight); + try ctx.closePath(); + try ctx.fill(); + + const chart_left = margin_left; + const chart_right = fwidth - margin_right; + const chart_w = chart_right - chart_left; + const chart_top = margin_top; + const chart_bottom = fheight - margin_bottom; + + // X-range + const x0_days: f64 = @floatFromInt(anchors[0].anchor_date.days); + const x1_days: f64 = @floatFromInt(anchors[anchors.len - 1].anchor_date.days); + const x_span: f64 = if (x1_days > x0_days) x1_days - x0_days else 1.0; + + // Y-range across all four series — include realized_* even + // when null (skip nulls without contributing). + var y_min: f64 = 0; + var y_max: f64 = 0; + for (anchors) |a| { + if (a.expected < y_min) y_min = a.expected; + if (a.expected > y_max) y_max = a.expected; + if (a.realized_1y) |v| { + if (v < y_min) y_min = v; + if (v > y_max) y_max = v; + } + if (a.realized_3y) |v| { + if (v < y_min) y_min = v; + if (v > y_max) y_max = v; + } + if (a.realized_5y) |v| { + if (v < y_min) y_min = v; + if (v > y_max) y_max = v; + } + } + const y_range = y_max - y_min; + const y_pad = if (y_range > 0) y_range * 0.10 else 0.05; + y_min -= y_pad; + y_max += y_pad; + if (y_min > 0) y_min = 0; // ensure y=0 is in view for the reference line + + // Grid lines + y=0 reference (subtle but distinct from the grid). + const grid_color = blendColor(th.text_muted, 40, bg); + try drawHorizontalGridLines(&ctx, chart_left, chart_right, chart_top, chart_bottom, 5, grid_color); + if (y_min < 0 and y_max > 0) { + const zero_y = mapY(0, y_min, y_max, chart_top, chart_bottom); + try drawHLine(&ctx, chart_left, chart_right, zero_y, blendColor(th.text_muted, 100, bg), 1.0); + } + + // Realized series first (so they're below the expected line in z-order). + // Distinct hues per horizon (cyan/yellow/green) so the legend + // is unambiguous; line styles (dotted/dashed/solid) reinforce + // it for users who are color-blind or running a low-contrast + // theme. Keep these aligned with the legend lines emitted by + // `drawBacktestWithKitty` in `projections_tab.zig`. + try drawSeries(&ctx, anchors, .realized_1y, x0_days, x_span, chart_left, chart_w, y_min, y_max, chart_top, chart_bottom, opaqueColor(th.info), 1.5, .dotted); + + try drawSeries(&ctx, anchors, .realized_3y, x0_days, x_span, chart_left, chart_w, y_min, y_max, chart_top, chart_bottom, opaqueColor(th.warning), 1.5, .dashed); + + try drawSeries(&ctx, anchors, .realized_5y, x0_days, x_span, chart_left, chart_w, y_min, y_max, chart_top, chart_bottom, opaqueColor(th.positive), 2.0, .solid); + + // Expected series last (on top): solid, accent, full opacity, bold width. + try drawSeries(&ctx, anchors, .expected, x0_days, x_span, chart_left, chart_w, y_min, y_max, chart_top, chart_bottom, opaqueColor(th.accent), 2.0, .solid); + + // Border + try drawRect(&ctx, chart_left, chart_top, chart_right, chart_bottom, blendColor(th.text_muted, 60, bg), 1.0); + + return .{ + .rgb_data = try extractRgb(alloc, &sfc), + .width = @intCast(width_px), + .height = @intCast(height_px), + .value_min = y_min, + .value_max = y_max, + }; +} + +const SeriesKey = enum { expected, realized_1y, realized_3y, realized_5y }; +const LineStyle = enum { solid, dashed, dotted }; +const DashPattern = struct { on: f64, off: f64 }; + +fn anchorValue(a: BacktestAnchor, key: SeriesKey) ?f64 { + return switch (key) { + .expected => a.expected, + .realized_1y => a.realized_1y, + .realized_3y => a.realized_3y, + .realized_5y => a.realized_5y, + }; +} + +/// Draw one series across the anchor list, skipping null values. +/// Disconnected (null-bridging) segments are emitted as separate +/// strokes — the line "lifts" over missing data rather than +/// drawing a phantom horizontal segment. +fn drawSeries( + ctx: *Context, + anchors: []const BacktestAnchor, + key: SeriesKey, + x0_days: f64, + x_span: f64, + chart_left: f64, + chart_w: f64, + y_min: f64, + y_max: f64, + chart_top: f64, + chart_bottom: f64, + color: Pixel, + line_w: f64, + style: LineStyle, +) !void { + ctx.setSourceToPixel(color); + ctx.setLineWidth(line_w); + + const dash_pattern: ?DashPattern = switch (style) { + .solid => null, + .dashed => .{ .on = 6.0, .off = 4.0 }, + .dotted => .{ .on = 2.0, .off = 3.0 }, + }; + + var have_segment = false; + + // Emit one stroke per contiguous run of non-null values. + // A null value breaks the run. + for (anchors, 0..) |a, i| { + const v_opt = anchorValue(a, key); + if (v_opt) |_| { + have_segment = true; + // If this is the last anchor, flush the segment. + if (i == anchors.len - 1) { + try strokeSegment(ctx, anchors, key, x0_days, x_span, chart_left, chart_w, y_min, y_max, chart_top, chart_bottom, dash_pattern); + have_segment = false; + } + } else if (have_segment) { + // Run broke. Stroke from segment start to last-known endpoint. + try strokeSegment(ctx, anchors[0..i], key, x0_days, x_span, chart_left, chart_w, y_min, y_max, chart_top, chart_bottom, dash_pattern); + have_segment = false; + } + } + + ctx.setLineWidth(2.0); +} + +/// Stroke the contiguous non-null segment of `anchors` for `key`. +/// For dashed/dotted styles, the segment is rasterized as +/// independent dash-length strokes rather than one continuous +/// path with z2d's dash array (which we don't use for cross-version +/// stability). Solid styles emit one continuous stroke. +fn strokeSegment( + ctx: *Context, + anchors: []const BacktestAnchor, + key: SeriesKey, + x0_days: f64, + x_span: f64, + chart_left: f64, + chart_w: f64, + y_min: f64, + y_max: f64, + chart_top: f64, + chart_bottom: f64, + dash: ?DashPattern, +) !void { + if (dash) |d| { + // Segment-by-segment with manual dashing along each + // pixel-length straight line between consecutive points. + var prev_x: ?f64 = null; + var prev_y: ?f64 = null; + for (anchors) |a| { + const v_opt = anchorValue(a, key); + if (v_opt) |v| { + const dx_days: f64 = @floatFromInt(a.anchor_date.days); + const x = chart_left + ((dx_days - x0_days) / x_span) * chart_w; + const y = mapY(v, y_min, y_max, chart_top, chart_bottom); + if (prev_x) |px| { + const py = prev_y.?; + try drawDashedLine(ctx, px, py, x, y, d.on, d.off); + } + prev_x = x; + prev_y = y; + } else { + prev_x = null; + prev_y = null; + } + } + } else { + // Solid: one path, then stroke. + var first = true; + ctx.resetPath(); + for (anchors) |a| { + const v_opt = anchorValue(a, key); + if (v_opt) |v| { + const dx_days: f64 = @floatFromInt(a.anchor_date.days); + const x = chart_left + ((dx_days - x0_days) / x_span) * chart_w; + const y = mapY(v, y_min, y_max, chart_top, chart_bottom); + if (first) { + try ctx.moveTo(x, y); + first = false; + } else { + try ctx.lineTo(x, y); + } + } + } + if (!first) try ctx.stroke(); + } +} + +fn drawDashedLine(ctx: *Context, x1: f64, y1: f64, x2: f64, y2: f64, dash_on: f64, dash_off: f64) !void { + const dx = x2 - x1; + const dy = y2 - y1; + const len = std.math.sqrt(dx * dx + dy * dy); + if (len <= 0) return; + + const ux = dx / len; + const uy = dy / len; + var t: f64 = 0; + while (t < len) { + const t_end = @min(t + dash_on, len); + const sx = x1 + t * ux; + const sy = y1 + t * uy; + const ex = x1 + t_end * ux; + const ey = y1 + t_end * uy; + ctx.resetPath(); + try ctx.moveTo(sx, sy); + try ctx.lineTo(ex, ey); + try ctx.stroke(); + t = t_end + dash_off; + } +} + +fn fillCircle(ctx: *Context, cx: f64, cy: f64, r: f64) !void { + // z2d doesn't expose `arc` here at present; approximate with + // an N-sided polygon. 12 sides is plenty for a 2-3 px dot. + const n: usize = 12; + ctx.resetPath(); + var i: usize = 0; + while (i < n) : (i += 1) { + const ang = @as(f64, @floatFromInt(i)) * 2.0 * std.math.pi / @as(f64, @floatFromInt(n)); + const x = cx + r * @cos(ang); + const y = cy + r * @sin(ang); + if (i == 0) try ctx.moveTo(x, y) else try ctx.lineTo(x, y); + } + try ctx.closePath(); + try ctx.fill(); +} + +// ── Shared helpers (mirrors of projection_chart's privates) ─── + +fn mapY(value: f64, min_val: f64, max_val: f64, top_px: f64, bottom_px: f64) f64 { + if (max_val == min_val) return (top_px + bottom_px) / 2; + const norm = (value - min_val) / (max_val - min_val); + return bottom_px - norm * (bottom_px - top_px); +} + +fn blendColor(fg: [3]u8, alpha: u8, bg_color: [3]u8) Pixel { + const a = @as(f64, @floatFromInt(alpha)) / 255.0; + const inv_a = 1.0 - a; + return .{ .rgb = .{ + .r = @intFromFloat(@as(f64, @floatFromInt(fg[0])) * a + @as(f64, @floatFromInt(bg_color[0])) * inv_a), + .g = @intFromFloat(@as(f64, @floatFromInt(fg[1])) * a + @as(f64, @floatFromInt(bg_color[1])) * inv_a), + .b = @intFromFloat(@as(f64, @floatFromInt(fg[2])) * a + @as(f64, @floatFromInt(bg_color[2])) * inv_a), + } }; +} + +fn opaqueColor(c: [3]u8) Pixel { + return .{ .rgb = .{ .r = c[0], .g = c[1], .b = c[2] } }; +} + +fn drawHorizontalGridLines( + ctx: *Context, + left: f64, + right: f64, + top: f64, + bottom: f64, + n_lines: usize, + col: Pixel, +) !void { + ctx.setSourceToPixel(col); + ctx.setLineWidth(0.5); + for (1..n_lines) |i| { + const frac = @as(f64, @floatFromInt(i)) / @as(f64, @floatFromInt(n_lines)); + const y = top + frac * (bottom - top); + ctx.resetPath(); + try ctx.moveTo(left, y); + try ctx.lineTo(right, y); + try ctx.stroke(); + } + ctx.setLineWidth(2.0); +} + +fn drawHLine(ctx: *Context, x1: f64, x2: f64, y: f64, col: Pixel, line_w: f64) !void { + ctx.setSourceToPixel(col); + ctx.setLineWidth(line_w); + ctx.resetPath(); + try ctx.moveTo(x1, y); + try ctx.lineTo(x2, y); + try ctx.stroke(); + ctx.setLineWidth(2.0); +} + +fn drawRect(ctx: *Context, x1: f64, y1: f64, x2: f64, y2: f64, col: Pixel, line_w: f64) !void { + ctx.setSourceToPixel(col); + ctx.setLineWidth(line_w); + ctx.resetPath(); + try ctx.moveTo(x1, y1); + try ctx.lineTo(x2, y1); + try ctx.lineTo(x2, y2); + try ctx.lineTo(x1, y2); + try ctx.closePath(); + try ctx.stroke(); + ctx.setLineWidth(2.0); +} + +/// Extract raw RGB bytes from an `image_surface_rgb`. Mirrors the +/// inline pattern in `projection_chart.zig` so both renderers +/// produce the same on-the-wire shape for Kitty graphics +/// transmission. Caller owns the returned slice. +fn extractRgb(alloc: std.mem.Allocator, sfc: *const Surface) ![]u8 { + const rgb_buf = switch (sfc.*) { + .image_surface_rgb => |s| s.buf, + else => unreachable, + }; + const out = try alloc.alloc(u8, rgb_buf.len * 3); + for (rgb_buf, 0..) |px, i| { + out[i * 3 + 0] = px.r; + out[i * 3 + 1] = px.g; + out[i * 3 + 2] = px.b; + } + return out; +} + +// ── Tests ───────────────────────────────────────────────────── + +const testing = std.testing; + +test "renderConvergenceChart produces RGB output" { + const points = [_]forecast.ConvergencePoint{ + .{ .observation_date = Date.fromYmd(2020, 1, 1), .projected_date = Date.fromYmd(2030, 1, 1), .years_until_retirement = 10.0, .reached = false }, + .{ .observation_date = Date.fromYmd(2022, 1, 1), .projected_date = Date.fromYmd(2030, 1, 1), .years_until_retirement = 8.0, .reached = false }, + .{ .observation_date = Date.fromYmd(2025, 1, 1), .projected_date = Date.fromYmd(2025, 1, 1), .years_until_retirement = 0.0, .reached = true }, + }; + const th = theme.default_theme; + const result = try renderConvergenceChart(testing.io, testing.allocator, &points, 200, 100, th); + defer testing.allocator.free(result.rgb_data); + try testing.expectEqual(@as(u16, 200), result.width); + try testing.expectEqual(@as(u16, 100), result.height); + try testing.expectEqual(@as(usize, 200 * 100 * 3), result.rgb_data.len); +} + +test "renderConvergenceChart insufficient data" { + const points = [_]forecast.ConvergencePoint{ + .{ .observation_date = Date.fromYmd(2020, 1, 1), .projected_date = Date.fromYmd(2030, 1, 1), .years_until_retirement = 10.0, .reached = false }, + }; + const th = theme.default_theme; + const result = renderConvergenceChart(testing.io, testing.allocator, &points, 200, 100, th); + try testing.expectError(error.InsufficientData, result); +} + +test "renderBacktestChart produces RGB output with all four series" { + const anchors = [_]BacktestAnchor{ + .{ .anchor_date = Date.fromYmd(2018, 1, 1), .expected = 0.10, .realized_1y = 0.12, .realized_3y = 0.09, .realized_5y = 0.08 }, + .{ .anchor_date = Date.fromYmd(2020, 1, 1), .expected = 0.08, .realized_1y = 0.18, .realized_3y = 0.10, .realized_5y = null }, + .{ .anchor_date = Date.fromYmd(2022, 1, 1), .expected = 0.12, .realized_1y = -0.05, .realized_3y = null, .realized_5y = null }, + .{ .anchor_date = Date.fromYmd(2024, 1, 1), .expected = 0.07, .realized_1y = null, .realized_3y = null, .realized_5y = null }, + }; + const th = theme.default_theme; + const result = try renderBacktestChart(testing.io, testing.allocator, &anchors, 200, 100, th); + defer testing.allocator.free(result.rgb_data); + try testing.expectEqual(@as(u16, 200), result.width); + try testing.expect(result.value_max > result.value_min); + // Y range should include at least y=0 (we force it in) + try testing.expect(result.value_min <= 0); +} + +test "renderBacktestChart insufficient data" { + const anchors = [_]BacktestAnchor{ + .{ .anchor_date = Date.fromYmd(2020, 1, 1), .expected = 0.10, .realized_1y = null, .realized_3y = null, .realized_5y = null }, + }; + const th = theme.default_theme; + const result = renderBacktestChart(testing.io, testing.allocator, &anchors, 200, 100, th); + try testing.expectError(error.InsufficientData, result); +} diff --git a/src/tui/projections_tab.zig b/src/tui/projections_tab.zig index d31d244..cdf2ba2 100644 --- a/src/tui/projections_tab.zig +++ b/src/tui/projections_tab.zig @@ -28,7 +28,12 @@ const theme = @import("theme.zig"); const tui = @import("../tui.zig"); const chart = @import("chart.zig"); const projection_chart = @import("projection_chart.zig"); +const forecast_chart = @import("forecast_chart.zig"); const projections = @import("../analytics/projections.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 benchmark = @import("../analytics/benchmark.zig"); const performance = @import("../analytics/performance.zig"); const valuation = @import("../analytics/valuation.zig"); @@ -67,6 +72,15 @@ pub const Action = enum { /// 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, }; // ── Tab-private state ───────────────────────────────────────── @@ -129,6 +143,25 @@ pub const State = struct { /// message and leaves this off otherwise. overlay_actuals: bool = false, + /// 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 `tui/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 @@ -137,6 +170,20 @@ pub const State = struct { 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. @@ -164,6 +211,8 @@ pub const tab = struct { .{ .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' } }, }; pub const action_labels = std.enums.EnumArray(Action, []const u8).init(.{ @@ -172,12 +221,16 @@ pub const tab = struct { .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", }); pub const status_hints: []const Action = &.{ .toggle_chart, .toggle_events, .as_of_input, + .toggle_convergence, + .toggle_return_backtest, }; pub fn init(state: *State, app: *App) !void { @@ -293,6 +346,33 @@ pub const tab = struct { tab.reload(state, app) catch {}; 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; + }, } } @@ -531,10 +611,107 @@ pub fn freeLoaded(state: *State, app: *App) void { 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.portfolio_path 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 { @@ -546,6 +723,34 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va .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(); @@ -1157,6 +1362,321 @@ fn appendAccumulationBlocks( } } +// ── 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 diff --git a/src/tui/theme.zig b/src/tui/theme.zig index 55a53ce..4c1f8f9 100644 --- a/src/tui/theme.zig +++ b/src/tui/theme.zig @@ -136,6 +136,8 @@ pub const Theme = struct { .positive => self.positiveStyle(), .negative => self.negativeStyle(), .warning => self.warningStyle(), + .accent => .{ .fg = vcolor(self.accent), .bg = vcolor(self.bg) }, + .info => self.infoStyle(), }; } diff --git a/src/views/projections.zig b/src/views/projections.zig index d48bce4..293484e 100644 --- a/src/views/projections.zig +++ b/src/views/projections.zig @@ -8,6 +8,7 @@ const Money = @import("../Money.zig"); const performance = @import("../analytics/performance.zig"); const benchmark = @import("../analytics/benchmark.zig"); const projections = @import("../analytics/projections.zig"); +const forecast = @import("../analytics/forecast_evaluation.zig"); const timeline = @import("../analytics/timeline.zig"); const valuation = @import("../analytics/valuation.zig"); const zfin = @import("../root.zig"); @@ -1136,6 +1137,361 @@ pub fn fmtEventLine(arena: std.mem.Allocator, ev: *const projections.LifeEvent, return .{ .text = text, .style = style }; } +// ── Forecast-vs-actual sub-views ────────────────────────────── +// +// `convergenceLines` and `backtestLines` produce renderer-agnostic +// pre-formatted lines for the two forecast-evaluation sub-views. +// The CLI command (`zfin projections --convergence` / `--return-backtest`) +// emits each line with ANSI per `intent`; the TUI scroll fallback +// emits each line with `theme.styleFor(intent)`. Same source of +// truth for column widths, header text, and stride logic — the +// previous implementation drift produced overflow in the TUI +// fallback, which is why this module exists at all. + +/// One line in the forecast-evaluation sub-view output. +/// +/// `bold` is honored by renderers that support bold (CLI via +/// `setBold`, TUI via the `vaxis.Style.bold` flag in the theme's +/// `headerStyle`). Renderers that don't support it ignore the +/// flag. +pub const ForecastLine = struct { + text: []const u8, + intent: StyleIntent, + bold: bool = false, +}; + +/// Column widths for the convergence table. Header underlines +/// are derived from these at emit time so the dashes can never +/// drift from the data widths. +const conv_col_observed = 12; // "YYYY-MM-DD" +const conv_col_projected = 12; // "YYYY-MM-DD" or "reached" +const conv_col_years = 14; // "Years until" → "12.34" + +/// Format strings derived from the column widths above. Built +/// at comptime so widths and dash counts stay in sync. +const conv_header_fmt = std.fmt.comptimePrint( + " {{s:<{d}}} {{s:<{d}}} {{s:>{d}}}", + .{ conv_col_observed, conv_col_projected, conv_col_years }, +); +const conv_sep_fmt = std.fmt.comptimePrint( + " {{s:-<{d}}} {{s:-<{d}}} {{s:->{d}}}", + .{ conv_col_observed, conv_col_projected, conv_col_years }, +); +const conv_row_fmt = std.fmt.comptimePrint( + " {{f}} {{s:<{d}}} {{s:>{d}}}", + .{ conv_col_projected, conv_col_years }, +); + +/// Build the renderer-agnostic line list for the convergence +/// sub-view. Stride logic mirrors the CLI: show first, last, +/// and every Nth observation in between for ~quarterly cadence +/// on weekly imported data. +/// +/// Caller owns nothing — all output strings are allocated in +/// `arena`. Output is the concatenation of `convergenceHeaderLines` +/// and `convergenceTableLines`; the two halves are exposed +/// separately so chart renderers can reuse just the header. +pub fn convergenceLines( + arena: std.mem.Allocator, + points: []const forecast.ConvergencePoint, +) ![]const ForecastLine { + var lines: std.ArrayList(ForecastLine) = .empty; + const header = try convergenceHeaderLines(arena, points); + try lines.appendSlice(arena, header); + if (points.len > 0) { + const table = try convergenceTableLines(arena, points); + try lines.appendSlice(arena, table); + } + return lines.toOwnedSlice(arena); +} + +/// Header section for the convergence sub-view: title, range, +/// caveat. Chart renderers use this directly; table renderers +/// use it via `convergenceLines`. +pub fn convergenceHeaderLines( + arena: std.mem.Allocator, + points: []const forecast.ConvergencePoint, +) ![]const ForecastLine { + var lines: std.ArrayList(ForecastLine) = .empty; + + try lines.append(arena, .{ + .text = "Projection convergence (spreadsheet-projected retirement date over time)", + .intent = .accent, + .bold = true, + }); + + if (points.len == 0) { + try lines.append(arena, .{ + .text = " No convergence data available (imported_values.srf empty or missing projected_retirement fields).", + .intent = .muted, + }); + return lines.toOwnedSlice(arena); + } + + try lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, " {d} observations from {f} → {f}", .{ + points.len, points[0].observation_date, points[points.len - 1].observation_date, + }), + .intent = .muted, + }); + try lines.append(arena, .{ + .text = " Caveat: tracks the model's directional honesty, not SWR validity.", + .intent = .muted, + }); + try lines.append(arena, .{ .text = "", .intent = .normal }); + + return lines.toOwnedSlice(arena); +} + +/// Table section for the convergence sub-view: column header + +/// separator + sampled body rows + optional stride caption. +/// Caller-provided `points` must be non-empty (the header +/// already handles the empty case). +pub fn convergenceTableLines( + arena: std.mem.Allocator, + points: []const forecast.ConvergencePoint, +) ![]const ForecastLine { + var lines: std.ArrayList(ForecastLine) = .empty; + + try lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, conv_header_fmt, .{ "Observed", "Projected", "Years until" }), + .intent = .muted, + }); + try lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, conv_sep_fmt, .{ "", "", "" }), + .intent = .muted, + }); + + const stride: usize = if (points.len > 26) (points.len + 25) / 26 else 1; + for (points, 0..) |p, i| { + if (i != 0 and i != points.len - 1 and (i % stride) != 0) continue; + + var proj_buf: [16]u8 = undefined; + const proj_str: []const u8 = if (p.reached) + "reached" + else + std.fmt.bufPrint(&proj_buf, "{f}", .{p.projected_date}) catch "??????????"; + + var years_buf: [16]u8 = undefined; + const years_str: []const u8 = if (p.reached) + "0.00" + else + std.fmt.bufPrint(&years_buf, "{d:.2}", .{p.years_until_retirement}) catch "??"; + + try lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, conv_row_fmt, .{ p.observation_date.padLeft(conv_col_observed), proj_str, years_str }), + .intent = .normal, + }); + } + + if (stride > 1) { + try lines.append(arena, .{ .text = "", .intent = .normal }); + try lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, " (Showing every {d}th observation — full chart on TUI projections tab.)", .{stride}), + .intent = .muted, + }); + } + + return lines.toOwnedSlice(arena); +} + +/// Column widths for the back-test table. Same source of truth +/// for CLI and TUI fallback. The numeric column is 11 cols wide +/// so the em-dash sentinel can sit dead center (5 leading + dash +/// + 5 trailing); 6-char numeric values like `12.34%` end up +/// right-aligned with 5 leading spaces. +const bt_col_anchor = 12; // "YYYY-MM-DD" +const bt_col_value = 11; // "12.34%" right-aligned / "—" centered + +const bt_header_fmt = std.fmt.comptimePrint( + " {{s:<{d}}} {{s}} {{s}} {{s}} {{s}}", + .{bt_col_anchor}, +); +const bt_sep_fmt = std.fmt.comptimePrint( + " {{s:-<{d}}} {{s:->{d}}} {{s:->{d}}} {{s:->{d}}} {{s:->{d}}}", + .{ bt_col_anchor, bt_col_value, bt_col_value, bt_col_value, bt_col_value }, +); +// Data-row format: numeric cells right-aligned via `{s:>11}` +// (safe — they're pure ASCII). Missing cells use the hard-coded +// `dash_cell` literal which is already 11 display cols wide; it +// passes through `{s:>11}` unchanged because the format spec +// doesn't truncate when content meets/exceeds the width. +const bt_row_fmt = std.fmt.comptimePrint( + " {{f}} {{s:>{d}}} {{s:>{d}}} {{s:>{d}}} {{s:>{d}}}", + .{ bt_col_value, bt_col_value, bt_col_value, bt_col_value }, +); + +/// Pre-centered column headers for the back-test table. Each is +/// exactly `bt_col_value` (11) display columns wide so they line +/// up with the data rows below. Hard-coded because the labels +/// are fixed at compile time — no need for a runtime centering +/// helper. +const bt_hdr_expected = " Expected "; // 1 lead + 8 chars + 2 trail = 11 +const bt_hdr_1y = " 1y "; // 4 lead + 2 chars + 5 trail = 11 +const bt_hdr_3y = " 3y "; // 4 lead + 2 chars + 5 trail = 11 +const bt_hdr_5y = " 5y "; // 4 lead + 2 chars + 5 trail = 11 + +/// Build the renderer-agnostic line list for the return-backtest +/// sub-view. `real_mode` toggles a methodology caption (whether +/// realized values are inflation-deflated). Stride logic shows +/// at most ~30 anchors. +/// +/// Caller owns nothing — all output strings are allocated in +/// `arena`. Output is the concatenation of `backtestHeaderLines` +/// and `backtestTableLines`. +pub fn backtestLines( + arena: std.mem.Allocator, + anchors: []const forecast.BacktestAnchor, + real_mode: bool, +) ![]const ForecastLine { + var lines: std.ArrayList(ForecastLine) = .empty; + const header = try backtestHeaderLines(arena, anchors, real_mode); + try lines.appendSlice(arena, header); + if (anchors.len > 0) { + const table = try backtestTableLines(arena, anchors); + try lines.appendSlice(arena, table); + } + return lines.toOwnedSlice(arena); +} + +/// Header section for the back-test sub-view: title, range, +/// color-coded legend, methodology caption, caveat. Chart +/// renderers use this directly; table renderers use it via +/// `backtestLines`. +pub fn backtestHeaderLines( + arena: std.mem.Allocator, + anchors: []const forecast.BacktestAnchor, + real_mode: bool, +) ![]const ForecastLine { + var lines: std.ArrayList(ForecastLine) = .empty; + + try lines.append(arena, .{ + .text = "Expected vs realized return back-test", + .intent = .accent, + .bold = true, + }); + + if (anchors.len == 0) { + try lines.append(arena, .{ + .text = " No back-test data available (imported_values.srf empty or missing expected_return fields).", + .intent = .muted, + }); + return lines.toOwnedSlice(arena); + } + + try lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, " {d} anchors from {f} → {f}", .{ + anchors.len, anchors[0].anchor_date, anchors[anchors.len - 1].anchor_date, + }), + .intent = .muted, + }); + + // Color-coded legend. Each line is rendered in the matching + // chart series color (purple/cyan/yellow/green) so the user + // can map line → series at a glance. Line styles + // (solid/dashed/dotted) reinforce the distinction for + // color-blind users. + try lines.append(arena, .{ + .text = " Expected (solid) — projected return at each anchor date", + .intent = .accent, + }); + try lines.append(arena, .{ + .text = " Realized 1y (dotted)", + .intent = .info, + }); + try lines.append(arena, .{ + .text = " Realized 3y (dashed)", + .intent = .warning, + }); + try lines.append(arena, .{ + .text = " Realized 5y (solid)", + .intent = .positive, + }); + + if (real_mode) { + try lines.append(arena, .{ + .text = " realized = inflation-deflated forward CAGR (Shiller CPI). expected is left nominal.", + .intent = .muted, + }); + } + try lines.append(arena, .{ + .text = " Caveat: tracks the model's expected-return honesty, not SWR validity.", + .intent = .muted, + }); + try lines.append(arena, .{ .text = "", .intent = .normal }); + + return lines.toOwnedSlice(arena); +} + +/// Hard-coded em-dash sentinel cell for missing back-test +/// values. 5 leading spaces + `—` + 5 trailing spaces = 11 +/// display columns. Sits dead center within the 11-col numeric +/// column. +const dash_cell = " — "; + +/// Table section for the back-test sub-view. Caller-provided +/// `anchors` must be non-empty. +pub fn backtestTableLines( + arena: std.mem.Allocator, + anchors: []const forecast.BacktestAnchor, +) ![]const ForecastLine { + var lines: std.ArrayList(ForecastLine) = .empty; + + try lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, bt_header_fmt, .{ " Anchor", bt_hdr_expected, bt_hdr_1y, bt_hdr_3y, bt_hdr_5y }), + .intent = .muted, + }); + try lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, bt_sep_fmt, .{ "", "", "", "", "" }), + .intent = .muted, + }); + + const stride: usize = if (anchors.len > 30) (anchors.len + 29) / 30 else 1; + for (anchors, 0..) |a, idx| { + if (idx != 0 and idx != anchors.len - 1 and (idx % stride) != 0) continue; + + var ebuf: [16]u8 = undefined; + var r1_buf: [16]u8 = undefined; + var r3_buf: [16]u8 = undefined; + var r5_buf: [16]u8 = undefined; + + const e_cell = std.fmt.bufPrint(&ebuf, "{d:.2}% ", .{a.expected * 100}) catch "??"; + + // Numeric cells are pure ASCII so the format string's + // `{s:>10}` byte-padding lines them up correctly. Missing + // cells use the hard-coded `dash_cell` literal which is + // already shaped to 10 display columns (Zig's byte-padding + // would under-pad the multibyte em-dash by 2 cols). + const r1_cell: []const u8 = if (a.realized_1y) |v| + std.fmt.bufPrint(&r1_buf, "{d:.2}% ", .{v * 100}) catch "??" + else + dash_cell; + const r3_cell: []const u8 = if (a.realized_3y) |v| + std.fmt.bufPrint(&r3_buf, "{d:.2}% ", .{v * 100}) catch "??" + else + dash_cell; + const r5_cell: []const u8 = if (a.realized_5y) |v| + std.fmt.bufPrint(&r5_buf, "{d:.2}% ", .{v * 100}) catch "??" + else + dash_cell; + + try lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, bt_row_fmt, .{ a.anchor_date.padLeft(bt_col_anchor), e_cell, r1_cell, r3_cell, r5_cell }), + .intent = .normal, + }); + } + + if (stride > 1) { + try lines.append(arena, .{ .text = "", .intent = .normal }); + try lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, " (Showing every {d}th anchor — full chart on TUI projections tab.)", .{stride}), + .intent = .muted, + }); + } + + return lines.toOwnedSlice(arena); +} + // ── Tests ────────────────────────────────────────────────────── test "fmtReturnCell positive" { @@ -1786,3 +2142,362 @@ test "buildOverlayActuals: empty range (today < as_of) produces empty points" { // can satisfy both — section is empty. try std.testing.expectEqual(@as(usize, 0), section.points.len); } + +// ── Forecast-vs-actual view-model tests ─────────────────────── + +test "convergenceLines: empty input yields title + 'no data' message" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const lines = try convergenceLines(arena.allocator(), &.{}); + try std.testing.expect(lines.len >= 2); + try std.testing.expect(lines[0].bold); + try std.testing.expectEqual(StyleIntent.accent, lines[0].intent); + // Second line is the muted "no data" caption. + try std.testing.expectEqual(StyleIntent.muted, lines[1].intent); +} + +test "convergenceLines: header constants drive aligned widths" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + const points = [_]forecast.ConvergencePoint{ + .{ + .observation_date = Date.fromYmd(2020, 1, 1), + .projected_date = Date.fromYmd(2030, 6, 15), + .years_until_retirement = 10.45, + .reached = false, + }, + .{ + .observation_date = Date.fromYmd(2025, 1, 1), + .projected_date = Date.fromYmd(2025, 1, 1), + .years_until_retirement = 0.0, + .reached = true, + }, + }; + const lines = try convergenceLines(arena.allocator(), &points); + // Find the column-header line (look for "Observed"). + var header_idx: ?usize = null; + for (lines, 0..) |ln, i| { + if (std.mem.indexOf(u8, ln.text, "Observed") != null) header_idx = i; + } + try std.testing.expect(header_idx != null); + const sep = lines[header_idx.? + 1].text; + // Separator is built from the same comptime widths as the header, + // so dash count must match the header's column extent. + try std.testing.expect(std.mem.indexOf(u8, sep, "------------") != null); +} + +test "backtestLines: emits color-coded legend for the chart series" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + const anchors = [_]forecast.BacktestAnchor{ + .{ + .anchor_date = Date.fromYmd(2020, 1, 1), + .expected = 0.08, + .realized_1y = 0.10, + .realized_3y = 0.12, + .realized_5y = null, + }, + }; + const lines = try backtestLines(arena.allocator(), &anchors, false); + + // Legend lines: each in a distinct intent so renderers can map + // line → series color. Counting matters because the chart + // renderer reads colors by intent and a missing legend line + // would silently break the user's "what is each color?" + // mental model. + var saw_accent = false; + var saw_info = false; + var saw_warning = false; + var saw_positive = false; + for (lines) |ln| { + if (ln.bold) continue; // skip title + if (std.mem.indexOf(u8, ln.text, "Expected (solid)") != null) { + try std.testing.expectEqual(StyleIntent.accent, ln.intent); + saw_accent = true; + } + if (std.mem.indexOf(u8, ln.text, "Realized 1y (dotted)") != null) { + try std.testing.expectEqual(StyleIntent.info, ln.intent); + saw_info = true; + } + if (std.mem.indexOf(u8, ln.text, "Realized 3y (dashed)") != null) { + try std.testing.expectEqual(StyleIntent.warning, ln.intent); + saw_warning = true; + } + if (std.mem.indexOf(u8, ln.text, "Realized 5y (solid)") != null) { + try std.testing.expectEqual(StyleIntent.positive, ln.intent); + saw_positive = true; + } + } + try std.testing.expect(saw_accent); + try std.testing.expect(saw_info); + try std.testing.expect(saw_warning); + try std.testing.expect(saw_positive); +} + +test "backtestLines: real_mode emits inflation-deflated caption" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + const anchors = [_]forecast.BacktestAnchor{ + .{ + .anchor_date = Date.fromYmd(2020, 1, 1), + .expected = 0.08, + .realized_1y = 0.10, + .realized_3y = null, + .realized_5y = null, + }, + }; + + const nominal = try backtestLines(arena.allocator(), &anchors, false); + var saw_nominal = false; + for (nominal) |ln| { + if (std.mem.indexOf(u8, ln.text, "inflation-deflated") != null) saw_nominal = true; + } + try std.testing.expect(!saw_nominal); + + const real = try backtestLines(arena.allocator(), &anchors, true); + var saw_real = false; + for (real) |ln| { + if (std.mem.indexOf(u8, ln.text, "inflation-deflated") != null) saw_real = true; + } + try std.testing.expect(saw_real); +} + +test "backtestLines: missing horizons render as em-dash" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + const anchors = [_]forecast.BacktestAnchor{ + .{ + .anchor_date = Date.fromYmd(2024, 6, 1), // recent → realized_5y missing + .expected = 0.08, + .realized_1y = 0.10, + .realized_3y = null, + .realized_5y = null, + }, + }; + const lines = try backtestLines(arena.allocator(), &anchors, false); + // The data row contains the anchor date AND the formatted + // expected percentage. Use the latter to disambiguate from + // the range-header line which also carries the date. + for (lines) |ln| { + if (std.mem.indexOf(u8, ln.text, "8.00%") != null and + std.mem.indexOf(u8, ln.text, "2024-06-01") != null) + { + try std.testing.expect(std.mem.indexOf(u8, ln.text, "—") != null); + return; + } + } + try std.testing.expect(false); // data row not found +} + +test "backtestLines: data rows align across mixed numeric/em-dash cells" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + // Two anchors: one fully populated, one with most horizons + // missing. Em-dashes are 3 bytes / 1 display col; missing + // cells use the hard-coded `dash_cell` literal pre-shaped to + // 10 display columns. Numeric cells are pure ASCII so byte- + // padding via `{s:>10}` is safe. Both rows must produce + // identical display widths. + const anchors = [_]forecast.BacktestAnchor{ + .{ + .anchor_date = Date.fromYmd(2014, 1, 1), + .expected = 0.12, + .realized_1y = 0.10, + .realized_3y = 0.09, + .realized_5y = 0.08, + }, + .{ + .anchor_date = Date.fromYmd(2025, 6, 1), + .expected = 0.07, + .realized_1y = null, + .realized_3y = null, + .realized_5y = null, + }, + }; + const lines = try backtestLines(arena.allocator(), &anchors, false); + + var full_row: ?[]const u8 = null; + var sparse_row: ?[]const u8 = null; + for (lines) |ln| { + // Look for the data row signature: leading two spaces, + // anchor date, no header / legend keywords. + if (std.mem.indexOf(u8, ln.text, "2014-01-01") != null and + std.mem.indexOf(u8, ln.text, "from") == null) + { + full_row = ln.text; + } + if (std.mem.indexOf(u8, ln.text, "2025-06-01") != null and + std.mem.indexOf(u8, ln.text, "from") == null) + { + sparse_row = ln.text; + } + } + try std.testing.expect(full_row != null); + try std.testing.expect(sparse_row != null); + // Both rows must occupy identical display-column widths + // even though their byte lengths differ (each em-dash is 2 + // extra bytes vs a 1-byte ASCII space). + try std.testing.expectEqual(fmt.displayCols(full_row.?), fmt.displayCols(sparse_row.?)); +} + +// ── Regression locks: back-test layout constants ────────────── +// +// The back-test table layout was tuned by eye. These tests +// freeze the exact widths and string contents so an "innocent" +// edit to the format string or a header constant trips a test +// instead of silently re-misaligning the table. + +test "backtest layout: column widths are 11 cols / 12 cols" { + try std.testing.expectEqual(@as(usize, 11), bt_col_value); + try std.testing.expectEqual(@as(usize, 12), bt_col_anchor); +} + +test "backtest layout: dash_cell is exactly 11 display columns" { + try std.testing.expectEqual(@as(usize, 11), fmt.displayCols(dash_cell)); +} + +test "backtest layout: dash_cell exact byte content" { + // The dash position was tuned to read as visually centered + // next to the right-aligned numeric data (which ends with + // a trailing space — see `bufPrint("{d:.2}% ", ...)`). If + // someone changes the dash position, this test fires and + // forces a deliberate update of both the dash_cell literal + // and the matching alignment test below. + try std.testing.expectEqualStrings(" — ", dash_cell); +} + +test "backtest layout: all column headers are exactly 11 display cols" { + try std.testing.expectEqual(@as(usize, 11), fmt.displayCols(bt_hdr_expected)); + try std.testing.expectEqual(@as(usize, 11), fmt.displayCols(bt_hdr_1y)); + try std.testing.expectEqual(@as(usize, 11), fmt.displayCols(bt_hdr_3y)); + try std.testing.expectEqual(@as(usize, 11), fmt.displayCols(bt_hdr_5y)); +} + +test "backtest layout: header strings exact byte content" { + // Frozen against the user-tuned alignment. Edit these + // strings only with a deliberate visual recheck of the + // table output. + try std.testing.expectEqualStrings(" Expected ", bt_hdr_expected); + try std.testing.expectEqualStrings(" 1y ", bt_hdr_1y); + try std.testing.expectEqualStrings(" 3y ", bt_hdr_3y); + try std.testing.expectEqualStrings(" 5y ", bt_hdr_5y); +} + +test "backtest layout: data row em-dash sits under header centerline" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + // Single anchor with all realized values missing — every + // numeric cell renders as `dash_cell`. Find the data row + // and the header row, then verify the em-dash byte position + // is consistent across all four numeric columns AND lines + // up with the column header strings beneath the dashes. + const anchors = [_]forecast.BacktestAnchor{ + .{ + .anchor_date = Date.fromYmd(2025, 6, 1), + .expected = 0.07, + .realized_1y = null, + .realized_3y = null, + .realized_5y = null, + }, + }; + const lines = try backtestLines(arena.allocator(), &anchors, false); + + var data_row: ?[]const u8 = null; + for (lines) |ln| { + if (std.mem.indexOf(u8, ln.text, "2025-06-01") != null and + std.mem.indexOf(u8, ln.text, "from") == null) + { + data_row = ln.text; + } + } + try std.testing.expect(data_row != null); + + // Count em-dash occurrences in the data row. Each missing + // realized horizon contributes exactly one — three total + // (1y, 3y, 5y; expected is always populated). + var dash_count: usize = 0; + var i: usize = 0; + while (i + 2 < data_row.?.len) : (i += 1) { + if (std.mem.eql(u8, data_row.?[i .. i + 3], "—")) dash_count += 1; + } + try std.testing.expectEqual(@as(usize, 3), dash_count); +} + +test "backtest layout: full-row width matches header-row width" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + const anchors = [_]forecast.BacktestAnchor{ + .{ + .anchor_date = Date.fromYmd(2014, 1, 1), + .expected = 0.12, + .realized_1y = 0.10, + .realized_3y = 0.09, + .realized_5y = 0.08, + }, + }; + const lines = try backtestLines(arena.allocator(), &anchors, false); + + // Find the column header line ("Anchor … Expected … 1y …") + // and the data row, then verify their display widths match. + // A drift between header padding and data padding would + // show up here as a mismatch. + var header_row: ?[]const u8 = null; + var data_row: ?[]const u8 = null; + for (lines) |ln| { + if (std.mem.indexOf(u8, ln.text, "Anchor") != null and + std.mem.indexOf(u8, ln.text, "Expected") != null) + { + header_row = ln.text; + } + if (std.mem.indexOf(u8, ln.text, "2014-01-01") != null and + std.mem.indexOf(u8, ln.text, "from") == null) + { + data_row = ln.text; + } + } + try std.testing.expect(header_row != null); + try std.testing.expect(data_row != null); + try std.testing.expectEqual(fmt.displayCols(header_row.?), fmt.displayCols(data_row.?)); +} + +test "backtest layout: separator row width matches header width" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + const anchors = [_]forecast.BacktestAnchor{ + .{ + .anchor_date = Date.fromYmd(2020, 1, 1), + .expected = 0.08, + .realized_1y = 0.10, + .realized_3y = null, + .realized_5y = null, + }, + }; + const lines = try backtestLines(arena.allocator(), &anchors, false); + + var header_row: ?[]const u8 = null; + var sep_row: ?[]const u8 = null; + for (lines) |ln| { + if (std.mem.indexOf(u8, ln.text, "Anchor") != null and + std.mem.indexOf(u8, ln.text, "Expected") != null) + { + header_row = ln.text; + } + // Separator row is all dashes after the leading 2 spaces. + if (ln.text.len > 4 and + std.mem.eql(u8, ln.text[0..4], " --")) + { + sep_row = ln.text; + } + } + try std.testing.expect(header_row != null); + try std.testing.expect(sep_row != null); + try std.testing.expectEqual(fmt.displayCols(header_row.?), fmt.displayCols(sep_row.?)); +}