ai: bug fixes and charting
This commit is contained in:
parent
3b66e38152
commit
4d093b86bf
6 changed files with 467 additions and 56 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ const usage =
|
|||
\\ -p, --portfolio <FILE> Portfolio file (.srf)
|
||||
\\ -w, --watchlist <FILE> Watchlist file (default: watchlist.srf)
|
||||
\\ -s, --symbol <SYMBOL> Initial symbol (default: VTI)
|
||||
\\ --chart <MODE> Chart graphics: auto, braille, or WxH (e.g. 1920x1080)
|
||||
\\ --default-keys Print default keybindings
|
||||
\\ --default-theme Print default theme
|
||||
\\
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
506
src/tui/main.zig
506
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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue