add backtest and convergence to tui

This commit is contained in:
Emil Lerch 2026-05-17 10:37:19 -07:00
parent 50dd3b5533
commit d7e7b76933
Signed by: lobo
GPG key ID: A7B62D657EF764F8
11 changed files with 2086 additions and 152 deletions

View file

@ -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

48
TODO.md
View file

@ -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

View file

@ -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);
}

View file

@ -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),
}
}

View file

@ -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 => {},
}

View file

@ -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", .{});
}

View file

@ -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.

630
src/tui/forecast_chart.zig Normal file
View file

@ -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);
}

View file

@ -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 `<portfolio_dir>/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

View file

@ -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(),
};
}

View file

@ -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.?));
}