projections CLI kitty-chart for return-backtest and convergence (if terminal supported)

This commit is contained in:
Emil Lerch 2026-06-26 16:23:39 -07:00
parent 42a16cbbd3
commit a70e61d873
Signed by: lobo
GPG key ID: A7B62D657EF764F8

View file

@ -24,6 +24,7 @@ const shiller = @import("../data/shiller.zig");
const chart_export = @import("../chart_export.zig");
const projection_chart = @import("../charts/projection_chart.zig");
const projections = @import("../analytics/projections.zig");
const forecast_chart = @import("../charts/forecast_chart.zig");
const braille = @import("../charts/braille.zig");
const term_graphics = @import("../term_graphics.zig");
const term_query = @import("../term_query.zig");
@ -251,9 +252,19 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
defer pf.deinit(allocator);
const file_path = pf.path;
// Inline kitty charts when the terminal supports it (or `--chart
// kitty` forces it). No braille fallback for the projection-family
// charts - non-kitty terminals get table-only output. Shared by the
// bands, convergence, and return-backtest modes.
const kitty_caps: ?term_query.Caps = switch (ctx.globals.chart_config.mode) {
.braille => null,
.kitty => ctx.graphics_caps,
.auto => if (ctx.graphics_caps.kitty) ctx.graphics_caps else null,
};
switch (parsed) {
.convergence => |args| try runConvergence(io, allocator, file_path, args.export_chart, color, out),
.return_backtest => |args| try runReturnBacktest(io, allocator, file_path, args.real, args.export_chart, color, out),
.convergence => |args| try runConvergence(io, allocator, file_path, args.export_chart, color, out, kitty_caps),
.return_backtest => |args| try runReturnBacktest(io, allocator, file_path, args.real, args.export_chart, color, out, kitty_caps),
.compare => |args| {
_ = ctx.svc orelse return error.MissingDataService;
// Pre-load today's live composition only when it's
@ -295,14 +306,6 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
// total. Snapshot-only as-of paths ignore it.
var live = try loadLiveData(ctx, today, color);
defer if (live) |*l| l.deinit(allocator);
// Inline kitty band chart when supported (or forced). There's
// no braille fallback for projections - non-kitty terminals
// keep the table-only output.
const kitty_caps: ?term_query.Caps = switch (ctx.globals.chart_config.mode) {
.braille => null,
.kitty => ctx.graphics_caps,
.auto => if (ctx.graphics_caps.kitty) ctx.graphics_caps else null,
};
try runBands(
io,
allocator,
@ -597,6 +600,19 @@ fn prepOverlayChart(
};
}
/// Pixel + cell dimensions for an inline projection-family chart at
/// the standard column width, derived from the terminal's cell size.
/// Shared by the bands, convergence, and return-backtest inline-kitty
/// paths so they all render at the same on-screen footprint.
const ProjChartDims = struct { width: u32, height: u32, cols: u16, rows: u16 };
fn projectionChartDims(caps: term_query.Caps) ProjChartDims {
const cols = term_graphics.projection_cols;
const rows = term_graphics.rowsForWidth(cols, caps.cell_w, caps.cell_h);
const dims = term_graphics.pixelDims(cols, rows, caps.cell_w, caps.cell_h);
return .{ .width = dims.width, .height = dims.height, .cols = cols, .rows = rows };
}
/// Render the percentile-band chart (longest horizon, with the actuals
/// overlay when present) as kitty graphics at `term_graphics.projection_cols`
/// wide and emit it inline. Returns `error.InsufficientData` when bands
@ -617,14 +633,11 @@ fn emitBandsKitty(
// overlay window) the same way the PNG-export path does.
const oc = prepOverlayChart(va, ctx, bands_ec);
const cols = term_graphics.projection_cols;
const rows = term_graphics.rowsForWidth(cols, caps.cell_w, caps.cell_h);
const dims = term_graphics.pixelDims(cols, rows, caps.cell_w, caps.cell_h);
var rendered = try projection_chart.renderToSurface(io, va, oc.bands, dims.width, dims.height, theme.default_theme, oc.overlay, true, ctx.retirement.boundaryYear());
const d = projectionChartDims(caps);
var rendered = try projection_chart.renderToSurface(io, va, oc.bands, d.width, d.height, theme.default_theme, oc.overlay, true, ctx.retirement.boundaryYear());
defer rendered.deinit(va);
const rgb = try rendered.extractRgb(va);
try term_graphics.placeInline(out, va, rgb, dims.width, dims.height, cols, rows);
try term_graphics.placeInline(out, va, rgb, d.width, d.height, d.cols, d.rows);
}
pub fn runBands(
@ -1202,6 +1215,7 @@ pub fn runConvergence(
export_chart: ?[]const u8,
color: bool,
out: *std.Io.Writer,
kitty_caps: ?term_query.Caps,
) !void {
var arena_state = std.heap.ArenaAllocator.init(allocator);
defer arena_state.deinit();
@ -1234,6 +1248,20 @@ pub fn runConvergence(
return;
}
// Inline kitty chart above the table when supported. No braille
// fallback - non-kitty terminals get the table only. Too few points
// skips the chart and still renders the table below.
if (kitty_caps) |kc| {
const d = projectionChartDims(kc);
if (forecast_chart.renderConvergenceChart(io, va, points, d.width, d.height, theme.default_theme)) |result| {
try out.print("\n", .{});
try term_graphics.placeInline(out, va, result.rgb_data, d.width, d.height, d.cols, d.rows);
} else |err| switch (err) {
error.InsufficientData => {},
else => return err,
}
}
const lines = try view.convergenceLines(va, points);
try renderForecastLines(out, color, lines);
}
@ -1258,6 +1286,7 @@ pub fn runReturnBacktest(
export_chart: ?[]const u8,
color: bool,
out: *std.Io.Writer,
kitty_caps: ?term_query.Caps,
) !void {
var arena_state = std.heap.ArenaAllocator.init(allocator);
defer arena_state.deinit();
@ -1298,6 +1327,19 @@ pub fn runReturnBacktest(
return;
}
// Inline kitty chart above the table when supported (see
// runConvergence). Too few anchors skips the chart; table still renders.
if (kitty_caps) |kc| {
const d = projectionChartDims(kc);
if (forecast_chart.renderBacktestChart(io, va, anchors, d.width, d.height, theme.default_theme)) |result| {
try out.print("\n", .{});
try term_graphics.placeInline(out, va, result.rgb_data, d.width, d.height, d.cols, d.rows);
} else |err| switch (err) {
error.InsufficientData => {},
else => return err,
}
}
const lines = try view.backtestLines(va, anchors, real_mode);
try renderForecastLines(out, color, lines);
}
@ -1904,6 +1946,14 @@ test "parseArgs: --overlay-actuals without --as-of is rejected" {
try testing.expectError(error.MutuallyExclusive, parseArgsForTest(today, &args));
}
test "projectionChartDims: standard column width, sane pixel/row footprint" {
const caps = term_query.Caps{ .kitty = true, .cell_w = 10, .cell_h = 20 };
const d = projectionChartDims(caps);
try testing.expectEqual(term_graphics.projection_cols, d.cols);
try testing.expect(d.rows > 0);
try testing.expect(d.width > 0 and d.height > 0);
}
const snapshot_model = @import("../models/snapshot.zig");
const snapshot = @import("snapshot.zig");