chart export for return backtest and convergence projection charts

This commit is contained in:
Emil Lerch 2026-06-26 15:23:50 -07:00
parent 6827b70f85
commit 98f0f96bf5
Signed by: lobo
GPG key ID: A7B62D657EF764F8
5 changed files with 398 additions and 47 deletions

12
TODO.md
View file

@ -73,14 +73,9 @@ ranking; unlabeled items are "someday, if the mood strikes."
## `--export-chart` follow-ups - priority LOW
V1 of `--export-chart <PATH>` shipped for `quote`, `projections`
(default bands mode only), and `history`. Several adjacent surfaces
still don't have PNG export and were deferred:
(bands, `--convergence`, and `--return-backtest` modes), and
`history`. Two adjacent surfaces still don't have PNG export:
- **`projections --convergence` / `--return-backtest`.** Both
render forecast-evaluation charts via `tui/forecast_chart.zig`.
Not refactored to expose a `renderToSurface` seam yet -
parser rejects `--export-chart` in those modes today. Low
effort to add (mirror the `tui/chart.zig` pattern).
- **`projections --vs <DATE>`.** No chart at all in this mode
(text-only delta table); `--export-chart` rejected at parse
time. Could grow a side-by-side bands comparison chart, but
@ -90,9 +85,6 @@ still don't have PNG export and were deferred:
time would let users render with their configured theme or a
presentation-friendly one. Out of scope for V1; gate when
someone asks for it.
- **File format alternatives.** SVG / PDF / WebP - `z2d` only
exports PNG natively today; would need an external dependency
or a pixel-buffer-to-format conversion.
## Refactor: trim `src/format.zig` once Money / Date have absorbed their helpers - priority LOW

View file

