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
|
## `--export-chart` follow-ups - priority LOW
|
||||||
|
|
||||||
V1 of `--export-chart <PATH>` shipped for `quote`, `projections`
|
V1 of `--export-chart <PATH>` shipped for `quote`, `projections`
|
||||||
(default bands mode only), and `history`. Several adjacent surfaces
|
(bands, `--convergence`, and `--return-backtest` modes), and
|
||||||
still don't have PNG export and were deferred:
|
`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
|
- **`projections --vs <DATE>`.** No chart at all in this mode
|
||||||
(text-only delta table); `--export-chart` rejected at parse
|
(text-only delta table); `--export-chart` rejected at parse
|
||||||
time. Could grow a side-by-side bands comparison chart, but
|
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
|
time would let users render with their configured theme or a
|
||||||
presentation-friendly one. Out of scope for V1; gate when
|
presentation-friendly one. Out of scope for V1; gate when
|
||||||
someone asks for it.
|
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
|
## 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 projection_chart = @import("charts/projection_chart.zig");
|
||||||
const line_chart = @import("charts/line_chart.zig");
|
const line_chart = @import("charts/line_chart.zig");
|
||||||
const projections = @import("analytics/projections.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");
|
const theme = @import("tui/theme.zig");
|
||||||
|
|
||||||
/// Default PNG export resolution. Matches `charts/chart.zig`'s
|
/// 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, .{});
|
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 ─────────────────────────────────────────────────────────────
|
// ── Tests ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
test "exportSymbolChart writes a non-empty PNG file" {
|
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),
|
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 "$?";
|
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
|
/// Draw `n + 1` left-aligned dollar labels evenly spaced from
|
||||||
/// `value_max` (at `top`) down to `value_min` (at `bottom`), placed in
|
/// `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
|
/// 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_min: f64,
|
||||||
value_max: f64,
|
value_max: f64,
|
||||||
n: usize,
|
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 {
|
) void {
|
||||||
const range = value_max - value_min;
|
const range = value_max - value_min;
|
||||||
const span = bottom - top;
|
const span = bottom - top;
|
||||||
|
|
@ -91,7 +119,11 @@ pub fn drawYDollarTicks(
|
||||||
const val = value_max - frac * range;
|
const val = value_max - frac * range;
|
||||||
const y = top + frac * span;
|
const y = top + frac * span;
|
||||||
var buf: [24]u8 = undefined;
|
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;
|
const ly = @as(i32, @intFromFloat(y)) - half_h;
|
||||||
text.drawText(sfc, lx, ly, scale, color, label);
|
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));
|
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" {
|
test "drawYDollarTicks stamps labels in the requested color" {
|
||||||
const alloc = testing.allocator;
|
const alloc = testing.allocator;
|
||||||
var sfc = try Surface.init(.image_surface_rgb, alloc, 300, 200);
|
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 forecast = @import("../analytics/forecast_evaluation.zig");
|
||||||
const Date = @import("../Date.zig");
|
const Date = @import("../Date.zig");
|
||||||
const draw = @import("draw.zig");
|
const draw = @import("draw.zig");
|
||||||
|
const axis = @import("axis.zig");
|
||||||
|
|
||||||
const Surface = z2d.Surface;
|
const Surface = z2d.Surface;
|
||||||
const Context = z2d.Context;
|
const Context = z2d.Context;
|
||||||
|
|
@ -50,6 +51,74 @@ pub const ChartResult = struct {
|
||||||
value_max: f64,
|
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 ────────────────────────────────
|
// ── View 1: Convergence chart ────────────────────────────────
|
||||||
|
|
||||||
/// Render the convergence chart. X-axis spans
|
/// Render the convergence chart. X-axis spans
|
||||||
|
|
@ -67,20 +136,21 @@ pub const ChartResult = struct {
|
||||||
/// - Solid line through the convergence points
|
/// - Solid line through the convergence points
|
||||||
/// - Distinct markers on `reached` rows (small filled dots,
|
/// - Distinct markers on `reached` rows (small filled dots,
|
||||||
/// theme accent color)
|
/// theme accent color)
|
||||||
pub fn renderConvergenceChart(
|
pub fn renderConvergenceToSurface(
|
||||||
io: std.Io,
|
io: std.Io,
|
||||||
alloc: std.mem.Allocator,
|
alloc: std.mem.Allocator,
|
||||||
points: []const forecast.ConvergencePoint,
|
points: []const forecast.ConvergencePoint,
|
||||||
width_px: u32,
|
width_px: u32,
|
||||||
height_px: u32,
|
height_px: u32,
|
||||||
th: theme.Theme,
|
th: theme.Theme,
|
||||||
) !ChartResult {
|
axis_labels: bool,
|
||||||
|
) !RenderedForecast {
|
||||||
if (points.len < 2) return error.InsufficientData;
|
if (points.len < 2) return error.InsufficientData;
|
||||||
|
|
||||||
const w: i32 = @intCast(width_px);
|
const w: i32 = @intCast(width_px);
|
||||||
const h: i32 = @intCast(height_px);
|
const h: i32 = @intCast(height_px);
|
||||||
var sfc = try Surface.init(.image_surface_rgb, alloc, w, h);
|
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);
|
var ctx = Context.init(io, alloc, &sfc);
|
||||||
defer ctx.deinit();
|
defer ctx.deinit();
|
||||||
|
|
@ -95,11 +165,19 @@ pub fn renderConvergenceChart(
|
||||||
// Background
|
// Background
|
||||||
try draw.fillBackground(&ctx, fwidth, fheight, bg);
|
try draw.fillBackground(&ctx, fwidth, fheight, bg);
|
||||||
|
|
||||||
const chart_left = margin_left;
|
// Chart area. With axis labels, reserve a right margin for the
|
||||||
const chart_right = fwidth - margin_right;
|
// 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_w = chart_right - chart_left;
|
||||||
const chart_top = margin_top;
|
const chart_top = m_top;
|
||||||
const chart_bottom = fheight - margin_bottom;
|
const chart_bottom = fheight - m_bottom;
|
||||||
|
|
||||||
// X-range: observation_date span
|
// X-range: observation_date span
|
||||||
const x0_days: f64 = @floatFromInt(points[0].observation_date.days);
|
const x0_days: f64 = @floatFromInt(points[0].observation_date.days);
|
||||||
|
|
@ -186,8 +264,20 @@ pub fn renderConvergenceChart(
|
||||||
// Border
|
// Border
|
||||||
try drawRect(&ctx, chart_left, chart_top, chart_right, chart_bottom, blendColor(th.text_muted, 60, bg), 1.0);
|
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 .{
|
return .{
|
||||||
.rgb_data = try extractRgb(alloc, &sfc),
|
.surface = sfc,
|
||||||
.width = @intCast(width_px),
|
.width = @intCast(width_px),
|
||||||
.height = @intCast(height_px),
|
.height = @intCast(height_px),
|
||||||
.value_min = y_min,
|
.value_min = y_min,
|
||||||
|
|
@ -212,20 +302,21 @@ pub const BacktestAnchor = forecast.BacktestAnchor;
|
||||||
/// - `realized_5y` (solid, theme positive - green)
|
/// - `realized_5y` (solid, theme positive - green)
|
||||||
///
|
///
|
||||||
/// Plus a y=0 reference line.
|
/// Plus a y=0 reference line.
|
||||||
pub fn renderBacktestChart(
|
pub fn renderBacktestToSurface(
|
||||||
io: std.Io,
|
io: std.Io,
|
||||||
alloc: std.mem.Allocator,
|
alloc: std.mem.Allocator,
|
||||||
anchors: []const BacktestAnchor,
|
anchors: []const BacktestAnchor,
|
||||||
width_px: u32,
|
width_px: u32,
|
||||||
height_px: u32,
|
height_px: u32,
|
||||||
th: theme.Theme,
|
th: theme.Theme,
|
||||||
) !ChartResult {
|
axis_labels: bool,
|
||||||
|
) !RenderedForecast {
|
||||||
if (anchors.len < 2) return error.InsufficientData;
|
if (anchors.len < 2) return error.InsufficientData;
|
||||||
|
|
||||||
const w: i32 = @intCast(width_px);
|
const w: i32 = @intCast(width_px);
|
||||||
const h: i32 = @intCast(height_px);
|
const h: i32 = @intCast(height_px);
|
||||||
var sfc = try Surface.init(.image_surface_rgb, alloc, w, h);
|
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);
|
var ctx = Context.init(io, alloc, &sfc);
|
||||||
defer ctx.deinit();
|
defer ctx.deinit();
|
||||||
|
|
@ -240,11 +331,19 @@ pub fn renderBacktestChart(
|
||||||
// Background
|
// Background
|
||||||
try draw.fillBackground(&ctx, fwidth, fheight, bg);
|
try draw.fillBackground(&ctx, fwidth, fheight, bg);
|
||||||
|
|
||||||
const chart_left = margin_left;
|
// Chart area. With axis labels, reserve a right margin for the
|
||||||
const chart_right = fwidth - margin_right;
|
// 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_w = chart_right - chart_left;
|
||||||
const chart_top = margin_top;
|
const chart_top = m_top;
|
||||||
const chart_bottom = fheight - margin_bottom;
|
const chart_bottom = fheight - m_bottom;
|
||||||
|
|
||||||
// X-range
|
// X-range
|
||||||
const x0_days: f64 = @floatFromInt(anchors[0].anchor_date.days);
|
const x0_days: f64 = @floatFromInt(anchors[0].anchor_date.days);
|
||||||
|
|
@ -303,8 +402,20 @@ pub fn renderBacktestChart(
|
||||||
// Border
|
// Border
|
||||||
try drawRect(&ctx, chart_left, chart_top, chart_right, chart_bottom, blendColor(th.text_muted, 60, bg), 1.0);
|
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 .{
|
return .{
|
||||||
.rgb_data = try extractRgb(alloc, &sfc),
|
.surface = sfc,
|
||||||
.width = @intCast(width_px),
|
.width = @intCast(width_px),
|
||||||
.height = @intCast(height_px),
|
.height = @intCast(height_px),
|
||||||
.value_min = y_min,
|
.value_min = y_min,
|
||||||
|
|
@ -492,7 +603,6 @@ const opaqueColor = draw.opaqueColor;
|
||||||
const drawHorizontalGridLines = draw.drawHorizontalGridLines;
|
const drawHorizontalGridLines = draw.drawHorizontalGridLines;
|
||||||
const drawHLine = draw.drawHLine;
|
const drawHLine = draw.drawHLine;
|
||||||
const drawRect = draw.drawRect;
|
const drawRect = draw.drawRect;
|
||||||
const extractRgb = draw.extractRgb;
|
|
||||||
|
|
||||||
// ── Tests ─────────────────────────────────────────────────────
|
// ── Tests ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,11 +40,12 @@ pub const ParsedArgs = union(enum) {
|
||||||
/// `--vs <DATE>`: side-by-side compare of two projections.
|
/// `--vs <DATE>`: side-by-side compare of two projections.
|
||||||
compare: CompareArgs,
|
compare: CompareArgs,
|
||||||
/// `--convergence`: plot the spreadsheet's predicted retirement
|
/// `--convergence`: plot the spreadsheet's predicted retirement
|
||||||
/// date over time. No knobs.
|
/// date over time. `export_chart` renders a PNG instead of the
|
||||||
convergence,
|
/// text table.
|
||||||
|
convergence: struct { export_chart: ?[]const u8 = null },
|
||||||
/// `--return-backtest [--real]`: plot expected_return vs realized
|
/// `--return-backtest [--real]`: plot expected_return vs realized
|
||||||
/// forward-CAGR.
|
/// forward-CAGR. `export_chart` renders a PNG instead of text.
|
||||||
return_backtest: struct { real: bool },
|
return_backtest: struct { real: bool, export_chart: ?[]const u8 = null },
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const BandsArgs = struct {
|
pub const BandsArgs = struct {
|
||||||
|
|
@ -105,11 +106,12 @@ pub const meta: framework.Meta = .{
|
||||||
\\ --return-backtest (see above)
|
\\ --return-backtest (see above)
|
||||||
\\ --real With --return-backtest, render in
|
\\ --real With --return-backtest, render in
|
||||||
\\ CPI-adjusted dollars.
|
\\ CPI-adjusted dollars.
|
||||||
\\ --export-chart <PATH> Render the percentile-band chart
|
\\ --export-chart <PATH> Render the current mode's chart as a
|
||||||
\\ (with optional overlay if
|
\\ PNG to PATH (1920x1080) and exit.
|
||||||
\\ --overlay-actuals is set) as a PNG
|
\\ Works in the default bands mode (with
|
||||||
\\ to PATH (1920x1080) and exit. Only
|
\\ the overlay if --overlay-actuals is
|
||||||
\\ valid in the default bands mode.
|
\\ set), --convergence, and
|
||||||
|
\\ --return-backtest. Not valid with --vs.
|
||||||
\\
|
\\
|
||||||
\\Date forms: YYYY-MM-DD or relative (1W/1M/1Q/1Y).
|
\\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");
|
cli.stderrPrint(io, "Error: --real only applies to --return-backtest.\n");
|
||||||
return error.MutuallyExclusive;
|
return error.MutuallyExclusive;
|
||||||
}
|
}
|
||||||
// Chart export only meaningful in default bands mode. The
|
// --vs is text-only (a then/now delta table) with no chart to
|
||||||
// forecast-evaluation views (convergence, return-backtest)
|
// export. The bands, convergence, and return-backtest modes all
|
||||||
// render via `forecast_chart.zig` which doesn't have a PNG
|
// support --export-chart.
|
||||||
// export path yet; --vs is text-only with no chart at all.
|
if (export_chart != null and vs_date != null) {
|
||||||
if (export_chart != null and (convergence or return_backtest or vs_date != null)) {
|
cli.stderrPrint(io, "Error: --export-chart is not supported with --vs (it has no chart).\n");
|
||||||
cli.stderrPrint(io, "Error: --export-chart only supported in the default projections (bands) mode.\n");
|
|
||||||
return error.MutuallyExclusive;
|
return error.MutuallyExclusive;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (convergence) return ParsedArgs{ .convergence = {} };
|
if (convergence) return ParsedArgs{ .convergence = .{ .export_chart = export_chart } };
|
||||||
if (return_backtest) return ParsedArgs{ .return_backtest = .{ .real = real_mode } };
|
if (return_backtest) return ParsedArgs{ .return_backtest = .{ .real = real_mode, .export_chart = export_chart } };
|
||||||
if (vs_date) |d| {
|
if (vs_date) |d| {
|
||||||
return ParsedArgs{ .compare = .{
|
return ParsedArgs{ .compare = .{
|
||||||
.events_enabled = events_enabled,
|
.events_enabled = events_enabled,
|
||||||
|
|
@ -241,8 +242,8 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
const file_path = pf.path;
|
const file_path = pf.path;
|
||||||
|
|
||||||
switch (parsed) {
|
switch (parsed) {
|
||||||
.convergence => try runConvergence(io, allocator, file_path, 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, color, out),
|
.return_backtest => |args| try runReturnBacktest(io, allocator, file_path, args.real, args.export_chart, color, out),
|
||||||
.compare => |args| {
|
.compare => |args| {
|
||||||
_ = ctx.svc orelse return error.MissingDataService;
|
_ = ctx.svc orelse return error.MissingDataService;
|
||||||
// Pre-load today's live composition only when it's
|
// Pre-load today's live composition only when it's
|
||||||
|
|
@ -1177,6 +1178,7 @@ pub fn runConvergence(
|
||||||
io: std.Io,
|
io: std.Io,
|
||||||
allocator: std.mem.Allocator,
|
allocator: std.mem.Allocator,
|
||||||
file_path: []const u8,
|
file_path: []const u8,
|
||||||
|
export_chart: ?[]const u8,
|
||||||
color: bool,
|
color: bool,
|
||||||
out: *std.Io.Writer,
|
out: *std.Io.Writer,
|
||||||
) !void {
|
) !void {
|
||||||
|
|
@ -1194,6 +1196,23 @@ pub fn runConvergence(
|
||||||
defer iv.deinit();
|
defer iv.deinit();
|
||||||
|
|
||||||
const points = try forecast.convergencePoints(va, iv.points);
|
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);
|
const lines = try view.convergenceLines(va, points);
|
||||||
try renderForecastLines(out, color, lines);
|
try renderForecastLines(out, color, lines);
|
||||||
}
|
}
|
||||||
|
|
@ -1215,6 +1234,7 @@ pub fn runReturnBacktest(
|
||||||
allocator: std.mem.Allocator,
|
allocator: std.mem.Allocator,
|
||||||
file_path: []const u8,
|
file_path: []const u8,
|
||||||
real_mode: bool,
|
real_mode: bool,
|
||||||
|
export_chart: ?[]const u8,
|
||||||
color: bool,
|
color: bool,
|
||||||
out: *std.Io.Writer,
|
out: *std.Io.Writer,
|
||||||
) !void {
|
) !void {
|
||||||
|
|
@ -1241,6 +1261,22 @@ pub fn runReturnBacktest(
|
||||||
|
|
||||||
const rows = try forecast.returnBacktest(va, iv.points, backtest_horizons, real_mode, cpi_list.items);
|
const rows = try forecast.returnBacktest(va, iv.points, backtest_horizons, real_mode, cpi_list.items);
|
||||||
const anchors = try forecast.pivotByAnchor(va, rows);
|
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);
|
const lines = try view.backtestLines(va, anchors, real_mode);
|
||||||
try renderForecastLines(out, color, lines);
|
try renderForecastLines(out, color, lines);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue