ai: bug fixes and charting

This commit is contained in:
Emil Lerch 2026-02-25 17:02:19 -08:00
parent 3b66e38152
commit 4d093b86bf
Signed by: lobo
GPG key ID: A7B62D657EF764F8
6 changed files with 467 additions and 56 deletions

View file

@ -15,6 +15,11 @@ pub fn build(b: *std.Build) void {
.optimize = optimize, .optimize = optimize,
}); });
const z2d_dep = b.dependency("z2d", .{
.target = target,
.optimize = optimize,
});
// Library module -- the public API for consumers of zfin // Library module -- the public API for consumers of zfin
const mod = b.addModule("zfin", .{ const mod = b.addModule("zfin", .{
.root_source_file = b.path("src/root.zig"), .root_source_file = b.path("src/root.zig"),
@ -32,6 +37,7 @@ pub fn build(b: *std.Build) void {
.{ .name = "zfin", .module = mod }, .{ .name = "zfin", .module = mod },
.{ .name = "srf", .module = srf_dep.module("srf") }, .{ .name = "srf", .module = srf_dep.module("srf") },
.{ .name = "vaxis", .module = vaxis_dep.module("vaxis") }, .{ .name = "vaxis", .module = vaxis_dep.module("vaxis") },
.{ .name = "z2d", .module = z2d_dep.module("z2d") },
}, },
}); });
@ -77,6 +83,7 @@ pub fn build(b: *std.Build) void {
.{ .name = "zfin", .module = mod }, .{ .name = "zfin", .module = mod },
.{ .name = "srf", .module = srf_dep.module("srf") }, .{ .name = "srf", .module = srf_dep.module("srf") },
.{ .name = "vaxis", .module = vaxis_dep.module("vaxis") }, .{ .name = "vaxis", .module = vaxis_dep.module("vaxis") },
.{ .name = "z2d", .module = z2d_dep.module("z2d") },
}, },
}) }); }) });
test_step.dependOn(&b.addRunArtifact(tui_tests).step); test_step.dependOn(&b.addRunArtifact(tui_tests).step);

View file

@ -11,6 +11,10 @@
.url = "git+https://github.com/rockorager/libvaxis.git#67bbc1ee072aa390838c66caf4ed47edee282dc4", .url = "git+https://github.com/rockorager/libvaxis.git#67bbc1ee072aa390838c66caf4ed47edee282dc4",
.hash = "vaxis-0.5.1-BWNV_IxJCQC5OGNaXQfNnqgn9_Vku0PMgey-dplubcQK", .hash = "vaxis-0.5.1-BWNV_IxJCQC5OGNaXQfNnqgn9_Vku0PMgey-dplubcQK",
}, },
.z2d = .{
.url = "git+https://github.com/vancluever/z2d?ref=v0.10.0#6d1d7bda6b696c0941d204e6042f1e8ee900e001",
.hash = "z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ",
},
}, },
.paths = .{ .paths = .{
"build.zig", "build.zig",

View file

@ -23,6 +23,7 @@ const usage =
\\ -p, --portfolio <FILE> Portfolio file (.srf) \\ -p, --portfolio <FILE> Portfolio file (.srf)
\\ -w, --watchlist <FILE> Watchlist file (default: watchlist.srf) \\ -w, --watchlist <FILE> Watchlist file (default: watchlist.srf)
\\ -s, --symbol <SYMBOL> Initial symbol (default: VTI) \\ -s, --symbol <SYMBOL> Initial symbol (default: VTI)
\\ --chart <MODE> Chart graphics: auto, braille, or WxH (e.g. 1920x1080)
\\ --default-keys Print default keybindings \\ --default-keys Print default keybindings
\\ --default-theme Print default theme \\ --default-theme Print default theme
\\ \\

View file

@ -36,6 +36,7 @@ pub const cache = @import("cache/store.zig");
// -- Analytics -- // -- Analytics --
pub const performance = @import("analytics/performance.zig"); pub const performance = @import("analytics/performance.zig");
pub const risk = @import("analytics/risk.zig"); pub const risk = @import("analytics/risk.zig");
pub const indicators = @import("analytics/indicators.zig");
// -- Service layer -- // -- Service layer --
pub const DataService = @import("service.zig").DataService; pub const DataService = @import("service.zig").DataService;

View file

@ -36,6 +36,8 @@ pub const Action = enum {
options_filter_7, options_filter_7,
options_filter_8, options_filter_8,
options_filter_9, options_filter_9,
chart_timeframe_next,
chart_timeframe_prev,
}; };
pub const KeyCombo = struct { pub const KeyCombo = struct {
@ -111,6 +113,8 @@ const default_bindings = [_]Binding{
.{ .action = .options_filter_7, .key = .{ .codepoint = '7', .mods = .{ .ctrl = true } } }, .{ .action = .options_filter_7, .key = .{ .codepoint = '7', .mods = .{ .ctrl = true } } },
.{ .action = .options_filter_8, .key = .{ .codepoint = '8', .mods = .{ .ctrl = true } } }, .{ .action = .options_filter_8, .key = .{ .codepoint = '8', .mods = .{ .ctrl = true } } },
.{ .action = .options_filter_9, .key = .{ .codepoint = '9', .mods = .{ .ctrl = true } } }, .{ .action = .options_filter_9, .key = .{ .codepoint = '9', .mods = .{ .ctrl = true } } },
.{ .action = .chart_timeframe_next, .key = .{ .codepoint = ']' } },
.{ .action = .chart_timeframe_prev, .key = .{ .codepoint = '[' } },
}; };
pub fn defaults() KeyMap { pub fn defaults() KeyMap {

View file

@ -3,6 +3,7 @@ const vaxis = @import("vaxis");
const zfin = @import("zfin"); const zfin = @import("zfin");
const keybinds = @import("keybinds.zig"); const keybinds = @import("keybinds.zig");
const theme_mod = @import("theme.zig"); const theme_mod = @import("theme.zig");
const chart_mod = @import("chart.zig");
/// Comptime-generated table of single-character grapheme slices with static lifetime. /// Comptime-generated table of single-character grapheme slices with static lifetime.
/// This avoids dangling pointers from stack-allocated temporaries in draw functions. /// This avoids dangling pointers from stack-allocated temporaries in draw functions.
@ -115,6 +116,7 @@ const App = struct {
cursor: usize = 0, // selected row in portfolio view cursor: usize = 0, // selected row in portfolio view
expanded: [64]bool = [_]bool{false} ** 64, // which positions are expanded expanded: [64]bool = [_]bool{false} ** 64, // which positions are expanded
portfolio_rows: std.ArrayList(PortfolioRow) = .empty, portfolio_rows: std.ArrayList(PortfolioRow) = .empty,
portfolio_header_lines: usize = 0, // number of styled lines before data rows
// Options navigation (inline expand/collapse like portfolio) // Options navigation (inline expand/collapse like portfolio)
options_cursor: usize = 0, // selected row in flattened options view options_cursor: usize = 0, // selected row in flattened options view
@ -123,10 +125,7 @@ const App = struct {
options_puts_collapsed: [64]bool = [_]bool{false} ** 64, // per-expiration: puts section collapsed options_puts_collapsed: [64]bool = [_]bool{false} ** 64, // per-expiration: puts section collapsed
options_near_the_money: usize = 8, // +/- strikes from ATM options_near_the_money: usize = 8, // +/- strikes from ATM
options_rows: std.ArrayList(OptionsRow) = .empty, options_rows: std.ArrayList(OptionsRow) = .empty,
options_header_lines: usize = 0, // number of styled lines before data rows
// Double-click tracking
last_click_row: usize = 0,
last_click_time: i64 = 0,
// Cached data for rendering // Cached data for rendering
candles: ?[]zfin.Candle = null, candles: ?[]zfin.Candle = null,
@ -159,6 +158,21 @@ const App = struct {
// Signal to the run loop to launch $EDITOR then restart // Signal to the run loop to launch $EDITOR then restart
wants_edit: bool = false, wants_edit: bool = false,
// Chart state (Kitty graphics)
chart_config: chart_mod.ChartConfig = .{},
vx_app: ?*vaxis.vxfw.App = null, // set during run(), for Kitty graphics access
chart_timeframe: chart_mod.Timeframe = .@"1Y",
chart_image_id: ?u32 = null, // currently transmitted Kitty image ID
chart_image_width: u16 = 0, // image width in cells
chart_image_height: u16 = 0, // image height in cells
chart_symbol: [16]u8 = undefined, // symbol the chart was rendered for
chart_symbol_len: usize = 0,
chart_timeframe_rendered: ?chart_mod.Timeframe = null, // timeframe the chart was rendered for
chart_dirty: bool = true, // needs re-render
chart_price_min: f64 = 0,
chart_price_max: f64 = 0,
chart_rsi_latest: ?f64 = null,
pub fn widget(self: *App) vaxis.vxfw.Widget { pub fn widget(self: *App) vaxis.vxfw.Widget {
return .{ return .{
.userdata = self, .userdata = self,
@ -235,44 +249,33 @@ const App = struct {
} }
} }
if (self.active_tab == .portfolio and mouse.row > 0) { if (self.active_tab == .portfolio and mouse.row > 0) {
const content_row = @as(usize, @intCast(mouse.row)) - 1 + self.scroll_offset; const content_row = @as(usize, @intCast(mouse.row)) + self.scroll_offset;
if (content_row >= 4 and self.portfolio_rows.items.len > 0) { if (content_row >= self.portfolio_header_lines and self.portfolio_rows.items.len > 0) {
const row_idx = content_row - 4; const row_idx = content_row - self.portfolio_header_lines;
if (row_idx < self.portfolio_rows.items.len) { if (row_idx < self.portfolio_rows.items.len) {
const now_ms = std.time.milliTimestamp(); self.cursor = row_idx;
if (row_idx == self.last_click_row and (now_ms - self.last_click_time) < 500) { self.toggleExpand();
// Double-click: expand/collapse
self.cursor = row_idx;
self.toggleExpand();
self.last_click_time = 0;
} else {
self.cursor = row_idx;
self.last_click_row = row_idx;
self.last_click_time = now_ms;
}
return ctx.consumeAndRedraw(); return ctx.consumeAndRedraw();
} }
} }
} }
// Options tab: click to select row, double-click to expand/collapse // Options tab: single-click to select and expand/collapse
if (self.active_tab == .options and mouse.row > 0) { if (self.active_tab == .options and mouse.row > 0) {
const content_row = @as(usize, @intCast(mouse.row)) - 1 + self.scroll_offset; const content_row = @as(usize, @intCast(mouse.row)) + self.scroll_offset;
// Options rows start after header lines (blank, header, underlying, blank, col header = 5 lines) if (content_row >= self.options_header_lines and self.options_rows.items.len > 0) {
if (content_row >= 5 and self.options_rows.items.len > 0) { // Walk options_rows tracking styled line position to find which
const row_idx = content_row - 5; // row was clicked. Each row = 1 styled line, except puts_header
if (row_idx < self.options_rows.items.len) { // which emits an extra blank line before it.
const now_ms = std.time.milliTimestamp(); const target_line = content_row - self.options_header_lines;
if (row_idx == self.last_click_row and (now_ms - self.last_click_time) < 500) { var current_line: usize = 0;
// Double-click: expand/collapse for (self.options_rows.items, 0..) |orow, oi| {
self.options_cursor = row_idx; if (orow.kind == .puts_header) current_line += 1; // extra blank
if (current_line == target_line) {
self.options_cursor = oi;
self.toggleOptionsExpand(); self.toggleOptionsExpand();
self.last_click_time = 0; return ctx.consumeAndRedraw();
} else {
self.options_cursor = row_idx;
self.last_click_row = row_idx;
self.last_click_time = now_ms;
} }
return ctx.consumeAndRedraw(); current_line += 1;
} }
} }
} }
@ -484,6 +487,22 @@ const App = struct {
return ctx.consumeAndRedraw(); return ctx.consumeAndRedraw();
} }
}, },
.chart_timeframe_next => {
if (self.active_tab == .quote) {
self.chart_timeframe = self.chart_timeframe.next();
self.chart_dirty = true;
self.setStatus(self.chart_timeframe.label());
return ctx.consumeAndRedraw();
}
},
.chart_timeframe_prev => {
if (self.active_tab == .quote) {
self.chart_timeframe = self.chart_timeframe.prev();
self.chart_dirty = true;
self.setStatus(self.chart_timeframe.label());
return ctx.consumeAndRedraw();
}
},
} }
} }
@ -682,6 +701,7 @@ const App = struct {
self.trailing_me_total = null; self.trailing_me_total = null;
self.risk_metrics = null; self.risk_metrics = null;
self.scroll_offset = 0; self.scroll_offset = 0;
self.chart_dirty = true;
} }
fn refreshCurrentTab(self: *App) void { fn refreshCurrentTab(self: *App) void {
@ -710,6 +730,7 @@ const App = struct {
self.perf_loaded = false; self.perf_loaded = false;
self.freeCandles(); self.freeCandles();
self.freeDividends(); self.freeDividends();
self.chart_dirty = true;
}, },
.earnings => { .earnings => {
self.earnings_loaded = false; self.earnings_loaded = false;
@ -1156,7 +1177,7 @@ const App = struct {
} else { } else {
switch (self.active_tab) { switch (self.active_tab) {
.portfolio => try self.drawPortfolioContent(ctx.arena, buf, width, height), .portfolio => try self.drawPortfolioContent(ctx.arena, buf, width, height),
.quote => try self.drawStyledContent(ctx.arena, buf, width, height, try self.buildQuoteStyledLines(ctx.arena)), .quote => try self.drawQuoteContent(ctx, buf, width, height),
.performance => try self.drawStyledContent(ctx.arena, buf, width, height, try self.buildPerfStyledLines(ctx.arena)), .performance => try self.drawStyledContent(ctx.arena, buf, width, height, try self.buildPerfStyledLines(ctx.arena)),
.options => try self.drawOptionsContent(ctx.arena, buf, width, height), .options => try self.drawOptionsContent(ctx.arena, buf, width, height),
.earnings => try self.drawStyledContent(ctx.arena, buf, width, height, try self.buildEarningsStyledLines(ctx.arena)), .earnings => try self.drawStyledContent(ctx.arena, buf, width, height, try self.buildEarningsStyledLines(ctx.arena)),
@ -1283,6 +1304,9 @@ const App = struct {
}); });
try lines.append(arena, .{ .text = hdr, .style = th.headerStyle() }); try lines.append(arena, .{ .text = hdr, .style = th.headerStyle() });
// Track header line count for mouse click mapping (after all header lines)
self.portfolio_header_lines = lines.items.len;
// Data rows // Data rows
for (self.portfolio_rows.items, 0..) |row, ri| { for (self.portfolio_rows.items, 0..) |row, ri| {
const is_cursor = ri == self.cursor; const is_cursor = ri == self.cursor;
@ -1454,6 +1478,331 @@ const App = struct {
// Quote tab // Quote tab
/// Draw the quote tab content. Uses Kitty graphics for the chart when available,
/// falling back to braille sparkline otherwise.
fn drawQuoteContent(self: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, width: u16, height: u16) !void {
const arena = ctx.arena;
// Determine whether to use Kitty graphics
const use_kitty = switch (self.chart_config.mode) {
.braille => false,
.kitty => true,
.auto => if (self.vx_app) |va| va.vx.caps.kitty_graphics else false,
};
if (use_kitty and self.candles != null and self.candles.?.len >= 40) {
self.drawQuoteWithKittyChart(ctx, buf, width, height) catch {
// On any failure, fall back to braille
try self.drawStyledContent(arena, buf, width, height, try self.buildQuoteStyledLines(arena));
};
} else {
// Fallback to styled lines with braille chart
try self.drawStyledContent(arena, buf, width, height, try self.buildQuoteStyledLines(arena));
}
}
/// Draw quote tab using Kitty graphics protocol for the chart.
fn drawQuoteWithKittyChart(self: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, width: u16, height: u16) !void {
const arena = ctx.arena;
const th = self.theme;
const c = self.candles orelse return;
// Build text header (symbol, price, change) first few lines
var lines: std.ArrayList(StyledLine) = .empty;
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
// Symbol + price header
if (self.quote) |q| {
const price_str = try std.fmt.allocPrint(arena, " {s} ${d:.2}", .{ self.symbol, q.close });
try lines.append(arena, .{ .text = price_str, .style = th.headerStyle() });
if (q.previous_close > 0) {
const change = q.close - q.previous_close;
const pct = (change / q.previous_close) * 100.0;
const change_style = if (change >= 0) th.positiveStyle() else th.negativeStyle();
if (change >= 0) {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: +${d:.2} (+{d:.2}%)", .{ change, pct }), .style = change_style });
} else {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: -${d:.2} ({d:.2}%)", .{ -change, pct }), .style = change_style });
}
}
} else if (c.len > 0) {
const last = c[c.len - 1];
const price_str = try std.fmt.allocPrint(arena, " {s} ${d:.2} (close)", .{ self.symbol, last.close });
try lines.append(arena, .{ .text = price_str, .style = th.headerStyle() });
if (c.len >= 2) {
const prev_close = c[c.len - 2].close;
const change = last.close - prev_close;
const pct = (change / prev_close) * 100.0;
const change_style = if (change >= 0) th.positiveStyle() else th.negativeStyle();
if (change >= 0) {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: +${d:.2} (+{d:.2}%)", .{ change, pct }), .style = change_style });
} else {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: -${d:.2} ({d:.2}%)", .{ -change, pct }), .style = change_style });
}
}
}
// Timeframe selector line
{
var tf_buf: [80]u8 = undefined;
var tf_pos: usize = 0;
const prefix = " Chart: ";
@memcpy(tf_buf[tf_pos..][0..prefix.len], prefix);
tf_pos += prefix.len;
const timeframes = [_]chart_mod.Timeframe{ .@"6M", .ytd, .@"1Y", .@"3Y", .@"5Y" };
for (timeframes) |tf| {
const lbl = tf.label();
if (tf == self.chart_timeframe) {
tf_buf[tf_pos] = '[';
tf_pos += 1;
@memcpy(tf_buf[tf_pos..][0..lbl.len], lbl);
tf_pos += lbl.len;
tf_buf[tf_pos] = ']';
tf_pos += 1;
} else {
tf_buf[tf_pos] = ' ';
tf_pos += 1;
@memcpy(tf_buf[tf_pos..][0..lbl.len], lbl);
tf_pos += lbl.len;
tf_buf[tf_pos] = ' ';
tf_pos += 1;
}
tf_buf[tf_pos] = ' ';
tf_pos += 1;
}
const hint = " ([ ] to change)";
@memcpy(tf_buf[tf_pos..][0..hint.len], hint);
tf_pos += hint.len;
try lines.append(arena, .{ .text = try arena.dupe(u8, tf_buf[0..tf_pos]), .style = th.mutedStyle() });
}
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
// Draw the text header
const header_lines = try lines.toOwnedSlice(arena);
try self.drawStyledContent(arena, buf, width, height, header_lines);
// Calculate chart area (below the header, leaving room for details below)
const header_rows: u16 = @intCast(@min(header_lines.len, height));
const detail_rows: u16 = 10; // reserve rows for quote details below chart
const chart_rows = height -| header_rows -| detail_rows;
if (chart_rows < 8) return; // not enough space
// Compute pixel dimensions from cell size
// cell_size may be 0 if terminal hasn't reported pixel dimensions yet
const cell_w: u32 = if (ctx.cell_size.width > 0) ctx.cell_size.width else 8;
const cell_h: u32 = if (ctx.cell_size.height > 0) ctx.cell_size.height else 16;
const label_cols: u16 = 10; // columns reserved for axis labels on the right
const chart_cols = width -| 2 -| label_cols; // 1 col left margin + label area on right
if (chart_cols == 0) return;
const px_w: u32 = @as(u32, chart_cols) * cell_w;
const px_h: u32 = @as(u32, chart_rows) * cell_h;
if (px_w < 100 or px_h < 100) return;
// Apply resolution cap from chart config
const capped_w = @min(px_w, self.chart_config.max_width);
const capped_h = @min(px_h, self.chart_config.max_height);
// Check if we need to re-render the chart image
const symbol_changed = self.chart_symbol_len != self.symbol.len or
!std.mem.eql(u8, self.chart_symbol[0..self.chart_symbol_len], self.symbol);
const tf_changed = self.chart_timeframe_rendered == null or self.chart_timeframe_rendered.? != self.chart_timeframe;
if (self.chart_dirty or symbol_changed or tf_changed) {
// Free old image
if (self.chart_image_id) |old_id| {
if (self.vx_app) |va| {
va.vx.freeImage(va.tty.writer(), old_id);
}
self.chart_image_id = null;
}
// Render and transmit use the app's main allocator, NOT the arena,
// because z2d allocates large pixel buffers that would bloat the arena.
if (self.vx_app) |va| {
const chart_result = chart_mod.renderChart(
self.allocator,
c,
self.chart_timeframe,
capped_w,
capped_h,
th,
) catch |err| {
self.chart_dirty = false;
var err_buf: [128]u8 = undefined;
const msg = std.fmt.bufPrint(&err_buf, "Chart render failed: {s}", .{@errorName(err)}) catch "Chart render failed";
self.setStatus(msg);
return;
};
defer self.allocator.free(chart_result.rgb_data);
// Base64-encode and transmit raw RGB data directly via Kitty protocol.
// This avoids the PNG encode file write file read PNG decode roundtrip.
const base64_enc = std.base64.standard.Encoder;
const b64_buf = self.allocator.alloc(u8, base64_enc.calcSize(chart_result.rgb_data.len)) catch {
self.chart_dirty = false;
self.setStatus("Chart: base64 alloc failed");
return;
};
defer self.allocator.free(b64_buf);
const encoded = base64_enc.encode(b64_buf, chart_result.rgb_data);
const img = va.vx.transmitPreEncodedImage(
va.tty.writer(),
encoded,
chart_result.width,
chart_result.height,
.rgb,
) catch |err| {
self.chart_dirty = false;
var err_buf: [128]u8 = undefined;
const msg = std.fmt.bufPrint(&err_buf, "Image transmit failed: {s}", .{@errorName(err)}) catch "Image transmit failed";
self.setStatus(msg);
return;
};
self.chart_image_id = img.id;
self.chart_image_width = @intCast(chart_cols);
self.chart_image_height = chart_rows;
// Track what we rendered
const sym_len = @min(self.symbol.len, 16);
@memcpy(self.chart_symbol[0..sym_len], self.symbol[0..sym_len]);
self.chart_symbol_len = sym_len;
self.chart_timeframe_rendered = self.chart_timeframe;
self.chart_price_min = chart_result.price_min;
self.chart_price_max = chart_result.price_max;
self.chart_rsi_latest = chart_result.rsi_latest;
self.chart_dirty = false;
}
}
// Place the image in the cell buffer
if (self.chart_image_id) |img_id| {
// Place image at the first cell of the chart area
const chart_row_start: usize = header_rows;
const chart_col_start: usize = 1; // 1 col left margin
const buf_idx = chart_row_start * @as(usize, width) + chart_col_start;
if (buf_idx < buf.len) {
buf[buf_idx] = .{
.char = .{ .grapheme = " " },
.style = th.contentStyle(),
.image = .{
.img_id = img_id,
.options = .{
.size = .{
.rows = self.chart_image_height,
.cols = self.chart_image_width,
},
.scale = .contain,
},
},
};
}
// Axis labels (terminal text in the right margin)
// The chart image uses layout fractions: price=72%, gap=8%, RSI=20%
// Map these to terminal rows to position labels.
const img_rows = self.chart_image_height;
const label_col: usize = @as(usize, chart_col_start) + @as(usize, self.chart_image_width) + 1;
const label_style = th.mutedStyle();
if (label_col + 8 <= width and img_rows >= 4 and self.chart_price_max > self.chart_price_min) {
// Price axis labels evenly spaced across the price panel (top 72%)
const price_panel_rows = @as(f64, @floatFromInt(img_rows)) * 0.72;
const n_price_labels: usize = 5;
for (0..n_price_labels) |i| {
const frac = @as(f64, @floatFromInt(i)) / @as(f64, @floatFromInt(n_price_labels - 1));
const price_val = self.chart_price_max - frac * (self.chart_price_max - self.chart_price_min);
const row_f = @as(f64, @floatFromInt(chart_row_start)) + frac * price_panel_rows;
const row: usize = @intFromFloat(@round(row_f));
if (row >= height) continue;
var lbl_buf: [16]u8 = undefined;
const lbl = fmtMoney2(&lbl_buf, price_val);
const start_idx = row * @as(usize, width) + label_col;
for (lbl, 0..) |ch, ci| {
const idx = start_idx + ci;
if (idx < buf.len and label_col + ci < width) {
buf[idx] = .{
.char = .{ .grapheme = glyph(ch) },
.style = label_style,
};
}
}
}
// RSI axis labels positioned within the RSI panel (bottom 20%, after 80% offset)
const rsi_panel_start_f = @as(f64, @floatFromInt(img_rows)) * 0.80;
const rsi_panel_h = @as(f64, @floatFromInt(img_rows)) * 0.20;
const rsi_labels = [_]struct { val: f64, label: []const u8 }{
.{ .val = 70, .label = "70" },
.{ .val = 50, .label = "50" },
.{ .val = 30, .label = "30" },
};
for (rsi_labels) |rl| {
// RSI maps 0-100 top-to-bottom within the RSI panel
const rsi_frac = 1.0 - (rl.val / 100.0);
const row_f = @as(f64, @floatFromInt(chart_row_start)) + rsi_panel_start_f + rsi_frac * rsi_panel_h;
const row: usize = @intFromFloat(@round(row_f));
if (row >= height) continue;
const start_idx = row * @as(usize, width) + label_col;
for (rl.label, 0..) |ch, ci| {
const idx = start_idx + ci;
if (idx < buf.len and label_col + ci < width) {
buf[idx] = .{
.char = .{ .grapheme = glyph(ch) },
.style = label_style,
};
}
}
}
}
// Render quote details below the chart image as styled text
const detail_start_row = header_rows + self.chart_image_height;
if (detail_start_row + 8 < height) {
var detail_lines: std.ArrayList(StyledLine) = .empty;
try detail_lines.append(arena, .{ .text = "", .style = th.contentStyle() });
const latest = c[c.len - 1];
const quote_data = self.quote;
const price = if (quote_data) |q| q.close else latest.close;
const prev_close = if (quote_data) |q| q.previous_close else if (c.len >= 2) c[c.len - 2].close else @as(f64, 0);
var date_buf2: [10]u8 = undefined;
var close_buf2: [24]u8 = undefined;
var vol_buf2: [32]u8 = undefined;
try detail_lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Date: {s}", .{latest.date.format(&date_buf2)}), .style = th.contentStyle() });
try detail_lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Price: {s}", .{fmtMoney(&close_buf2, price)}), .style = th.contentStyle() });
try detail_lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Open: ${d:.2}", .{if (quote_data) |q| q.open else latest.open}), .style = th.mutedStyle() });
try detail_lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " High: ${d:.2}", .{if (quote_data) |q| q.high else latest.high}), .style = th.mutedStyle() });
try detail_lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Low: ${d:.2}", .{if (quote_data) |q| q.low else latest.low}), .style = th.mutedStyle() });
try detail_lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Volume: {s}", .{fmtIntCommas(&vol_buf2, if (quote_data) |q| q.volume else latest.volume)}), .style = th.mutedStyle() });
if (prev_close > 0) {
const change = price - prev_close;
const pct = (change / prev_close) * 100.0;
const change_style = if (change >= 0) th.positiveStyle() else th.negativeStyle();
if (change >= 0) {
try detail_lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: +${d:.2} (+{d:.2}%)", .{ change, pct }), .style = change_style });
} else {
try detail_lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: -${d:.2} ({d:.2}%)", .{ -change, pct }), .style = change_style });
}
}
// Write detail lines into the buffer below the image
const detail_buf_start = detail_start_row * @as(usize, width);
const remaining_height = height - @as(u16, @intCast(detail_start_row));
const detail_slice = try detail_lines.toOwnedSlice(arena);
if (detail_buf_start < buf.len) {
try self.drawStyledContent(arena, buf[detail_buf_start..], width, remaining_height, detail_slice);
}
}
}
}
fn buildQuoteStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine { fn buildQuoteStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine {
const th = self.theme; const th = self.theme;
var lines: std.ArrayList(StyledLine) = .empty; var lines: std.ArrayList(StyledLine) = .empty;
@ -1508,12 +1857,13 @@ const App = struct {
const latest = c[c.len - 1]; const latest = c[c.len - 1];
var date_buf: [10]u8 = undefined; var date_buf: [10]u8 = undefined;
var close_buf: [24]u8 = undefined; var close_buf: [24]u8 = undefined;
var vol_buf: [32]u8 = undefined;
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Date: {s}", .{latest.date.format(&date_buf)}), .style = th.contentStyle() }); try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Date: {s}", .{latest.date.format(&date_buf)}), .style = th.contentStyle() });
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Price: {s}", .{fmtMoney(&close_buf, price)}), .style = th.contentStyle() }); try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Price: {s}", .{fmtMoney(&close_buf, price)}), .style = th.contentStyle() });
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Open: ${d:.2}", .{if (quote_data) |q| q.open else latest.open}), .style = th.mutedStyle() }); try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Open: ${d:.2}", .{if (quote_data) |q| q.open else latest.open}), .style = th.mutedStyle() });
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " High: ${d:.2}", .{if (quote_data) |q| q.high else latest.high}), .style = th.mutedStyle() }); try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " High: ${d:.2}", .{if (quote_data) |q| q.high else latest.high}), .style = th.mutedStyle() });
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Low: ${d:.2}", .{if (quote_data) |q| q.low else latest.low}), .style = th.mutedStyle() }); try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Low: ${d:.2}", .{if (quote_data) |q| q.low else latest.low}), .style = th.mutedStyle() });
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Volume: {d}", .{if (quote_data) |q| q.volume else latest.volume}), .style = th.mutedStyle() }); try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Volume: {s}", .{fmtIntCommas(&vol_buf, if (quote_data) |q| q.volume else latest.volume)}), .style = th.mutedStyle() });
if (prev_close > 0) { if (prev_close > 0) {
const change = price - prev_close; const change = price - prev_close;
@ -1540,9 +1890,10 @@ const App = struct {
const start_idx = if (c.len > 20) c.len - 20 else 0; const start_idx = if (c.len > 20) c.len - 20 else 0;
for (c[start_idx..]) |candle| { for (c[start_idx..]) |candle| {
var db: [10]u8 = undefined; var db: [10]u8 = undefined;
var vb: [32]u8 = undefined;
const day_change = if (candle.close >= candle.open) th.positiveStyle() else th.negativeStyle(); const day_change = if (candle.close >= candle.open) th.positiveStyle() else th.negativeStyle();
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:>12} {d:>10.2} {d:>10.2} {d:>10.2} {d:>10.2} {d:>12}", .{ try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:>12} {d:>10.2} {d:>10.2} {d:>10.2} {d:>10.2} {s:>12}", .{
candle.date.format(&db), candle.open, candle.high, candle.low, candle.close, candle.volume, candle.date.format(&db), candle.open, candle.high, candle.low, candle.close, fmtIntCommas(&vb, candle.volume),
}), .style = day_change }); }), .style = day_change });
} }
@ -1722,8 +2073,13 @@ const App = struct {
var price_buf: [24]u8 = undefined; var price_buf: [24]u8 = undefined;
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Underlying: {s} {d} expiration(s) +/- {d} strikes NTM (Ctrl+1-9 to change)", .{ fmtMoney(&price_buf, price), chains.len, self.options_near_the_money }), .style = th.contentStyle() }); try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Underlying: {s} {d} expiration(s) +/- {d} strikes NTM (Ctrl+1-9 to change)", .{ fmtMoney(&price_buf, price), chains.len, self.options_near_the_money }), .style = th.contentStyle() });
} }
try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
// Track header line count for mouse click mapping (after all non-data lines)
self.options_header_lines = lines.items.len;
// Flat list of options rows with inline expand/collapse // Flat list of options rows with inline expand/collapse
for (self.options_rows.items, 0..) |row, ri| { for (self.options_rows.items, 0..) |row, ri| {
const is_cursor = ri == self.options_cursor; const is_cursor = ri == self.options_cursor;
@ -1747,34 +2103,26 @@ const App = struct {
}, },
.calls_header => { .calls_header => {
const calls_collapsed = row.exp_idx < self.options_calls_collapsed.len and self.options_calls_collapsed[row.exp_idx]; const calls_collapsed = row.exp_idx < self.options_calls_collapsed.len and self.options_calls_collapsed[row.exp_idx];
const arrow: []const u8 = if (calls_collapsed) " > " else " v "; const arrow: []const u8 = if (calls_collapsed) " > " else " v ";
const style = if (is_cursor) th.selectStyle() else th.headerStyle(); const style = if (is_cursor) th.selectStyle() else th.headerStyle();
if (calls_collapsed) { try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, "{s}{s:>10} {s:>10} {s:>10} {s:>10} {s:>10} {s:>8} {s:>8} Calls", .{
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, "{s}Calls (collapsed, Enter to expand)", .{arrow}), .style = style }); arrow, "Strike", "Last", "Bid", "Ask", "Volume", "OI", "IV",
} else { }), .style = style });
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, "{s}{s:>10} {s:>10} {s:>10} {s:>10} {s:>10} {s:>8} {s:>8} Calls", .{
arrow, "Strike", "Last", "Bid", "Ask", "Volume", "OI", "IV",
}), .style = style });
}
}, },
.puts_header => { .puts_header => {
const puts_collapsed = row.exp_idx < self.options_puts_collapsed.len and self.options_puts_collapsed[row.exp_idx]; const puts_collapsed = row.exp_idx < self.options_puts_collapsed.len and self.options_puts_collapsed[row.exp_idx];
const arrow: []const u8 = if (puts_collapsed) " > " else " v "; const arrow: []const u8 = if (puts_collapsed) " > " else " v ";
try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
const style = if (is_cursor) th.selectStyle() else th.headerStyle(); const style = if (is_cursor) th.selectStyle() else th.headerStyle();
if (puts_collapsed) { try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, "{s}{s:>10} {s:>10} {s:>10} {s:>10} {s:>10} {s:>8} {s:>8} Puts", .{
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, "{s}Puts (collapsed, Enter to expand)", .{arrow}), .style = style }); arrow, "Strike", "Last", "Bid", "Ask", "Volume", "OI", "IV",
} else { }), .style = style });
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, "{s}{s:>10} {s:>10} {s:>10} {s:>10} {s:>10} {s:>8} {s:>8} Puts", .{
arrow, "Strike", "Last", "Bid", "Ask", "Volume", "OI", "IV",
}), .style = style });
}
}, },
.call => { .call => {
if (row.contract) |cc| { if (row.contract) |cc| {
const atm_price = chains[0].underlying_price orelse 0; const atm_price = chains[0].underlying_price orelse 0;
const itm = cc.strike <= atm_price; const itm = cc.strike <= atm_price;
const prefix: []const u8 = if (itm) " |" else " "; const prefix: []const u8 = if (itm) " |" else " ";
const text = try fmtContractLine(arena, prefix, cc); const text = try fmtContractLine(arena, prefix, cc);
const style = if (is_cursor) th.selectStyle() else th.contentStyle(); const style = if (is_cursor) th.selectStyle() else th.contentStyle();
try lines.append(arena, .{ .text = text, .style = style }); try lines.append(arena, .{ .text = text, .style = style });
@ -1784,7 +2132,7 @@ const App = struct {
if (row.contract) |p| { if (row.contract) |p| {
const atm_price = chains[0].underlying_price orelse 0; const atm_price = chains[0].underlying_price orelse 0;
const itm = p.strike >= atm_price; const itm = p.strike >= atm_price;
const prefix: []const u8 = if (itm) " |" else " "; const prefix: []const u8 = if (itm) " |" else " ";
const text = try fmtContractLine(arena, prefix, p); const text = try fmtContractLine(arena, prefix, p);
const style = if (is_cursor) th.selectStyle() else th.contentStyle(); const style = if (is_cursor) th.selectStyle() else th.contentStyle();
try lines.append(arena, .{ .text = text, .style = style }); try lines.append(arena, .{ .text = text, .style = style });
@ -1899,6 +2247,7 @@ const App = struct {
"Filter +/- 1 NTM", "Filter +/- 2 NTM", "Filter +/- 3 NTM", "Filter +/- 1 NTM", "Filter +/- 2 NTM", "Filter +/- 3 NTM",
"Filter +/- 4 NTM", "Filter +/- 5 NTM", "Filter +/- 6 NTM", "Filter +/- 4 NTM", "Filter +/- 5 NTM", "Filter +/- 6 NTM",
"Filter +/- 7 NTM", "Filter +/- 8 NTM", "Filter +/- 9 NTM", "Filter +/- 7 NTM", "Filter +/- 8 NTM", "Filter +/- 9 NTM",
"Chart: next timeframe", "Chart: prev timeframe",
}; };
for (actions, 0..) |action, ai| { for (actions, 0..) |action, ai| {
@ -2023,6 +2372,33 @@ fn fmtMoney2(buf: []u8, amount: f64) []const u8 {
return std.fmt.bufPrint(buf, "${d:.2}", .{amount}) catch "$?"; return std.fmt.bufPrint(buf, "${d:.2}", .{amount}) catch "$?";
} }
/// Format an integer with commas (e.g. 1234567 "1,234,567").
fn fmtIntCommas(buf: []u8, value: u64) []const u8 {
var tmp: [32]u8 = undefined;
var pos: usize = tmp.len;
var v = value;
var digit_count: usize = 0;
if (v == 0) {
pos -= 1;
tmp[pos] = '0';
} else {
while (v > 0) {
if (digit_count > 0 and digit_count % 3 == 0) {
pos -= 1;
tmp[pos] = ',';
}
pos -= 1;
tmp[pos] = '0' + @as(u8, @intCast(v % 10));
v /= 10;
digit_count += 1;
}
}
const len = tmp.len - pos;
if (len > buf.len) return "?";
@memcpy(buf[0..len], tmp[pos..]);
return buf[0..len];
}
/// Format a unix timestamp as relative time ("just now", "5m ago", "2h ago", "3d ago"). /// Format a unix timestamp as relative time ("just now", "5m ago", "2h ago", "3d ago").
fn fmtTimeAgo(buf: []u8, timestamp: i64) []const u8 { fn fmtTimeAgo(buf: []u8, timestamp: i64) []const u8 {
if (timestamp == 0) return ""; if (timestamp == 0) return "";
@ -2326,6 +2702,7 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, args: []const []co
var symbol: []const u8 = ""; var symbol: []const u8 = "";
var has_explicit_symbol = false; var has_explicit_symbol = false;
var skip_watchlist = false; var skip_watchlist = false;
var chart_config: chart_mod.ChartConfig = .{};
var i: usize = 2; var i: usize = 2;
while (i < args.len) : (i += 1) { while (i < args.len) : (i += 1) {
if (std.mem.eql(u8, args[i], "--default-keys")) { if (std.mem.eql(u8, args[i], "--default-keys")) {
@ -2353,6 +2730,13 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, args: []const []co
has_explicit_symbol = true; has_explicit_symbol = true;
skip_watchlist = true; skip_watchlist = true;
} }
} else if (std.mem.eql(u8, args[i], "--chart")) {
if (i + 1 < args.len) {
i += 1;
if (chart_mod.ChartConfig.parse(args[i])) |cc| {
chart_config = cc;
}
}
} else if (args[i].len > 0 and args[i][0] != '-') { } else if (args[i].len > 0 and args[i][0] != '-') {
symbol = args[i]; symbol = args[i];
has_explicit_symbol = true; has_explicit_symbol = true;
@ -2398,6 +2782,7 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, args: []const []co
.portfolio_path = portfolio_path, .portfolio_path = portfolio_path,
.symbol = symbol, .symbol = symbol,
.has_explicit_symbol = has_explicit_symbol, .has_explicit_symbol = has_explicit_symbol,
.chart_config = chart_config,
}; };
if (portfolio_path) |path| { if (portfolio_path) |path| {
@ -2433,6 +2818,15 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, args: []const []co
{ {
var vx_app = try vaxis.vxfw.App.init(allocator); var vx_app = try vaxis.vxfw.App.init(allocator);
defer vx_app.deinit(); defer vx_app.deinit();
app_inst.vx_app = &vx_app;
defer app_inst.vx_app = null;
defer {
// Free any chart image before vaxis is torn down
if (app_inst.chart_image_id) |id| {
vx_app.vx.freeImage(vx_app.tty.writer(), id);
app_inst.chart_image_id = null;
}
}
try vx_app.run(app_inst.widget(), .{}); try vx_app.run(app_inst.widget(), .{});
} }
// vx_app is fully torn down here (terminal restored to cooked mode) // vx_app is fully torn down here (terminal restored to cooked mode)