//! Financial chart renderer using z2d. //! Renders price + Bollinger Bands, volume bars, and RSI panel to raw RGB pixel data //! suitable for Kitty graphics protocol transmission. const std = @import("std"); const z2d = @import("z2d"); const zfin = @import("../root.zig"); const theme = @import("theme.zig"); const Surface = z2d.Surface; /// Chart rendering mode. pub const ChartMode = enum { /// Auto-detect: use Kitty graphics if terminal supports it, otherwise braille. auto, /// Force braille chart (no pixel graphics). braille, /// Kitty graphics with a custom resolution cap (width x height). kitty, }; /// Chart graphics configuration. pub const ChartConfig = struct { mode: ChartMode = .auto, max_width: u32 = 1920, max_height: u32 = 1080, /// Parse a --chart argument value. /// Accepted formats: /// "auto" — auto-detect (default) /// "braille" — force braille /// "WxH" — Kitty graphics with custom resolution (e.g. "1920x1080") pub fn parse(value: []const u8) ?ChartConfig { if (std.mem.eql(u8, value, "auto")) return .{ .mode = .auto }; if (std.mem.eql(u8, value, "braille")) return .{ .mode = .braille }; // Try WxH format if (std.mem.indexOfScalar(u8, value, 'x')) |sep| { const w = std.fmt.parseInt(u32, value[0..sep], 10) catch return null; const h = std.fmt.parseInt(u32, value[sep + 1 ..], 10) catch return null; if (w < 100 or h < 100) return null; return .{ .mode = .kitty, .max_width = w, .max_height = h }; } return null; } }; const Context = z2d.Context; const Path = z2d.Path; const Pixel = z2d.Pixel; const Color = z2d.Color; /// Chart timeframe selection. pub const Timeframe = enum { @"6M", ytd, @"1Y", @"3Y", @"5Y", pub fn label(self: Timeframe) []const u8 { return switch (self) { .@"6M" => "6M", .ytd => "YTD", .@"1Y" => "1Y", .@"3Y" => "3Y", .@"5Y" => "5Y", }; } pub fn tradingDays(self: Timeframe) usize { return switch (self) { .@"6M" => 126, .ytd => 252, // approximation, we'll clamp .@"1Y" => 252, .@"3Y" => 756, .@"5Y" => 1260, }; } pub fn next(self: Timeframe) Timeframe { return switch (self) { .@"6M" => .ytd, .ytd => .@"1Y", .@"1Y" => .@"3Y", .@"3Y" => .@"5Y", .@"5Y" => .@"6M", }; } pub fn prev(self: Timeframe) Timeframe { return switch (self) { .@"6M" => .@"5Y", .ytd => .@"6M", .@"1Y" => .ytd, .@"3Y" => .@"1Y", .@"5Y" => .@"3Y", }; } }; /// Layout constants (fractions of total height). const price_frac: f64 = 0.72; // price panel takes 72% const rsi_frac: f64 = 0.20; // RSI panel takes 20% const gap_frac: f64 = 0.08; // gap between panels /// Margins in pixels. const margin_left: f64 = 4; const margin_right: f64 = 4; const margin_top: f64 = 4; const margin_bottom: f64 = 4; /// Chart render result — raw RGB pixel data ready for Kitty graphics transmission. pub const ChartResult = struct { /// Raw RGB pixel data (3 bytes per pixel, row-major). rgb_data: []const u8, width: u16, height: u16, /// Price range for external label rendering. price_min: f64, price_max: f64, /// Latest RSI value (or null if not enough data). rsi_latest: ?f64, }; /// Cached indicator data to avoid recomputation across frames. /// All slices are owned and must be freed with deinit(). pub const CachedIndicators = struct { closes: []f64, volumes: []f64, bb: []?zfin.indicators.BollingerBand, rsi_vals: []?f64, /// Free all allocated memory. pub fn deinit(self: *CachedIndicators, alloc: std.mem.Allocator) void { alloc.free(self.closes); alloc.free(self.volumes); alloc.free(self.bb); alloc.free(self.rsi_vals); self.* = undefined; } }; /// Compute indicators for the given candle data. /// Returns owned CachedIndicators that must be freed with deinit(). pub fn computeIndicators( alloc: std.mem.Allocator, candles: []const zfin.Candle, timeframe: Timeframe, ) !CachedIndicators { if (candles.len < 20) return error.InsufficientData; // Slice candles to timeframe const max_days = timeframe.tradingDays(); const n = @min(candles.len, max_days); const data = candles[candles.len - n ..]; // Extract data series const closes = try zfin.indicators.closePrices(alloc, data); errdefer alloc.free(closes); const vols = try zfin.indicators.volumes(alloc, data); errdefer alloc.free(vols); // Compute indicators const bb = try zfin.indicators.bollingerBands(alloc, closes, 20, 2.0); errdefer alloc.free(bb); const rsi_vals = try zfin.indicators.rsi(alloc, closes, 14); return .{ .closes = closes, .volumes = vols, .bb = bb, .rsi_vals = rsi_vals, }; } /// 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 { if (candles.len < 20) return error.InsufficientData; // Slice candles to timeframe const max_days = timeframe.tradingDays(); const n = @min(candles.len, max_days); const data = candles[candles.len - n ..]; // Use cached indicators or compute fresh ones var local_closes: ?[]f64 = null; var local_vols: ?[]f64 = null; var local_bb: ?[]?zfin.indicators.BollingerBand = null; var local_rsi: ?[]?f64 = null; defer { if (local_closes) |c| alloc.free(c); if (local_vols) |v| alloc.free(v); if (local_bb) |b| alloc.free(b); if (local_rsi) |r| alloc.free(r); } const closes: []const f64 = if (cached) |c| c.closes else blk: { local_closes = try zfin.indicators.closePrices(alloc, data); break :blk local_closes.?; }; const vols: []const f64 = if (cached) |c| c.volumes else blk: { local_vols = try zfin.indicators.volumes(alloc, data); break :blk local_vols.?; }; const bb: []const ?zfin.indicators.BollingerBand = if (cached) |c| c.bb else blk: { local_bb = try zfin.indicators.bollingerBands(alloc, closes, 20, 2.0); break :blk local_bb.?; }; const rsi_vals: []const ?f64 = if (cached) |c| c.rsi_vals else blk: { local_rsi = try zfin.indicators.rsi(alloc, closes, 14); break :blk local_rsi.?; }; // Create z2d surface — use RGB (not RGBA) since we're rendering onto a solid // background. This avoids integer overflow in z2d's RGBA compositor when // compositing semi-transparent fills (alpha < 255). 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); // Create drawing context var ctx = Context.init(io, alloc, &sfc); defer ctx.deinit(); // Disable anti-aliasing and use direct pixel writes (.source operator) // to avoid integer overflow bugs in z2d's src_over compositor. // Semi-transparent colors are pre-blended against bg in blendColor(). 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(); // Panel dimensions const chart_left = margin_left; const chart_right = fwidth - margin_right; const chart_w = chart_right - chart_left; const chart_top = margin_top; const total_h = fheight - margin_top - margin_bottom; const price_h = total_h * price_frac; const price_top = chart_top; const price_bottom = price_top + price_h; const gap_h = total_h * gap_frac; const rsi_h = total_h * rsi_frac; const rsi_top = price_bottom + gap_h; const rsi_bottom = rsi_top + rsi_h; // Price range (include Bollinger bands in range) var price_min: f64 = closes[0]; var price_max: f64 = closes[0]; for (closes) |c| { if (c < price_min) price_min = c; if (c > price_max) price_max = c; } for (bb) |b_opt| { if (b_opt) |b| { if (b.lower < price_min) price_min = b.lower; if (b.upper > price_max) price_max = b.upper; } } // Add 5% padding const price_pad = (price_max - price_min) * 0.05; price_min -= price_pad; price_max += price_pad; // Volume max var vol_max: f64 = 0; for (vols) |v| { if (v > vol_max) vol_max = v; } if (vol_max == 0) vol_max = 1; // Helper: map data index to x const x_step = chart_w / @as(f64, @floatFromInt(data.len - 1)); // ── Grid lines ──────────────────────────────────────────────────── const grid_color = blendColor(th.text_muted, 60, bg); try drawHorizontalGridLines(&ctx, chart_left, chart_right, price_top, price_bottom, 5, grid_color); try drawHorizontalGridLines(&ctx, chart_left, chart_right, rsi_top, rsi_bottom, 4, grid_color); // ── Volume bars (overlaid on price panel bottom 25%) ───────────── { const vol_panel_h = price_h * 0.25; const vol_bottom_y = price_bottom; const bar_w = @max(x_step * 0.7, 1.0); for (data, 0..) |candle, ci| { const x = chart_left + @as(f64, @floatFromInt(ci)) * x_step; const vol_h_px = (vols[ci] / vol_max) * vol_panel_h; const bar_top = vol_bottom_y - vol_h_px; const is_up = candle.close >= candle.open; const col = if (is_up) blendColor(th.positive, 50, bg) else blendColor(th.negative, 50, bg); ctx.setSourceToPixel(col); ctx.resetPath(); try ctx.moveTo(x - bar_w / 2, bar_top); try ctx.lineTo(x + bar_w / 2, bar_top); try ctx.lineTo(x + bar_w / 2, vol_bottom_y); try ctx.lineTo(x - bar_w / 2, vol_bottom_y); try ctx.closePath(); try ctx.fill(); } } // ── Bollinger Bands fill (drawn FIRST so price fill paints over it) ── { const band_fill_color = blendColor(th.accent, 25, bg); ctx.setSourceToPixel(band_fill_color); ctx.resetPath(); var started = false; for (bb, 0..) |b_opt, ci| { if (b_opt) |b| { const x = chart_left + @as(f64, @floatFromInt(ci)) * x_step; const y = mapY(b.upper, price_min, price_max, price_top, price_bottom); if (!started) { try ctx.moveTo(x, y); started = true; } else { try ctx.lineTo(x, y); } } } if (started) { var ci: usize = data.len; while (ci > 0) { ci -= 1; if (bb[ci]) |b| { const x = chart_left + @as(f64, @floatFromInt(ci)) * x_step; const y = mapY(b.lower, price_min, price_max, price_top, price_bottom); try ctx.lineTo(x, y); } } try ctx.closePath(); try ctx.fill(); } } // ── Price filled area (on top of BB fill) ────────────────────────── { const start_price = closes[0]; const end_price = closes[closes.len - 1]; const fill_color = if (end_price >= start_price) blendColor(th.positive, 30, bg) else blendColor(th.negative, 30, bg); ctx.setSourceToPixel(fill_color); ctx.resetPath(); for (closes, 0..) |c, ci| { const x = chart_left + @as(f64, @floatFromInt(ci)) * x_step; const y = mapY(c, price_min, price_max, price_top, price_bottom); if (ci == 0) try ctx.moveTo(x, y) else try ctx.lineTo(x, y); } const last_x = chart_left + @as(f64, @floatFromInt(closes.len - 1)) * x_step; try ctx.lineTo(last_x, price_bottom); try ctx.lineTo(chart_left, price_bottom); try ctx.closePath(); try ctx.fill(); } // ── Bollinger Band boundary lines + SMA (on top of fills) ────────── { const band_line_color = blendColor(th.text_muted, 100, bg); try drawLineSeries(&ctx, bb, data.len, price_min, price_max, price_top, price_bottom, chart_left, x_step, band_line_color, 1.0, .upper); try drawLineSeries(&ctx, bb, data.len, price_min, price_max, price_top, price_bottom, chart_left, x_step, band_line_color, 1.0, .lower); // SMA (middle) try drawLineSeries(&ctx, bb, data.len, price_min, price_max, price_top, price_bottom, chart_left, x_step, blendColor(th.text_muted, 160, bg), 1.0, .middle); } // ── Price line (on top of everything) ───────────────────────────── { const start_price = closes[0]; const end_price = closes[closes.len - 1]; const price_color = if (end_price >= start_price) opaqueColor(th.positive) else opaqueColor(th.negative); ctx.setSourceToPixel(price_color); ctx.setLineWidth(2.0); ctx.resetPath(); for (closes, 0..) |c, ci| { const x = chart_left + @as(f64, @floatFromInt(ci)) * x_step; const y = mapY(c, price_min, price_max, price_top, price_bottom); if (ci == 0) try ctx.moveTo(x, y) else try ctx.lineTo(x, y); } try ctx.stroke(); } // ── RSI panel ───────────────────────────────────────────────────── { const ref_color = blendColor(th.text_muted, 100, bg); try drawHLine(&ctx, chart_left, chart_right, mapY(70, 0, 100, rsi_top, rsi_bottom), ref_color, 1.0); try drawHLine(&ctx, chart_left, chart_right, mapY(30, 0, 100, rsi_top, rsi_bottom), ref_color, 1.0); try drawHLine(&ctx, chart_left, chart_right, mapY(50, 0, 100, rsi_top, rsi_bottom), blendColor(th.text_muted, 50, bg), 1.0); const rsi_color = blendColor(th.info, 220, bg); ctx.setSourceToPixel(rsi_color); ctx.setLineWidth(1.5); ctx.resetPath(); var rsi_started = false; for (rsi_vals, 0..) |r_opt, ci| { if (r_opt) |r| { const x = chart_left + @as(f64, @floatFromInt(ci)) * x_step; const y = mapY(r, 0, 100, rsi_top, rsi_bottom); if (!rsi_started) { try ctx.moveTo(x, y); rsi_started = true; } else { try ctx.lineTo(x, y); } } } if (rsi_started) try ctx.stroke(); } // ── Panel borders ───────────────────────────────────────────────── { const border_color = blendColor(th.border, 80, bg); try drawRect(&ctx, chart_left, price_top, chart_right, price_bottom, border_color, 1.0); try drawRect(&ctx, chart_left, rsi_top, chart_right, rsi_bottom, border_color, 1.0); } // Get latest RSI var rsi_latest: ?f64 = null; { var ri: usize = rsi_vals.len; while (ri > 0) { ri -= 1; if (rsi_vals[ri]) |r| { rsi_latest = r; break; } } } // 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, .width = @intCast(width_px), .height = @intCast(height_px), .price_min = price_min, .price_max = price_max, .rsi_latest = rsi_latest, }; } // ── Drawing helpers ─────────────────────────────────────────────────── 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); } /// Pre-blend a foreground color with alpha against a background color. /// Returns a fully opaque pixel. This avoids z2d's broken src_over compositor. 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), } }; } /// Opaque pixel from theme color. fn opaqueColor(c: [3]u8) Pixel { return .{ .rgb = .{ .r = c[0], .g = c[1], .b = c[2] } }; } const BandField = enum { upper, middle, lower }; fn drawLineSeries( ctx: *Context, bb: []const ?zfin.indicators.BollingerBand, len: usize, price_min: f64, price_max: f64, price_top: f64, price_bottom: f64, chart_left: f64, x_step: f64, col: Pixel, line_w: f64, field: BandField, ) !void { ctx.setSourceToPixel(col); ctx.setLineWidth(line_w); ctx.resetPath(); var started = false; for (0..len) |i| { if (bb[i]) |b| { const val = switch (field) { .upper => b.upper, .middle => b.middle, .lower => b.lower, }; const x = chart_left + @as(f64, @floatFromInt(i)) * x_step; const y = mapY(val, price_min, price_max, price_top, price_bottom); if (!started) { try ctx.moveTo(x, y); started = true; } else { try ctx.lineTo(x, y); } } } if (started) try ctx.stroke(); ctx.setLineWidth(2.0); } 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, w: f64) !void { ctx.setSourceToPixel(col); ctx.setLineWidth(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, w: f64) !void { ctx.setSourceToPixel(col); ctx.setLineWidth(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); } // ── Tests ───────────────────────────────────────────────────────────── test "mapY maps value to pixel coordinate" { // value at min → bottom try std.testing.expectEqual(@as(f64, 500.0), mapY(0, 0, 100, 100, 500)); // value at max → top try std.testing.expectEqual(@as(f64, 100.0), mapY(100, 0, 100, 100, 500)); // value at midpoint → midpoint try std.testing.expectEqual(@as(f64, 300.0), mapY(50, 0, 100, 100, 500)); // flat range → midpoint try std.testing.expectEqual(@as(f64, 300.0), mapY(42, 42, 42, 100, 500)); } test "blendColor alpha blending" { const white = [3]u8{ 255, 255, 255 }; const black = [3]u8{ 0, 0, 0 }; // Full alpha → foreground const full = blendColor(white, 255, black); try std.testing.expectEqual(@as(u8, 255), full.rgb.r); try std.testing.expectEqual(@as(u8, 255), full.rgb.g); // Zero alpha → background const zero = blendColor(white, 0, black); try std.testing.expectEqual(@as(u8, 0), zero.rgb.r); // Half alpha → midpoint const half = blendColor(white, 128, black); // 255 * (128/255) + 0 * (127/255) ≈ 128 try std.testing.expect(half.rgb.r >= 127 and half.rgb.r <= 129); } test "opaqueColor wraps theme color" { const px = opaqueColor(.{ 0x7f, 0xd8, 0x8f }); try std.testing.expectEqual(@as(u8, 0x7f), px.rgb.r); try std.testing.expectEqual(@as(u8, 0xd8), px.rgb.g); try std.testing.expectEqual(@as(u8, 0x8f), px.rgb.b); } test "ChartConfig.parse" { // Named modes const auto = ChartConfig.parse("auto").?; try std.testing.expectEqual(ChartMode.auto, auto.mode); const braille = ChartConfig.parse("braille").?; try std.testing.expectEqual(ChartMode.braille, braille.mode); // WxH format const custom = ChartConfig.parse("800x600").?; try std.testing.expectEqual(ChartMode.kitty, custom.mode); try std.testing.expectEqual(@as(u32, 800), custom.max_width); try std.testing.expectEqual(@as(u32, 600), custom.max_height); // Too small try std.testing.expectEqual(@as(?ChartConfig, null), ChartConfig.parse("50x50")); // Invalid try std.testing.expectEqual(@as(?ChartConfig, null), ChartConfig.parse("garbage")); } test "Timeframe next/prev cycle" { // next cycles through all values try std.testing.expectEqual(Timeframe.ytd, Timeframe.@"6M".next()); try std.testing.expectEqual(Timeframe.@"1Y", Timeframe.ytd.next()); try std.testing.expectEqual(Timeframe.@"6M", Timeframe.@"5Y".next()); // wraps // prev is the reverse try std.testing.expectEqual(Timeframe.@"5Y", Timeframe.@"6M".prev()); // wraps try std.testing.expectEqual(Timeframe.@"6M", Timeframe.ytd.prev()); } test "Timeframe tradingDays" { try std.testing.expectEqual(@as(usize, 126), Timeframe.@"6M".tradingDays()); try std.testing.expectEqual(@as(usize, 252), Timeframe.@"1Y".tradingDays()); try std.testing.expectEqual(@as(usize, 1260), Timeframe.@"5Y".tradingDays()); }