From 25f074f622fc48377c7ddc18569a4545fdb27825 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Thu, 25 Jun 2026 16:44:40 -0700 Subject: [PATCH] add kitty chart for quote command --- src/commands/quote.zig | 118 +++++++++++++++++++++++++++++++++-------- 1 file changed, 97 insertions(+), 21 deletions(-) diff --git a/src/commands/quote.zig b/src/commands/quote.zig index 49002c4..4fb17e3 100644 --- a/src/commands/quote.zig +++ b/src/commands/quote.zig @@ -6,6 +6,9 @@ const fmt = cli.fmt; const Money = @import("../Money.zig"); const chart_export = @import("../chart_export.zig"); const tui_chart = @import("../charts/chart.zig"); +const term_graphics = @import("../term_graphics.zig"); +const term_query = @import("../term_query.zig"); +const theme = @import("../tui/theme.zig"); pub const ParsedArgs = struct { symbol: []const u8, @@ -115,13 +118,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { // back to shorter ones until one fits - so the user gets the // most chart context without having to think about it. if (parsed.export_chart) |path| { - const tf: tui_chart.Timeframe = blk: { - const candidates = [_]tui_chart.Timeframe{ .@"5Y", .@"3Y", .@"1Y", .@"6M" }; - for (candidates) |c| { - if (candles.len >= c.tradingDays()) break :blk c; - } - break :blk .@"6M"; // fallback; renderToSurface enforces >= 20 candles - }; + const tf: tui_chart.Timeframe = pickTimeframe(candles); chart_export.exportSymbolChart(ctx.io, ctx.allocator, candles, tf, path) catch |err| switch (err) { error.InsufficientData => { cli.stderrPrint(ctx.io, "Error: not enough candle history to render a chart (need >= 20 candles).\n"); @@ -179,7 +176,13 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { } else |_| {} } - try display(ctx.allocator, candles, quote, parsed.symbol, name, ctx.today, ctx.color, ctx.out); + const k: KittyChart = .{ .io = ctx.io, .caps = ctx.graphics_caps }; + const chart_render: ChartRender = switch (ctx.globals.chart_config.mode) { + .braille => .braille, + .kitty => .{ .kitty = k }, + .auto => if (ctx.graphics_caps.kitty) .{ .kitty = k } else .braille, + }; + try display(ctx.allocator, candles, quote, parsed.symbol, name, ctx.today, ctx.color, ctx.out, chart_render); } /// Copy `s` (clamped to `buf`'s capacity) into `buf` and return the @@ -220,7 +223,79 @@ fn loadClassificationMap(ctx: *framework.RunCtx) ?zfin.classification.Classifica return zfin.classification.parseClassificationFile(ctx.allocator, meta_data) catch null; } -pub fn display(allocator: std.mem.Allocator, candles: []const zfin.Candle, quote: ?QuoteData, symbol: []const u8, name: ?[]const u8, as_of: zfin.Date, color: bool, out: *std.Io.Writer) !void { +/// How `display` draws the price chart. +const ChartRender = union(enum) { + /// Braille price line - the universal fallback. + braille, + /// Inline kitty graphics (price + Bollinger + volume + RSI). + kitty: KittyChart, +}; + +const KittyChart = struct { + io: std.Io, + caps: term_query.Caps, +}; + +/// Pick the longest timeframe the candle history can fill, falling back +/// to 6M. Shared by `--export-chart` and the inline kitty chart. +fn pickTimeframe(candles: []const zfin.Candle) tui_chart.Timeframe { + const candidates = [_]tui_chart.Timeframe{ .@"5Y", .@"3Y", .@"1Y", .@"6M" }; + for (candidates) |c| { + if (candles.len >= c.tradingDays()) return c; + } + return .@"6M"; +} + +/// Braille price chart of the last 60 candles (the fallback path). +fn renderBrailleCandles(allocator: std.mem.Allocator, out: *std.Io.Writer, color: bool, candles: []const zfin.Candle) !void { + const chart_days: usize = @min(candles.len, 60); + const chart_data = candles[candles.len - chart_days ..]; + var ch = fmt.computeBrailleChart(allocator, chart_data, 60, 10, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch return; + defer ch.deinit(allocator); + try fmt.writeBrailleAnsi(out, &ch, color, cli.CLR_MUTED, false); +} + +/// Render the price+Bollinger+volume+RSI chart as kitty graphics at +/// `term_graphics.quote_cols` wide and emit it inline. Displays the same +/// last-60-candle window as the braille fallback, but computes the +/// Bollinger/RSI overlays over an extra `warmup` candles of lookback and +/// slices them to the window - so the bands are valid from the first +/// *displayed* candle instead of warming up a third of the way in. The +/// standalone `--export-chart` PNG still shows the longest history. +/// Returns `error.InsufficientData` when there's too little history +/// (< 20 candles) so the caller can fall back to braille. +fn emitQuoteKitty(allocator: std.mem.Allocator, out: *std.Io.Writer, candles: []const zfin.Candle, k: KittyChart) !void { + const display_n: usize = @min(candles.len, 60); + const warmup: usize = 20; // >= the BB(20) / RSI(14) warmup periods + const window_n: usize = @min(candles.len, display_n + warmup); + const window = candles[candles.len - window_n ..]; + + // Compute indicators over the (display + warmup) window, then view + // the last `display_n` of each so they align with the displayed + // candles and carry no warmup gap. `full` owns the backing arrays; + // `cached` is a non-owning slice into them. + var full = try tui_chart.computeIndicators(allocator, window, .@"6M"); + defer full.deinit(allocator); + const off = window_n - display_n; + const cached: tui_chart.CachedIndicators = .{ + .closes = full.closes[off..], + .volumes = full.volumes[off..], + .bb = full.bb[off..], + .rsi_vals = full.rsi_vals[off..], + }; + + const display_data = candles[candles.len - display_n ..]; + const cols = term_graphics.quote_cols; + const rows = term_graphics.rowsForWidth(cols, k.caps.cell_w, k.caps.cell_h); + const dims = term_graphics.pixelDims(cols, rows, k.caps.cell_w, k.caps.cell_h); + var rendered = try tui_chart.renderToSurface(k.io, allocator, display_data, .@"6M", dims.width, dims.height, theme.default_theme, &cached, true); + defer rendered.deinit(allocator); + const rgb = try rendered.extractRgb(allocator); + defer allocator.free(rgb); + try term_graphics.placeInline(out, allocator, rgb, dims.width, dims.height, cols, rows); +} + +pub fn display(allocator: std.mem.Allocator, candles: []const zfin.Candle, quote: ?QuoteData, symbol: []const u8, name: ?[]const u8, as_of: zfin.Date, color: bool, out: *std.Io.Writer, chart_render: ChartRender) !void { const has_quote = quote != null; // Header. The security name (when resolved) renders between the @@ -266,15 +341,16 @@ pub fn display(allocator: std.mem.Allocator, candles: []const zfin.Candle, quote } } - // Braille chart (60 columns, 10 rows) + // Chart: inline kitty graphics when supported, else a braille price + // chart of the last 60 candles. if (candles.len >= 2) { try out.print("\n", .{}); - const chart_days: usize = @min(candles.len, 60); - const chart_data = candles[candles.len - chart_days ..]; - var chart = fmt.computeBrailleChart(allocator, chart_data, 60, 10, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch null; - if (chart) |*ch| { - defer ch.deinit(allocator); - try fmt.writeBrailleAnsi(out, ch, color, cli.CLR_MUTED, false); + switch (chart_render) { + .braille => try renderBrailleCandles(allocator, out, color, candles), + .kitty => |k| emitQuoteKitty(allocator, out, candles, k) catch |err| switch (err) { + error.InsufficientData => try renderBrailleCandles(allocator, out, color, candles), + else => return err, + }, } } @@ -359,7 +435,7 @@ test "display with candles only" { .{ .date = .{ .days = 20000 }, .open = 150.0, .high = 155.0, .low = 149.0, .close = 153.0, .adj_close = 153.0, .volume = 50_000_000 }, .{ .date = .{ .days = 20001 }, .open = 153.0, .high = 158.0, .low = 152.0, .close = 156.0, .adj_close = 156.0, .volume = 45_000_000 }, }; - try display(std.testing.allocator, &candles, null, "AAPL", null, zfin.Date.fromYmd(2026, 5, 8), false, &w); + try display(std.testing.allocator, &candles, null, "AAPL", null, zfin.Date.fromYmd(2026, 5, 8), false, &w, .braille); const out = w.buffered(); try std.testing.expect(std.mem.indexOf(u8, out, "AAPL") != null); try std.testing.expect(std.mem.indexOf(u8, out, "(close)") != null); @@ -380,7 +456,7 @@ test "display with quote data" { .prev_close = 172.00, .date = .{ .days = 20001 }, }; - try display(std.testing.allocator, &candles, quote, "AAPL", null, zfin.Date.fromYmd(2026, 5, 8), false, &w); + try display(std.testing.allocator, &candles, quote, "AAPL", null, zfin.Date.fromYmd(2026, 5, 8), false, &w, .braille); const out = w.buffered(); try std.testing.expect(std.mem.indexOf(u8, out, "AAPL") != null); try std.testing.expect(std.mem.indexOf(u8, out, "Change") != null); @@ -394,7 +470,7 @@ test "display renders the security name when provided" { const candles = [_]zfin.Candle{ .{ .date = .{ .days = 20000 }, .open = 150.0, .high = 155.0, .low = 149.0, .close = 153.0, .adj_close = 153.0, .volume = 50_000_000 }, }; - try display(std.testing.allocator, &candles, null, "AAPL", "Apple Inc.", zfin.Date.fromYmd(2026, 5, 8), false, &w); + try display(std.testing.allocator, &candles, null, "AAPL", "Apple Inc.", zfin.Date.fromYmd(2026, 5, 8), false, &w, .braille); const out = w.buffered(); // Name appears between the symbol and the price. try std.testing.expect(std.mem.indexOf(u8, out, "AAPL Apple Inc.") != null); @@ -406,7 +482,7 @@ test "display omits an empty name" { const candles = [_]zfin.Candle{ .{ .date = .{ .days = 20000 }, .open = 150.0, .high = 155.0, .low = 149.0, .close = 153.0, .adj_close = 153.0, .volume = 50_000_000 }, }; - try display(std.testing.allocator, &candles, null, "AAPL", "", zfin.Date.fromYmd(2026, 5, 8), false, &w); + try display(std.testing.allocator, &candles, null, "AAPL", "", zfin.Date.fromYmd(2026, 5, 8), false, &w, .braille); const out = w.buffered(); // No double-space orphan where the name would have gone. try std.testing.expect(std.mem.indexOf(u8, out, "AAPL $") != null); @@ -418,7 +494,7 @@ test "display no ANSI without color" { const candles = [_]zfin.Candle{ .{ .date = .{ .days = 20000 }, .open = 100.0, .high = 105.0, .low = 99.0, .close = 103.0, .adj_close = 103.0, .volume = 1_000_000 }, }; - try display(std.testing.allocator, &candles, null, "SPY", null, zfin.Date.fromYmd(2026, 5, 8), false, &w); + try display(std.testing.allocator, &candles, null, "SPY", null, zfin.Date.fromYmd(2026, 5, 8), false, &w, .braille); const out = w.buffered(); try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null); }