zfin/src/tui/forecast_chart.zig

630 lines
24 KiB
Zig

//! Forecast-evaluation chart renderer using z2d.
//!
//! Sibling to `projection_chart.zig` for plain line-shaped charts
//! (no percentile bands). Used by the projections tab's
//! convergence and return-back-test sub-views.
//!
//! Two render entry points:
//! - `renderConvergenceChart`: single-series line of
//! years-until-retirement vs. observation date, with a dashed
//! `slope=-1` reference line for "perfect convergence" and
//! small markers on `reached` rows.
//! - `renderBacktestChart`: multi-series line chart showing
//! `expected_return` (primary, solid) alongside realized 1y/3y/5y
//! forward CAGR (faint, line styles vary by horizon). Y=0
//! reference line for sanity.
//!
//! Both produce raw RGB pixel data for Kitty graphics protocol
//! transmission, mirroring `projection_chart.zig`'s output shape.
//!
//! The two functions share substantial scaffolding (margins,
//! axes, grid lines, value-range expansion). Helpers are
//! file-private; `renderProjectionChart`'s helpers are re-derived
//! locally to avoid leaking implementation details across the
//! module boundary. Sibling rather than shared because the chart
//! shapes are different enough that a shared core would be
//! awkwardly parameterized.
const std = @import("std");
const z2d = @import("z2d");
const theme = @import("theme.zig");
const forecast = @import("../analytics/forecast_evaluation.zig");
const Date = @import("../Date.zig");
const Surface = z2d.Surface;
const Context = z2d.Context;
const Pixel = z2d.Pixel;
const margin_left: f64 = 4;
const margin_right: f64 = 4;
const margin_top: f64 = 4;
const margin_bottom: f64 = 4;
pub const ChartResult = struct {
rgb_data: []const u8,
width: u16,
height: u16,
/// Y-range used; renderers may want this for label rendering.
value_min: f64,
value_max: f64,
};
// ── View 1: Convergence chart ────────────────────────────────
/// Render the convergence chart. X-axis spans
/// `points[0].observation_date` to
/// `points[points.len-1].observation_date`. Y-axis is
/// years-until-retirement (Encoding B per the spec).
///
/// Visual layers (bottom to top):
/// - Background
/// - Horizontal grid lines (at y values 0, 5, 10, ...)
/// - Dashed `slope=-1` reference line: at the leftmost x it
/// starts at `points[0].years_until_retirement` and decreases
/// by 1 year per year of x progression. This is "what the
/// line would look like if the model converged perfectly."
/// - Solid line through the convergence points
/// - Distinct markers on `reached` rows (small filled dots,
/// theme accent color)
pub fn renderConvergenceChart(
io: std.Io,
alloc: std.mem.Allocator,
points: []const forecast.ConvergencePoint,
width_px: u32,
height_px: u32,
th: theme.Theme,
) !ChartResult {
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);
var ctx = Context.init(io, alloc, &sfc);
defer ctx.deinit();
ctx.setAntiAliasingMode(.none);
ctx.setOperator(.src);
const bg = th.bg;
const fwidth: f64 = @floatFromInt(width_px);
const fheight: f64 = @floatFromInt(height_px);
// Background
ctx.setSourceToPixel(opaqueColor(bg));
ctx.resetPath();
try ctx.moveTo(0, 0);
try ctx.lineTo(fwidth, 0);
try ctx.lineTo(fwidth, fheight);
try ctx.lineTo(0, fheight);
try ctx.closePath();
try ctx.fill();
const chart_left = margin_left;
const chart_right = fwidth - margin_right;
const chart_w = chart_right - chart_left;
const chart_top = margin_top;
const chart_bottom = fheight - margin_bottom;
// X-range: observation_date span
const x0_days: f64 = @floatFromInt(points[0].observation_date.days);
const x1_days: f64 = @floatFromInt(points[points.len - 1].observation_date.days);
const x_span: f64 = if (x1_days > x0_days) x1_days - x0_days else 1.0;
// Y-range: years_until_retirement, padded
const y_min: f64 = 0;
var y_max: f64 = 0;
for (points) |p| {
if (p.years_until_retirement > y_max) y_max = p.years_until_retirement;
}
// The reference line ends at `points[0].years_until_retirement -
// (x1 - x0) / 365.25`, which can be negative. Clamp the y-range
// floor at 0 - negative years-until-retirement isn't a
// meaningful display value.
if (y_max < 1) y_max = 1; // ensure at least a 1-year scale
const y_pad = y_max * 0.1;
y_max += y_pad;
// Grid lines
const grid_color = blendColor(th.text_muted, 40, bg);
try drawHorizontalGridLines(&ctx, chart_left, chart_right, chart_top, chart_bottom, 5, grid_color);
// Reference line: slope = -1 year/year, starting at the leftmost
// anchor's years_until_retirement value. If a point converges
// perfectly it'd lie on this reference.
{
const ref_start_y = points[0].years_until_retirement;
const x_years_span = x_span / 365.25;
const ref_end_y = ref_start_y - x_years_span;
const ref_color = blendColor(th.text_muted, 100, bg);
ctx.setSourceToPixel(ref_color);
ctx.setLineWidth(1.0);
// Dashed: emit segment-pairs.
const dash_len: f64 = 6.0;
const gap_len: f64 = 4.0;
var dx: f64 = 0;
const total_pixels = chart_w;
while (dx < total_pixels) {
const dx_end = @min(dx + dash_len, total_pixels);
const f0 = dx / total_pixels;
const f1 = dx_end / total_pixels;
const y0 = mapY(ref_start_y + (ref_end_y - ref_start_y) * f0, y_min, y_max, chart_top, chart_bottom);
const y1 = mapY(ref_start_y + (ref_end_y - ref_start_y) * f1, y_min, y_max, chart_top, chart_bottom);
ctx.resetPath();
try ctx.moveTo(chart_left + dx, y0);
try ctx.lineTo(chart_left + dx_end, y1);
try ctx.stroke();
dx = dx_end + gap_len;
}
ctx.setLineWidth(2.0);
}
// Main series: solid line through all points, theme accent.
{
ctx.setSourceToPixel(opaqueColor(th.accent));
ctx.setLineWidth(2.0);
ctx.resetPath();
for (points, 0..) |p, i| {
const dx_days: f64 = @floatFromInt(p.observation_date.days);
const x_frac = (dx_days - x0_days) / x_span;
const x = chart_left + x_frac * chart_w;
const y = mapY(p.years_until_retirement, y_min, y_max, chart_top, chart_bottom);
if (i == 0) try ctx.moveTo(x, y) else try ctx.lineTo(x, y);
}
try ctx.stroke();
}
// Reached markers (small filled dots).
{
ctx.setSourceToPixel(opaqueColor(th.positive));
const dot_radius: f64 = 2.5;
for (points) |p| {
if (!p.reached) continue;
const dx_days: f64 = @floatFromInt(p.observation_date.days);
const x_frac = (dx_days - x0_days) / x_span;
const x = chart_left + x_frac * chart_w;
const y = mapY(p.years_until_retirement, y_min, y_max, chart_top, chart_bottom);
try fillCircle(&ctx, x, y, dot_radius);
}
}
// Border
try drawRect(&ctx, chart_left, chart_top, chart_right, chart_bottom, blendColor(th.text_muted, 60, bg), 1.0);
return .{
.rgb_data = try extractRgb(alloc, &sfc),
.width = @intCast(width_px),
.height = @intCast(height_px),
.value_min = y_min,
.value_max = y_max,
};
}
// ── View 2: Return back-test chart ───────────────────────────
/// Pivot of `forecast.BacktestPoint` rows into a single anchor's
/// realized-by-horizon view. One per anchor; passed to
/// `renderBacktestChart` as the renderer-friendly shape.
pub const BacktestAnchor = forecast.BacktestAnchor;
/// Render the return back-test chart. X-axis spans the anchor
/// dates; y-axis is decimal return rate. Renders four lines with
/// distinct hues so the legend is unambiguous; line styles
/// (dotted/dashed/solid) reinforce it for color-blind users:
/// - `expected` (solid, theme accent - purple)
/// - `realized_1y` (dotted, theme info - cyan)
/// - `realized_3y` (dashed, theme warning - yellow)
/// - `realized_5y` (solid, theme positive - green)
///
/// Plus a y=0 reference line.
pub fn renderBacktestChart(
io: std.Io,
alloc: std.mem.Allocator,
anchors: []const BacktestAnchor,
width_px: u32,
height_px: u32,
th: theme.Theme,
) !ChartResult {
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);
var ctx = Context.init(io, alloc, &sfc);
defer ctx.deinit();
ctx.setAntiAliasingMode(.none);
ctx.setOperator(.src);
const bg = th.bg;
const fwidth: f64 = @floatFromInt(width_px);
const fheight: f64 = @floatFromInt(height_px);
// Background
ctx.setSourceToPixel(opaqueColor(bg));
ctx.resetPath();
try ctx.moveTo(0, 0);
try ctx.lineTo(fwidth, 0);
try ctx.lineTo(fwidth, fheight);
try ctx.lineTo(0, fheight);
try ctx.closePath();
try ctx.fill();
const chart_left = margin_left;
const chart_right = fwidth - margin_right;
const chart_w = chart_right - chart_left;
const chart_top = margin_top;
const chart_bottom = fheight - margin_bottom;
// X-range
const x0_days: f64 = @floatFromInt(anchors[0].anchor_date.days);
const x1_days: f64 = @floatFromInt(anchors[anchors.len - 1].anchor_date.days);
const x_span: f64 = if (x1_days > x0_days) x1_days - x0_days else 1.0;
// Y-range across all four series - include realized_* even
// when null (skip nulls without contributing).
var y_min: f64 = 0;
var y_max: f64 = 0;
for (anchors) |a| {
if (a.expected < y_min) y_min = a.expected;
if (a.expected > y_max) y_max = a.expected;
if (a.realized_1y) |v| {
if (v < y_min) y_min = v;
if (v > y_max) y_max = v;
}
if (a.realized_3y) |v| {
if (v < y_min) y_min = v;
if (v > y_max) y_max = v;
}
if (a.realized_5y) |v| {
if (v < y_min) y_min = v;
if (v > y_max) y_max = v;
}
}
const y_range = y_max - y_min;
const y_pad = if (y_range > 0) y_range * 0.10 else 0.05;
y_min -= y_pad;
y_max += y_pad;
if (y_min > 0) y_min = 0; // ensure y=0 is in view for the reference line
// Grid lines + y=0 reference (subtle but distinct from the grid).
const grid_color = blendColor(th.text_muted, 40, bg);
try drawHorizontalGridLines(&ctx, chart_left, chart_right, chart_top, chart_bottom, 5, grid_color);
if (y_min < 0 and y_max > 0) {
const zero_y = mapY(0, y_min, y_max, chart_top, chart_bottom);
try drawHLine(&ctx, chart_left, chart_right, zero_y, blendColor(th.text_muted, 100, bg), 1.0);
}
// Realized series first (so they're below the expected line in z-order).
// Distinct hues per horizon (cyan/yellow/green) so the legend
// is unambiguous; line styles (dotted/dashed/solid) reinforce
// it for users who are color-blind or running a low-contrast
// theme. Keep these aligned with the legend lines emitted by
// `drawBacktestWithKitty` in `projections_tab.zig`.
try drawSeries(&ctx, anchors, .realized_1y, x0_days, x_span, chart_left, chart_w, y_min, y_max, chart_top, chart_bottom, opaqueColor(th.info), 1.5, .dotted);
try drawSeries(&ctx, anchors, .realized_3y, x0_days, x_span, chart_left, chart_w, y_min, y_max, chart_top, chart_bottom, opaqueColor(th.warning), 1.5, .dashed);
try drawSeries(&ctx, anchors, .realized_5y, x0_days, x_span, chart_left, chart_w, y_min, y_max, chart_top, chart_bottom, opaqueColor(th.positive), 2.0, .solid);
// Expected series last (on top): solid, accent, full opacity, bold width.
try drawSeries(&ctx, anchors, .expected, x0_days, x_span, chart_left, chart_w, y_min, y_max, chart_top, chart_bottom, opaqueColor(th.accent), 2.0, .solid);
// Border
try drawRect(&ctx, chart_left, chart_top, chart_right, chart_bottom, blendColor(th.text_muted, 60, bg), 1.0);
return .{
.rgb_data = try extractRgb(alloc, &sfc),
.width = @intCast(width_px),
.height = @intCast(height_px),
.value_min = y_min,
.value_max = y_max,
};
}
const SeriesKey = enum { expected, realized_1y, realized_3y, realized_5y };
const LineStyle = enum { solid, dashed, dotted };
const DashPattern = struct { on: f64, off: f64 };
fn anchorValue(a: BacktestAnchor, key: SeriesKey) ?f64 {
return switch (key) {
.expected => a.expected,
.realized_1y => a.realized_1y,
.realized_3y => a.realized_3y,
.realized_5y => a.realized_5y,
};
}
/// Draw one series across the anchor list, skipping null values.
/// Disconnected (null-bridging) segments are emitted as separate
/// strokes - the line "lifts" over missing data rather than
/// drawing a phantom horizontal segment.
fn drawSeries(
ctx: *Context,
anchors: []const BacktestAnchor,
key: SeriesKey,
x0_days: f64,
x_span: f64,
chart_left: f64,
chart_w: f64,
y_min: f64,
y_max: f64,
chart_top: f64,
chart_bottom: f64,
color: Pixel,
line_w: f64,
style: LineStyle,
) !void {
ctx.setSourceToPixel(color);
ctx.setLineWidth(line_w);
const dash_pattern: ?DashPattern = switch (style) {
.solid => null,
.dashed => .{ .on = 6.0, .off = 4.0 },
.dotted => .{ .on = 2.0, .off = 3.0 },
};
var have_segment = false;
// Emit one stroke per contiguous run of non-null values.
// A null value breaks the run.
for (anchors, 0..) |a, i| {
const v_opt = anchorValue(a, key);
if (v_opt) |_| {
have_segment = true;
// If this is the last anchor, flush the segment.
if (i == anchors.len - 1) {
try strokeSegment(ctx, anchors, key, x0_days, x_span, chart_left, chart_w, y_min, y_max, chart_top, chart_bottom, dash_pattern);
have_segment = false;
}
} else if (have_segment) {
// Run broke. Stroke from segment start to last-known endpoint.
try strokeSegment(ctx, anchors[0..i], key, x0_days, x_span, chart_left, chart_w, y_min, y_max, chart_top, chart_bottom, dash_pattern);
have_segment = false;
}
}
ctx.setLineWidth(2.0);
}
/// Stroke the contiguous non-null segment of `anchors` for `key`.
/// For dashed/dotted styles, the segment is rasterized as
/// independent dash-length strokes rather than one continuous
/// path with z2d's dash array (which we don't use for cross-version
/// stability). Solid styles emit one continuous stroke.
fn strokeSegment(
ctx: *Context,
anchors: []const BacktestAnchor,
key: SeriesKey,
x0_days: f64,
x_span: f64,
chart_left: f64,
chart_w: f64,
y_min: f64,
y_max: f64,
chart_top: f64,
chart_bottom: f64,
dash: ?DashPattern,
) !void {
if (dash) |d| {
// Segment-by-segment with manual dashing along each
// pixel-length straight line between consecutive points.
var prev_x: ?f64 = null;
var prev_y: ?f64 = null;
for (anchors) |a| {
const v_opt = anchorValue(a, key);
if (v_opt) |v| {
const dx_days: f64 = @floatFromInt(a.anchor_date.days);
const x = chart_left + ((dx_days - x0_days) / x_span) * chart_w;
const y = mapY(v, y_min, y_max, chart_top, chart_bottom);
if (prev_x) |px| {
const py = prev_y.?;
try drawDashedLine(ctx, px, py, x, y, d.on, d.off);
}
prev_x = x;
prev_y = y;
} else {
prev_x = null;
prev_y = null;
}
}
} else {
// Solid: one path, then stroke.
var first = true;
ctx.resetPath();
for (anchors) |a| {
const v_opt = anchorValue(a, key);
if (v_opt) |v| {
const dx_days: f64 = @floatFromInt(a.anchor_date.days);
const x = chart_left + ((dx_days - x0_days) / x_span) * chart_w;
const y = mapY(v, y_min, y_max, chart_top, chart_bottom);
if (first) {
try ctx.moveTo(x, y);
first = false;
} else {
try ctx.lineTo(x, y);
}
}
}
if (!first) try ctx.stroke();
}
}
fn drawDashedLine(ctx: *Context, x1: f64, y1: f64, x2: f64, y2: f64, dash_on: f64, dash_off: f64) !void {
const dx = x2 - x1;
const dy = y2 - y1;
const len = std.math.sqrt(dx * dx + dy * dy);
if (len <= 0) return;
const ux = dx / len;
const uy = dy / len;
var t: f64 = 0;
while (t < len) {
const t_end = @min(t + dash_on, len);
const sx = x1 + t * ux;
const sy = y1 + t * uy;
const ex = x1 + t_end * ux;
const ey = y1 + t_end * uy;
ctx.resetPath();
try ctx.moveTo(sx, sy);
try ctx.lineTo(ex, ey);
try ctx.stroke();
t = t_end + dash_off;
}
}
fn fillCircle(ctx: *Context, cx: f64, cy: f64, r: f64) !void {
// z2d doesn't expose `arc` here at present; approximate with
// an N-sided polygon. 12 sides is plenty for a 2-3 px dot.
const n: usize = 12;
ctx.resetPath();
var i: usize = 0;
while (i < n) : (i += 1) {
const ang = @as(f64, @floatFromInt(i)) * 2.0 * std.math.pi / @as(f64, @floatFromInt(n));
const x = cx + r * @cos(ang);
const y = cy + r * @sin(ang);
if (i == 0) try ctx.moveTo(x, y) else try ctx.lineTo(x, y);
}
try ctx.closePath();
try ctx.fill();
}
// ── Shared helpers (mirrors of projection_chart's privates) ───
fn mapY(value: f64, min_val: f64, max_val: f64, top_px: f64, bottom_px: f64) f64 {
if (max_val == min_val) return (top_px + bottom_px) / 2;
const norm = (value - min_val) / (max_val - min_val);
return bottom_px - norm * (bottom_px - top_px);
}
fn blendColor(fg: [3]u8, alpha: u8, bg_color: [3]u8) Pixel {
const a = @as(f64, @floatFromInt(alpha)) / 255.0;
const inv_a = 1.0 - a;
return .{ .rgb = .{
.r = @intFromFloat(@as(f64, @floatFromInt(fg[0])) * a + @as(f64, @floatFromInt(bg_color[0])) * inv_a),
.g = @intFromFloat(@as(f64, @floatFromInt(fg[1])) * a + @as(f64, @floatFromInt(bg_color[1])) * inv_a),
.b = @intFromFloat(@as(f64, @floatFromInt(fg[2])) * a + @as(f64, @floatFromInt(bg_color[2])) * inv_a),
} };
}
fn opaqueColor(c: [3]u8) Pixel {
return .{ .rgb = .{ .r = c[0], .g = c[1], .b = c[2] } };
}
fn drawHorizontalGridLines(
ctx: *Context,
left: f64,
right: f64,
top: f64,
bottom: f64,
n_lines: usize,
col: Pixel,
) !void {
ctx.setSourceToPixel(col);
ctx.setLineWidth(0.5);
for (1..n_lines) |i| {
const frac = @as(f64, @floatFromInt(i)) / @as(f64, @floatFromInt(n_lines));
const y = top + frac * (bottom - top);
ctx.resetPath();
try ctx.moveTo(left, y);
try ctx.lineTo(right, y);
try ctx.stroke();
}
ctx.setLineWidth(2.0);
}
fn drawHLine(ctx: *Context, x1: f64, x2: f64, y: f64, col: Pixel, line_w: f64) !void {
ctx.setSourceToPixel(col);
ctx.setLineWidth(line_w);
ctx.resetPath();
try ctx.moveTo(x1, y);
try ctx.lineTo(x2, y);
try ctx.stroke();
ctx.setLineWidth(2.0);
}
fn drawRect(ctx: *Context, x1: f64, y1: f64, x2: f64, y2: f64, col: Pixel, line_w: f64) !void {
ctx.setSourceToPixel(col);
ctx.setLineWidth(line_w);
ctx.resetPath();
try ctx.moveTo(x1, y1);
try ctx.lineTo(x2, y1);
try ctx.lineTo(x2, y2);
try ctx.lineTo(x1, y2);
try ctx.closePath();
try ctx.stroke();
ctx.setLineWidth(2.0);
}
/// Extract raw RGB bytes from an `image_surface_rgb`. Mirrors the
/// inline pattern in `projection_chart.zig` so both renderers
/// produce the same on-the-wire shape for Kitty graphics
/// transmission. Caller owns the returned slice.
fn extractRgb(alloc: std.mem.Allocator, sfc: *const Surface) ![]u8 {
const rgb_buf = switch (sfc.*) {
.image_surface_rgb => |s| s.buf,
else => unreachable,
};
const out = try alloc.alloc(u8, rgb_buf.len * 3);
for (rgb_buf, 0..) |px, i| {
out[i * 3 + 0] = px.r;
out[i * 3 + 1] = px.g;
out[i * 3 + 2] = px.b;
}
return out;
}
// ── Tests ─────────────────────────────────────────────────────
const testing = std.testing;
test "renderConvergenceChart produces RGB output" {
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 },
.{ .observation_date = Date.fromYmd(2022, 1, 1), .projected_date = Date.fromYmd(2030, 1, 1), .years_until_retirement = 8.0, .reached = false },
.{ .observation_date = Date.fromYmd(2025, 1, 1), .projected_date = Date.fromYmd(2025, 1, 1), .years_until_retirement = 0.0, .reached = true },
};
const th = theme.default_theme;
const result = try renderConvergenceChart(testing.io, testing.allocator, &points, 200, 100, th);
defer testing.allocator.free(result.rgb_data);
try testing.expectEqual(@as(u16, 200), result.width);
try testing.expectEqual(@as(u16, 100), result.height);
try testing.expectEqual(@as(usize, 200 * 100 * 3), result.rgb_data.len);
}
test "renderConvergenceChart insufficient data" {
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 },
};
const th = theme.default_theme;
const result = renderConvergenceChart(testing.io, testing.allocator, &points, 200, 100, th);
try testing.expectError(error.InsufficientData, result);
}
test "renderBacktestChart produces RGB output with all four series" {
const anchors = [_]BacktestAnchor{
.{ .anchor_date = Date.fromYmd(2018, 1, 1), .expected = 0.10, .realized_1y = 0.12, .realized_3y = 0.09, .realized_5y = 0.08 },
.{ .anchor_date = Date.fromYmd(2020, 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.12, .realized_1y = -0.05, .realized_3y = null, .realized_5y = null },
.{ .anchor_date = Date.fromYmd(2024, 1, 1), .expected = 0.07, .realized_1y = null, .realized_3y = null, .realized_5y = null },
};
const th = theme.default_theme;
const result = try renderBacktestChart(testing.io, testing.allocator, &anchors, 200, 100, th);
defer testing.allocator.free(result.rgb_data);
try testing.expectEqual(@as(u16, 200), result.width);
try testing.expect(result.value_max > result.value_min);
// Y range should include at least y=0 (we force it in)
try testing.expect(result.value_min <= 0);
}
test "renderBacktestChart insufficient data" {
const anchors = [_]BacktestAnchor{
.{ .anchor_date = Date.fromYmd(2020, 1, 1), .expected = 0.10, .realized_1y = null, .realized_3y = null, .realized_5y = null },
};
const th = theme.default_theme;
const result = renderBacktestChart(testing.io, testing.allocator, &anchors, 200, 100, th);
try testing.expectError(error.InsufficientData, result);
}