@ -29,6 +29,8 @@ const chart = @import("charts/chart.zig");
const projection_chart = @import("charts/projection_chart.zig");
const line_chart = @import("charts/line_chart.zig");
const projections = @import("analytics/projections.zig");
const forecast = @import("analytics/forecast_evaluation.zig");
const forecast_chart = @import("charts/forecast_chart.zig");
const theme = @import("tui/theme.zig");
/// Default PNG export resolution. Matches `charts/chart.zig`'s
@ -135,6 +137,58 @@ pub fn exportTimelineChart(
try z2d.png_exporter.writeToPNGFile(io, rendered.surface, path, .{});
}
/// Export the convergence forecast chart (years-until-retirement vs.
/// observation date) as a PNG. Wraps
/// `forecast_chart.renderConvergenceToSurface` + `writeToPNGFile`.
pub fn exportConvergenceChart(
io: std.Io,
alloc: std.mem.Allocator,
points: []const forecast.ConvergencePoint,
path: []const u8,
) !void {
var rendered = forecast_chart.renderConvergenceToSurface(
io,
alloc,
points,
default_width,
default_height,
theme.default_theme,
true,
) catch |err| switch (err) {
error.InsufficientData => return error.InsufficientData,
else => return err,
};
defer rendered.deinit(alloc);
try z2d.png_exporter.writeToPNGFile(io, rendered.surface, path, .{});
}
/// Export the return back-test forecast chart (expected vs. realized
/// forward CAGR by anchor) as a PNG. Wraps
/// `forecast_chart.renderBacktestToSurface` + `writeToPNGFile`.
pub fn exportBacktestChart(
io: std.Io,
alloc: std.mem.Allocator,
anchors: []const forecast.BacktestAnchor,
path: []const u8,
) !void {
var rendered = forecast_chart.renderBacktestToSurface(
io,
alloc,
anchors,
default_width,
default_height,
theme.default_theme,
true,
) catch |err| switch (err) {
error.InsufficientData => return error.InsufficientData,
else => return err,
};
defer rendered.deinit(alloc);
try z2d.png_exporter.writeToPNGFile(io, rendered.surface, path, .{});
}
// Tests
test "exportSymbolChart writes a non-empty PNG file" {
@ -333,3 +387,113 @@ test "exportTimelineChart returns InsufficientData with a single point" {
exportTimelineChart(io, alloc, &points, .fit, path),
);
}
test "exportConvergenceChart writes a non-empty PNG file" {
const Date = @import("Date.zig");
const alloc = std.testing.allocator;
const io = std.testing.io;
const points = [_]forecast.ConvergencePoint{
.{ .observation_date = Date.fromYmd(2020, 1, 1), .projected_date = Date.fromYmd(2032, 1, 1), .years_until_retirement = 12.0, .reached = false },
.{ .observation_date = Date.fromYmd(2022, 1, 1), .projected_date = Date.fromYmd(2031, 1, 1), .years_until_retirement = 9.0, .reached = false },
.{ .observation_date = Date.fromYmd(2025, 1, 1), .projected_date = Date.fromYmd(2030, 1, 1), .years_until_retirement = 5.0, .reached = false },
};
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
const dir_path = path_buf[0..dir_len];
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_convergence.png" });
defer alloc.free(path);
try exportConvergenceChart(io, alloc, &points, path);
var file = try tmp.dir.openFile(io, "test_export_convergence.png", .{});
defer file.close(io);
const size = (try file.stat(io)).size;
try std.testing.expect(size > 1024);
var magic: [8]u8 = undefined;
var reader = file.reader(io, &.{});
_ = try reader.interface.readSliceShort(&magic);
try std.testing.expectEqualSlices(u8, "\x89PNG\x0D\x0A\x1A\x0A", &magic);
}
test "exportConvergenceChart returns InsufficientData with a single point" {
const Date = @import("Date.zig");
const alloc = std.testing.allocator;
const io = std.testing.io;
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 },
};
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
const dir_path = path_buf[0..dir_len];
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_convergence_insufficient.png" });
defer alloc.free(path);
try std.testing.expectError(
error.InsufficientData,
exportConvergenceChart(io, alloc, &points, path),
);
}
test "exportBacktestChart writes a non-empty PNG file" {
const Date = @import("Date.zig");
const alloc = std.testing.allocator;
const io = std.testing.io;
const anchors = [_]forecast.BacktestAnchor{
.{ .anchor_date = Date.fromYmd(2016, 1, 1), .expected = 0.07, .realized_1y = 0.12, .realized_3y = 0.09, .realized_5y = 0.08 },
.{ .anchor_date = Date.fromYmd(2019, 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.06, .realized_1y = -0.05, .realized_3y = null, .realized_5y = null },
};
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
const dir_path = path_buf[0..dir_len];
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_backtest.png" });
defer alloc.free(path);
try exportBacktestChart(io, alloc, &anchors, path);
var file = try tmp.dir.openFile(io, "test_export_backtest.png", .{});
defer file.close(io);
const size = (try file.stat(io)).size;
try std.testing.expect(size > 1024);
var magic: [8]u8 = undefined;
var reader = file.reader(io, &.{});
_ = try reader.interface.readSliceShort(&magic);
try std.testing.expectEqualSlices(u8, "\x89PNG\x0D\x0A\x1A\x0A", &magic);
}
test "exportBacktestChart returns InsufficientData with a single anchor" {
const Date = @import("Date.zig");
const alloc = std.testing.allocator;
const io = std.testing.io;
const anchors = [_]forecast.BacktestAnchor{
.{ .anchor_date = Date.fromYmd(2020, 1, 1), .expected = 0.10, .realized_1y = null, .realized_3y = null, .realized_5y = null },
};
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
const dir_path = path_buf[0..dir_len];
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_backtest_insufficient.png" });
defer alloc.free(path);
try std.testing.expectError(
error.InsufficientData,
exportBacktestChart(io, alloc, &anchors, path),
);
}

View file

@ -63,6 +63,13 @@ pub fn fmtDollar(buf: []u8, value: f64) []const u8 {
return std.fmt.bufPrint(buf, "{s}${s}", .{ sign, commas }) catch "$?";
}
/// Unit for `drawYTicks` y-axis labels. `dollars` and `percent`
/// delegate to the canonical formatters (`fmtDollar`,
/// `format.fmtPct`); `years` is a compact whole-year tick ("10y")
/// for the convergence chart - the codebase has no shared compact-
/// years helper (the verbose "N Year" lives in the projections view).
pub const TickUnit = enum { dollars, percent, years };
/// Draw `n + 1` left-aligned dollar labels evenly spaced from
/// `value_max` (at `top`) down to `value_min` (at `bottom`), placed in
/// the right margin a small pad to the right of `plot_right` (the plot's
@ -79,6 +86,27 @@ pub fn drawYDollarTicks(
value_min: f64,
value_max: f64,
n: usize,
) void {
drawYTicks(sfc, scale, color, plot_right, top, bottom, value_min, value_max, n, .dollars);
}
/// Like `drawYDollarTicks` but renders the tick values in the given
/// `unit`, so non-dollar axes (percent returns, years-until-
/// retirement) reuse the same tick spacing and placement.
/// `drawYDollarTicks` is the dollar-specialized wrapper. Percent
/// ticks go through `format.fmtPct` (the canonical percent
/// formatter) rather than a local reimplementation.
pub fn drawYTicks(
sfc: *Surface,
scale: i32,
color: [3]u8,
plot_right: f64,
top: f64,
bottom: f64,
value_min: f64,
value_max: f64,
n: usize,
unit: TickUnit,
) void {
const range = value_max - value_min;
const span = bottom - top;
@ -91,7 +119,11 @@ pub fn drawYDollarTicks(
const val = value_max - frac * range;
const y = top + frac * span;
var buf: [24]u8 = undefined;
const label = fmtDollar(&buf, val);
const label = switch (unit) {
.dollars => fmtDollar(&buf, val),
.percent => fmt.fmtPct(&buf, val, .{ .decimals = 0 }),
.years => std.fmt.bufPrint(&buf, "{d}y", .{@as(i64, @intFromFloat(@round(val)))}) catch "?y",
};
const ly = @as(i32, @intFromFloat(y)) - half_h;
text.drawText(sfc, lx, ly, scale, color, label);
}
@ -136,6 +168,23 @@ test "fmtDollar: M/B suffixes at/above a million, commas below, sign for negativ
try testing.expectEqualStrings("-$1.2M", fmtDollar(&buf, -1_200_000));
}
test "drawYTicks renders percent and years units" {
const alloc = testing.allocator;
const color = [3]u8{ 0xCC, 0xCC, 0xCC };
// Percent ticks (decimal rates, via format.fmtPct) stamp glyphs.
var sfc = try Surface.init(.image_surface_rgb, alloc, 300, 200);
defer sfc.deinit(alloc);
drawYTicks(&sfc, 2, color, 180, 10, 190, -0.05, 0.15, 5, .percent);
try testing.expect(draw.countColor(&sfc, color) > 0);
// Years ticks stamp glyphs too.
var sfc2 = try Surface.init(.image_surface_rgb, alloc, 300, 200);
defer sfc2.deinit(alloc);
drawYTicks(&sfc2, 2, color, 180, 10, 190, 0, 12, 5, .years);
try testing.expect(draw.countColor(&sfc2, color) > 0);
}
test "drawYDollarTicks stamps labels in the requested color" {
const alloc = testing.allocator;
var sfc = try Surface.init(.image_surface_rgb, alloc, 300, 200);

View file

@ -31,6 +31,7 @@ const theme = @import("../tui/theme.zig");
const forecast = @import("../analytics/forecast_evaluation.zig");
const Date = @import("../Date.zig");
const draw = @import("draw.zig");
const axis = @import("axis.zig");
const Surface = z2d.Surface;
const Context = z2d.Context;
@ -50,6 +51,74 @@ pub const ChartResult = struct {
value_max: f64,
};
/// Owned by the caller - call `result.deinit(alloc)` when done. The
/// shared surface seam for both forecast charts: the `--export-chart`
/// path keeps the surface to hand to the PNG encoder, while the kitty
/// wrappers (`renderConvergenceChart` / `renderBacktestChart`) extract
/// RGB and free it. Mirrors `projection_chart.RenderedProjection`.
pub const RenderedForecast = struct {
surface: Surface,
width: u16,
height: u16,
value_min: f64,
value_max: f64,
pub fn deinit(self: *RenderedForecast, alloc: std.mem.Allocator) void {
self.surface.deinit(alloc);
self.* = undefined;
}
/// Extract a flat []u8 of R,G,B triplets; caller owns it. The
/// surface is left intact.
pub fn extractRgb(self: *const RenderedForecast, alloc: std.mem.Allocator) ![]u8 {
return draw.extractRgb(alloc, &self.surface);
}
};
/// Thin RGB wrapper over `renderConvergenceToSurface` for the TUI's
/// kitty path: renders without axis labels, extracts RGB, frees the
/// surface.
pub fn renderConvergenceChart(
io: std.Io,
alloc: std.mem.Allocator,
points: []const forecast.ConvergencePoint,
width_px: u32,
height_px: u32,
th: theme.Theme,
) !ChartResult {
var rendered = try renderConvergenceToSurface(io, alloc, points, width_px, height_px, th, false);
defer rendered.deinit(alloc);
return .{
.rgb_data = try rendered.extractRgb(alloc),
.width = rendered.width,
.height = rendered.height,
.value_min = rendered.value_min,
.value_max = rendered.value_max,
};
}
/// Thin RGB wrapper over `renderBacktestToSurface` for the TUI's
/// kitty path: renders without axis labels, extracts RGB, frees the
/// surface.
pub fn renderBacktestChart(
io: std.Io,
alloc: std.mem.Allocator,
anchors: []const BacktestAnchor,
width_px: u32,
height_px: u32,
th: theme.Theme,
) !ChartResult {
var rendered = try renderBacktestToSurface(io, alloc, anchors, width_px, height_px, th, false);
defer rendered.deinit(alloc);
return .{
.rgb_data = try rendered.extractRgb(alloc),
.width = rendered.width,
.height = rendered.height,
.value_min = rendered.value_min,
.value_max = rendered.value_max,
};
}
// View 1: Convergence chart
/// Render the convergence chart. X-axis spans
@ -67,20 +136,21 @@ pub const ChartResult = struct {
/// - Solid line through the convergence points
/// - Distinct markers on `reached` rows (small filled dots,
/// theme accent color)
pub fn renderConvergenceChart(
pub fn renderConvergenceToSurface(
io: std.Io,
alloc: std.mem.Allocator,
points: []const forecast.ConvergencePoint,
width_px: u32,
height_px: u32,
th: theme.Theme,
) !ChartResult {
axis_labels: bool,
) !RenderedForecast {
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);
errdefer sfc.deinit(alloc);
var ctx = Context.init(io, alloc, &sfc);
defer ctx.deinit();
@ -95,11 +165,19 @@ pub fn renderConvergenceChart(
// Background
try draw.fillBackground(&ctx, fwidth, fheight, bg);
const chart_left = margin_left;
const chart_right = fwidth - margin_right;
// Chart area. With axis labels, reserve a right margin for the
// y-axis years ticks and a bottom margin for the date endpoints.
const label_scale: i32 = axis.labelScale(h);
const label_char_h: f64 = axis.charHeight(label_scale);
const m_left: f64 = if (axis_labels) label_char_h else margin_left;
const m_right: f64 = if (axis_labels) axis.yAxisMargin(label_scale) else margin_right;
const m_top: f64 = if (axis_labels) (label_char_h / 2 + 4) else margin_top;
const m_bottom: f64 = if (axis_labels) axis.bottomMargin(label_scale) else margin_bottom;
const chart_left = m_left;
const chart_right = fwidth - m_right;
const chart_w = chart_right - chart_left;
const chart_top = margin_top;
const chart_bottom = fheight - margin_bottom;
const chart_top = m_top;
const chart_bottom = fheight - m_bottom;
// X-range: observation_date span
const x0_days: f64 = @floatFromInt(points[0].observation_date.days);
@ -186,8 +264,20 @@ pub fn renderConvergenceChart(
// Border
try drawRect(&ctx, chart_left, chart_top, chart_right, chart_bottom, blendColor(th.text_muted, 60, bg), 1.0);
// Axis labels (export only): right-side y-axis years ticks and
// x-axis observation-date endpoints.
if (axis_labels) {
axis.drawYTicks(&sfc, label_scale, th.text_muted, chart_right, chart_top, chart_bottom, y_min, y_max, 5, .years);
var fbuf: [10]u8 = undefined;
var lbuf: [10]u8 = undefined;
const first_s = std.fmt.bufPrint(&fbuf, "{f}", .{points[0].observation_date}) catch "";
const last_s = std.fmt.bufPrint(&lbuf, "{f}", .{points[points.len - 1].observation_date}) catch "";
const date_y = chart_bottom + axis.labelGap(label_scale);
axis.drawXEndpoints(&sfc, label_scale, th.text_muted, chart_left, chart_right, date_y, first_s, last_s);
}
return .{
.rgb_data = try extractRgb(alloc, &sfc),
.surface = sfc,
.width = @intCast(width_px),
.height = @intCast(height_px),
.value_min = y_min,
@ -212,20 +302,21 @@ pub const BacktestAnchor = forecast.BacktestAnchor;
/// - `realized_5y` (solid, theme positive - green)
///
/// Plus a y=0 reference line.
pub fn renderBacktestChart(
pub fn renderBacktestToSurface(
io: std.Io,
alloc: std.mem.Allocator,
anchors: []const BacktestAnchor,
width_px: u32,
height_px: u32,
th: theme.Theme,
) !ChartResult {
axis_labels: bool,
) !RenderedForecast {
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);
errdefer sfc.deinit(alloc);
var ctx = Context.init(io, alloc, &sfc);
defer ctx.deinit();
@ -240,11 +331,19 @@ pub fn renderBacktestChart(
// Background
try draw.fillBackground(&ctx, fwidth, fheight, bg);
const chart_left = margin_left;
const chart_right = fwidth - margin_right;
// Chart area. With axis labels, reserve a right margin for the
// y-axis percent ticks and a bottom margin for the date endpoints.
const label_scale: i32 = axis.labelScale(h);
const label_char_h: f64 = axis.charHeight(label_scale);
const m_left: f64 = if (axis_labels) label_char_h else margin_left;
const m_right: f64 = if (axis_labels) axis.yAxisMargin(label_scale) else margin_right;
const m_top: f64 = if (axis_labels) (label_char_h / 2 + 4) else margin_top;
const m_bottom: f64 = if (axis_labels) axis.bottomMargin(label_scale) else margin_bottom;
const chart_left = m_left;
const chart_right = fwidth - m_right;
const chart_w = chart_right - chart_left;
const chart_top = margin_top;
const chart_bottom = fheight - margin_bottom;
const chart_top = m_top;
const chart_bottom = fheight - m_bottom;
// X-range
const x0_days: f64 = @floatFromInt(anchors[0].anchor_date.days);
@ -303,8 +402,20 @@ pub fn renderBacktestChart(
// Border
try drawRect(&ctx, chart_left, chart_top, chart_right, chart_bottom, blendColor(th.text_muted, 60, bg), 1.0);
// Axis labels (export only): right-side y-axis percent ticks and
// x-axis anchor-date endpoints.
if (axis_labels) {
axis.drawYTicks(&sfc, label_scale, th.text_muted, chart_right, chart_top, chart_bottom, y_min, y_max, 5, .percent);
var fbuf: [10]u8 = undefined;
var lbuf: [10]u8 = undefined;
const first_s = std.fmt.bufPrint(&fbuf, "{f}", .{anchors[0].anchor_date}) catch "";
const last_s = std.fmt.bufPrint(&lbuf, "{f}", .{anchors[anchors.len - 1].anchor_date}) catch "";
const date_y = chart_bottom + axis.labelGap(label_scale);
axis.drawXEndpoints(&sfc, label_scale, th.text_muted, chart_left, chart_right, date_y, first_s, last_s);
}
return .{
.rgb_data = try extractRgb(alloc, &sfc),
.surface = sfc,
.width = @intCast(width_px),
.height = @intCast(height_px),
.value_min = y_min,
@ -492,7 +603,6 @@ const opaqueColor = draw.opaqueColor;
const drawHorizontalGridLines = draw.drawHorizontalGridLines;
const drawHLine = draw.drawHLine;
const drawRect = draw.drawRect;
const extractRgb = draw.extractRgb;
// Tests

View file

@ -40,11 +40,12 @@ pub const ParsedArgs = union(enum) {
/// `--vs <DATE>`: side-by-side compare of two projections.
compare: CompareArgs,
/// `--convergence`: plot the spreadsheet's predicted retirement
/// date over time. No knobs.
convergence,
/// date over time. `export_chart` renders a PNG instead of the
/// text table.
convergence: struct { export_chart: ?[]const u8 = null },
/// `--return-backtest [--real]`: plot expected_return vs realized
/// forward-CAGR.
return_backtest: struct { real: bool },
/// forward-CAGR. `export_chart` renders a PNG instead of text.
return_backtest: struct { real: bool, export_chart: ?[]const u8 = null },
};
pub const BandsArgs = struct {
@ -105,11 +106,12 @@ pub const meta: framework.Meta = .{
\\ --return-backtest (see above)
\\ --real With --return-backtest, render in
\\ CPI-adjusted dollars.
\\ --export-chart <PATH> Render the percentile-band chart
\\ (with optional overlay if
\\ --overlay-actuals is set) as a PNG
\\ to PATH (1920x1080) and exit. Only
\\ valid in the default bands mode.
\\ --export-chart <PATH> Render the current mode's chart as a
\\ PNG to PATH (1920x1080) and exit.
\\ Works in the default bands mode (with
\\ the overlay if --overlay-actuals is
\\ set), --convergence, and
\\ --return-backtest. Not valid with --vs.
\\
\\Date forms: YYYY-MM-DD or relative (1W/1M/1Q/1Y).
\\
@ -203,17 +205,16 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
cli.stderrPrint(io, "Error: --real only applies to --return-backtest.\n");
return error.MutuallyExclusive;
}
// Chart export only meaningful in default bands mode. The
// forecast-evaluation views (convergence, return-backtest)
// render via `forecast_chart.zig` which doesn't have a PNG
// export path yet; --vs is text-only with no chart at all.
if (export_chart != null and (convergence or return_backtest or vs_date != null)) {
cli.stderrPrint(io, "Error: --export-chart only supported in the default projections (bands) mode.\n");
// --vs is text-only (a then/now delta table) with no chart to
// export. The bands, convergence, and return-backtest modes all
// support --export-chart.
if (export_chart != null and vs_date != null) {
cli.stderrPrint(io, "Error: --export-chart is not supported with --vs (it has no chart).\n");
return error.MutuallyExclusive;
}
if (convergence) return ParsedArgs{ .convergence = {} };
if (return_backtest) return ParsedArgs{ .return_backtest = .{ .real = real_mode } };
if (convergence) return ParsedArgs{ .convergence = .{ .export_chart = export_chart } };
if (return_backtest) return ParsedArgs{ .return_backtest = .{ .real = real_mode, .export_chart = export_chart } };
if (vs_date) |d| {
return ParsedArgs{ .compare = .{
.events_enabled = events_enabled,
@ -241,8 +242,8 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
const file_path = pf.path;
switch (parsed) {
.convergence => try runConvergence(io, allocator, file_path, color, out),
.return_backtest => |args| try runReturnBacktest(io, allocator, file_path, args.real, color, out),
.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),
.compare => |args| {
_ = ctx.svc orelse return error.MissingDataService;
// Pre-load today's live composition only when it's
@ -1177,6 +1178,7 @@ pub fn runConvergence(
io: std.Io,
allocator: std.mem.Allocator,
file_path: []const u8,
export_chart: ?[]const u8,
color: bool,
out: *std.Io.Writer,
) !void {
@ -1194,6 +1196,23 @@ pub fn runConvergence(
defer iv.deinit();
const points = try forecast.convergencePoints(va, iv.points);
// --export-chart: render the convergence chart to a PNG and exit
// before any text output.
if (export_chart) |export_path| {
chart_export.exportConvergenceChart(io, allocator, points, export_path) catch |err| switch (err) {
error.InsufficientData => {
cli.stderrPrint(io, "Error: not enough convergence data to render a chart.\n");
return;
},
else => {
cli.stderrPrint(io, "Error: failed to write PNG.\n");
return err;
},
};
return;
}
const lines = try view.convergenceLines(va, points);
try renderForecastLines(out, color, lines);
}
@ -1215,6 +1234,7 @@ pub fn runReturnBacktest(
allocator: std.mem.Allocator,
file_path: []const u8,
real_mode: bool,
export_chart: ?[]const u8,
color: bool,
out: *std.Io.Writer,
) !void {
@ -1241,6 +1261,22 @@ 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);
// --export-chart: render the back-test chart to a PNG and exit.
if (export_chart) |export_path| {
chart_export.exportBacktestChart(io, allocator, anchors, export_path) catch |err| switch (err) {
error.InsufficientData => {
cli.stderrPrint(io, "Error: not enough back-test data to render a chart.\n");
return;
},
else => {
cli.stderrPrint(io, "Error: failed to write PNG.\n");
return err;
},
};
return;
}
const lines = try view.backtestLines(va, anchors, real_mode);
try renderForecastLines(out, color, lines);
}