//! Chart-to-PNG export. //! //! The CLI's `--export-chart ` flag funnels through this //! module. Each chart-bearing command (`quote`, `projections`) //! has a corresponding `export*` function here that: //! //! 1. Calls the relevant `renderToSurface` (in `charts/chart.zig` or //! `charts/projection_chart.zig`) to draw the chart into a z2d //! `Surface`. //! 2. Calls `z2d.png_exporter.writeToPNGFile` to land the surface //! as a PNG file at the user-supplied path. //! 3. Frees the surface. //! //! Default export resolution is 1920x1080 - matches the TUI's //! `chart_config.max_width`/`max_height` defaults so the exported //! image has the same fidelity the user sees in the terminal. //! The TUI's adaptive chart sizing isn't used here because the //! export target is a file, not a cell grid. //! //! Each `export*` takes the `theme.Theme` to render with - the CLI //! resolves it from `--theme ` (a `theme.srf`), defaulting to //! the built-in theme so exports keep the TUI's visual identity unless //! the user opts into a custom or presentation-friendly palette. const std = @import("std"); const z2d = @import("z2d"); const zfin = @import("root.zig"); 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 compare_chart = @import("charts/compare_chart.zig"); const theme = @import("tui/theme.zig"); /// Default PNG export resolution. Matches `charts/chart.zig`'s /// `ChartConfig.max_width/max_height` defaults so an exported /// image carries the same fidelity as a maximally-sized TUI /// chart. pub const default_width: u32 = 1920; pub const default_height: u32 = 1080; /// Export a price+Bollinger+RSI chart for a single symbol, showing the /// most recent `display_count` candles. The overlays are computed with a /// warmup lookback (via `chart.computeIndicatorsWarmup`) so they're valid /// from the first displayed candle. Wraps `renderToSurface` + `writeToPNGFile`. pub fn exportSymbolChart( io: std.Io, alloc: std.mem.Allocator, candles: []const zfin.Candle, display_count: usize, th: theme.Theme, path: []const u8, ) !void { var cached = chart.computeIndicatorsWarmup(alloc, candles, display_count, 20) catch |err| switch (err) { error.InsufficientData => return error.InsufficientData, else => return err, }; defer cached.deinit(alloc); const n = @min(candles.len, display_count); const display = candles[candles.len - n ..]; var rendered = chart.renderToSurface( io, alloc, display, null, default_width, default_height, th, &cached, 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 a projection percentile-band chart with optional actuals /// overlay. Wraps `projection_chart.renderToSurface` + /// `writeToPNGFile`. `retirement_boundary_year` (pass /// `ResolvedRetirement.boundaryYear()`) draws the /// accumulation/distribution divider when set. pub fn exportProjectionChart( io: std.Io, alloc: std.mem.Allocator, bands: []const projections.YearPercentiles, actuals: ?projection_chart.ActualsOverlay, retirement_boundary_year: ?u16, th: theme.Theme, path: []const u8, ) !void { var rendered = projection_chart.renderToSurface( io, alloc, bands, default_width, default_height, th, actuals, true, retirement_boundary_year, ) 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 a single-series portfolio-value timeline as a PNG. Wraps /// `line_chart.renderToSurface` + `writeToPNGFile`. `baseline` selects /// whether the y-axis fits the data or anchors at zero. pub fn exportTimelineChart( io: std.Io, alloc: std.mem.Allocator, points: []const line_chart.LinePoint, baseline: line_chart.Baseline, th: theme.Theme, path: []const u8, ) !void { var rendered = line_chart.renderToSurface( io, alloc, points, default_width, default_height, th, .{ .baseline = baseline, .axis_labels = 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 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, th: theme.Theme, path: []const u8, ) !void { var rendered = forecast_chart.renderConvergenceToSurface( io, alloc, points, default_width, default_height, th, 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, th: theme.Theme, path: []const u8, ) !void { var rendered = forecast_chart.renderBacktestToSurface( io, alloc, anchors, default_width, default_height, th, 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 `--vs` projection comparison overlay - two percentile- /// band envelopes ("then" and "now") on one chart. Wraps /// `compare_chart.renderToSurface` + `writeToPNGFile`. pub fn exportCompareChart( io: std.Io, alloc: std.mem.Allocator, then_bands: []const projections.YearPercentiles, now_bands: []const projections.YearPercentiles, th: theme.Theme, path: []const u8, ) !void { var rendered = compare_chart.renderToSurface( io, alloc, then_bands, now_bands, default_width, default_height, th, 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" { const Date = @import("Date.zig"); const alloc = std.testing.allocator; const io = std.testing.io; // Minimum candle count (renderToSurface requires >= 20) var candles: [25]zfin.Candle = undefined; for (0..25) |i| { const price: f64 = 100.0 + @as(f64, @floatFromInt(i)); candles[i] = .{ .date = Date.fromYmd(2024, 1, 2).addDays(@intCast(i)), .open = price, .high = price + 1, .low = price - 1, .close = price, .adj_close = price, .volume = 1000, }; } 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_symbol.png" }); defer alloc.free(path); try exportSymbolChart(io, alloc, &candles, 60, theme.default_theme, path); // Verify the file exists, starts with the PNG magic, and is // big enough to plausibly contain a chart (not just headers). var file = try tmp.dir.openFile(io, "test_export_symbol.png", .{}); defer file.close(io); const size = (try file.stat(io)).size; try std.testing.expect(size > 1024); // arbitrary lower bound 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 "exportSymbolChart returns InsufficientData on too-few candles" { const Date = @import("Date.zig"); const alloc = std.testing.allocator; const io = std.testing.io; var candles: [10]zfin.Candle = undefined; for (0..10) |i| { candles[i] = .{ .date = Date.fromYmd(2024, 1, 2).addDays(@intCast(i)), .open = 100, .high = 100, .low = 100, .close = 100, .adj_close = 100, .volume = 0, }; } 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_symbol_insufficient.png" }); defer alloc.free(path); try std.testing.expectError( error.InsufficientData, exportSymbolChart(io, alloc, &candles, 60, theme.default_theme, path), ); } test "exportProjectionChart writes a non-empty PNG file" { const alloc = std.testing.allocator; const io = std.testing.io; // Synthetic 11-year horizon: bands grow linearly, all // percentiles spread around p50. var bands: [11]projections.YearPercentiles = undefined; for (0..11) |i| { const base: f64 = 100_000.0 * (1.0 + 0.07 * @as(f64, @floatFromInt(i))); bands[i] = .{ .year = @intCast(i), .p10 = base * 0.6, .p25 = base * 0.8, .p50 = base, .p75 = base * 1.2, .p90 = base * 1.5, }; } 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_projection.png" }); defer alloc.free(path); try exportProjectionChart(io, alloc, &bands, null, null, theme.default_theme, path); var file = try tmp.dir.openFile(io, "test_export_projection.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 "exportProjectionChart returns InsufficientData with single band" { const alloc = std.testing.allocator; const io = std.testing.io; var bands: [1]projections.YearPercentiles = .{.{ .year = 0, .p10 = 100, .p25 = 110, .p50 = 120, .p75 = 130, .p90 = 140, }}; 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_projection_insufficient.png" }); defer alloc.free(path); try std.testing.expectError( error.InsufficientData, exportProjectionChart(io, alloc, &bands, null, null, theme.default_theme, path), ); } test "exportTimelineChart writes a non-empty PNG file" { const Date = @import("Date.zig"); const alloc = std.testing.allocator; const io = std.testing.io; var points: [12]line_chart.LinePoint = undefined; for (0..12) |i| { points[i] = .{ .date = Date.fromYmd(2025, 1, 1).addDays(@intCast(i * 30)), .value = 1_000_000.0 + 25_000.0 * @as(f64, @floatFromInt(i)), }; } 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_timeline.png" }); defer alloc.free(path); try exportTimelineChart(io, alloc, &points, .fit, theme.default_theme, path); var file = try tmp.dir.openFile(io, "test_export_timeline.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 "exportTimelineChart returns InsufficientData with a single point" { const Date = @import("Date.zig"); const alloc = std.testing.allocator; const io = std.testing.io; var points: [1]line_chart.LinePoint = .{.{ .date = Date.fromYmd(2025, 1, 1), .value = 1_000_000 }}; 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_timeline_insufficient.png" }); defer alloc.free(path); try std.testing.expectError( error.InsufficientData, exportTimelineChart(io, alloc, &points, .fit, theme.default_theme, 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, theme.default_theme, 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, theme.default_theme, 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, theme.default_theme, 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, theme.default_theme, path), ); } test "exportCompareChart writes a non-empty PNG file" { const alloc = std.testing.allocator; const io = std.testing.io; var then_bands: [11]projections.YearPercentiles = undefined; var now_bands: [11]projections.YearPercentiles = undefined; for (0..11) |i| { const t: f64 = 1_000_000.0 * (1.0 + 0.05 * @as(f64, @floatFromInt(i))); const n: f64 = 1_200_000.0 * (1.0 + 0.06 * @as(f64, @floatFromInt(i))); then_bands[i] = .{ .year = @intCast(i), .p10 = t * 0.6, .p25 = t * 0.8, .p50 = t, .p75 = t * 1.2, .p90 = t * 1.5 }; now_bands[i] = .{ .year = @intCast(i), .p10 = n * 0.6, .p25 = n * 0.8, .p50 = n, .p75 = n * 1.2, .p90 = n * 1.5 }; } 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_compare.png" }); defer alloc.free(path); try exportCompareChart(io, alloc, &then_bands, &now_bands, theme.default_theme, path); var file = try tmp.dir.openFile(io, "test_export_compare.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 "exportCompareChart returns InsufficientData with a single-year side" { const alloc = std.testing.allocator; const io = std.testing.io; const then_bands = [_]projections.YearPercentiles{.{ .year = 0, .p10 = 1, .p25 = 2, .p50 = 3, .p75 = 4, .p90 = 5 }}; var now_bands: [3]projections.YearPercentiles = undefined; for (0..3) |i| now_bands[i] = .{ .year = @intCast(i), .p10 = 1, .p25 = 2, .p50 = 3, .p75 = 4, .p90 = 5 }; 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_compare_insufficient.png" }); defer alloc.free(path); try std.testing.expectError( error.InsufficientData, exportCompareChart(io, alloc, &then_bands, &now_bands, theme.default_theme, path), ); }