add kitty chart for quote command

This commit is contained in:
Emil Lerch 2026-06-25 16:44:40 -07:00
parent dd550a85d9
commit 25f074f622
Signed by: lobo
GPG key ID: A7B62D657EF764F8

View file

@ -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);
}