diff --git a/README.md b/README.md index a3e0c6f..eccb5a6 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,17 @@ Per-symbol price change (5 held throughout) ``` +### Chart export (`--export-chart `) + +The `quote` and `projections` commands support `--export-chart ` to render their charts as PNG files (1920x1080) instead of emitting text. Useful for write-ups, sharing, or capturing a back-dated projection without screenshot-and-crop. + +``` +zfin quote AAPL --export-chart aapl.png +zfin projections --as-of 1Y --overlay-actuals --export-chart proj.png +``` + +The exported image uses the TUI's default theme. When `--export-chart` is set, no other text output is emitted — the command exits after writing the file. Only the default `projections` mode is supported; `--convergence`, `--return-backtest`, and `--vs` reject the flag (their charts still need PNG plumbing). The `history` command's portfolio-value chart is also not yet exportable — it uses a single-series braille format that doesn't share the z2d pipeline used by `quote` and `projections`. + ### Interactive TUI flags ``` diff --git a/TODO.md b/TODO.md index 4ed85da..b5afca0 100644 --- a/TODO.md +++ b/TODO.md @@ -102,37 +102,47 @@ ranking; unlabeled items are "someday, if the mood strikes." faithfulness one notch. Pick whichever has the highest payoff vs. complexity when this gets revisited. -## Export chart as PNG (`--export-chart `) — priority MEDIUM +## `--export-chart` follow-ups — priority LOW -z2d already supports PNG export natively. Today the chart-bearing -commands (`quote`, `history`, `projections`, plus the equivalent TUI -tabs) render to braille (CLI) or Kitty graphics (TUI). Adding a -`--export-chart ` flag would land just the chart (not the -surrounding text output) as a PNG file at the given path, at full -fidelity, regardless of which surface invoked it. +V1 of `--export-chart ` shipped for `quote` and `projections` +(default bands mode only). Several adjacent surfaces still don't +have PNG export and were deferred: -Driver: when reviewing a back-dated projection or a notable price -move, capturing the chart as an image (e.g. for a write-up, an email -to the household, or a wiki page) is currently a screenshot-and-crop -chore. PNG export makes it a one-shot CLI invocation. - -Sketch: -- `zfin quote AAPL --export-chart aapl.png` → just the price+ - Bollinger chart as a PNG, no other output. -- `zfin projections --as-of 1Y --overlay-actuals --export-chart projection.png` - → the projection-bands chart plus overlay, no other output. -- The chart code already produces RGB pixel buffers via z2d; replace - the `transmitPreEncodedImage` call (TUI) or the braille text path - (CLI) with a `Surface.write_png` call when the flag is present. - -Plumbing: a thin "chart-only render" entry point in each chart -module (`projection_chart.zig`, `chart.zig` for symbols), called -from the relevant command's `run()` when `--export-chart` is set. -Exits before the rest of the text output renders. - -Out of scope for V1: file-format alternatives (SVG, PDF), themed -color overrides for export (always uses the active terminal theme), -non-chart command output as PNG. +- **`history --export-chart`.** The `history` command renders a + single-series braille chart of portfolio value over time + (synthesized into `Candle` records and fed to + `format.computeBrailleChart`). It doesn't share the z2d + pipeline that `quote` (`tui/chart.zig`) and `projections` + (`tui/projection_chart.zig`) use. To export, options: + - **A.** Pipe the synthesized candles through + `tui/chart.zig`'s `renderChart` — but that draws Bollinger + Bands and an RSI panel, both meaningless on a portfolio- + value series. + - **B.** Add a minimal "single-series line chart" z2d + renderer (a slimmed-down `projection_chart.zig` without + bands). ~150 lines. Same renderToSurface shape so PNG + export is trivial after. + - **C.** Skip it permanently; the braille chart is fine for + what `history` is. Document as "not exportable". + B is the right answer if PNG export of the history chart is + ever requested. +- **`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 + that's a feature of its own — not just an export plumbing job. +- **Theme overrides at export time.** Today the export always + uses `theme.default_theme`. A `--theme ` flag at export + 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. ## `zfin doctor` health-check command — priority LOW diff --git a/src/chart_export.zig b/src/chart_export.zig new file mode 100644 index 0000000..32374d1 --- /dev/null +++ b/src/chart_export.zig @@ -0,0 +1,236 @@ +//! 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), + ); +} diff --git a/src/commands/projections.zig b/src/commands/projections.zig index 8ed75cd..2d1af64 100644 --- a/src/commands/projections.zig +++ b/src/commands/projections.zig @@ -26,6 +26,7 @@ const imported = @import("../data/imported_values.zig"); const forecast = @import("../analytics/forecast_evaluation.zig"); const milestones = @import("../analytics/milestones.zig"); const shiller = @import("../data/shiller.zig"); +const chart_export = @import("../chart_export.zig"); /// Hardcoded benchmark symbols (configurable in a future version). const stock_benchmark = "SPY"; @@ -55,6 +56,11 @@ pub const BandsArgs = struct { /// `null` means live (today). Non-null = historical snapshot. as_of: ?Date = null, overlay_actuals: bool = false, + /// When set, render the percentile-band chart (with optional + /// overlay) as a PNG to this path and exit. No text output. + /// Only supported in the default bands mode; --convergence, + /// --return-backtest, and --vs reject the flag at parse time. + export_chart: ?[]const u8 = null, }; pub const CompareArgs = struct { @@ -103,6 +109,11 @@ 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. \\ \\Date forms: YYYY-MM-DD or relative (1W/1M/1Q/1Y). \\ @@ -122,6 +133,7 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr var convergence = false; var return_backtest = false; var real_mode = false; + var export_chart: ?[]const u8 = null; var i: usize = 0; while (i < cmd_args.len) : (i += 1) { @@ -136,6 +148,13 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr return_backtest = true; } else if (std.mem.eql(u8, a, "--real")) { real_mode = true; + } else if (std.mem.eql(u8, a, "--export-chart")) { + if (i + 1 >= cmd_args.len) { + try cli.stderrPrint(io, "Error: --export-chart requires a path argument.\n"); + return error.MissingFlagValue; + } + export_chart = cmd_args[i + 1]; + i += 1; } else if (std.mem.eql(u8, a, "--as-of") or std.mem.eql(u8, a, "--vs")) { if (i + 1 >= cmd_args.len) { try cli.stderrPrint(io, "Error: "); @@ -193,6 +212,14 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr try 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)) { + try cli.stderrPrint(io, "Error: --export-chart only supported in the default projections (bands) mode.\n"); + return error.MutuallyExclusive; + } if (convergence) return ParsedArgs{ .convergence = {} }; if (return_backtest) return ParsedArgs{ .return_backtest = .{ .real = real_mode } }; @@ -207,6 +234,7 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr .events_enabled = events_enabled, .as_of = as_of, .overlay_actuals = overlay_actuals, + .export_chart = export_chart, } }; } @@ -256,6 +284,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { .today = today, .overlay_actuals = args.overlay_actuals, .refresh = ctx.globals.refresh_policy, + .export_chart = args.export_chart, }, color, out, @@ -319,6 +348,10 @@ pub const BandsOptions = struct { /// Refresh policy threaded through the live "now" price /// load. Has no effect when `from_snapshot = true`. refresh: framework.RefreshPolicy = .auto, + /// When set, render the percentile-band chart (with optional + /// overlay) to a PNG at this path and exit before any text + /// output renders. See `chart_export.exportProjectionChart`. + export_chart: ?[]const u8 = null, }; pub fn runBands( @@ -490,6 +523,55 @@ pub fn runBands( } } + // ── PNG export short-circuit ───────────────────────────────── + // + // When --export-chart is set, render the percentile-band chart + // (with overlay if loaded) to the requested PNG path and exit + // before any text output. Uses the longest configured horizon — + // matching what the TUI shows by default. + if (opts.export_chart) |export_path| { + const horizons_ec = ctx.config.getHorizons(); + if (horizons_ec.len == 0) { + try cli.stderrPrint(io, "Error: no horizons configured; cannot export chart.\n"); + return; + } + const last_idx_ec = horizons_ec.len - 1; + const bands_ec = ctx.data.bands[last_idx_ec] orelse { + try cli.stderrPrint(io, "Error: projection bands unavailable for the longest horizon; cannot export chart.\n"); + return; + }; + + // Translate the view-layer overlay points (if any) into the + // chart-module's ActualsPoint shape. Same conversion the TUI + // does in `projections_tab.drawWithKittyChart`. + var overlay_buf: ?[]@import("../tui/projection_chart.zig").ActualsPoint = null; + defer if (overlay_buf) |ob| va.free(ob); + const overlay_input = blk: { + const ov = ctx.overlay_actuals orelse break :blk @as(?@import("../tui/projection_chart.zig").ActualsOverlay, null); + const buf = va.alloc(@import("../tui/projection_chart.zig").ActualsPoint, ov.points.len) catch break :blk @as(?@import("../tui/projection_chart.zig").ActualsOverlay, null); + for (ov.points, 0..) |p, i| { + buf[i] = .{ .years_from_as_of = p.years_from_as_of, .liquid = p.liquid }; + } + overlay_buf = buf; + break :blk @import("../tui/projection_chart.zig").ActualsOverlay{ + .points = buf, + .today_years = ov.today_years, + }; + }; + + chart_export.exportProjectionChart(io, allocator, bands_ec, overlay_input, export_path) catch |err| switch (err) { + error.InsufficientData => { + try cli.stderrPrint(io, "Error: not enough projection data to render a chart.\n"); + return; + }, + else => { + try cli.stderrPrint(io, "Error: failed to write PNG.\n"); + return err; + }, + }; + return; + } + const horizons = ctx.config.getHorizons(); const confidence_levels = ctx.config.getConfidenceLevels(); const comparison = ctx.comparison; diff --git a/src/commands/quote.zig b/src/commands/quote.zig index 0bf1a66..78d4575 100644 --- a/src/commands/quote.zig +++ b/src/commands/quote.zig @@ -4,9 +4,16 @@ const cli = @import("common.zig"); const framework = @import("framework.zig"); const fmt = cli.fmt; const Money = @import("../Money.zig"); +const chart_export = @import("../chart_export.zig"); +const tui_chart = @import("../tui/chart.zig"); pub const ParsedArgs = struct { symbol: []const u8, + /// When set, render the chart as a PNG to this path instead of + /// emitting any text output. The chart code already produces + /// z2d-rendered pixel buffers for the TUI; this flag just lands + /// the same pixels in a file via z2d's PNG exporter. + export_chart: ?[]const u8 = null, }; pub const meta: framework.Meta = .{ @@ -15,7 +22,7 @@ pub const meta: framework.Meta = .{ .synopsis = "Show latest quote with chart and 20-day history", .uppercase_first_arg = true, .help = - \\Usage: zfin quote + \\Usage: zfin quote [--export-chart ] \\ \\Show the latest real-time quote for a symbol (Yahoo / TwelveData) \\plus a braille price chart of the last 60 candles and a table @@ -25,12 +32,20 @@ pub const meta: framework.Meta = .{ \\Yahoo path is free and unauthenticated; TwelveData requires \\TWELVEDATA_API_KEY. \\ + \\Options: + \\ --export-chart Render the price+Bollinger+RSI chart + \\ to a PNG file at the given path + \\ (1920x1080) and exit. No text output + \\ is emitted. Uses the TUI's default + \\ theme. + \\ \\Examples: \\ zfin quote AAPL \\ zfin quote spy # symbols are case-insensitive + \\ zfin quote AAPL --export-chart aapl.png \\ , - .user_errors = error{ MissingSymbol, UnexpectedArg }, + .user_errors = error{ MissingSymbol, UnexpectedArg, MissingFlagValue }, }; /// Quote data extracted from the real-time API (or synthesized from candles). @@ -45,15 +60,38 @@ pub const QuoteData = struct { }; pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { - if (cmd_args.len < 1) { + var symbol: ?[]const u8 = null; + var export_chart: ?[]const u8 = null; + + var i: usize = 0; + while (i < cmd_args.len) : (i += 1) { + const a = cmd_args[i]; + if (std.mem.eql(u8, a, "--export-chart")) { + if (i + 1 >= cmd_args.len) { + try cli.stderrPrint(ctx.io, "Error: --export-chart requires a path argument.\n"); + return error.MissingFlagValue; + } + export_chart = cmd_args[i + 1]; + i += 1; + } else if (std.mem.startsWith(u8, a, "--")) { + try cli.stderrPrint(ctx.io, "Error: 'quote': unexpected flag "); + try cli.stderrPrint(ctx.io, a); + try cli.stderrPrint(ctx.io, "\n"); + return error.UnexpectedArg; + } else { + if (symbol != null) { + try cli.stderrPrint(ctx.io, "Error: 'quote' takes a single symbol argument\n"); + return error.UnexpectedArg; + } + symbol = a; + } + } + + if (symbol == null) { try cli.stderrPrint(ctx.io, "Error: 'quote' requires a symbol argument\n"); return error.MissingSymbol; } - if (cmd_args.len > 1) { - try cli.stderrPrint(ctx.io, "Error: 'quote' takes a single symbol argument\n"); - return error.UnexpectedArg; - } - return .{ .symbol = cmd_args[0] }; + return .{ .symbol = symbol.?, .export_chart = export_chart }; } pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { @@ -72,6 +110,31 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { defer candle_result.deinit(); const candles = candle_result.data; + // PNG export short-circuits all text rendering. Use the + // longest timeframe the candle history can support — falling + // back to shorter ones until one fits — so the user gets the + // most chart context without having to think about it. + if (parsed.export_chart) |path| { + const tf: tui_chart.Timeframe = blk: { + const candidates = [_]tui_chart.Timeframe{ .@"5Y", .@"3Y", .@"1Y", .@"6M" }; + for (candidates) |c| { + if (candles.len >= c.tradingDays()) break :blk c; + } + break :blk .@"6M"; // fallback; renderToSurface enforces >= 20 candles + }; + chart_export.exportSymbolChart(ctx.io, ctx.allocator, candles, tf, path) catch |err| switch (err) { + error.InsufficientData => { + try cli.stderrPrint(ctx.io, "Error: not enough candle history to render a chart (need >= 20 candles).\n"); + return; + }, + else => { + try cli.stderrPrint(ctx.io, "Error: failed to write PNG.\n"); + return err; + }, + }; + return; + } + // Fetch real-time quote via DataService var quote: ?QuoteData = null; if (svc.getQuote(parsed.symbol)) |q| { diff --git a/src/tui/chart.zig b/src/tui/chart.zig index c6a2acb..01b6c7c 100644 --- a/src/tui/chart.zig +++ b/src/tui/chart.zig @@ -178,7 +178,49 @@ pub fn computeIndicators( /// Render a complete financial chart to raw RGB pixel data. /// The returned rgb_data is allocated with `alloc` and must be freed by caller. /// If `cached` is provided, uses pre-computed indicators instead of recomputing. -pub fn renderChart( +/// Owned by the caller — call `result.deinit(alloc)` after using it. +/// Used as the shared mid-stage between RGB extraction (kitty) and +/// PNG export (`--export-chart`). See `renderToSurface`. +pub const RenderedChart = struct { + surface: Surface, + width: u16, + height: u16, + price_min: f64, + price_max: f64, + rsi_latest: ?f64, + + pub fn deinit(self: *RenderedChart, alloc: std.mem.Allocator) void { + self.surface.deinit(alloc); + self.* = undefined; + } + + /// Extract a flat []u8 of R,G,B triplets from the surface buffer. + /// Caller owns the returned slice. The surface is left intact so + /// the caller can still call `deinit`. + pub fn extractRgb(self: *const RenderedChart, alloc: std.mem.Allocator) ![]u8 { + const rgb_buf = switch (self.surface) { + .image_surface_rgb => |s| s.buf, + else => unreachable, + }; + const raw = try alloc.alloc(u8, rgb_buf.len * 3); + for (rgb_buf, 0..) |px, i| { + raw[i * 3 + 0] = px.r; + raw[i * 3 + 1] = px.g; + raw[i * 3 + 2] = px.b; + } + return raw; + } +}; + +/// Render the chart into a `Surface` and return both. Caller owns +/// the result and must call `deinit`. +/// +/// Two consumers today: +/// - `renderChart` wraps this for the TUI's kitty graphics path +/// (extracts RGB, frees surface). +/// - `--export-chart` (CLI) wraps this for PNG export via +/// `z2d.png_exporter.writeToPNGFile`. +pub fn renderToSurface( io: std.Io, alloc: std.mem.Allocator, candles: []const zfin.Candle, @@ -187,7 +229,7 @@ pub fn renderChart( height_px: u32, th: theme.Theme, cached: ?*const CachedIndicators, -) !ChartResult { +) !RenderedChart { if (candles.len < 20) return error.InsufficientData; // Slice candles to timeframe @@ -230,7 +272,7 @@ pub fn renderChart( 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); // Create drawing context var ctx = Context.init(io, alloc, &sfc); @@ -464,22 +506,8 @@ pub fn renderChart( } } - // Extract raw RGB pixel data from the z2d surface buffer. - // The surface is image_surface_rgb, so the buffer is []pixel.RGB (packed u24). - // We need to convert to a flat []u8 of R,G,B triplets. - const rgb_buf = switch (sfc) { - .image_surface_rgb => |s| s.buf, - else => unreachable, - }; - const pixel_count = rgb_buf.len; - const raw = try alloc.alloc(u8, pixel_count * 3); - for (rgb_buf, 0..) |px, i| { - raw[i * 3 + 0] = px.r; - raw[i * 3 + 1] = px.g; - raw[i * 3 + 2] = px.b; - } return .{ - .rgb_data = raw, + .surface = sfc, .width = @intCast(width_px), .height = @intCast(height_px), .price_min = price_min, @@ -488,6 +516,32 @@ pub fn renderChart( }; } +/// Render a complete financial chart to raw RGB pixel data. +/// The returned rgb_data is allocated with `alloc` and must be freed by caller. +/// If `cached` is provided, uses pre-computed indicators instead of recomputing. +pub fn renderChart( + io: std.Io, + alloc: std.mem.Allocator, + candles: []const zfin.Candle, + timeframe: Timeframe, + width_px: u32, + height_px: u32, + th: theme.Theme, + cached: ?*const CachedIndicators, +) !ChartResult { + var rendered = try renderToSurface(io, alloc, candles, timeframe, width_px, height_px, th, cached); + defer rendered.deinit(alloc); + const raw = try rendered.extractRgb(alloc); + return .{ + .rgb_data = raw, + .width = rendered.width, + .height = rendered.height, + .price_min = rendered.price_min, + .price_max = rendered.price_max, + .rsi_latest = rendered.rsi_latest, + }; +} + // ── Drawing helpers ─────────────────────────────────────────────────── fn mapY(value: f64, min_val: f64, max_val: f64, top_px: f64, bottom_px: f64) f64 { @@ -675,3 +729,182 @@ test "Timeframe tradingDays" { try std.testing.expectEqual(@as(usize, 252), Timeframe.@"1Y".tradingDays()); try std.testing.expectEqual(@as(usize, 1260), Timeframe.@"5Y".tradingDays()); } + +// ── renderToSurface tests ───────────────────────────────────────────── +// +// These exercise the actual chart rendering pipeline (z2d surface + +// indicator computation) rather than just the helpers. They live in +// this file so they share the testing harness with the existing +// helper tests. + +const test_alloc = std.testing.allocator; +const Date = @import("../Date.zig"); + +/// Build a slice of synthetic candles with linearly-rising prices. +/// Used by multiple renderToSurface tests. +fn buildLinearCandles(arr: []zfin.Candle, start_price: f64) void { + for (arr, 0..) |*c, i| { + const price: f64 = start_price + @as(f64, @floatFromInt(i)); + c.* = .{ + .date = Date.fromYmd(2024, 1, 2).addDays(@intCast(i)), + .open = price, + .high = price + 1, + .low = price - 1, + .close = price, + .adj_close = price, + .volume = 1000, + }; + } +} + +test "renderToSurface returns InsufficientData with < 20 candles" { + var candles: [10]zfin.Candle = undefined; + buildLinearCandles(&candles, 100.0); + const result = renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null); + try std.testing.expectError(error.InsufficientData, result); +} + +test "renderToSurface produces a populated surface at requested dimensions" { + var candles: [30]zfin.Candle = undefined; + buildLinearCandles(&candles, 100.0); + + var rendered = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null); + defer rendered.deinit(test_alloc); + + try std.testing.expectEqual(@as(u16, 200), rendered.width); + try std.testing.expectEqual(@as(u16, 100), rendered.height); + // Surface must be the RGB variant the chart code commits to. + switch (rendered.surface) { + .image_surface_rgb => {}, + else => try std.testing.expect(false), + } +} + +test "renderToSurface price range covers the input close range" { + var candles: [30]zfin.Candle = undefined; + buildLinearCandles(&candles, 100.0); // closes: 100..129 + + var rendered = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null); + defer rendered.deinit(test_alloc); + + // 5% padding is applied inside renderToSurface, so the recorded + // min must be <= the actual min close, and max >= actual max. + // Bollinger bands can also expand the range, so we check the + // weaker invariant rather than equality. + try std.testing.expect(rendered.price_min <= 100.0); + try std.testing.expect(rendered.price_max >= 129.0); +} + +test "renderToSurface uses chartClose so split-day cliffs don't widen the price range" { + // 30 candles: first 15 have raw close=300, adj_close=100; last 15 + // have raw close=100, adj_close=100. If renderToSurface used raw + // `close`, price_max would be ~315 (300 + padding); using + // `chartClose` it should stay near 100. + var candles: [30]zfin.Candle = undefined; + for (0..30) |i| { + const raw: f64 = if (i < 15) 300 else 100; + candles[i] = .{ + .date = Date.fromYmd(2024, 1, 2).addDays(@intCast(i)), + .open = raw, + .high = raw, + .low = raw, + .close = raw, + .adj_close = 100, // adjusted is always 100 + .volume = 1000, + }; + } + + var rendered = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null); + defer rendered.deinit(test_alloc); + + // With chartClose, max should be near 100 — definitely not 250+. + // (Bollinger bands of a flat 100 series stay near 100, so the + // upper bound is tight.) + try std.testing.expect(rendered.price_max < 200); +} + +test "renderToSurface fills background with theme bg" { + var candles: [30]zfin.Candle = undefined; + buildLinearCandles(&candles, 100.0); + + // Use a custom theme with a distinctive bg color we can detect. + var th = theme.default_theme; + th.bg = .{ 0x12, 0x34, 0x56 }; + + var rendered = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, th, null); + defer rendered.deinit(test_alloc); + + // Pixel at (0, 0) is in the top-left margin — outside the chart + // area where lines/fills render — so it should still be the + // background color. + const buf = switch (rendered.surface) { + .image_surface_rgb => |s| s.buf, + else => unreachable, + }; + try std.testing.expect(buf.len > 0); + try std.testing.expectEqual(@as(u8, 0x12), buf[0].r); + try std.testing.expectEqual(@as(u8, 0x34), buf[0].g); + try std.testing.expectEqual(@as(u8, 0x56), buf[0].b); +} + +test "renderToSurface is deterministic across two calls with same input" { + var candles: [30]zfin.Candle = undefined; + buildLinearCandles(&candles, 100.0); + + var a = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null); + defer a.deinit(test_alloc); + var b = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null); + defer b.deinit(test_alloc); + + const buf_a = switch (a.surface) { + .image_surface_rgb => |s| s.buf, + else => unreachable, + }; + const buf_b = switch (b.surface) { + .image_surface_rgb => |s| s.buf, + else => unreachable, + }; + try std.testing.expectEqual(buf_a.len, buf_b.len); + // Compare a sample of pixels rather than the whole buffer to keep + // the failure message readable when this regresses. + var i: usize = 0; + while (i < buf_a.len) : (i += 100) { + try std.testing.expectEqual(buf_a[i].r, buf_b[i].r); + try std.testing.expectEqual(buf_a[i].g, buf_b[i].g); + try std.testing.expectEqual(buf_a[i].b, buf_b[i].b); + } +} + +test "RenderedChart.extractRgb produces 3 bytes per pixel matching surface buffer" { + var candles: [30]zfin.Candle = undefined; + buildLinearCandles(&candles, 100.0); + + var rendered = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 50, 40, theme.default_theme, null); + defer rendered.deinit(test_alloc); + + const raw = try rendered.extractRgb(test_alloc); + defer test_alloc.free(raw); + + const buf = switch (rendered.surface) { + .image_surface_rgb => |s| s.buf, + else => unreachable, + }; + try std.testing.expectEqual(buf.len * 3, raw.len); + // Spot-check first pixel: (R, G, B) at indices 0, 1, 2. + try std.testing.expectEqual(buf[0].r, raw[0]); + try std.testing.expectEqual(buf[0].g, raw[1]); + try std.testing.expectEqual(buf[0].b, raw[2]); +} + +test "renderChart wraps renderToSurface and frees the surface" { + // Smoke check that the legacy `renderChart` path still works + // after the refactor — same input shape, RGB extraction succeeds. + var candles: [30]zfin.Candle = undefined; + buildLinearCandles(&candles, 100.0); + + const result = try renderChart(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null); + defer test_alloc.free(result.rgb_data); + try std.testing.expectEqual(@as(u16, 200), result.width); + try std.testing.expectEqual(@as(u16, 100), result.height); + try std.testing.expectEqual(@as(usize, 200 * 100 * 3), result.rgb_data.len); +} diff --git a/src/tui/projection_chart.zig b/src/tui/projection_chart.zig index e4f3fdb..8daf1fe 100644 --- a/src/tui/projection_chart.zig +++ b/src/tui/projection_chart.zig @@ -59,13 +59,47 @@ pub const ProjectionChartResult = struct { value_max: f64, }; -/// Render a projection percentile band chart to raw RGB pixel data. -/// Draws p10-p90 outer band, p25-p75 inner band, and p50 median line. +/// Owned by the caller — call `result.deinit(alloc)` after using it. +/// Used as the shared mid-stage between RGB extraction (kitty) and +/// PNG export (`--export-chart`). See `renderToSurface`. +pub const RenderedProjection = struct { + surface: Surface, + width: u16, + height: u16, + value_min: f64, + value_max: f64, + + pub fn deinit(self: *RenderedProjection, alloc: std.mem.Allocator) void { + self.surface.deinit(alloc); + self.* = undefined; + } + + /// Extract a flat []u8 of R,G,B triplets from the surface buffer. + /// Caller owns the returned slice. The surface is left intact. + pub fn extractRgb(self: *const RenderedProjection, alloc: std.mem.Allocator) ![]u8 { + const rgb_buf = switch (self.surface) { + .image_surface_rgb => |s| s.buf, + else => unreachable, + }; + const raw = try alloc.alloc(u8, rgb_buf.len * 3); + for (rgb_buf, 0..) |px, i| { + raw[i * 3 + 0] = px.r; + raw[i * 3 + 1] = px.g; + raw[i * 3 + 2] = px.b; + } + return raw; + } +}; + +/// Render a projection percentile band chart into a `Surface` and +/// return both. Caller owns the result and must call `deinit`. /// -/// `bands` is the array of YearPercentiles (year 0 through horizon). -/// `actuals` is an optional realized-trajectory overlay. -/// The returned rgb_data is allocated with `alloc` and must be freed by caller. -pub fn renderProjectionChart( +/// Two consumers today: +/// - `renderProjectionChart` wraps this for the TUI's kitty +/// graphics path (extracts RGB, frees surface). +/// - `--export-chart` (CLI) wraps this for PNG export via +/// `z2d.png_exporter.writeToPNGFile`. +pub fn renderToSurface( io: std.Io, alloc: std.mem.Allocator, bands: []const projections.YearPercentiles, @@ -73,13 +107,13 @@ pub fn renderProjectionChart( height_px: u32, th: theme.Theme, actuals: ?ActualsOverlay, -) !ProjectionChartResult { +) !RenderedProjection { if (bands.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(); @@ -283,21 +317,8 @@ pub fn renderProjectionChart( try drawRect(&ctx, chart_left, chart_top, chart_right, chart_bottom, border_color, 1.0); } - // Extract raw RGB pixel data - const rgb_buf = switch (sfc) { - .image_surface_rgb => |s| s.buf, - else => unreachable, - }; - const pixel_count = rgb_buf.len; - const raw = try alloc.alloc(u8, pixel_count * 3); - for (rgb_buf, 0..) |px, pi| { - raw[pi * 3 + 0] = px.r; - raw[pi * 3 + 1] = px.g; - raw[pi * 3 + 2] = px.b; - } - return .{ - .rgb_data = raw, + .surface = sfc, .width = @intCast(width_px), .height = @intCast(height_px), .value_min = value_min, @@ -305,6 +326,33 @@ pub fn renderProjectionChart( }; } +/// Render a projection percentile band chart to raw RGB pixel data. +/// Draws p10-p90 outer band, p25-p75 inner band, and p50 median line. +/// +/// `bands` is the array of YearPercentiles (year 0 through horizon). +/// `actuals` is an optional realized-trajectory overlay. +/// The returned rgb_data is allocated with `alloc` and must be freed by caller. +pub fn renderProjectionChart( + io: std.Io, + alloc: std.mem.Allocator, + bands: []const projections.YearPercentiles, + width_px: u32, + height_px: u32, + th: theme.Theme, + actuals: ?ActualsOverlay, +) !ProjectionChartResult { + var rendered = try renderToSurface(io, alloc, bands, width_px, height_px, th, actuals); + defer rendered.deinit(alloc); + const raw = try rendered.extractRgb(alloc); + return .{ + .rgb_data = raw, + .width = rendered.width, + .height = rendered.height, + .value_min = rendered.value_min, + .value_max = rendered.value_max, + }; +} + // ── Drawing helpers ─────────────────────────────────────────────────── fn mapY(value: f64, min_val: f64, max_val: f64, top_px: f64, bottom_px: f64) f64 { @@ -493,3 +541,121 @@ test "renderProjectionChart overlay with no points renders without crash" { defer alloc.free(result.rgb_data); try std.testing.expect(result.rgb_data.len > 0); } + +// ── renderToSurface direct tests ────────────────────────────────────── +// +// Exercise the surface-builder seam introduced for --export-chart. +// Verifies the surface is the right type/dimensions and that +// extractRgb round-trips correctly. + +test "renderToSurface returns a populated RGB surface at requested dimensions" { + const alloc = std.testing.allocator; + const bands = [_]projections.YearPercentiles{ + .{ .year = 0, .p10 = 1, .p25 = 2, .p50 = 3, .p75 = 4, .p90 = 5 }, + .{ .year = 1, .p10 = 2, .p25 = 3, .p50 = 4, .p75 = 5, .p90 = 6 }, + }; + const th = @import("theme.zig").default_theme; + var rendered = try renderToSurface(std.testing.io, alloc, &bands, 150, 80, th, null); + defer rendered.deinit(alloc); + + try std.testing.expectEqual(@as(u16, 150), rendered.width); + try std.testing.expectEqual(@as(u16, 80), rendered.height); + switch (rendered.surface) { + .image_surface_rgb => {}, + else => try std.testing.expect(false), + } +} + +test "renderToSurface fills background with theme bg" { + const alloc = std.testing.allocator; + const bands = [_]projections.YearPercentiles{ + .{ .year = 0, .p10 = 100, .p25 = 110, .p50 = 120, .p75 = 130, .p90 = 140 }, + .{ .year = 1, .p10 = 100, .p25 = 110, .p50 = 120, .p75 = 130, .p90 = 140 }, + }; + var th = @import("theme.zig").default_theme; + th.bg = .{ 0xab, 0xcd, 0xef }; + + var rendered = try renderToSurface(std.testing.io, alloc, &bands, 100, 50, th, null); + defer rendered.deinit(alloc); + + const buf = switch (rendered.surface) { + .image_surface_rgb => |s| s.buf, + else => unreachable, + }; + try std.testing.expectEqual(@as(u8, 0xab), buf[0].r); + try std.testing.expectEqual(@as(u8, 0xcd), buf[0].g); + try std.testing.expectEqual(@as(u8, 0xef), buf[0].b); +} + +test "renderToSurface is deterministic across calls with same input" { + const alloc = std.testing.allocator; + const bands = [_]projections.YearPercentiles{ + .{ .year = 0, .p10 = 100, .p25 = 110, .p50 = 120, .p75 = 130, .p90 = 140 }, + .{ .year = 5, .p10 = 90, .p25 = 110, .p50 = 130, .p75 = 160, .p90 = 200 }, + .{ .year = 10, .p10 = 80, .p25 = 120, .p50 = 160, .p75 = 220, .p90 = 300 }, + }; + const th = @import("theme.zig").default_theme; + + var a = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null); + defer a.deinit(alloc); + var b = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null); + defer b.deinit(alloc); + + const buf_a = switch (a.surface) { + .image_surface_rgb => |s| s.buf, + else => unreachable, + }; + const buf_b = switch (b.surface) { + .image_surface_rgb => |s| s.buf, + else => unreachable, + }; + try std.testing.expectEqual(buf_a.len, buf_b.len); + var i: usize = 0; + while (i < buf_a.len) : (i += 50) { + try std.testing.expectEqual(buf_a[i].r, buf_b[i].r); + try std.testing.expectEqual(buf_a[i].g, buf_b[i].g); + try std.testing.expectEqual(buf_a[i].b, buf_b[i].b); + } +} + +test "RenderedProjection.extractRgb produces 3 bytes per pixel" { + const alloc = std.testing.allocator; + const bands = [_]projections.YearPercentiles{ + .{ .year = 0, .p10 = 1, .p25 = 2, .p50 = 3, .p75 = 4, .p90 = 5 }, + .{ .year = 1, .p10 = 2, .p25 = 3, .p50 = 4, .p75 = 5, .p90 = 6 }, + }; + const th = @import("theme.zig").default_theme; + var rendered = try renderToSurface(std.testing.io, alloc, &bands, 50, 40, th, null); + defer rendered.deinit(alloc); + + const raw = try rendered.extractRgb(alloc); + defer alloc.free(raw); + + const buf = switch (rendered.surface) { + .image_surface_rgb => |s| s.buf, + else => unreachable, + }; + try std.testing.expectEqual(buf.len * 3, raw.len); + try std.testing.expectEqual(buf[0].r, raw[0]); + try std.testing.expectEqual(buf[0].g, raw[1]); + try std.testing.expectEqual(buf[0].b, raw[2]); +} + +test "renderToSurface clamps value_min to zero when bands include negatives" { + const alloc = std.testing.allocator; + // p10 dipping below zero with no padding clamping would make + // the chart's negative region uselessly large. + const bands = [_]projections.YearPercentiles{ + .{ .year = 0, .p10 = -100, .p25 = -50, .p50 = 0, .p75 = 50, .p90 = 100 }, + .{ .year = 1, .p10 = -200, .p25 = -100, .p50 = 0, .p75 = 100, .p90 = 200 }, + }; + const th = @import("theme.zig").default_theme; + var rendered = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null); + defer rendered.deinit(alloc); + + // After 5% padding and the `if (value_min < 0) value_min = 0` + // clamp inside renderToSurface, the recorded min should be 0 + // even though the raw band min is negative. + try std.testing.expectEqual(@as(f64, 0), rendered.value_min); + try std.testing.expect(rendered.value_max > 0); +}