add kitty chart for quote command
This commit is contained in:
parent
dd550a85d9
commit
25f074f622
1 changed files with 97 additions and 21 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue