export chart as png

This commit is contained in:
Emil Lerch 2026-05-19 14:18:53 -07:00
parent 5ab2600946
commit d3b1c04a3a
Signed by: lobo
GPG key ID: A7B62D657EF764F8
7 changed files with 878 additions and 77 deletions

View file

@ -235,6 +235,17 @@ Per-symbol price change (5 held throughout)
```
### Chart export (`--export-chart <PATH>`)
The `quote` and `projections` commands support `--export-chart <PATH>` 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
```

68
TODO.md
View file

@ -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 <path>`) — 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 <path>` 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 <PATH>` 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 <DATE>`.** No chart at all in this mode
(text-only delta table); `--export-chart` rejected at parse
time. Could grow a side-by-side bands comparison chart, but
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 <PATH>` 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

236
src/chart_export.zig Normal file
View file

@ -0,0 +1,236 @@
//! Chart-to-PNG export.
//!
//! The CLI's `--export-chart <path>` 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),
);
}

View file

@ -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 <PATH> Render the percentile-band chart
\\ (with optional overlay if
\\ --overlay-actuals is set) as a PNG
\\ to PATH (1920x1080) and exit. Only
\\ valid in the default bands mode.
\\
\\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;

View file

@ -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 <SYMBOL>
\\Usage: zfin quote <SYMBOL> [--export-chart <PATH>]
\\
\\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 <PATH> 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| {

View file

@ -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);
}

View file

@ -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);
}