diff --git a/TODO.md b/TODO.md index c3aa2a5..bb76851 100644 --- a/TODO.md +++ b/TODO.md @@ -73,14 +73,9 @@ ranking; unlabeled items are "someday, if the mood strikes." ## `--export-chart` follow-ups - priority LOW V1 of `--export-chart ` 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 `.** 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 diff --git a/src/chart_export.zig b/src/chart_export.zig index 2701cbb..a2e9de8 100644 --- a/src/chart_export.zig +++ b/src/chart_export.zig @@ -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), + ); +} diff --git a/src/charts/axis.zig b/src/charts/axis.zig index 19723d5..1a1ef09 100644 --- a/src/charts/axis.zig +++ b/src/charts/axis.zig @@ -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); diff --git a/src/charts/forecast_chart.zig b/src/charts/forecast_chart.zig index 6ea7ae0..57c6ca8 100644 --- a/src/charts/forecast_chart.zig +++ b/src/charts/forecast_chart.zig @@ -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 ───────────────────────────────────────────────────── diff --git a/src/commands/projections.zig b/src/commands/projections.zig index 934099b..3f5eb5b 100644 --- a/src/commands/projections.zig +++ b/src/commands/projections.zig @@ -40,11 +40,12 @@ pub const ParsedArgs = union(enum) { /// `--vs `: 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 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 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); }