chart export for return backtest and convergence projection charts
This commit is contained in:
parent
6827b70f85
commit
98f0f96bf5
5 changed files with 398 additions and 47 deletions
12
TODO.md
12
TODO.md
|
|
@ -73,14 +73,9 @@ ranking; unlabeled items are "someday, if the mood strikes."
|
|||
## `--export-chart` follow-ups - priority LOW
|
||||
|
||||
V1 of `--export-chart <PATH>` shipped for `quote`, `projections`
|
||||
(default bands mode only), and `history`. Several adjacent surfaces
|
||||
still don't have PNG export and were deferred:
|
||||
(bands, `--convergence`, and `--return-backtest` modes), and
|
||||
`history`. Two adjacent surfaces still don't have PNG export:
|
||||
|
||||
- **`projections --convergence` / `--return-backtest`.** Both
|
||||
render forecast-evaluation charts via `tui/forecast_chart.zig`.
|
||||
Not refactored to expose a `renderToSurface` seam yet -
|
||||
parser rejects `--export-chart` in those modes today. Low
|
||||
effort to add (mirror the `tui/chart.zig` pattern).
|
||||
- **`projections --vs <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
|
||||
|
|
@ -90,9 +85,6 @@ still don't have PNG export and were deferred:
|
|||
time would let users render with their configured theme or a
|
||||
presentation-friendly one. Out of scope for V1; gate when
|
||||
someone asks for it.
|
||||
- **File format alternatives.** SVG / PDF / WebP - `z2d` only
|
||||
exports PNG natively today; would need an external dependency
|
||||
or a pixel-buffer-to-format conversion.
|
||||
|
||||
## Refactor: trim `src/format.zig` once Money / Date have absorbed their helpers - priority LOW
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ const chart = @import("charts/chart.zig");
|
|||
const projection_chart = @import("charts/projection_chart.zig");
|
||||
const line_chart = @import("charts/line_chart.zig");
|
||||
const projections = @import("analytics/projections.zig");
|
||||
const forecast = @import("analytics/forecast_evaluation.zig");
|
||||
const forecast_chart = @import("charts/forecast_chart.zig");
|
||||
const theme = @import("tui/theme.zig");
|
||||
|
||||
/// Default PNG export resolution. Matches `charts/chart.zig`'s
|
||||
|
|
@ -135,6 +137,58 @@ pub fn exportTimelineChart(
|
|||
try z2d.png_exporter.writeToPNGFile(io, rendered.surface, path, .{});
|
||||
}
|
||||
|
||||
/// Export the convergence forecast chart (years-until-retirement vs.
|
||||
/// observation date) as a PNG. Wraps
|
||||
/// `forecast_chart.renderConvergenceToSurface` + `writeToPNGFile`.
|
||||
pub fn exportConvergenceChart(
|
||||
io: std.Io,
|
||||
alloc: std.mem.Allocator,
|
||||
points: []const forecast.ConvergencePoint,
|
||||
path: []const u8,
|
||||
) !void {
|
||||
var rendered = forecast_chart.renderConvergenceToSurface(
|
||||
io,
|
||||
alloc,
|
||||
points,
|
||||
default_width,
|
||||
default_height,
|
||||
theme.default_theme,
|
||||
true,
|
||||
) catch |err| switch (err) {
|
||||
error.InsufficientData => return error.InsufficientData,
|
||||
else => return err,
|
||||
};
|
||||
defer rendered.deinit(alloc);
|
||||
|
||||
try z2d.png_exporter.writeToPNGFile(io, rendered.surface, path, .{});
|
||||
}
|
||||
|
||||
/// Export the return back-test forecast chart (expected vs. realized
|
||||
/// forward CAGR by anchor) as a PNG. Wraps
|
||||
/// `forecast_chart.renderBacktestToSurface` + `writeToPNGFile`.
|
||||
pub fn exportBacktestChart(
|
||||
io: std.Io,
|
||||
alloc: std.mem.Allocator,
|
||||
anchors: []const forecast.BacktestAnchor,
|
||||
path: []const u8,
|
||||
) !void {
|
||||
var rendered = forecast_chart.renderBacktestToSurface(
|
||||
io,
|
||||
alloc,
|
||||
anchors,
|
||||
default_width,
|
||||
default_height,
|
||||
theme.default_theme,
|
||||
true,
|
||||
) catch |err| switch (err) {
|
||||
error.InsufficientData => return error.InsufficientData,
|
||||
else => return err,
|
||||
};
|
||||
defer rendered.deinit(alloc);
|
||||
|
||||
try z2d.png_exporter.writeToPNGFile(io, rendered.surface, path, .{});
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────
|
||||
|
||||
test "exportSymbolChart writes a non-empty PNG file" {
|
||||
|
|
@ -333,3 +387,113 @@ test "exportTimelineChart returns InsufficientData with a single point" {
|
|||
exportTimelineChart(io, alloc, &points, .fit, path),
|
||||
);
|
||||
}
|
||||
|
||||
test "exportConvergenceChart writes a non-empty PNG file" {
|
||||
const Date = @import("Date.zig");
|
||||
const alloc = std.testing.allocator;
|
||||
const io = std.testing.io;
|
||||
|
||||
const points = [_]forecast.ConvergencePoint{
|
||||
.{ .observation_date = Date.fromYmd(2020, 1, 1), .projected_date = Date.fromYmd(2032, 1, 1), .years_until_retirement = 12.0, .reached = false },
|
||||
.{ .observation_date = Date.fromYmd(2022, 1, 1), .projected_date = Date.fromYmd(2031, 1, 1), .years_until_retirement = 9.0, .reached = false },
|
||||
.{ .observation_date = Date.fromYmd(2025, 1, 1), .projected_date = Date.fromYmd(2030, 1, 1), .years_until_retirement = 5.0, .reached = false },
|
||||
};
|
||||
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
|
||||
const dir_path = path_buf[0..dir_len];
|
||||
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_convergence.png" });
|
||||
defer alloc.free(path);
|
||||
|
||||
try exportConvergenceChart(io, alloc, &points, path);
|
||||
|
||||
var file = try tmp.dir.openFile(io, "test_export_convergence.png", .{});
|
||||
defer file.close(io);
|
||||
const size = (try file.stat(io)).size;
|
||||
try std.testing.expect(size > 1024);
|
||||
|
||||
var magic: [8]u8 = undefined;
|
||||
var reader = file.reader(io, &.{});
|
||||
_ = try reader.interface.readSliceShort(&magic);
|
||||
try std.testing.expectEqualSlices(u8, "\x89PNG\x0D\x0A\x1A\x0A", &magic);
|
||||
}
|
||||
|
||||
test "exportConvergenceChart returns InsufficientData with a single point" {
|
||||
const Date = @import("Date.zig");
|
||||
const alloc = std.testing.allocator;
|
||||
const io = std.testing.io;
|
||||
|
||||
const points = [_]forecast.ConvergencePoint{
|
||||
.{ .observation_date = Date.fromYmd(2020, 1, 1), .projected_date = Date.fromYmd(2030, 1, 1), .years_until_retirement = 10.0, .reached = false },
|
||||
};
|
||||
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
|
||||
const dir_path = path_buf[0..dir_len];
|
||||
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_convergence_insufficient.png" });
|
||||
defer alloc.free(path);
|
||||
|
||||
try std.testing.expectError(
|
||||
error.InsufficientData,
|
||||
exportConvergenceChart(io, alloc, &points, path),
|
||||
);
|
||||
}
|
||||
|
||||
test "exportBacktestChart writes a non-empty PNG file" {
|
||||
const Date = @import("Date.zig");
|
||||
const alloc = std.testing.allocator;
|
||||
const io = std.testing.io;
|
||||
|
||||
const anchors = [_]forecast.BacktestAnchor{
|
||||
.{ .anchor_date = Date.fromYmd(2016, 1, 1), .expected = 0.07, .realized_1y = 0.12, .realized_3y = 0.09, .realized_5y = 0.08 },
|
||||
.{ .anchor_date = Date.fromYmd(2019, 1, 1), .expected = 0.08, .realized_1y = 0.18, .realized_3y = 0.10, .realized_5y = null },
|
||||
.{ .anchor_date = Date.fromYmd(2022, 1, 1), .expected = 0.06, .realized_1y = -0.05, .realized_3y = null, .realized_5y = null },
|
||||
};
|
||||
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
|
||||
const dir_path = path_buf[0..dir_len];
|
||||
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_backtest.png" });
|
||||
defer alloc.free(path);
|
||||
|
||||
try exportBacktestChart(io, alloc, &anchors, path);
|
||||
|
||||
var file = try tmp.dir.openFile(io, "test_export_backtest.png", .{});
|
||||
defer file.close(io);
|
||||
const size = (try file.stat(io)).size;
|
||||
try std.testing.expect(size > 1024);
|
||||
|
||||
var magic: [8]u8 = undefined;
|
||||
var reader = file.reader(io, &.{});
|
||||
_ = try reader.interface.readSliceShort(&magic);
|
||||
try std.testing.expectEqualSlices(u8, "\x89PNG\x0D\x0A\x1A\x0A", &magic);
|
||||
}
|
||||
|
||||
test "exportBacktestChart returns InsufficientData with a single anchor" {
|
||||
const Date = @import("Date.zig");
|
||||
const alloc = std.testing.allocator;
|
||||
const io = std.testing.io;
|
||||
|
||||
const anchors = [_]forecast.BacktestAnchor{
|
||||
.{ .anchor_date = Date.fromYmd(2020, 1, 1), .expected = 0.10, .realized_1y = null, .realized_3y = null, .realized_5y = null },
|
||||
};
|
||||
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
|
||||
const dir_path = path_buf[0..dir_len];
|
||||
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_backtest_insufficient.png" });
|
||||
defer alloc.free(path);
|
||||
|
||||
try std.testing.expectError(
|
||||
error.InsufficientData,
|
||||
exportBacktestChart(io, alloc, &anchors, path),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,6 +63,13 @@ pub fn fmtDollar(buf: []u8, value: f64) []const u8 {
|
|||
return std.fmt.bufPrint(buf, "{s}${s}", .{ sign, commas }) catch "$?";
|
||||
}
|
||||
|
||||
/// Unit for `drawYTicks` y-axis labels. `dollars` and `percent`
|
||||
/// delegate to the canonical formatters (`fmtDollar`,
|
||||
/// `format.fmtPct`); `years` is a compact whole-year tick ("10y")
|
||||
/// for the convergence chart - the codebase has no shared compact-
|
||||
/// years helper (the verbose "N Year" lives in the projections view).
|
||||
pub const TickUnit = enum { dollars, percent, years };
|
||||
|
||||
/// Draw `n + 1` left-aligned dollar labels evenly spaced from
|
||||
/// `value_max` (at `top`) down to `value_min` (at `bottom`), placed in
|
||||
/// the right margin a small pad to the right of `plot_right` (the plot's
|
||||
|
|
@ -79,6 +86,27 @@ pub fn drawYDollarTicks(
|
|||
value_min: f64,
|
||||
value_max: f64,
|
||||
n: usize,
|
||||
) void {
|
||||
drawYTicks(sfc, scale, color, plot_right, top, bottom, value_min, value_max, n, .dollars);
|
||||
}
|
||||
|
||||
/// Like `drawYDollarTicks` but renders the tick values in the given
|
||||
/// `unit`, so non-dollar axes (percent returns, years-until-
|
||||
/// retirement) reuse the same tick spacing and placement.
|
||||
/// `drawYDollarTicks` is the dollar-specialized wrapper. Percent
|
||||
/// ticks go through `format.fmtPct` (the canonical percent
|
||||
/// formatter) rather than a local reimplementation.
|
||||
pub fn drawYTicks(
|
||||
sfc: *Surface,
|
||||
scale: i32,
|
||||
color: [3]u8,
|
||||
plot_right: f64,
|
||||
top: f64,
|
||||
bottom: f64,
|
||||
value_min: f64,
|
||||
value_max: f64,
|
||||
n: usize,
|
||||
unit: TickUnit,
|
||||
) void {
|
||||
const range = value_max - value_min;
|
||||
const span = bottom - top;
|
||||
|
|
@ -91,7 +119,11 @@ pub fn drawYDollarTicks(
|
|||
const val = value_max - frac * range;
|
||||
const y = top + frac * span;
|
||||
var buf: [24]u8 = undefined;
|
||||
const label = fmtDollar(&buf, val);
|
||||
const label = switch (unit) {
|
||||
.dollars => fmtDollar(&buf, val),
|
||||
.percent => fmt.fmtPct(&buf, val, .{ .decimals = 0 }),
|
||||
.years => std.fmt.bufPrint(&buf, "{d}y", .{@as(i64, @intFromFloat(@round(val)))}) catch "?y",
|
||||
};
|
||||
const ly = @as(i32, @intFromFloat(y)) - half_h;
|
||||
text.drawText(sfc, lx, ly, scale, color, label);
|
||||
}
|
||||
|
|
@ -136,6 +168,23 @@ test "fmtDollar: M/B suffixes at/above a million, commas below, sign for negativ
|
|||
try testing.expectEqualStrings("-$1.2M", fmtDollar(&buf, -1_200_000));
|
||||
}
|
||||
|
||||
test "drawYTicks renders percent and years units" {
|
||||
const alloc = testing.allocator;
|
||||
const color = [3]u8{ 0xCC, 0xCC, 0xCC };
|
||||
|
||||
// Percent ticks (decimal rates, via format.fmtPct) stamp glyphs.
|
||||
var sfc = try Surface.init(.image_surface_rgb, alloc, 300, 200);
|
||||
defer sfc.deinit(alloc);
|
||||
drawYTicks(&sfc, 2, color, 180, 10, 190, -0.05, 0.15, 5, .percent);
|
||||
try testing.expect(draw.countColor(&sfc, color) > 0);
|
||||
|
||||
// Years ticks stamp glyphs too.
|
||||
var sfc2 = try Surface.init(.image_surface_rgb, alloc, 300, 200);
|
||||
defer sfc2.deinit(alloc);
|
||||
drawYTicks(&sfc2, 2, color, 180, 10, 190, 0, 12, 5, .years);
|
||||
try testing.expect(draw.countColor(&sfc2, color) > 0);
|
||||
}
|
||||
|
||||
test "drawYDollarTicks stamps labels in the requested color" {
|
||||
const alloc = testing.allocator;
|
||||
var sfc = try Surface.init(.image_surface_rgb, alloc, 300, 200);
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ const theme = @import("../tui/theme.zig");
|
|||
const forecast = @import("../analytics/forecast_evaluation.zig");
|
||||
const Date = @import("../Date.zig");
|
||||
const draw = @import("draw.zig");
|
||||
const axis = @import("axis.zig");
|
||||
|
||||
const Surface = z2d.Surface;
|
||||
const Context = z2d.Context;
|
||||
|
|
@ -50,6 +51,74 @@ pub const ChartResult = struct {
|
|||
value_max: f64,
|
||||
};
|
||||
|
||||
/// Owned by the caller - call `result.deinit(alloc)` when done. The
|
||||
/// shared surface seam for both forecast charts: the `--export-chart`
|
||||
/// path keeps the surface to hand to the PNG encoder, while the kitty
|
||||
/// wrappers (`renderConvergenceChart` / `renderBacktestChart`) extract
|
||||
/// RGB and free it. Mirrors `projection_chart.RenderedProjection`.
|
||||
pub const RenderedForecast = struct {
|
||||
surface: Surface,
|
||||
width: u16,
|
||||
height: u16,
|
||||
value_min: f64,
|
||||
value_max: f64,
|
||||
|
||||
pub fn deinit(self: *RenderedForecast, alloc: std.mem.Allocator) void {
|
||||
self.surface.deinit(alloc);
|
||||
self.* = undefined;
|
||||
}
|
||||
|
||||
/// Extract a flat []u8 of R,G,B triplets; caller owns it. The
|
||||
/// surface is left intact.
|
||||
pub fn extractRgb(self: *const RenderedForecast, alloc: std.mem.Allocator) ![]u8 {
|
||||
return draw.extractRgb(alloc, &self.surface);
|
||||
}
|
||||
};
|
||||
|
||||
/// Thin RGB wrapper over `renderConvergenceToSurface` for the TUI's
|
||||
/// kitty path: renders without axis labels, extracts RGB, frees the
|
||||
/// surface.
|
||||
pub fn renderConvergenceChart(
|
||||
io: std.Io,
|
||||
alloc: std.mem.Allocator,
|
||||
points: []const forecast.ConvergencePoint,
|
||||
width_px: u32,
|
||||
height_px: u32,
|
||||
th: theme.Theme,
|
||||
) !ChartResult {
|
||||
var rendered = try renderConvergenceToSurface(io, alloc, points, width_px, height_px, th, false);
|
||||
defer rendered.deinit(alloc);
|
||||
return .{
|
||||
.rgb_data = try rendered.extractRgb(alloc),
|
||||
.width = rendered.width,
|
||||
.height = rendered.height,
|
||||
.value_min = rendered.value_min,
|
||||
.value_max = rendered.value_max,
|
||||
};
|
||||
}
|
||||
|
||||
/// Thin RGB wrapper over `renderBacktestToSurface` for the TUI's
|
||||
/// kitty path: renders without axis labels, extracts RGB, frees the
|
||||
/// surface.
|
||||
pub fn renderBacktestChart(
|
||||
io: std.Io,
|
||||
alloc: std.mem.Allocator,
|
||||
anchors: []const BacktestAnchor,
|
||||
width_px: u32,
|
||||
height_px: u32,
|
||||
th: theme.Theme,
|
||||
) !ChartResult {
|
||||
var rendered = try renderBacktestToSurface(io, alloc, anchors, width_px, height_px, th, false);
|
||||
defer rendered.deinit(alloc);
|
||||
return .{
|
||||
.rgb_data = try rendered.extractRgb(alloc),
|
||||
.width = rendered.width,
|
||||
.height = rendered.height,
|
||||
.value_min = rendered.value_min,
|
||||
.value_max = rendered.value_max,
|
||||
};
|
||||
}
|
||||
|
||||
// ── View 1: Convergence chart ────────────────────────────────
|
||||
|
||||
/// Render the convergence chart. X-axis spans
|
||||
|
|
@ -67,20 +136,21 @@ pub const ChartResult = struct {
|
|||
/// - Solid line through the convergence points
|
||||
/// - Distinct markers on `reached` rows (small filled dots,
|
||||
/// theme accent color)
|
||||
pub fn renderConvergenceChart(
|
||||
pub fn renderConvergenceToSurface(
|
||||
io: std.Io,
|
||||
alloc: std.mem.Allocator,
|
||||
points: []const forecast.ConvergencePoint,
|
||||
width_px: u32,
|
||||
height_px: u32,
|
||||
th: theme.Theme,
|
||||
) !ChartResult {
|
||||
axis_labels: bool,
|
||||
) !RenderedForecast {
|
||||
if (points.len < 2) return error.InsufficientData;
|
||||
|
||||
const w: i32 = @intCast(width_px);
|
||||
const h: i32 = @intCast(height_px);
|
||||
var sfc = try Surface.init(.image_surface_rgb, alloc, w, h);
|
||||
defer sfc.deinit(alloc);
|
||||
errdefer sfc.deinit(alloc);
|
||||
|
||||
var ctx = Context.init(io, alloc, &sfc);
|
||||
defer ctx.deinit();
|
||||
|
|
@ -95,11 +165,19 @@ pub fn renderConvergenceChart(
|
|||
// Background
|
||||
try draw.fillBackground(&ctx, fwidth, fheight, bg);
|
||||
|
||||
const chart_left = margin_left;
|
||||
const chart_right = fwidth - margin_right;
|
||||
// Chart area. With axis labels, reserve a right margin for the
|
||||
// y-axis years ticks and a bottom margin for the date endpoints.
|
||||
const label_scale: i32 = axis.labelScale(h);
|
||||
const label_char_h: f64 = axis.charHeight(label_scale);
|
||||
const m_left: f64 = if (axis_labels) label_char_h else margin_left;
|
||||
const m_right: f64 = if (axis_labels) axis.yAxisMargin(label_scale) else margin_right;
|
||||
const m_top: f64 = if (axis_labels) (label_char_h / 2 + 4) else margin_top;
|
||||
const m_bottom: f64 = if (axis_labels) axis.bottomMargin(label_scale) else margin_bottom;
|
||||
const chart_left = m_left;
|
||||
const chart_right = fwidth - m_right;
|
||||
const chart_w = chart_right - chart_left;
|
||||
const chart_top = margin_top;
|
||||
const chart_bottom = fheight - margin_bottom;
|
||||
const chart_top = m_top;
|
||||
const chart_bottom = fheight - m_bottom;
|
||||
|
||||
// X-range: observation_date span
|
||||
const x0_days: f64 = @floatFromInt(points[0].observation_date.days);
|
||||
|
|
@ -186,8 +264,20 @@ pub fn renderConvergenceChart(
|
|||
// Border
|
||||
try drawRect(&ctx, chart_left, chart_top, chart_right, chart_bottom, blendColor(th.text_muted, 60, bg), 1.0);
|
||||
|
||||
// Axis labels (export only): right-side y-axis years ticks and
|
||||
// x-axis observation-date endpoints.
|
||||
if (axis_labels) {
|
||||
axis.drawYTicks(&sfc, label_scale, th.text_muted, chart_right, chart_top, chart_bottom, y_min, y_max, 5, .years);
|
||||
var fbuf: [10]u8 = undefined;
|
||||
var lbuf: [10]u8 = undefined;
|
||||
const first_s = std.fmt.bufPrint(&fbuf, "{f}", .{points[0].observation_date}) catch "";
|
||||
const last_s = std.fmt.bufPrint(&lbuf, "{f}", .{points[points.len - 1].observation_date}) catch "";
|
||||
const date_y = chart_bottom + axis.labelGap(label_scale);
|
||||
axis.drawXEndpoints(&sfc, label_scale, th.text_muted, chart_left, chart_right, date_y, first_s, last_s);
|
||||
}
|
||||
|
||||
return .{
|
||||
.rgb_data = try extractRgb(alloc, &sfc),
|
||||
.surface = sfc,
|
||||
.width = @intCast(width_px),
|
||||
.height = @intCast(height_px),
|
||||
.value_min = y_min,
|
||||
|
|
@ -212,20 +302,21 @@ pub const BacktestAnchor = forecast.BacktestAnchor;
|
|||
/// - `realized_5y` (solid, theme positive - green)
|
||||
///
|
||||
/// Plus a y=0 reference line.
|
||||
pub fn renderBacktestChart(
|
||||
pub fn renderBacktestToSurface(
|
||||
io: std.Io,
|
||||
alloc: std.mem.Allocator,
|
||||
anchors: []const BacktestAnchor,
|
||||
width_px: u32,
|
||||
height_px: u32,
|
||||
th: theme.Theme,
|
||||
) !ChartResult {
|
||||
axis_labels: bool,
|
||||
) !RenderedForecast {
|
||||
if (anchors.len < 2) return error.InsufficientData;
|
||||
|
||||
const w: i32 = @intCast(width_px);
|
||||
const h: i32 = @intCast(height_px);
|
||||
var sfc = try Surface.init(.image_surface_rgb, alloc, w, h);
|
||||
defer sfc.deinit(alloc);
|
||||
errdefer sfc.deinit(alloc);
|
||||
|
||||
var ctx = Context.init(io, alloc, &sfc);
|
||||
defer ctx.deinit();
|
||||
|
|
@ -240,11 +331,19 @@ pub fn renderBacktestChart(
|
|||
// Background
|
||||
try draw.fillBackground(&ctx, fwidth, fheight, bg);
|
||||
|
||||
const chart_left = margin_left;
|
||||
const chart_right = fwidth - margin_right;
|
||||
// Chart area. With axis labels, reserve a right margin for the
|
||||
// y-axis percent ticks and a bottom margin for the date endpoints.
|
||||
const label_scale: i32 = axis.labelScale(h);
|
||||
const label_char_h: f64 = axis.charHeight(label_scale);
|
||||
const m_left: f64 = if (axis_labels) label_char_h else margin_left;
|
||||
const m_right: f64 = if (axis_labels) axis.yAxisMargin(label_scale) else margin_right;
|
||||
const m_top: f64 = if (axis_labels) (label_char_h / 2 + 4) else margin_top;
|
||||
const m_bottom: f64 = if (axis_labels) axis.bottomMargin(label_scale) else margin_bottom;
|
||||
const chart_left = m_left;
|
||||
const chart_right = fwidth - m_right;
|
||||
const chart_w = chart_right - chart_left;
|
||||
const chart_top = margin_top;
|
||||
const chart_bottom = fheight - margin_bottom;
|
||||
const chart_top = m_top;
|
||||
const chart_bottom = fheight - m_bottom;
|
||||
|
||||
// X-range
|
||||
const x0_days: f64 = @floatFromInt(anchors[0].anchor_date.days);
|
||||
|
|
@ -303,8 +402,20 @@ pub fn renderBacktestChart(
|
|||
// Border
|
||||
try drawRect(&ctx, chart_left, chart_top, chart_right, chart_bottom, blendColor(th.text_muted, 60, bg), 1.0);
|
||||
|
||||
// Axis labels (export only): right-side y-axis percent ticks and
|
||||
// x-axis anchor-date endpoints.
|
||||
if (axis_labels) {
|
||||
axis.drawYTicks(&sfc, label_scale, th.text_muted, chart_right, chart_top, chart_bottom, y_min, y_max, 5, .percent);
|
||||
var fbuf: [10]u8 = undefined;
|
||||
var lbuf: [10]u8 = undefined;
|
||||
const first_s = std.fmt.bufPrint(&fbuf, "{f}", .{anchors[0].anchor_date}) catch "";
|
||||
const last_s = std.fmt.bufPrint(&lbuf, "{f}", .{anchors[anchors.len - 1].anchor_date}) catch "";
|
||||
const date_y = chart_bottom + axis.labelGap(label_scale);
|
||||
axis.drawXEndpoints(&sfc, label_scale, th.text_muted, chart_left, chart_right, date_y, first_s, last_s);
|
||||
}
|
||||
|
||||
return .{
|
||||
.rgb_data = try extractRgb(alloc, &sfc),
|
||||
.surface = sfc,
|
||||
.width = @intCast(width_px),
|
||||
.height = @intCast(height_px),
|
||||
.value_min = y_min,
|
||||
|
|
@ -492,7 +603,6 @@ const opaqueColor = draw.opaqueColor;
|
|||
const drawHorizontalGridLines = draw.drawHorizontalGridLines;
|
||||
const drawHLine = draw.drawHLine;
|
||||
const drawRect = draw.drawRect;
|
||||
const extractRgb = draw.extractRgb;
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -40,11 +40,12 @@ pub const ParsedArgs = union(enum) {
|
|||
/// `--vs <DATE>`: side-by-side compare of two projections.
|
||||
compare: CompareArgs,
|
||||
/// `--convergence`: plot the spreadsheet's predicted retirement
|
||||
/// date over time. No knobs.
|
||||
convergence,
|
||||
/// date over time. `export_chart` renders a PNG instead of the
|
||||
/// text table.
|
||||
convergence: struct { export_chart: ?[]const u8 = null },
|
||||
/// `--return-backtest [--real]`: plot expected_return vs realized
|
||||
/// forward-CAGR.
|
||||
return_backtest: struct { real: bool },
|
||||
/// forward-CAGR. `export_chart` renders a PNG instead of text.
|
||||
return_backtest: struct { real: bool, export_chart: ?[]const u8 = null },
|
||||
};
|
||||
|
||||
pub const BandsArgs = struct {
|
||||
|
|
@ -105,11 +106,12 @@ pub const meta: framework.Meta = .{
|
|||
\\ --return-backtest (see above)
|
||||
\\ --real With --return-backtest, render in
|
||||
\\ CPI-adjusted dollars.
|
||||
\\ --export-chart <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.
|
||||
\\ --export-chart <PATH> Render the current mode's chart as a
|
||||
\\ PNG to PATH (1920x1080) and exit.
|
||||
\\ Works in the default bands mode (with
|
||||
\\ the overlay if --overlay-actuals is
|
||||
\\ set), --convergence, and
|
||||
\\ --return-backtest. Not valid with --vs.
|
||||
\\
|
||||
\\Date forms: YYYY-MM-DD or relative (1W/1M/1Q/1Y).
|
||||
\\
|
||||
|
|
@ -203,17 +205,16 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
|
|||
cli.stderrPrint(io, "Error: --real only applies to --return-backtest.\n");
|
||||
return error.MutuallyExclusive;
|
||||
}
|
||||
// Chart export only meaningful in default bands mode. The
|
||||
// forecast-evaluation views (convergence, return-backtest)
|
||||
// render via `forecast_chart.zig` which doesn't have a PNG
|
||||
// export path yet; --vs is text-only with no chart at all.
|
||||
if (export_chart != null and (convergence or return_backtest or vs_date != null)) {
|
||||
cli.stderrPrint(io, "Error: --export-chart only supported in the default projections (bands) mode.\n");
|
||||
// --vs is text-only (a then/now delta table) with no chart to
|
||||
// export. The bands, convergence, and return-backtest modes all
|
||||
// support --export-chart.
|
||||
if (export_chart != null and vs_date != null) {
|
||||
cli.stderrPrint(io, "Error: --export-chart is not supported with --vs (it has no chart).\n");
|
||||
return error.MutuallyExclusive;
|
||||
}
|
||||
|
||||
if (convergence) return ParsedArgs{ .convergence = {} };
|
||||
if (return_backtest) return ParsedArgs{ .return_backtest = .{ .real = real_mode } };
|
||||
if (convergence) return ParsedArgs{ .convergence = .{ .export_chart = export_chart } };
|
||||
if (return_backtest) return ParsedArgs{ .return_backtest = .{ .real = real_mode, .export_chart = export_chart } };
|
||||
if (vs_date) |d| {
|
||||
return ParsedArgs{ .compare = .{
|
||||
.events_enabled = events_enabled,
|
||||
|
|
@ -241,8 +242,8 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
const file_path = pf.path;
|
||||
|
||||
switch (parsed) {
|
||||
.convergence => try runConvergence(io, allocator, file_path, color, out),
|
||||
.return_backtest => |args| try runReturnBacktest(io, allocator, file_path, args.real, color, out),
|
||||
.convergence => |args| try runConvergence(io, allocator, file_path, args.export_chart, color, out),
|
||||
.return_backtest => |args| try runReturnBacktest(io, allocator, file_path, args.real, args.export_chart, color, out),
|
||||
.compare => |args| {
|
||||
_ = ctx.svc orelse return error.MissingDataService;
|
||||
// Pre-load today's live composition only when it's
|
||||
|
|
@ -1177,6 +1178,7 @@ pub fn runConvergence(
|
|||
io: std.Io,
|
||||
allocator: std.mem.Allocator,
|
||||
file_path: []const u8,
|
||||
export_chart: ?[]const u8,
|
||||
color: bool,
|
||||
out: *std.Io.Writer,
|
||||
) !void {
|
||||
|
|
@ -1194,6 +1196,23 @@ pub fn runConvergence(
|
|||
defer iv.deinit();
|
||||
|
||||
const points = try forecast.convergencePoints(va, iv.points);
|
||||
|
||||
// --export-chart: render the convergence chart to a PNG and exit
|
||||
// before any text output.
|
||||
if (export_chart) |export_path| {
|
||||
chart_export.exportConvergenceChart(io, allocator, points, export_path) catch |err| switch (err) {
|
||||
error.InsufficientData => {
|
||||
cli.stderrPrint(io, "Error: not enough convergence data to render a chart.\n");
|
||||
return;
|
||||
},
|
||||
else => {
|
||||
cli.stderrPrint(io, "Error: failed to write PNG.\n");
|
||||
return err;
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = try view.convergenceLines(va, points);
|
||||
try renderForecastLines(out, color, lines);
|
||||
}
|
||||
|
|
@ -1215,6 +1234,7 @@ pub fn runReturnBacktest(
|
|||
allocator: std.mem.Allocator,
|
||||
file_path: []const u8,
|
||||
real_mode: bool,
|
||||
export_chart: ?[]const u8,
|
||||
color: bool,
|
||||
out: *std.Io.Writer,
|
||||
) !void {
|
||||
|
|
@ -1241,6 +1261,22 @@ pub fn runReturnBacktest(
|
|||
|
||||
const rows = try forecast.returnBacktest(va, iv.points, backtest_horizons, real_mode, cpi_list.items);
|
||||
const anchors = try forecast.pivotByAnchor(va, rows);
|
||||
|
||||
// --export-chart: render the back-test chart to a PNG and exit.
|
||||
if (export_chart) |export_path| {
|
||||
chart_export.exportBacktestChart(io, allocator, anchors, export_path) catch |err| switch (err) {
|
||||
error.InsufficientData => {
|
||||
cli.stderrPrint(io, "Error: not enough back-test data to render a chart.\n");
|
||||
return;
|
||||
},
|
||||
else => {
|
||||
cli.stderrPrint(io, "Error: failed to write PNG.\n");
|
||||
return err;
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = try view.backtestLines(va, anchors, real_mode);
|
||||
try renderForecastLines(out, color, lines);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue