//! 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 `tui/chart.zig` or //! `tui/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. //! //! The module deliberately reuses the TUI's `default_theme` for //! consistent visual identity across the live TUI surface and //! exported images. A `--theme` override at export time is out of //! scope for V1 (see TODO follow-up). const std = @import("std"); const z2d = @import("z2d"); const zfin = @import("root.zig"); const chart = @import("tui/chart.zig"); const projection_chart = @import("tui/projection_chart.zig"); const projections = @import("analytics/projections.zig"); const theme = @import("tui/theme.zig"); /// Default PNG export resolution. Matches `tui/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. /// Wraps `chart.renderToSurface` + `writeToPNGFile`. pub fn exportSymbolChart( io: std.Io, alloc: std.mem.Allocator, candles: []const zfin.Candle, timeframe: chart.Timeframe, path: []const u8, ) !void { var rendered = chart.renderToSurface( io, alloc, candles, timeframe, default_width, default_height, theme.default_theme, null, ) 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`. pub fn exportProjectionChart( io: std.Io, alloc: std.mem.Allocator, bands: []const projections.YearPercentiles, actuals: ?projection_chart.ActualsOverlay, path: []const u8, ) !void { var rendered = projection_chart.renderToSurface( io, alloc, bands, default_width, default_height, theme.default_theme, actuals, ) 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, .@"6M", 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, .@"6M", 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, 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, path), ); }