add backtest and convergence to tui
This commit is contained in:
parent
50dd3b5533
commit
d7e7b76933
11 changed files with 2086 additions and 152 deletions
41
AGENTS.md
41
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
|
||||
|
|
|
|||
48
TODO.md
48
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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 => {},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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", .{});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
630
src/tui/forecast_chart.zig
Normal 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);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.?));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue