From 4d093b86bf50c695aa4a5ed91b288fa3d38b9181 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Wed, 25 Feb 2026 17:02:19 -0800 Subject: [PATCH] ai: bug fixes and charting --- build.zig | 7 + build.zig.zon | 4 + src/cli/main.zig | 1 + src/root.zig | 1 + src/tui/keybinds.zig | 4 + src/tui/main.zig | 506 ++++++++++++++++++++++++++++++++++++++----- 6 files changed, 467 insertions(+), 56 deletions(-) diff --git a/build.zig b/build.zig index 6752514..b7d9979 100644 --- a/build.zig +++ b/build.zig @@ -15,6 +15,11 @@ pub fn build(b: *std.Build) void { .optimize = optimize, }); + const z2d_dep = b.dependency("z2d", .{ + .target = target, + .optimize = optimize, + }); + // Library module -- the public API for consumers of zfin const mod = b.addModule("zfin", .{ .root_source_file = b.path("src/root.zig"), @@ -32,6 +37,7 @@ pub fn build(b: *std.Build) void { .{ .name = "zfin", .module = mod }, .{ .name = "srf", .module = srf_dep.module("srf") }, .{ .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 = "srf", .module = srf_dep.module("srf") }, .{ .name = "vaxis", .module = vaxis_dep.module("vaxis") }, + .{ .name = "z2d", .module = z2d_dep.module("z2d") }, }, }) }); test_step.dependOn(&b.addRunArtifact(tui_tests).step); diff --git a/build.zig.zon b/build.zig.zon index 3644e9d..1c06d0f 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -11,6 +11,10 @@ .url = "git+https://github.com/rockorager/libvaxis.git#67bbc1ee072aa390838c66caf4ed47edee282dc4", .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 = .{ "build.zig", diff --git a/src/cli/main.zig b/src/cli/main.zig index 63aeb6f..9e4ae82 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -23,6 +23,7 @@ const usage = \\ -p, --portfolio Portfolio file (.srf) \\ -w, --watchlist Watchlist file (default: watchlist.srf) \\ -s, --symbol Initial symbol (default: VTI) + \\ --chart Chart graphics: auto, braille, or WxH (e.g. 1920x1080) \\ --default-keys Print default keybindings \\ --default-theme Print default theme \\ diff --git a/src/root.zig b/src/root.zig index 5118447..942c3c2 100644 --- a/src/root.zig +++ b/src/root.zig @@ -36,6 +36,7 @@ pub const cache = @import("cache/store.zig"); // -- Analytics -- pub const performance = @import("analytics/performance.zig"); pub const risk = @import("analytics/risk.zig"); +pub const indicators = @import("analytics/indicators.zig"); // -- Service layer -- pub const DataService = @import("service.zig").DataService; diff --git a/src/tui/keybinds.zig b/src/tui/keybinds.zig index 56989ae..45ad087 100644 --- a/src/tui/keybinds.zig +++ b/src/tui/keybinds.zig @@ -36,6 +36,8 @@ pub const Action = enum { options_filter_7, options_filter_8, options_filter_9, + chart_timeframe_next, + chart_timeframe_prev, }; 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_8, .key = .{ .codepoint = '8', .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 { diff --git a/src/tui/main.zig b/src/tui/main.zig index 83aa89c..2b9eeeb 100644 --- a/src/tui/main.zig +++ b/src/tui/main.zig @@ -3,6 +3,7 @@ const vaxis = @import("vaxis"); const zfin = @import("zfin"); const keybinds = @import("keybinds.zig"); const theme_mod = @import("theme.zig"); +const chart_mod = @import("chart.zig"); /// Comptime-generated table of single-character grapheme slices with static lifetime. /// 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 expanded: [64]bool = [_]bool{false} ** 64, // which positions are expanded 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_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_near_the_money: usize = 8, // +/- strikes from ATM options_rows: std.ArrayList(OptionsRow) = .empty, - - // Double-click tracking - last_click_row: usize = 0, - last_click_time: i64 = 0, + options_header_lines: usize = 0, // number of styled lines before data rows // Cached data for rendering candles: ?[]zfin.Candle = null, @@ -159,6 +158,21 @@ const App = struct { // Signal to the run loop to launch $EDITOR then restart 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 { return .{ .userdata = self, @@ -235,44 +249,33 @@ const App = struct { } } if (self.active_tab == .portfolio and mouse.row > 0) { - const content_row = @as(usize, @intCast(mouse.row)) - 1 + self.scroll_offset; - if (content_row >= 4 and self.portfolio_rows.items.len > 0) { - const row_idx = content_row - 4; + const content_row = @as(usize, @intCast(mouse.row)) + self.scroll_offset; + if (content_row >= self.portfolio_header_lines and self.portfolio_rows.items.len > 0) { + const row_idx = content_row - self.portfolio_header_lines; if (row_idx < self.portfolio_rows.items.len) { - const now_ms = std.time.milliTimestamp(); - if (row_idx == self.last_click_row and (now_ms - self.last_click_time) < 500) { - // 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; - } + self.cursor = row_idx; + self.toggleExpand(); 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) { - const content_row = @as(usize, @intCast(mouse.row)) - 1 + self.scroll_offset; - // Options rows start after header lines (blank, header, underlying, blank, col header = 5 lines) - if (content_row >= 5 and self.options_rows.items.len > 0) { - const row_idx = content_row - 5; - if (row_idx < self.options_rows.items.len) { - const now_ms = std.time.milliTimestamp(); - if (row_idx == self.last_click_row and (now_ms - self.last_click_time) < 500) { - // Double-click: expand/collapse - self.options_cursor = row_idx; + const content_row = @as(usize, @intCast(mouse.row)) + self.scroll_offset; + if (content_row >= self.options_header_lines and self.options_rows.items.len > 0) { + // Walk options_rows tracking styled line position to find which + // row was clicked. Each row = 1 styled line, except puts_header + // which emits an extra blank line before it. + const target_line = content_row - self.options_header_lines; + var current_line: usize = 0; + for (self.options_rows.items, 0..) |orow, oi| { + if (orow.kind == .puts_header) current_line += 1; // extra blank + if (current_line == target_line) { + self.options_cursor = oi; self.toggleOptionsExpand(); - self.last_click_time = 0; - } else { - self.options_cursor = row_idx; - self.last_click_row = row_idx; - self.last_click_time = now_ms; + return ctx.consumeAndRedraw(); } - return ctx.consumeAndRedraw(); + current_line += 1; } } } @@ -484,6 +487,22 @@ const App = struct { 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.risk_metrics = null; self.scroll_offset = 0; + self.chart_dirty = true; } fn refreshCurrentTab(self: *App) void { @@ -710,6 +730,7 @@ const App = struct { self.perf_loaded = false; self.freeCandles(); self.freeDividends(); + self.chart_dirty = true; }, .earnings => { self.earnings_loaded = false; @@ -1156,7 +1177,7 @@ const App = struct { } else { switch (self.active_tab) { .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)), .options => try self.drawOptionsContent(ctx.arena, buf, width, height), .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() }); + // Track header line count for mouse click mapping (after all header lines) + self.portfolio_header_lines = lines.items.len; + // Data rows for (self.portfolio_rows.items, 0..) |row, ri| { const is_cursor = ri == self.cursor; @@ -1454,6 +1478,331 @@ const App = struct { // ── 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 { const th = self.theme; var lines: std.ArrayList(StyledLine) = .empty; @@ -1508,12 +1857,13 @@ const App = struct { const latest = c[c.len - 1]; var date_buf: [10]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, " 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, " 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, " 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) { const change = price - prev_close; @@ -1540,9 +1890,10 @@ const App = struct { const start_idx = if (c.len > 20) c.len - 20 else 0; for (c[start_idx..]) |candle| { var db: [10]u8 = undefined; + var vb: [32]u8 = undefined; 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}", .{ - candle.date.format(&db), candle.open, candle.high, candle.low, candle.close, candle.volume, + 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, fmtIntCommas(&vb, candle.volume), }), .style = day_change }); } @@ -1722,8 +2073,13 @@ const App = struct { 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 = "", .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 for (self.options_rows.items, 0..) |row, ri| { const is_cursor = ri == self.options_cursor; @@ -1747,34 +2103,26 @@ const App = struct { }, .calls_header => { 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(); - if (calls_collapsed) { - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, "{s}Calls (collapsed, Enter to expand)", .{arrow}), .style = style }); - } else { - 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 }); - } + 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 => { 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() }); const style = if (is_cursor) th.selectStyle() else th.headerStyle(); - if (puts_collapsed) { - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, "{s}Puts (collapsed, Enter to expand)", .{arrow}), .style = style }); - } else { - 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 }); - } + 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 => { if (row.contract) |cc| { const atm_price = chains[0].underlying_price orelse 0; 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 style = if (is_cursor) th.selectStyle() else th.contentStyle(); try lines.append(arena, .{ .text = text, .style = style }); @@ -1784,7 +2132,7 @@ const App = struct { if (row.contract) |p| { const atm_price = chains[0].underlying_price orelse 0; 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 style = if (is_cursor) th.selectStyle() else th.contentStyle(); 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 +/- 4 NTM", "Filter +/- 5 NTM", "Filter +/- 6 NTM", "Filter +/- 7 NTM", "Filter +/- 8 NTM", "Filter +/- 9 NTM", + "Chart: next timeframe", "Chart: prev timeframe", }; 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 "$?"; } +/// 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"). fn fmtTimeAgo(buf: []u8, timestamp: i64) []const u8 { 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 has_explicit_symbol = false; var skip_watchlist = false; + var chart_config: chart_mod.ChartConfig = .{}; var i: usize = 2; while (i < args.len) : (i += 1) { 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; 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] != '-') { symbol = args[i]; 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, .symbol = symbol, .has_explicit_symbol = has_explicit_symbol, + .chart_config = chart_config, }; 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); 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(), .{}); } // vx_app is fully torn down here (terminal restored to cooked mode)