3073 lines
140 KiB
Zig
3073 lines
140 KiB
Zig
const std = @import("std");
|
|
const vaxis = @import("vaxis");
|
|
const zfin = @import("zfin");
|
|
const fmt = zfin.format;
|
|
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.
|
|
const ascii_g = blk: {
|
|
var table: [128][]const u8 = undefined;
|
|
for (0..128) |i| {
|
|
const ch: [1]u8 = .{@as(u8, @intCast(i))};
|
|
table[i] = &ch;
|
|
}
|
|
break :blk table;
|
|
};
|
|
|
|
fn glyph(ch: u8) []const u8 {
|
|
if (ch < 128) return ascii_g[ch];
|
|
return " ";
|
|
}
|
|
|
|
/// Return a string of `n` spaces using the arena allocator.
|
|
fn allocSpaces(arena: std.mem.Allocator, n: usize) ![]const u8 {
|
|
const buf = try arena.alloc(u8, n);
|
|
@memset(buf, ' ');
|
|
return buf;
|
|
}
|
|
|
|
const Tab = enum {
|
|
portfolio,
|
|
quote,
|
|
performance,
|
|
options,
|
|
earnings,
|
|
|
|
fn label(self: Tab) []const u8 {
|
|
return switch (self) {
|
|
.portfolio => " 1:Portfolio ",
|
|
.quote => " 2:Quote ",
|
|
.performance => " 3:Performance ",
|
|
.options => " 4:Options ",
|
|
.earnings => " 5:Earnings ",
|
|
};
|
|
}
|
|
};
|
|
|
|
const tabs = [_]Tab{ .portfolio, .quote, .performance, .options, .earnings };
|
|
|
|
const InputMode = enum {
|
|
normal,
|
|
symbol_input,
|
|
help,
|
|
};
|
|
|
|
/// A row in the portfolio view -- position header, lot detail, or special sections.
|
|
const PortfolioRow = struct {
|
|
kind: Kind,
|
|
symbol: []const u8,
|
|
/// For position rows: index into allocations; for lot rows: lot data.
|
|
pos_idx: usize = 0,
|
|
lot: ?zfin.Lot = null,
|
|
/// Number of lots for this symbol (set on position rows)
|
|
lot_count: usize = 0,
|
|
|
|
const Kind = enum { position, lot, watchlist, section_header, option_row, cd_row, cash_row, cash_total };
|
|
};
|
|
|
|
/// Styled line for rendering
|
|
const StyledLine = struct {
|
|
text: []const u8,
|
|
style: vaxis.Style,
|
|
// Optional per-character style override ranges (for mixed-color lines)
|
|
alt_text: ?[]const u8 = null, // text for the gain/loss column
|
|
alt_style: ?vaxis.Style = null,
|
|
alt_start: usize = 0,
|
|
alt_end: usize = 0,
|
|
// Optional pre-encoded grapheme array for multi-byte Unicode (e.g. braille charts).
|
|
// When set, each element is a grapheme string for one column position.
|
|
graphemes: ?[]const []const u8 = null,
|
|
// Optional per-cell style array (same length as graphemes). Enables color gradients.
|
|
cell_styles: ?[]const vaxis.Style = null,
|
|
};
|
|
|
|
const OptionsRowKind = enum { expiration, calls_header, puts_header, call, put };
|
|
|
|
/// A row in the flattened options view (expiration header or contract sub-row).
|
|
const OptionsRow = struct {
|
|
kind: OptionsRowKind,
|
|
exp_idx: usize = 0, // index into options_data chains
|
|
contract: ?zfin.OptionContract = null,
|
|
};
|
|
|
|
const App = struct {
|
|
allocator: std.mem.Allocator,
|
|
config: zfin.Config,
|
|
svc: *zfin.DataService,
|
|
keymap: keybinds.KeyMap,
|
|
theme: theme_mod.Theme,
|
|
active_tab: Tab = .portfolio,
|
|
symbol: []const u8 = "",
|
|
symbol_buf: [16]u8 = undefined,
|
|
symbol_owned: bool = false,
|
|
scroll_offset: usize = 0,
|
|
visible_height: u16 = 24, // updated each draw
|
|
|
|
has_explicit_symbol: bool = false, // true if -s was used
|
|
|
|
portfolio: ?zfin.Portfolio = null,
|
|
portfolio_path: ?[]const u8 = null,
|
|
watchlist: ?[][]const u8 = null,
|
|
watchlist_path: ?[]const u8 = null,
|
|
status_msg: [256]u8 = undefined,
|
|
status_len: usize = 0,
|
|
|
|
// Input mode state
|
|
mode: InputMode = .normal,
|
|
input_buf: [16]u8 = undefined,
|
|
input_len: usize = 0,
|
|
|
|
// Portfolio navigation
|
|
cursor: usize = 0, // selected row in portfolio view
|
|
expanded: [64]bool = [_]bool{false} ** 64, // which positions are expanded
|
|
cash_expanded: bool = false, // whether cash section is expanded to show per-account
|
|
portfolio_rows: std.ArrayList(PortfolioRow) = .empty,
|
|
portfolio_header_lines: usize = 0, // number of styled lines before data rows
|
|
portfolio_line_to_row: [256]usize = [_]usize{0} ** 256, // maps styled line index -> portfolio_rows index
|
|
portfolio_line_count: usize = 0, // total styled lines in portfolio view
|
|
|
|
// Options navigation (inline expand/collapse like portfolio)
|
|
options_cursor: usize = 0, // selected row in flattened options view
|
|
options_expanded: [64]bool = [_]bool{false} ** 64, // which expirations are expanded
|
|
options_calls_collapsed: [64]bool = [_]bool{false} ** 64, // per-expiration: calls 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_rows: std.ArrayList(OptionsRow) = .empty,
|
|
options_header_lines: usize = 0, // number of styled lines before data rows
|
|
|
|
// Cached data for rendering
|
|
candles: ?[]zfin.Candle = null,
|
|
dividends: ?[]zfin.Dividend = null,
|
|
earnings_data: ?[]zfin.EarningsEvent = null,
|
|
options_data: ?[]zfin.OptionsChain = null,
|
|
portfolio_summary: ?zfin.risk.PortfolioSummary = null,
|
|
risk_metrics: ?zfin.risk.RiskMetrics = null,
|
|
trailing_price: ?zfin.performance.TrailingReturns = null,
|
|
trailing_total: ?zfin.performance.TrailingReturns = null,
|
|
trailing_me_price: ?zfin.performance.TrailingReturns = null,
|
|
trailing_me_total: ?zfin.performance.TrailingReturns = null,
|
|
candle_count: usize = 0,
|
|
candle_first_date: ?zfin.Date = null,
|
|
candle_last_date: ?zfin.Date = null,
|
|
data_error: ?[]const u8 = null,
|
|
perf_loaded: bool = false,
|
|
earnings_loaded: bool = false,
|
|
options_loaded: bool = false,
|
|
portfolio_loaded: bool = false,
|
|
// Data timestamps (unix seconds)
|
|
candle_timestamp: i64 = 0,
|
|
options_timestamp: i64 = 0,
|
|
earnings_timestamp: i64 = 0,
|
|
// Stored real-time quote (only fetched on manual refresh)
|
|
quote: ?zfin.Quote = null,
|
|
quote_timestamp: i64 = 0,
|
|
// Track whether earnings tab should be disabled (ETF, no data)
|
|
earnings_disabled: bool = false,
|
|
// ETF profile (loaded lazily on quote tab)
|
|
etf_profile: ?zfin.EtfProfile = null,
|
|
etf_loaded: bool = false,
|
|
// 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,
|
|
.eventHandler = typeErasedEventHandler,
|
|
.drawFn = typeErasedDrawFn,
|
|
};
|
|
}
|
|
|
|
fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vaxis.vxfw.EventContext, event: vaxis.vxfw.Event) anyerror!void {
|
|
const self: *App = @ptrCast(@alignCast(ptr));
|
|
switch (event) {
|
|
.key_press => |key| {
|
|
if (self.mode == .symbol_input) {
|
|
return self.handleInputKey(ctx, key);
|
|
}
|
|
if (self.mode == .help) {
|
|
self.mode = .normal;
|
|
return ctx.consumeAndRedraw();
|
|
}
|
|
return self.handleNormalKey(ctx, key);
|
|
},
|
|
.mouse => |mouse| {
|
|
return self.handleMouse(ctx, mouse);
|
|
},
|
|
.init => {
|
|
self.loadTabData();
|
|
},
|
|
else => {},
|
|
}
|
|
}
|
|
|
|
fn handleMouse(self: *App, ctx: *vaxis.vxfw.EventContext, mouse: vaxis.Mouse) void {
|
|
switch (mouse.button) {
|
|
.wheel_up => {
|
|
if (self.active_tab == .portfolio) {
|
|
if (self.cursor > 0) self.cursor -= 1;
|
|
self.ensureCursorVisible();
|
|
} else if (self.active_tab == .options) {
|
|
if (self.options_cursor > 0) self.options_cursor -= 1;
|
|
self.ensureOptionsCursorVisible();
|
|
} else {
|
|
if (self.scroll_offset > 0) self.scroll_offset -= 3;
|
|
}
|
|
return ctx.consumeAndRedraw();
|
|
},
|
|
.wheel_down => {
|
|
if (self.active_tab == .portfolio) {
|
|
if (self.portfolio_rows.items.len > 0 and self.cursor < self.portfolio_rows.items.len - 1)
|
|
self.cursor += 1;
|
|
self.ensureCursorVisible();
|
|
} else if (self.active_tab == .options) {
|
|
if (self.options_rows.items.len > 0 and self.options_cursor < self.options_rows.items.len - 1)
|
|
self.options_cursor += 1;
|
|
self.ensureOptionsCursorVisible();
|
|
} else {
|
|
self.scroll_offset += 3;
|
|
}
|
|
return ctx.consumeAndRedraw();
|
|
},
|
|
.left => {
|
|
if (mouse.type == .press) {
|
|
if (mouse.row == 0) {
|
|
var col: i16 = 0;
|
|
for (tabs) |t| {
|
|
const lbl_len: i16 = @intCast(t.label().len);
|
|
if (mouse.col >= col and mouse.col < col + lbl_len) {
|
|
if (t == .earnings and self.earnings_disabled) return;
|
|
self.active_tab = t;
|
|
self.scroll_offset = 0;
|
|
self.loadTabData();
|
|
return ctx.consumeAndRedraw();
|
|
}
|
|
col += lbl_len;
|
|
}
|
|
}
|
|
if (self.active_tab == .portfolio and mouse.row > 0) {
|
|
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 line_idx = content_row - self.portfolio_header_lines;
|
|
if (line_idx < self.portfolio_line_count and line_idx < self.portfolio_line_to_row.len) {
|
|
const row_idx = self.portfolio_line_to_row[line_idx];
|
|
if (row_idx < self.portfolio_rows.items.len) {
|
|
self.cursor = row_idx;
|
|
self.toggleExpand();
|
|
return ctx.consumeAndRedraw();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// 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)) + 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();
|
|
return ctx.consumeAndRedraw();
|
|
}
|
|
current_line += 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
else => {},
|
|
}
|
|
}
|
|
|
|
fn handleInputKey(self: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) void {
|
|
if (key.codepoint == vaxis.Key.escape) {
|
|
self.mode = .normal;
|
|
self.input_len = 0;
|
|
self.setStatus("Cancelled");
|
|
return ctx.consumeAndRedraw();
|
|
}
|
|
if (key.codepoint == vaxis.Key.enter) {
|
|
if (self.input_len > 0) {
|
|
for (self.input_buf[0..self.input_len]) |*ch| {
|
|
if (ch.* >= 'a' and ch.* <= 'z') ch.* = ch.* - 32;
|
|
}
|
|
@memcpy(self.symbol_buf[0..self.input_len], self.input_buf[0..self.input_len]);
|
|
self.symbol = self.symbol_buf[0..self.input_len];
|
|
self.symbol_owned = true;
|
|
self.has_explicit_symbol = true;
|
|
self.resetSymbolData();
|
|
self.active_tab = .quote;
|
|
self.loadTabData();
|
|
}
|
|
self.mode = .normal;
|
|
self.input_len = 0;
|
|
return ctx.consumeAndRedraw();
|
|
}
|
|
if (key.codepoint == vaxis.Key.backspace) {
|
|
if (self.input_len > 0) self.input_len -= 1;
|
|
return ctx.consumeAndRedraw();
|
|
}
|
|
if (key.matches('u', .{ .ctrl = true })) {
|
|
self.input_len = 0;
|
|
return ctx.consumeAndRedraw();
|
|
}
|
|
if (key.codepoint >= 0x20 and key.codepoint < 0x7f and self.input_len < self.input_buf.len) {
|
|
self.input_buf[self.input_len] = @intCast(key.codepoint);
|
|
self.input_len += 1;
|
|
return ctx.consumeAndRedraw();
|
|
}
|
|
}
|
|
|
|
fn handleNormalKey(self: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) void {
|
|
// Escape: no special behavior needed (options is now inline)
|
|
if (key.codepoint == vaxis.Key.escape) {
|
|
return;
|
|
}
|
|
|
|
const action = self.keymap.matchAction(key) orelse return;
|
|
switch (action) {
|
|
.quit => {
|
|
ctx.quit = true;
|
|
},
|
|
.symbol_input => {
|
|
self.mode = .symbol_input;
|
|
self.input_len = 0;
|
|
return ctx.consumeAndRedraw();
|
|
},
|
|
.select_symbol => {
|
|
// 's' selects the current portfolio row's symbol as the active symbol
|
|
if (self.active_tab == .portfolio and self.portfolio_rows.items.len > 0 and self.cursor < self.portfolio_rows.items.len) {
|
|
const row = self.portfolio_rows.items[self.cursor];
|
|
self.setActiveSymbol(row.symbol);
|
|
// Format into a separate buffer to avoid aliasing with status_msg
|
|
var tmp_buf: [256]u8 = undefined;
|
|
const msg = std.fmt.bufPrint(&tmp_buf, "Active: {s}", .{row.symbol}) catch "Active";
|
|
self.setStatus(msg);
|
|
return ctx.consumeAndRedraw();
|
|
}
|
|
},
|
|
.refresh => {
|
|
self.refreshCurrentTab();
|
|
return ctx.consumeAndRedraw();
|
|
},
|
|
.prev_tab => {
|
|
self.prevTab();
|
|
self.scroll_offset = 0;
|
|
self.loadTabData();
|
|
return ctx.consumeAndRedraw();
|
|
},
|
|
.next_tab => {
|
|
self.nextTab();
|
|
self.scroll_offset = 0;
|
|
self.loadTabData();
|
|
return ctx.consumeAndRedraw();
|
|
},
|
|
.tab_1, .tab_2, .tab_3, .tab_4, .tab_5 => {
|
|
const idx = @intFromEnum(action) - @intFromEnum(keybinds.Action.tab_1);
|
|
if (idx < tabs.len) {
|
|
const target = tabs[idx];
|
|
if (target == .earnings and self.earnings_disabled) return;
|
|
self.active_tab = target;
|
|
self.scroll_offset = 0;
|
|
self.loadTabData();
|
|
return ctx.consumeAndRedraw();
|
|
}
|
|
},
|
|
.select_next => {
|
|
if (self.active_tab == .portfolio) {
|
|
if (self.portfolio_rows.items.len > 0 and self.cursor < self.portfolio_rows.items.len - 1)
|
|
self.cursor += 1;
|
|
self.ensureCursorVisible();
|
|
} else if (self.active_tab == .options) {
|
|
if (self.options_rows.items.len > 0 and self.options_cursor < self.options_rows.items.len - 1)
|
|
self.options_cursor += 1;
|
|
self.ensureOptionsCursorVisible();
|
|
} else {
|
|
self.scroll_offset += 1;
|
|
}
|
|
return ctx.consumeAndRedraw();
|
|
},
|
|
.select_prev => {
|
|
if (self.active_tab == .portfolio) {
|
|
if (self.cursor > 0) self.cursor -= 1;
|
|
self.ensureCursorVisible();
|
|
} else if (self.active_tab == .options) {
|
|
if (self.options_cursor > 0)
|
|
self.options_cursor -= 1;
|
|
self.ensureOptionsCursorVisible();
|
|
} else {
|
|
if (self.scroll_offset > 0) self.scroll_offset -= 1;
|
|
}
|
|
return ctx.consumeAndRedraw();
|
|
},
|
|
.expand_collapse => {
|
|
if (self.active_tab == .portfolio) {
|
|
self.toggleExpand();
|
|
return ctx.consumeAndRedraw();
|
|
} else if (self.active_tab == .options) {
|
|
self.toggleOptionsExpand();
|
|
return ctx.consumeAndRedraw();
|
|
}
|
|
},
|
|
.scroll_down => {
|
|
const half = @max(1, self.visible_height / 2);
|
|
self.scroll_offset += half;
|
|
return ctx.consumeAndRedraw();
|
|
},
|
|
.scroll_up => {
|
|
const half = @max(1, self.visible_height / 2);
|
|
if (self.scroll_offset > half) self.scroll_offset -= half else self.scroll_offset = 0;
|
|
return ctx.consumeAndRedraw();
|
|
},
|
|
.page_down => {
|
|
self.scroll_offset += self.visible_height;
|
|
return ctx.consumeAndRedraw();
|
|
},
|
|
.page_up => {
|
|
if (self.scroll_offset > self.visible_height)
|
|
self.scroll_offset -= self.visible_height
|
|
else
|
|
self.scroll_offset = 0;
|
|
return ctx.consumeAndRedraw();
|
|
},
|
|
.scroll_top => {
|
|
self.scroll_offset = 0;
|
|
if (self.active_tab == .portfolio) self.cursor = 0;
|
|
if (self.active_tab == .options) self.options_cursor = 0;
|
|
return ctx.consumeAndRedraw();
|
|
},
|
|
.scroll_bottom => {
|
|
self.scroll_offset = 999;
|
|
if (self.active_tab == .portfolio and self.portfolio_rows.items.len > 0)
|
|
self.cursor = self.portfolio_rows.items.len - 1;
|
|
if (self.active_tab == .options and self.options_rows.items.len > 0)
|
|
self.options_cursor = self.options_rows.items.len - 1;
|
|
return ctx.consumeAndRedraw();
|
|
},
|
|
.help => {
|
|
self.mode = .help;
|
|
self.scroll_offset = 0;
|
|
return ctx.consumeAndRedraw();
|
|
},
|
|
.edit => {
|
|
if (self.portfolio_path != null or self.watchlist_path != null) {
|
|
self.wants_edit = true;
|
|
ctx.quit = true;
|
|
} else {
|
|
self.setStatus("No portfolio or watchlist file to edit");
|
|
return ctx.consumeAndRedraw();
|
|
}
|
|
},
|
|
.collapse_all_calls => {
|
|
if (self.active_tab == .options) {
|
|
self.toggleAllCallsPuts(true);
|
|
return ctx.consumeAndRedraw();
|
|
}
|
|
},
|
|
.collapse_all_puts => {
|
|
if (self.active_tab == .options) {
|
|
self.toggleAllCallsPuts(false);
|
|
return ctx.consumeAndRedraw();
|
|
}
|
|
},
|
|
.options_filter_1, .options_filter_2, .options_filter_3, .options_filter_4, .options_filter_5, .options_filter_6, .options_filter_7, .options_filter_8, .options_filter_9 => {
|
|
if (self.active_tab == .options) {
|
|
const n = @intFromEnum(action) - @intFromEnum(keybinds.Action.options_filter_1) + 1;
|
|
self.options_near_the_money = n;
|
|
self.rebuildOptionsRows();
|
|
var tmp_buf: [256]u8 = undefined;
|
|
const msg = std.fmt.bufPrint(&tmp_buf, "Filtered to +/- {d} strikes NTM", .{n}) catch "Filtered";
|
|
self.setStatus(msg);
|
|
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();
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
fn ensureCursorVisible(self: *App) void {
|
|
const cursor_row = self.cursor + 4; // 4 header lines
|
|
if (cursor_row < self.scroll_offset) {
|
|
self.scroll_offset = cursor_row;
|
|
}
|
|
const vis: usize = self.visible_height;
|
|
if (cursor_row >= self.scroll_offset + vis) {
|
|
self.scroll_offset = cursor_row - vis + 1;
|
|
}
|
|
}
|
|
|
|
fn toggleExpand(self: *App) void {
|
|
if (self.portfolio_rows.items.len == 0) return;
|
|
if (self.cursor >= self.portfolio_rows.items.len) return;
|
|
const row = self.portfolio_rows.items[self.cursor];
|
|
switch (row.kind) {
|
|
.position => {
|
|
// Single-lot positions don't expand
|
|
if (row.lot_count <= 1) return;
|
|
if (row.pos_idx < self.expanded.len) {
|
|
self.expanded[row.pos_idx] = !self.expanded[row.pos_idx];
|
|
self.rebuildPortfolioRows();
|
|
}
|
|
},
|
|
.lot, .option_row, .cd_row, .cash_row, .section_header => {},
|
|
.cash_total => {
|
|
self.cash_expanded = !self.cash_expanded;
|
|
self.rebuildPortfolioRows();
|
|
},
|
|
.watchlist => {
|
|
self.setActiveSymbol(row.symbol);
|
|
self.active_tab = .quote;
|
|
self.loadTabData();
|
|
},
|
|
}
|
|
}
|
|
|
|
fn toggleOptionsExpand(self: *App) void {
|
|
if (self.options_rows.items.len == 0) return;
|
|
if (self.options_cursor >= self.options_rows.items.len) return;
|
|
const row = self.options_rows.items[self.options_cursor];
|
|
switch (row.kind) {
|
|
.expiration => {
|
|
if (row.exp_idx < self.options_expanded.len) {
|
|
self.options_expanded[row.exp_idx] = !self.options_expanded[row.exp_idx];
|
|
self.rebuildOptionsRows();
|
|
}
|
|
},
|
|
.calls_header => {
|
|
if (row.exp_idx < self.options_calls_collapsed.len) {
|
|
self.options_calls_collapsed[row.exp_idx] = !self.options_calls_collapsed[row.exp_idx];
|
|
self.rebuildOptionsRows();
|
|
}
|
|
},
|
|
.puts_header => {
|
|
if (row.exp_idx < self.options_puts_collapsed.len) {
|
|
self.options_puts_collapsed[row.exp_idx] = !self.options_puts_collapsed[row.exp_idx];
|
|
self.rebuildOptionsRows();
|
|
}
|
|
},
|
|
// Clicking on a contract does nothing
|
|
else => {},
|
|
}
|
|
}
|
|
|
|
/// Toggle all calls (is_calls=true) or all puts (is_calls=false) collapsed state.
|
|
fn toggleAllCallsPuts(self: *App, is_calls: bool) void {
|
|
const chains = self.options_data orelse return;
|
|
// Determine whether to collapse or expand: if any expanded chain has this section visible, collapse all; otherwise expand all
|
|
var any_visible = false;
|
|
for (chains, 0..) |_, ci| {
|
|
if (ci >= self.options_expanded.len) break;
|
|
if (!self.options_expanded[ci]) continue; // only count expanded expirations
|
|
if (is_calls) {
|
|
if (ci < self.options_calls_collapsed.len and !self.options_calls_collapsed[ci]) {
|
|
any_visible = true;
|
|
break;
|
|
}
|
|
} else {
|
|
if (ci < self.options_puts_collapsed.len and !self.options_puts_collapsed[ci]) {
|
|
any_visible = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
// If any are visible, collapse all; otherwise expand all
|
|
const new_state = any_visible;
|
|
for (chains, 0..) |_, ci| {
|
|
if (ci >= 64) break;
|
|
if (is_calls) {
|
|
self.options_calls_collapsed[ci] = new_state;
|
|
} else {
|
|
self.options_puts_collapsed[ci] = new_state;
|
|
}
|
|
}
|
|
self.rebuildOptionsRows();
|
|
if (is_calls) {
|
|
self.setStatus(if (new_state) "All calls collapsed" else "All calls expanded");
|
|
} else {
|
|
self.setStatus(if (new_state) "All puts collapsed" else "All puts expanded");
|
|
}
|
|
}
|
|
|
|
fn rebuildOptionsRows(self: *App) void {
|
|
self.options_rows.clearRetainingCapacity();
|
|
const chains = self.options_data orelse return;
|
|
const atm_price = if (chains.len > 0) chains[0].underlying_price orelse 0 else @as(f64, 0);
|
|
|
|
for (chains, 0..) |chain, ci| {
|
|
self.options_rows.append(self.allocator, .{
|
|
.kind = .expiration,
|
|
.exp_idx = ci,
|
|
}) catch continue;
|
|
|
|
if (ci < self.options_expanded.len and self.options_expanded[ci]) {
|
|
// Calls header (always shown, acts as toggle)
|
|
self.options_rows.append(self.allocator, .{
|
|
.kind = .calls_header,
|
|
.exp_idx = ci,
|
|
}) catch continue;
|
|
|
|
// Calls contracts (only if not collapsed)
|
|
if (!(ci < self.options_calls_collapsed.len and self.options_calls_collapsed[ci])) {
|
|
const filtered_calls = fmt.filterNearMoney(chain.calls, atm_price, self.options_near_the_money);
|
|
for (filtered_calls) |cc| {
|
|
self.options_rows.append(self.allocator, .{
|
|
.kind = .call,
|
|
.exp_idx = ci,
|
|
.contract = cc,
|
|
}) catch continue;
|
|
}
|
|
}
|
|
|
|
// Puts header (always shown, acts as toggle)
|
|
self.options_rows.append(self.allocator, .{
|
|
.kind = .puts_header,
|
|
.exp_idx = ci,
|
|
}) catch continue;
|
|
|
|
// Puts contracts (only if not collapsed)
|
|
if (!(ci < self.options_puts_collapsed.len and self.options_puts_collapsed[ci])) {
|
|
const filtered_puts = fmt.filterNearMoney(chain.puts, atm_price, self.options_near_the_money);
|
|
for (filtered_puts) |p| {
|
|
self.options_rows.append(self.allocator, .{
|
|
.kind = .put,
|
|
.exp_idx = ci,
|
|
.contract = p,
|
|
}) catch continue;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn ensureOptionsCursorVisible(self: *App) void {
|
|
const cursor_row = self.options_cursor + 5; // 5 header lines in options content
|
|
if (cursor_row < self.scroll_offset) {
|
|
self.scroll_offset = cursor_row;
|
|
}
|
|
const vis: usize = self.visible_height;
|
|
if (cursor_row >= self.scroll_offset + vis) {
|
|
self.scroll_offset = cursor_row - vis + 1;
|
|
}
|
|
}
|
|
|
|
fn setActiveSymbol(self: *App, sym: []const u8) void {
|
|
const len = @min(sym.len, self.symbol_buf.len);
|
|
@memcpy(self.symbol_buf[0..len], sym[0..len]);
|
|
self.symbol = self.symbol_buf[0..len];
|
|
self.symbol_owned = true;
|
|
self.has_explicit_symbol = true;
|
|
self.resetSymbolData();
|
|
}
|
|
|
|
fn resetSymbolData(self: *App) void {
|
|
self.perf_loaded = false;
|
|
self.earnings_loaded = false;
|
|
self.earnings_disabled = false;
|
|
self.options_loaded = false;
|
|
self.etf_loaded = false;
|
|
self.options_cursor = 0;
|
|
self.options_expanded = [_]bool{false} ** 64;
|
|
self.options_calls_collapsed = [_]bool{false} ** 64;
|
|
self.options_puts_collapsed = [_]bool{false} ** 64;
|
|
self.options_rows.clearRetainingCapacity();
|
|
self.candle_timestamp = 0;
|
|
self.options_timestamp = 0;
|
|
self.earnings_timestamp = 0;
|
|
self.quote = null;
|
|
self.quote_timestamp = 0;
|
|
self.freeCandles();
|
|
self.freeDividends();
|
|
self.freeEarnings();
|
|
self.freeOptions();
|
|
self.freeEtfProfile();
|
|
self.trailing_price = null;
|
|
self.trailing_total = null;
|
|
self.trailing_me_price = null;
|
|
self.trailing_me_total = null;
|
|
self.risk_metrics = null;
|
|
self.scroll_offset = 0;
|
|
self.chart_dirty = true;
|
|
}
|
|
|
|
fn refreshCurrentTab(self: *App) void {
|
|
// Invalidate cache so the next load forces a fresh fetch
|
|
if (self.symbol.len > 0) {
|
|
switch (self.active_tab) {
|
|
.quote, .performance => {
|
|
self.svc.invalidate(self.symbol, .candles_daily);
|
|
self.svc.invalidate(self.symbol, .dividends);
|
|
},
|
|
.earnings => {
|
|
self.svc.invalidate(self.symbol, .earnings);
|
|
},
|
|
.options => {
|
|
self.svc.invalidate(self.symbol, .options);
|
|
},
|
|
.portfolio => {},
|
|
}
|
|
}
|
|
switch (self.active_tab) {
|
|
.portfolio => {
|
|
self.portfolio_loaded = false;
|
|
self.freePortfolioSummary();
|
|
},
|
|
.quote, .performance => {
|
|
self.perf_loaded = false;
|
|
self.freeCandles();
|
|
self.freeDividends();
|
|
self.chart_dirty = true;
|
|
},
|
|
.earnings => {
|
|
self.earnings_loaded = false;
|
|
self.freeEarnings();
|
|
},
|
|
.options => {
|
|
self.options_loaded = false;
|
|
self.freeOptions();
|
|
},
|
|
}
|
|
self.loadTabData();
|
|
|
|
// After reload, fetch live quote for active symbol (costs 1 API call)
|
|
switch (self.active_tab) {
|
|
.quote, .performance => {
|
|
if (self.symbol.len > 0) {
|
|
if (self.svc.getQuote(self.symbol)) |q| {
|
|
self.quote = q;
|
|
self.quote_timestamp = std.time.timestamp();
|
|
} else |_| {}
|
|
}
|
|
},
|
|
else => {},
|
|
}
|
|
}
|
|
|
|
fn loadTabData(self: *App) void {
|
|
self.data_error = null;
|
|
switch (self.active_tab) {
|
|
.portfolio => {
|
|
if (!self.portfolio_loaded) self.loadPortfolioData();
|
|
},
|
|
.quote, .performance => {
|
|
if (self.symbol.len == 0) return;
|
|
if (!self.perf_loaded) self.loadPerfData();
|
|
},
|
|
.earnings => {
|
|
if (self.symbol.len == 0) return;
|
|
if (self.earnings_disabled) return;
|
|
if (!self.earnings_loaded) self.loadEarningsData();
|
|
},
|
|
.options => {
|
|
if (self.symbol.len == 0) return;
|
|
if (!self.options_loaded) self.loadOptionsData();
|
|
},
|
|
}
|
|
}
|
|
|
|
fn loadPortfolioData(self: *App) void {
|
|
self.portfolio_loaded = true;
|
|
self.freePortfolioSummary();
|
|
|
|
// Fetch data for watchlist symbols so they have prices to display
|
|
if (self.watchlist) |wl| {
|
|
for (wl) |sym| {
|
|
const result = self.svc.getCandles(sym) catch continue;
|
|
self.allocator.free(result.data);
|
|
}
|
|
}
|
|
|
|
const pf = self.portfolio orelse return;
|
|
|
|
const positions = pf.positions(self.allocator) catch {
|
|
self.setStatus("Error computing positions");
|
|
return;
|
|
};
|
|
defer self.allocator.free(positions);
|
|
|
|
var prices = std.StringHashMap(f64).init(self.allocator);
|
|
defer prices.deinit();
|
|
|
|
// Only fetch prices for stock/ETF symbols (skip options, CDs, cash)
|
|
const syms = pf.stockSymbols(self.allocator) catch {
|
|
self.setStatus("Error getting symbols");
|
|
return;
|
|
};
|
|
defer self.allocator.free(syms);
|
|
|
|
var latest_date: ?zfin.Date = null;
|
|
var fail_count: usize = 0;
|
|
for (syms) |sym| {
|
|
// Try cache first; if miss, fetch (handles new securities / stale cache)
|
|
const candles_slice = self.svc.getCachedCandles(sym) orelse blk: {
|
|
const result = self.svc.getCandles(sym) catch {
|
|
fail_count += 1;
|
|
break :blk null;
|
|
};
|
|
break :blk result.data;
|
|
};
|
|
if (candles_slice) |cs| {
|
|
defer self.allocator.free(cs);
|
|
if (cs.len > 0) {
|
|
prices.put(sym, cs[cs.len - 1].close) catch {};
|
|
const d = cs[cs.len - 1].date;
|
|
if (latest_date == null or d.days > latest_date.?.days) latest_date = d;
|
|
}
|
|
}
|
|
}
|
|
self.candle_last_date = latest_date;
|
|
|
|
var summary = zfin.risk.portfolioSummary(self.allocator, positions, prices) catch {
|
|
self.setStatus("Error computing portfolio summary");
|
|
return;
|
|
};
|
|
|
|
if (summary.allocations.len == 0) {
|
|
summary.deinit(self.allocator);
|
|
self.setStatus("No cached prices. Run: zfin perf <SYMBOL> first");
|
|
return;
|
|
}
|
|
|
|
// Include non-stock assets in the grand total
|
|
// Cash and CDs add equally to value and cost (no gain/loss),
|
|
// options add at cost basis (no live pricing).
|
|
// This keeps unrealized_pnl correct (only stocks contribute market gains)
|
|
// but dilutes the return% against the full portfolio cost base.
|
|
const cash_total = pf.totalCash();
|
|
const cd_total = pf.totalCdFaceValue();
|
|
const opt_total = pf.totalOptionCost();
|
|
const non_stock = cash_total + cd_total + opt_total;
|
|
summary.total_value += non_stock;
|
|
summary.total_cost += non_stock;
|
|
if (summary.total_cost > 0) {
|
|
summary.unrealized_return = summary.unrealized_pnl / summary.total_cost;
|
|
}
|
|
// Reweight allocations against grand total
|
|
if (summary.total_value > 0) {
|
|
for (summary.allocations) |*a| {
|
|
a.weight = a.market_value / summary.total_value;
|
|
}
|
|
}
|
|
|
|
self.portfolio_summary = summary;
|
|
self.rebuildPortfolioRows();
|
|
|
|
if (self.symbol.len == 0 and summary.allocations.len > 0) {
|
|
self.setActiveSymbol(summary.allocations[0].symbol);
|
|
}
|
|
|
|
// Show warning if any securities failed to load
|
|
if (fail_count > 0) {
|
|
var warn_buf: [128]u8 = undefined;
|
|
const warn_msg = std.fmt.bufPrint(&warn_buf, "Warning: {d} securities failed to load prices", .{fail_count}) catch "Warning: some securities failed";
|
|
self.setStatus(warn_msg);
|
|
} else {
|
|
self.setStatus("j/k navigate | Enter expand | s select symbol | / search | ? help");
|
|
}
|
|
}
|
|
|
|
fn rebuildPortfolioRows(self: *App) void {
|
|
self.portfolio_rows.clearRetainingCapacity();
|
|
|
|
if (self.portfolio_summary) |s| {
|
|
for (s.allocations, 0..) |a, i| {
|
|
// Count lots for this symbol
|
|
var lcount: usize = 0;
|
|
if (self.portfolio) |pf| {
|
|
for (pf.lots) |lot| {
|
|
if (lot.lot_type == .stock and std.mem.eql(u8, lot.symbol, a.symbol)) lcount += 1;
|
|
}
|
|
}
|
|
|
|
self.portfolio_rows.append(self.allocator, .{
|
|
.kind = .position,
|
|
.symbol = a.symbol,
|
|
.pos_idx = i,
|
|
.lot_count = lcount,
|
|
}) catch continue;
|
|
|
|
// Only expand if multi-lot
|
|
if (lcount > 1 and i < self.expanded.len and self.expanded[i]) {
|
|
if (self.portfolio) |pf| {
|
|
// Collect matching lots, sort: open first (date desc), then closed (date desc)
|
|
var matching: std.ArrayList(zfin.Lot) = .empty;
|
|
defer matching.deinit(self.allocator);
|
|
for (pf.lots) |lot| {
|
|
if (lot.lot_type == .stock and std.mem.eql(u8, lot.symbol, a.symbol)) {
|
|
matching.append(self.allocator, lot) catch continue;
|
|
}
|
|
}
|
|
std.mem.sort(zfin.Lot, matching.items, {}, fmt.lotSortFn);
|
|
for (matching.items) |lot| {
|
|
self.portfolio_rows.append(self.allocator, .{
|
|
.kind = .lot,
|
|
.symbol = lot.symbol,
|
|
.pos_idx = i,
|
|
.lot = lot,
|
|
}) catch continue;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add watchlist items (integrated, dimmed)
|
|
if (self.watchlist) |wl| {
|
|
for (wl) |sym| {
|
|
if (self.portfolio_summary) |s| {
|
|
var found = false;
|
|
for (s.allocations) |a| {
|
|
if (std.mem.eql(u8, a.symbol, sym)) {
|
|
found = true;
|
|
break;
|
|
}
|
|
}
|
|
if (found) continue;
|
|
}
|
|
self.portfolio_rows.append(self.allocator, .{
|
|
.kind = .watchlist,
|
|
.symbol = sym,
|
|
}) catch continue;
|
|
}
|
|
}
|
|
|
|
// Options section
|
|
if (self.portfolio) |pf| {
|
|
if (pf.hasType(.option)) {
|
|
self.portfolio_rows.append(self.allocator, .{
|
|
.kind = .section_header,
|
|
.symbol = "Options",
|
|
}) catch {};
|
|
for (pf.lots) |lot| {
|
|
if (lot.lot_type == .option) {
|
|
self.portfolio_rows.append(self.allocator, .{
|
|
.kind = .option_row,
|
|
.symbol = lot.symbol,
|
|
.lot = lot,
|
|
}) catch continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
// CDs section (sorted by maturity date, earliest first)
|
|
if (pf.hasType(.cd)) {
|
|
self.portfolio_rows.append(self.allocator, .{
|
|
.kind = .section_header,
|
|
.symbol = "Certificates of Deposit",
|
|
}) catch {};
|
|
var cd_lots: std.ArrayList(zfin.Lot) = .empty;
|
|
defer cd_lots.deinit(self.allocator);
|
|
for (pf.lots) |lot| {
|
|
if (lot.lot_type == .cd) {
|
|
cd_lots.append(self.allocator, lot) catch continue;
|
|
}
|
|
}
|
|
std.mem.sort(zfin.Lot, cd_lots.items, {}, struct {
|
|
fn f(ctx: void, a: zfin.Lot, b: zfin.Lot) bool {
|
|
_ = ctx;
|
|
const ad = if (a.maturity_date) |d| d.days else std.math.maxInt(i32);
|
|
const bd = if (b.maturity_date) |d| d.days else std.math.maxInt(i32);
|
|
return ad < bd;
|
|
}
|
|
}.f);
|
|
for (cd_lots.items) |lot| {
|
|
self.portfolio_rows.append(self.allocator, .{
|
|
.kind = .cd_row,
|
|
.symbol = lot.symbol,
|
|
.lot = lot,
|
|
}) catch continue;
|
|
}
|
|
}
|
|
|
|
// Cash section (single total row, expandable to show per-account)
|
|
if (pf.hasType(.cash)) {
|
|
self.portfolio_rows.append(self.allocator, .{
|
|
.kind = .section_header,
|
|
.symbol = "Cash",
|
|
}) catch {};
|
|
// Total cash row
|
|
self.portfolio_rows.append(self.allocator, .{
|
|
.kind = .cash_total,
|
|
.symbol = "CASH",
|
|
}) catch {};
|
|
// Per-account cash rows (expanded when cash_total is toggled)
|
|
if (self.cash_expanded) {
|
|
for (pf.lots) |lot| {
|
|
if (lot.lot_type == .cash) {
|
|
self.portfolio_rows.append(self.allocator, .{
|
|
.kind = .cash_row,
|
|
.symbol = lot.account orelse "Unknown",
|
|
.lot = lot,
|
|
}) catch continue;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn loadPerfData(self: *App) void {
|
|
self.perf_loaded = true;
|
|
self.freeCandles();
|
|
self.freeDividends();
|
|
self.trailing_price = null;
|
|
self.trailing_total = null;
|
|
self.trailing_me_price = null;
|
|
self.trailing_me_total = null;
|
|
self.candle_count = 0;
|
|
self.candle_first_date = null;
|
|
self.candle_last_date = null;
|
|
|
|
const candle_result = self.svc.getCandles(self.symbol) catch |err| {
|
|
switch (err) {
|
|
zfin.DataError.NoApiKey => self.setStatus("No API key. Set TWELVEDATA_API_KEY"),
|
|
zfin.DataError.FetchFailed => self.setStatus("Fetch failed (network error or rate limit)"),
|
|
else => self.setStatus("Error loading data"),
|
|
}
|
|
return;
|
|
};
|
|
self.candles = candle_result.data;
|
|
self.candle_timestamp = candle_result.timestamp;
|
|
|
|
const c = self.candles.?;
|
|
if (c.len == 0) {
|
|
self.setStatus("No data available for symbol");
|
|
return;
|
|
}
|
|
self.candle_count = c.len;
|
|
self.candle_first_date = c[0].date;
|
|
self.candle_last_date = c[c.len - 1].date;
|
|
|
|
const today = fmt.todayDate();
|
|
self.trailing_price = zfin.performance.trailingReturns(c);
|
|
self.trailing_me_price = zfin.performance.trailingReturnsMonthEnd(c, today);
|
|
|
|
if (self.svc.getDividends(self.symbol)) |div_result| {
|
|
self.dividends = div_result.data;
|
|
self.trailing_total = zfin.performance.trailingReturnsWithDividends(c, div_result.data);
|
|
self.trailing_me_total = zfin.performance.trailingReturnsMonthEndWithDividends(c, div_result.data, today);
|
|
} else |_| {}
|
|
|
|
self.risk_metrics = zfin.risk.computeRisk(c);
|
|
|
|
// Try to load ETF profile (non-fatal, won't show for non-ETFs)
|
|
if (!self.etf_loaded) {
|
|
self.etf_loaded = true;
|
|
if (self.svc.getEtfProfile(self.symbol)) |etf_result| {
|
|
if (etf_result.data.isEtf()) {
|
|
self.etf_profile = etf_result.data;
|
|
}
|
|
} else |_| {}
|
|
}
|
|
|
|
self.setStatus(if (candle_result.source == .cached) "r/F5 to refresh" else "Fetched | r/F5 to refresh");
|
|
}
|
|
|
|
fn loadEarningsData(self: *App) void {
|
|
self.earnings_loaded = true;
|
|
self.freeEarnings();
|
|
|
|
const result = self.svc.getEarnings(self.symbol) catch |err| {
|
|
switch (err) {
|
|
zfin.DataError.NoApiKey => self.setStatus("No API key. Set FINNHUB_API_KEY"),
|
|
zfin.DataError.FetchFailed => {
|
|
self.earnings_disabled = true;
|
|
self.setStatus("No earnings data (ETF/index?)");
|
|
},
|
|
else => self.setStatus("Error loading earnings"),
|
|
}
|
|
return;
|
|
};
|
|
self.earnings_data = result.data;
|
|
self.earnings_timestamp = result.timestamp;
|
|
|
|
if (result.data.len == 0) {
|
|
self.earnings_disabled = true;
|
|
self.setStatus("No earnings data available (ETF/index?)");
|
|
return;
|
|
}
|
|
self.setStatus(if (result.source == .cached) "r/F5 to refresh" else "Fetched | r/F5 to refresh");
|
|
}
|
|
|
|
fn loadOptionsData(self: *App) void {
|
|
self.options_loaded = true;
|
|
self.freeOptions();
|
|
|
|
const result = self.svc.getOptions(self.symbol) catch |err| {
|
|
switch (err) {
|
|
zfin.DataError.FetchFailed => self.setStatus("CBOE fetch failed (network error)"),
|
|
else => self.setStatus("Error loading options"),
|
|
}
|
|
return;
|
|
};
|
|
self.options_data = result.data;
|
|
self.options_timestamp = result.timestamp;
|
|
self.options_cursor = 0;
|
|
self.options_expanded = [_]bool{false} ** 64;
|
|
self.options_calls_collapsed = [_]bool{false} ** 64;
|
|
self.options_puts_collapsed = [_]bool{false} ** 64;
|
|
self.rebuildOptionsRows();
|
|
self.setStatus(if (result.source == .cached) "Cached (1hr TTL) | r/F5 to refresh" else "Fetched | r/F5 to refresh");
|
|
}
|
|
|
|
fn setStatus(self: *App, msg: []const u8) void {
|
|
const len = @min(msg.len, self.status_msg.len);
|
|
@memcpy(self.status_msg[0..len], msg[0..len]);
|
|
self.status_len = len;
|
|
}
|
|
|
|
fn getStatus(self: *App) []const u8 {
|
|
if (self.status_len == 0) return "h/l tabs | j/k select | Enter expand | s select | / symbol | ? help";
|
|
return self.status_msg[0..self.status_len];
|
|
}
|
|
|
|
fn freeCandles(self: *App) void {
|
|
if (self.candles) |c| self.allocator.free(c);
|
|
self.candles = null;
|
|
}
|
|
|
|
fn freeDividends(self: *App) void {
|
|
if (self.dividends) |d| self.allocator.free(d);
|
|
self.dividends = null;
|
|
}
|
|
|
|
fn freeEarnings(self: *App) void {
|
|
if (self.earnings_data) |e| self.allocator.free(e);
|
|
self.earnings_data = null;
|
|
}
|
|
|
|
fn freeOptions(self: *App) void {
|
|
if (self.options_data) |chains| {
|
|
for (chains) |chain| {
|
|
self.allocator.free(chain.calls);
|
|
self.allocator.free(chain.puts);
|
|
self.allocator.free(chain.underlying_symbol);
|
|
}
|
|
self.allocator.free(chains);
|
|
}
|
|
self.options_data = null;
|
|
}
|
|
|
|
fn freeEtfProfile(self: *App) void {
|
|
if (self.etf_profile) |profile| {
|
|
if (profile.holdings) |h| {
|
|
for (h) |holding| {
|
|
if (holding.symbol) |s| self.allocator.free(s);
|
|
self.allocator.free(holding.name);
|
|
}
|
|
self.allocator.free(h);
|
|
}
|
|
if (profile.sectors) |s| {
|
|
for (s) |sec| self.allocator.free(sec.sector);
|
|
self.allocator.free(s);
|
|
}
|
|
}
|
|
self.etf_profile = null;
|
|
self.etf_loaded = false;
|
|
}
|
|
|
|
fn freePortfolioSummary(self: *App) void {
|
|
if (self.portfolio_summary) |*s| s.deinit(self.allocator);
|
|
self.portfolio_summary = null;
|
|
}
|
|
|
|
fn deinitData(self: *App) void {
|
|
self.freeCandles();
|
|
self.freeDividends();
|
|
self.freeEarnings();
|
|
self.freeOptions();
|
|
self.freeEtfProfile();
|
|
self.freePortfolioSummary();
|
|
self.portfolio_rows.deinit(self.allocator);
|
|
self.options_rows.deinit(self.allocator);
|
|
}
|
|
|
|
fn reloadFiles(self: *App) void {
|
|
// Reload portfolio
|
|
if (self.portfolio) |*pf| pf.deinit();
|
|
self.portfolio = null;
|
|
if (self.portfolio_path) |path| {
|
|
const file_data = std.fs.cwd().readFileAlloc(self.allocator, path, 10 * 1024 * 1024) catch null;
|
|
if (file_data) |d| {
|
|
defer self.allocator.free(d);
|
|
if (zfin.cache.deserializePortfolio(self.allocator, d)) |pf| {
|
|
self.portfolio = pf;
|
|
} else |_| {}
|
|
}
|
|
}
|
|
|
|
// Reload watchlist
|
|
freeWatchlist(self.allocator, self.watchlist);
|
|
self.watchlist = null;
|
|
if (self.watchlist_path) |path| {
|
|
self.watchlist = loadWatchlist(self.allocator, path);
|
|
}
|
|
|
|
// Reset portfolio view state
|
|
self.portfolio_loaded = false;
|
|
self.freePortfolioSummary();
|
|
self.expanded = [_]bool{false} ** 64;
|
|
self.cursor = 0;
|
|
self.scroll_offset = 0;
|
|
self.portfolio_rows.clearRetainingCapacity();
|
|
}
|
|
|
|
// ── Drawing ──────────────────────────────────────────────────
|
|
|
|
fn typeErasedDrawFn(ptr: *anyopaque, ctx: vaxis.vxfw.DrawContext) std.mem.Allocator.Error!vaxis.vxfw.Surface {
|
|
const self: *App = @ptrCast(@alignCast(ptr));
|
|
const max_size = ctx.max.size();
|
|
|
|
if (max_size.height < 3) {
|
|
return .{ .size = max_size, .widget = self.widget(), .buffer = &.{}, .children = &.{} };
|
|
}
|
|
|
|
self.visible_height = max_size.height -| 2;
|
|
|
|
var children: std.ArrayList(vaxis.vxfw.SubSurface) = .empty;
|
|
|
|
const tab_surface = try self.drawTabBar(ctx, max_size.width);
|
|
try children.append(ctx.arena, .{ .origin = .{ .row = 0, .col = 0 }, .surface = tab_surface });
|
|
|
|
const content_height = max_size.height - 2;
|
|
const content_surface = try self.drawContent(ctx, max_size.width, content_height);
|
|
try children.append(ctx.arena, .{ .origin = .{ .row = 1, .col = 0 }, .surface = content_surface });
|
|
|
|
const status_surface = try self.drawStatusBar(ctx, max_size.width);
|
|
try children.append(ctx.arena, .{ .origin = .{ .row = @intCast(max_size.height - 1), .col = 0 }, .surface = status_surface });
|
|
|
|
return .{ .size = max_size, .widget = self.widget(), .buffer = &.{}, .children = try children.toOwnedSlice(ctx.arena) };
|
|
}
|
|
|
|
fn drawTabBar(self: *App, ctx: vaxis.vxfw.DrawContext, width: u16) !vaxis.vxfw.Surface {
|
|
const th = self.theme;
|
|
const buf = try ctx.arena.alloc(vaxis.Cell, width);
|
|
const inactive_style = th.tabStyle();
|
|
@memset(buf, .{ .char = .{ .grapheme = " " }, .style = inactive_style });
|
|
|
|
var col: usize = 0;
|
|
for (tabs) |t| {
|
|
const lbl = t.label();
|
|
const is_active = t == self.active_tab;
|
|
const is_disabled = t == .earnings and self.earnings_disabled;
|
|
const tab_style: vaxis.Style = if (is_active) th.tabActiveStyle() else if (is_disabled) th.tabDisabledStyle() else inactive_style;
|
|
|
|
for (lbl) |ch| {
|
|
if (col >= width) break;
|
|
buf[col] = .{ .char = .{ .grapheme = glyph(ch) }, .style = tab_style };
|
|
col += 1;
|
|
}
|
|
}
|
|
|
|
// Right-align the active symbol if set
|
|
if (self.symbol.len > 0) {
|
|
const is_selected = self.isSymbolSelected();
|
|
const prefix: []const u8 = if (is_selected) " * " else " ";
|
|
const sym_label = try std.fmt.allocPrint(ctx.arena, "{s}{s} ", .{ prefix, self.symbol });
|
|
if (width > sym_label.len + col) {
|
|
const sym_start = width - sym_label.len;
|
|
const sym_style: vaxis.Style = .{
|
|
.fg = theme_mod.Theme.vcolor(if (is_selected) th.warning else th.info),
|
|
.bg = theme_mod.Theme.vcolor(th.tab_bg),
|
|
.bold = is_selected,
|
|
};
|
|
for (0..sym_label.len) |i| {
|
|
buf[sym_start + i] = .{ .char = .{ .grapheme = glyph(sym_label[i]) }, .style = sym_style };
|
|
}
|
|
}
|
|
}
|
|
|
|
return .{ .size = .{ .width = width, .height = 1 }, .widget = self.widget(), .buffer = buf, .children = &.{} };
|
|
}
|
|
|
|
fn isSymbolSelected(self: *App) bool {
|
|
// Symbol is "selected" if it matches a portfolio/watchlist row the user explicitly selected with 's'
|
|
if (self.active_tab != .portfolio) return false;
|
|
if (self.portfolio_rows.items.len == 0) return false;
|
|
if (self.cursor >= self.portfolio_rows.items.len) return false;
|
|
return std.mem.eql(u8, self.portfolio_rows.items[self.cursor].symbol, self.symbol);
|
|
}
|
|
|
|
fn drawContent(self: *App, ctx: vaxis.vxfw.DrawContext, width: u16, height: u16) !vaxis.vxfw.Surface {
|
|
const th = self.theme;
|
|
const content_style = th.contentStyle();
|
|
const buf_size: usize = @as(usize, width) * height;
|
|
const buf = try ctx.arena.alloc(vaxis.Cell, buf_size);
|
|
@memset(buf, .{ .char = .{ .grapheme = " " }, .style = content_style });
|
|
|
|
if (self.mode == .help) {
|
|
try self.drawStyledContent(ctx.arena, buf, width, height, try self.buildHelpStyledLines(ctx.arena));
|
|
} else {
|
|
switch (self.active_tab) {
|
|
.portfolio => try self.drawPortfolioContent(ctx.arena, buf, width, height),
|
|
.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)),
|
|
}
|
|
}
|
|
|
|
return .{ .size = .{ .width = width, .height = height }, .widget = self.widget(), .buffer = buf, .children = &.{} };
|
|
}
|
|
|
|
fn drawStyledContent(_: *App, _: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16, lines: []const StyledLine) !void {
|
|
for (lines, 0..) |line, row| {
|
|
if (row >= height) break;
|
|
// Fill row with style bg
|
|
for (0..width) |ci| {
|
|
buf[row * width + ci] = .{ .char = .{ .grapheme = " " }, .style = line.style };
|
|
}
|
|
// Grapheme-based rendering (for braille / multi-byte Unicode lines)
|
|
if (line.graphemes) |graphemes| {
|
|
const cell_styles = line.cell_styles;
|
|
for (0..@min(graphemes.len, width)) |ci| {
|
|
const s = if (cell_styles) |cs| cs[ci] else line.style;
|
|
buf[row * width + ci] = .{ .char = .{ .grapheme = graphemes[ci] }, .style = s };
|
|
}
|
|
} else {
|
|
for (0..@min(line.text.len, width)) |ci| {
|
|
var s = line.style;
|
|
// Apply alt_style for the gain/loss column range
|
|
if (line.alt_style) |alt| {
|
|
if (ci >= line.alt_start and ci < line.alt_end) s = alt;
|
|
}
|
|
buf[row * width + ci] = .{ .char = .{ .grapheme = glyph(line.text[ci]) }, .style = s };
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn drawStatusBar(self: *App, ctx: vaxis.vxfw.DrawContext, width: u16) !vaxis.vxfw.Surface {
|
|
const t = self.theme;
|
|
const buf = try ctx.arena.alloc(vaxis.Cell, width);
|
|
|
|
if (self.mode == .symbol_input) {
|
|
const prompt_style = t.inputStyle();
|
|
@memset(buf, .{ .char = .{ .grapheme = " " }, .style = prompt_style });
|
|
|
|
const prompt = "Symbol: ";
|
|
for (0..@min(prompt.len, width)) |i| {
|
|
buf[i] = .{ .char = .{ .grapheme = glyph(prompt[i]) }, .style = prompt_style };
|
|
}
|
|
const input = self.input_buf[0..self.input_len];
|
|
for (0..@min(input.len, @as(usize, width) -| prompt.len)) |i| {
|
|
buf[prompt.len + i] = .{ .char = .{ .grapheme = glyph(input[i]) }, .style = prompt_style };
|
|
}
|
|
const cursor_pos = prompt.len + self.input_len;
|
|
if (cursor_pos < width) {
|
|
var cursor_style = prompt_style;
|
|
cursor_style.blink = true;
|
|
buf[cursor_pos] = .{ .char = .{ .grapheme = "_" }, .style = cursor_style };
|
|
}
|
|
const hint = " Enter=confirm Esc=cancel ";
|
|
if (width > hint.len + cursor_pos + 2) {
|
|
const hint_start = width - hint.len;
|
|
const hint_style = t.inputHintStyle();
|
|
for (0..hint.len) |i| {
|
|
buf[hint_start + i] = .{ .char = .{ .grapheme = glyph(hint[i]) }, .style = hint_style };
|
|
}
|
|
}
|
|
} else {
|
|
const status_style = t.statusStyle();
|
|
@memset(buf, .{ .char = .{ .grapheme = " " }, .style = status_style });
|
|
const msg = self.getStatus();
|
|
for (0..@min(msg.len, width)) |i| {
|
|
buf[i] = .{ .char = .{ .grapheme = glyph(msg[i]) }, .style = status_style };
|
|
}
|
|
}
|
|
|
|
return .{ .size = .{ .width = width, .height = 1 }, .widget = self.widget(), .buffer = buf, .children = &.{} };
|
|
}
|
|
|
|
// ── Portfolio content ─────────────────────────────────────────
|
|
|
|
fn drawPortfolioContent(self: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
|
|
const th = self.theme;
|
|
|
|
if (self.portfolio == null and self.watchlist == null) {
|
|
try self.drawWelcomeScreen(arena, buf, width, height);
|
|
return;
|
|
}
|
|
|
|
var lines: std.ArrayList(StyledLine) = .empty;
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
|
|
if (self.portfolio_summary) |s| {
|
|
var val_buf: [24]u8 = undefined;
|
|
var cost_buf: [24]u8 = undefined;
|
|
var gl_buf: [24]u8 = undefined;
|
|
const val_str = fmt.fmtMoney(&val_buf, s.total_value);
|
|
const cost_str = fmt.fmtMoney(&cost_buf, s.total_cost);
|
|
const gl_abs = if (s.unrealized_pnl >= 0) s.unrealized_pnl else -s.unrealized_pnl;
|
|
const gl_str = fmt.fmtMoney(&gl_buf, gl_abs);
|
|
const summary_text = try std.fmt.allocPrint(arena, " Value: {s} Cost: {s} Gain/Loss: {s}{s} ({d:.1}%)", .{
|
|
val_str, cost_str, if (s.unrealized_pnl >= 0) @as([]const u8, "+") else @as([]const u8, "-"), gl_str, s.unrealized_return * 100.0,
|
|
});
|
|
const summary_style = if (s.unrealized_pnl >= 0) th.positiveStyle() else th.negativeStyle();
|
|
try lines.append(arena, .{ .text = summary_text, .style = summary_style });
|
|
|
|
// "as of" date indicator
|
|
if (self.candle_last_date) |d| {
|
|
var asof_buf: [10]u8 = undefined;
|
|
const asof_text = try std.fmt.allocPrint(arena, " (as of close on {s})", .{d.format(&asof_buf)});
|
|
try lines.append(arena, .{ .text = asof_text, .style = th.mutedStyle() });
|
|
}
|
|
} else if (self.portfolio != null) {
|
|
try lines.append(arena, .{ .text = " No cached prices. Run 'zfin perf <SYMBOL>' for each holding.", .style = th.mutedStyle() });
|
|
} else {
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
}
|
|
|
|
// Empty line before header
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
|
|
// Column header (4-char prefix to match arrow(2)+star(2) in data rows)
|
|
const hdr = try std.fmt.allocPrint(arena, " {s:<6} {s:>8} {s:>10} {s:>10} {s:>16} {s:>14} {s:>8} {s:>13} {s}", .{
|
|
"Symbol", "Shares", "Avg Cost", "Price", "Market Value", "Gain/Loss", "Weight", "Date", "Account",
|
|
});
|
|
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;
|
|
self.portfolio_line_count = 0;
|
|
|
|
// Data rows
|
|
for (self.portfolio_rows.items, 0..) |row, ri| {
|
|
const lines_before = lines.items.len;
|
|
const is_cursor = ri == self.cursor;
|
|
const is_active_sym = std.mem.eql(u8, row.symbol, self.symbol);
|
|
switch (row.kind) {
|
|
.position => {
|
|
if (self.portfolio_summary) |s| {
|
|
if (row.pos_idx < s.allocations.len) {
|
|
const a = s.allocations[row.pos_idx];
|
|
const is_multi = row.lot_count > 1;
|
|
const is_expanded = is_multi and row.pos_idx < self.expanded.len and self.expanded[row.pos_idx];
|
|
const arrow: []const u8 = if (!is_multi) " " else if (is_expanded) "v " else "> ";
|
|
const star: []const u8 = if (is_active_sym) "* " else " ";
|
|
const pnl_pct = if (a.cost_basis > 0) (a.unrealized_pnl / a.cost_basis) * 100.0 else @as(f64, 0);
|
|
var gl_val_buf: [24]u8 = undefined;
|
|
const gl_abs = if (a.unrealized_pnl >= 0) a.unrealized_pnl else -a.unrealized_pnl;
|
|
const gl_money = fmt.fmtMoney(&gl_val_buf, gl_abs);
|
|
var pnl_buf: [20]u8 = undefined;
|
|
const pnl_str = if (a.unrealized_pnl >= 0)
|
|
std.fmt.bufPrint(&pnl_buf, "+{s}", .{gl_money}) catch "?"
|
|
else
|
|
std.fmt.bufPrint(&pnl_buf, "-{s}", .{gl_money}) catch "?";
|
|
var mv_buf: [24]u8 = undefined;
|
|
const mv_str = fmt.fmtMoney(&mv_buf, a.market_value);
|
|
var cost_buf2: [24]u8 = undefined;
|
|
const cost_str = fmt.fmtMoney2(&cost_buf2, a.avg_cost);
|
|
var price_buf2: [24]u8 = undefined;
|
|
const price_str = fmt.fmtMoney2(&price_buf2, a.current_price);
|
|
|
|
// Date + ST/LT: show for single-lot, blank for multi-lot
|
|
var pos_date_buf: [10]u8 = undefined;
|
|
var date_col: []const u8 = "";
|
|
var acct_col: []const u8 = "";
|
|
if (!is_multi) {
|
|
if (self.portfolio) |pf| {
|
|
for (pf.lots) |lot| {
|
|
if (lot.lot_type == .stock and std.mem.eql(u8, lot.symbol, a.symbol)) {
|
|
const ds = lot.open_date.format(&pos_date_buf);
|
|
const indicator = fmt.capitalGainsIndicator(lot.open_date);
|
|
date_col = std.fmt.allocPrint(arena, "{s} {s}", .{ ds, indicator }) catch ds;
|
|
acct_col = lot.account orelse "";
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// Multi-lot: show account if all lots share the same one
|
|
if (self.portfolio) |pf| {
|
|
var common_acct: ?[]const u8 = null;
|
|
var mixed = false;
|
|
for (pf.lots) |lot| {
|
|
if (lot.lot_type == .stock and std.mem.eql(u8, lot.symbol, a.symbol)) {
|
|
if (common_acct) |ca| {
|
|
const la = lot.account orelse "";
|
|
if (!std.mem.eql(u8, ca, la)) {
|
|
mixed = true;
|
|
break;
|
|
}
|
|
} else {
|
|
common_acct = lot.account orelse "";
|
|
}
|
|
}
|
|
}
|
|
if (!mixed) {
|
|
acct_col = common_acct orelse "";
|
|
} else {
|
|
acct_col = "Multiple";
|
|
}
|
|
}
|
|
}
|
|
|
|
const text = try std.fmt.allocPrint(arena, "{s}{s}{s:<6} {d:>8.1} {s:>10} {s:>10} {s:>16} {s:>14} {d:>7.1}% {s:>13} {s}", .{
|
|
arrow, star, a.symbol, a.shares, cost_str, price_str, mv_str, pnl_str, a.weight * 100.0, date_col, acct_col,
|
|
});
|
|
|
|
// base: neutral text for main cols, green/red only for gain/loss col
|
|
const base_style = if (is_cursor) th.selectStyle() else th.contentStyle();
|
|
const gl_style = if (is_cursor) th.selectStyle() else if (pnl_pct >= 0) th.positiveStyle() else th.negativeStyle();
|
|
|
|
// The gain/loss column starts after market value
|
|
// prefix(4) + sym(6+1) + shares(8+1) + avgcost(10+1) + price(10+1) + mv(16+1) = 59
|
|
try lines.append(arena, .{
|
|
.text = text,
|
|
.style = base_style,
|
|
.alt_style = gl_style,
|
|
.alt_start = 59,
|
|
.alt_end = 59 + 14,
|
|
});
|
|
}
|
|
}
|
|
},
|
|
.lot => {
|
|
if (row.lot) |lot| {
|
|
var date_buf: [10]u8 = undefined;
|
|
const date_str = lot.open_date.format(&date_buf);
|
|
|
|
// Compute lot gain/loss if we have a price
|
|
var lot_gl_str: []const u8 = "";
|
|
var lot_positive = true;
|
|
if (self.portfolio_summary) |s| {
|
|
if (row.pos_idx < s.allocations.len) {
|
|
const price = s.allocations[row.pos_idx].current_price;
|
|
const use_price = lot.close_price orelse price;
|
|
const gl = lot.shares * (use_price - lot.open_price);
|
|
lot_positive = gl >= 0;
|
|
var lot_gl_money_buf: [24]u8 = undefined;
|
|
const lot_gl_money = fmt.fmtMoney(&lot_gl_money_buf, if (gl >= 0) gl else -gl);
|
|
lot_gl_str = try std.fmt.allocPrint(arena, "{s}{s}", .{
|
|
if (gl >= 0) @as([]const u8, "+") else @as([]const u8, "-"), lot_gl_money,
|
|
});
|
|
}
|
|
}
|
|
|
|
var price_str2: [24]u8 = undefined;
|
|
const lot_price_str = fmt.fmtMoney2(&price_str2, lot.open_price);
|
|
const status_str: []const u8 = if (lot.isOpen()) "open" else "closed";
|
|
const indicator = fmt.capitalGainsIndicator(lot.open_date);
|
|
const lot_date_col = try std.fmt.allocPrint(arena, "{s} {s}", .{ date_str, indicator });
|
|
const acct_col: []const u8 = lot.account orelse "";
|
|
const text = try std.fmt.allocPrint(arena, " {s:<6} {d:>8.1} {s:>10} {s:>10} {s:>16} {s:>14} {s:>8} {s:>13} {s}", .{
|
|
status_str, lot.shares, lot_price_str, "", "", lot_gl_str, "", lot_date_col, acct_col,
|
|
});
|
|
const base_style = if (is_cursor) th.selectStyle() else th.mutedStyle();
|
|
const gl_col_style = if (is_cursor) th.selectStyle() else if (lot_positive) th.positiveStyle() else th.negativeStyle();
|
|
try lines.append(arena, .{
|
|
.text = text,
|
|
.style = base_style,
|
|
.alt_style = gl_col_style,
|
|
.alt_start = 59,
|
|
.alt_end = 59 + 14,
|
|
});
|
|
}
|
|
},
|
|
.watchlist => {
|
|
var price_str3: [16]u8 = undefined;
|
|
const ps = if (self.svc.getCachedCandles(row.symbol)) |candles_slice| blk: {
|
|
defer self.allocator.free(candles_slice);
|
|
if (candles_slice.len > 0)
|
|
break :blk fmt.fmtMoney2(&price_str3, candles_slice[candles_slice.len - 1].close)
|
|
else
|
|
break :blk @as([]const u8, "--");
|
|
} else "--";
|
|
const star2: []const u8 = if (is_active_sym) "* " else " ";
|
|
const text = try std.fmt.allocPrint(arena, " {s}{s:<6} {s:>8} {s:>10} {s:>10} {s:>16} {s:>14} {s:>8} {s:>13}", .{
|
|
star2, row.symbol, "--", "--", ps, "--", "--", "watch", "",
|
|
});
|
|
const row_style = if (is_cursor) th.selectStyle() else th.contentStyle();
|
|
try lines.append(arena, .{ .text = text, .style = row_style });
|
|
},
|
|
.section_header => {
|
|
// Blank line before section header
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
const hdr_text = try std.fmt.allocPrint(arena, " {s}", .{row.symbol});
|
|
const hdr_style = if (is_cursor) th.selectStyle() else th.headerStyle();
|
|
try lines.append(arena, .{ .text = hdr_text, .style = hdr_style });
|
|
// Add column headers for each section type
|
|
if (std.mem.eql(u8, row.symbol, "Options")) {
|
|
const col_hdr = try std.fmt.allocPrint(arena, " {s:<30} {s:>6} {s:>12} {s:>14} {s}", .{
|
|
"Contract", "Qty", "Cost/Ctrct", "Total Cost", "Account",
|
|
});
|
|
try lines.append(arena, .{ .text = col_hdr, .style = th.mutedStyle() });
|
|
} else if (std.mem.eql(u8, row.symbol, "Certificates of Deposit")) {
|
|
const col_hdr = try std.fmt.allocPrint(arena, " {s:<12} {s:>14} {s:>7} {s:>10} {s} {s}", .{
|
|
"CUSIP", "Face Value", "Rate", "Maturity", "Description", "Account",
|
|
});
|
|
try lines.append(arena, .{ .text = col_hdr, .style = th.mutedStyle() });
|
|
}
|
|
},
|
|
.option_row => {
|
|
if (row.lot) |lot| {
|
|
// Options: symbol (description), qty (contracts), cost/contract, cost basis, account
|
|
const qty = lot.shares; // negative = short
|
|
const cost_per = lot.open_price; // per-contract cost
|
|
const total_cost = @abs(qty) * cost_per;
|
|
var cost_buf3: [24]u8 = undefined;
|
|
var total_buf: [24]u8 = undefined;
|
|
const acct_col2: []const u8 = lot.account orelse "";
|
|
const text = try std.fmt.allocPrint(arena, " {s:<30} {d:>6.0} {s:>12} {s:>14} {s}", .{
|
|
lot.symbol,
|
|
qty,
|
|
fmt.fmtMoney2(&cost_buf3, cost_per),
|
|
fmt.fmtMoney(&total_buf, total_cost),
|
|
acct_col2,
|
|
});
|
|
const row_style2 = if (is_cursor) th.selectStyle() else th.contentStyle();
|
|
try lines.append(arena, .{ .text = text, .style = row_style2 });
|
|
}
|
|
},
|
|
.cd_row => {
|
|
if (row.lot) |lot| {
|
|
// CDs: symbol (CUSIP), face value, rate%, maturity date, note, account
|
|
var face_buf: [24]u8 = undefined;
|
|
var mat_buf: [10]u8 = undefined;
|
|
const mat_str: []const u8 = if (lot.maturity_date) |md| md.format(&mat_buf) else "--";
|
|
var rate_str_buf: [10]u8 = undefined;
|
|
const rate_str: []const u8 = if (lot.rate) |r|
|
|
std.fmt.bufPrint(&rate_str_buf, "{d:.2}%", .{r}) catch "--"
|
|
else
|
|
"--";
|
|
const note_str: []const u8 = lot.note orelse "";
|
|
// Truncate note to 40 chars for display
|
|
const note_display = if (note_str.len > 40) note_str[0..40] else note_str;
|
|
const acct_col3: []const u8 = lot.account orelse "";
|
|
const text = try std.fmt.allocPrint(arena, " {s:<12} {s:>14} {s:>7} {s:>10} {s} {s}", .{
|
|
lot.symbol,
|
|
fmt.fmtMoney(&face_buf, lot.shares),
|
|
rate_str,
|
|
mat_str,
|
|
note_display,
|
|
acct_col3,
|
|
});
|
|
const row_style3 = if (is_cursor) th.selectStyle() else th.contentStyle();
|
|
try lines.append(arena, .{ .text = text, .style = row_style3 });
|
|
}
|
|
},
|
|
.cash_total => {
|
|
if (self.portfolio) |pf| {
|
|
const total_cash = pf.totalCash();
|
|
var cash_buf: [24]u8 = undefined;
|
|
const arrow3: []const u8 = if (self.cash_expanded) "v " else "> ";
|
|
const text = try std.fmt.allocPrint(arena, " {s}Total Cash {s:>14}", .{
|
|
arrow3,
|
|
fmt.fmtMoney(&cash_buf, total_cash),
|
|
});
|
|
const row_style4 = if (is_cursor) th.selectStyle() else th.contentStyle();
|
|
try lines.append(arena, .{ .text = text, .style = row_style4 });
|
|
}
|
|
},
|
|
.cash_row => {
|
|
if (row.lot) |lot| {
|
|
var cash_amt_buf: [24]u8 = undefined;
|
|
const text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14}", .{
|
|
row.symbol, // account name
|
|
fmt.fmtMoney(&cash_amt_buf, lot.shares),
|
|
});
|
|
const row_style5 = if (is_cursor) th.selectStyle() else th.mutedStyle();
|
|
try lines.append(arena, .{ .text = text, .style = row_style5 });
|
|
}
|
|
},
|
|
}
|
|
// Map all styled lines produced by this row back to the row index
|
|
const lines_after = lines.items.len;
|
|
for (lines_before..lines_after) |li| {
|
|
const map_idx = li - self.portfolio_header_lines;
|
|
if (map_idx < self.portfolio_line_to_row.len) {
|
|
self.portfolio_line_to_row[map_idx] = ri;
|
|
}
|
|
}
|
|
self.portfolio_line_count = lines_after - self.portfolio_header_lines;
|
|
}
|
|
|
|
// Render
|
|
const start = @min(self.scroll_offset, if (lines.items.len > 0) lines.items.len - 1 else 0);
|
|
try self.drawStyledContent(arena, buf, width, height, lines.items[start..]);
|
|
}
|
|
|
|
fn drawWelcomeScreen(self: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
|
|
const th = self.theme;
|
|
const welcome_lines = [_]StyledLine{
|
|
.{ .text = "", .style = th.contentStyle() },
|
|
.{ .text = " zfin", .style = th.headerStyle() },
|
|
.{ .text = "", .style = th.contentStyle() },
|
|
.{ .text = " No portfolio loaded.", .style = th.mutedStyle() },
|
|
.{ .text = "", .style = th.contentStyle() },
|
|
.{ .text = " Getting started:", .style = th.contentStyle() },
|
|
.{ .text = " / Enter a stock symbol (e.g. AAPL, VTI)", .style = th.contentStyle() },
|
|
.{ .text = "", .style = th.contentStyle() },
|
|
.{ .text = " Portfolio mode:", .style = th.contentStyle() },
|
|
.{ .text = " zfin -p portfolio.srf Load a portfolio file", .style = th.mutedStyle() },
|
|
.{ .text = try std.fmt.allocPrint(arena, " portfolio.srf Auto-loaded from cwd if present", .{}), .style = th.mutedStyle() },
|
|
.{ .text = "", .style = th.contentStyle() },
|
|
.{ .text = " Navigation:", .style = th.contentStyle() },
|
|
.{ .text = " h / l Previous / next tab", .style = th.mutedStyle() },
|
|
.{ .text = " j / k Select next / prev item", .style = th.mutedStyle() },
|
|
.{ .text = " Enter Expand position lots", .style = th.mutedStyle() },
|
|
.{ .text = " s Select symbol for other tabs", .style = th.mutedStyle() },
|
|
.{ .text = " 1-5 Jump to tab", .style = th.mutedStyle() },
|
|
.{ .text = " ? Full help", .style = th.mutedStyle() },
|
|
.{ .text = " q Quit", .style = th.mutedStyle() },
|
|
.{ .text = "", .style = th.contentStyle() },
|
|
.{ .text = " Sample portfolio.srf:", .style = th.contentStyle() },
|
|
.{ .text = " symbol::VTI,shares::100,open_date::2024-01-15,open_price::220.50", .style = th.dimStyle() },
|
|
.{ .text = " symbol::AAPL,shares::50,open_date::2024-03-01,open_price::170.00", .style = th.dimStyle() },
|
|
};
|
|
try self.drawStyledContent(arena, buf, width, height, &welcome_lines);
|
|
}
|
|
|
|
// ── Options content (with cursor/scroll) ─────────────────────
|
|
|
|
fn drawOptionsContent(self: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
|
|
const styled_lines = try self.buildOptionsStyledLines(arena);
|
|
const start = @min(self.scroll_offset, if (styled_lines.len > 0) styled_lines.len - 1 else 0);
|
|
try self.drawStyledContent(arena, buf, width, height, styled_lines[start..]);
|
|
}
|
|
|
|
// ── 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 = fmt.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);
|
|
|
|
try self.buildDetailColumns(arena, &detail_lines, latest, quote_data, price, prev_close);
|
|
|
|
// 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;
|
|
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
|
|
if (self.symbol.len == 0) {
|
|
try lines.append(arena, .{ .text = " No symbol selected. Press / to enter a symbol.", .style = th.mutedStyle() });
|
|
return lines.toOwnedSlice(arena);
|
|
}
|
|
|
|
var ago_buf: [16]u8 = undefined;
|
|
if (self.quote != null and self.quote_timestamp > 0) {
|
|
const ago_str = fmt.fmtTimeAgo(&ago_buf, self.quote_timestamp);
|
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s} (live, ~15 min delay, refreshed {s})", .{ self.symbol, ago_str }), .style = th.headerStyle() });
|
|
} else if (self.candle_last_date) |d| {
|
|
var cdate_buf: [10]u8 = undefined;
|
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s} (as of close on {s})", .{ self.symbol, d.format(&cdate_buf) }), .style = th.headerStyle() });
|
|
} else {
|
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s}", .{self.symbol}), .style = th.headerStyle() });
|
|
}
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
|
|
if (self.candles == null and !self.perf_loaded) self.loadPerfData();
|
|
|
|
// Use stored real-time quote if available (fetched on manual refresh)
|
|
const quote_data = self.quote;
|
|
|
|
const c = self.candles orelse {
|
|
if (quote_data) |q| {
|
|
// No candle data but have a quote - show it
|
|
var qclose_buf: [24]u8 = undefined;
|
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Price: {s}", .{fmt.fmtMoney(&qclose_buf, q.close)}), .style = th.contentStyle() });
|
|
if (q.change >= 0) {
|
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: +${d:.2} (+{d:.2}%)", .{ q.change, q.percent_change }), .style = th.positiveStyle() });
|
|
} else {
|
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: -${d:.2} ({d:.2}%)", .{ -q.change, q.percent_change }), .style = th.negativeStyle() });
|
|
}
|
|
return lines.toOwnedSlice(arena);
|
|
}
|
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " No data. Run: zfin perf {s}", .{self.symbol}), .style = th.mutedStyle() });
|
|
return lines.toOwnedSlice(arena);
|
|
};
|
|
if (c.len == 0) {
|
|
try lines.append(arena, .{ .text = " No candle data.", .style = th.mutedStyle() });
|
|
return lines.toOwnedSlice(arena);
|
|
}
|
|
|
|
// Use real-time quote price if available, otherwise latest candle
|
|
const price = if (quote_data) |q| q.close else c[c.len - 1].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);
|
|
const latest = c[c.len - 1];
|
|
|
|
try self.buildDetailColumns(arena, &lines, latest, quote_data, price, prev_close);
|
|
|
|
// Braille sparkline chart of recent 60 trading days
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
const chart_days: usize = @min(c.len, 60);
|
|
const chart_data = c[c.len - chart_days ..];
|
|
try renderBrailleToStyledLines(arena, &lines, chart_data, th);
|
|
|
|
// Recent history table
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
try lines.append(arena, .{ .text = " Recent History:", .style = th.headerStyle() });
|
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:>12} {s:>10} {s:>10} {s:>10} {s:>10} {s:>12}", .{ "Date", "Open", "High", "Low", "Close", "Volume" }), .style = th.mutedStyle() });
|
|
|
|
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} {s:>12}", .{
|
|
candle.date.format(&db), candle.open, candle.high, candle.low, candle.close, fmt.fmtIntCommas(&vb, candle.volume),
|
|
}), .style = day_change });
|
|
}
|
|
|
|
return lines.toOwnedSlice(arena);
|
|
}
|
|
|
|
// ── Quote detail columns (price/OHLCV | ETF stats | sectors | holdings) ──
|
|
|
|
const Column = struct {
|
|
texts: std.ArrayList([]const u8),
|
|
styles: std.ArrayList(vaxis.Style),
|
|
width: usize, // fixed column width for padding
|
|
|
|
fn init() Column {
|
|
return .{
|
|
.texts = .empty,
|
|
.styles = .empty,
|
|
.width = 0,
|
|
};
|
|
}
|
|
|
|
fn add(self: *Column, arena: std.mem.Allocator, text: []const u8, style: vaxis.Style) !void {
|
|
try self.texts.append(arena, text);
|
|
try self.styles.append(arena, style);
|
|
}
|
|
|
|
fn len(self: *const Column) usize {
|
|
return self.texts.items.len;
|
|
}
|
|
};
|
|
|
|
fn buildDetailColumns(
|
|
self: *App,
|
|
arena: std.mem.Allocator,
|
|
lines: *std.ArrayList(StyledLine),
|
|
latest: zfin.Candle,
|
|
quote_data: ?zfin.Quote,
|
|
price: f64,
|
|
prev_close: f64,
|
|
) !void {
|
|
const th = self.theme;
|
|
var date_buf: [10]u8 = undefined;
|
|
var close_buf: [24]u8 = undefined;
|
|
var vol_buf: [32]u8 = undefined;
|
|
|
|
// Column 1: Price/OHLCV
|
|
var col1 = Column.init();
|
|
col1.width = 30;
|
|
try col1.add(arena, try std.fmt.allocPrint(arena, " Date: {s}", .{latest.date.format(&date_buf)}), th.contentStyle());
|
|
try col1.add(arena, try std.fmt.allocPrint(arena, " Price: {s}", .{fmt.fmtMoney(&close_buf, price)}), th.contentStyle());
|
|
try col1.add(arena, try std.fmt.allocPrint(arena, " Open: ${d:.2}", .{if (quote_data) |q| q.open else latest.open}), th.mutedStyle());
|
|
try col1.add(arena, try std.fmt.allocPrint(arena, " High: ${d:.2}", .{if (quote_data) |q| q.high else latest.high}), th.mutedStyle());
|
|
try col1.add(arena, try std.fmt.allocPrint(arena, " Low: ${d:.2}", .{if (quote_data) |q| q.low else latest.low}), th.mutedStyle());
|
|
try col1.add(arena, try std.fmt.allocPrint(arena, " Volume: {s}", .{fmt.fmtIntCommas(&vol_buf, if (quote_data) |q| q.volume else latest.volume)}), 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 col1.add(arena, try std.fmt.allocPrint(arena, " Change: +${d:.2} (+{d:.2}%)", .{ change, pct }), change_style);
|
|
} else {
|
|
try col1.add(arena, try std.fmt.allocPrint(arena, " Change: -${d:.2} ({d:.2}%)", .{ -change, pct }), change_style);
|
|
}
|
|
}
|
|
|
|
// Columns 2-4: ETF profile (only for actual ETFs)
|
|
var col2 = Column.init(); // ETF stats
|
|
col2.width = 22;
|
|
var col3 = Column.init(); // Sectors
|
|
col3.width = 26;
|
|
var col4 = Column.init(); // Top holdings
|
|
col4.width = 30;
|
|
|
|
if (self.etf_profile) |profile| {
|
|
// Col 2: ETF key stats
|
|
try col2.add(arena, "ETF Profile", th.headerStyle());
|
|
if (profile.expense_ratio) |er| {
|
|
try col2.add(arena, try std.fmt.allocPrint(arena, " Expense: {d:.2}%", .{er * 100.0}), th.contentStyle());
|
|
}
|
|
if (profile.net_assets) |na| {
|
|
try col2.add(arena, try std.fmt.allocPrint(arena, " Assets: ${s}", .{std.mem.trimRight(u8, &fmt.fmtLargeNum(na), &.{' '})}), th.contentStyle());
|
|
}
|
|
if (profile.dividend_yield) |dy| {
|
|
try col2.add(arena, try std.fmt.allocPrint(arena, " Yield: {d:.2}%", .{dy * 100.0}), th.contentStyle());
|
|
}
|
|
if (profile.total_holdings) |th_val| {
|
|
try col2.add(arena, try std.fmt.allocPrint(arena, " Holdings: {d}", .{th_val}), th.mutedStyle());
|
|
}
|
|
|
|
// Col 3: Sector allocation
|
|
if (profile.sectors) |sectors| {
|
|
if (sectors.len > 0) {
|
|
try col3.add(arena, "Sectors", th.headerStyle());
|
|
const show = @min(sectors.len, 7);
|
|
for (sectors[0..show]) |sec| {
|
|
// Truncate long sector names
|
|
const name = if (sec.sector.len > 20) sec.sector[0..20] else sec.sector;
|
|
try col3.add(arena, try std.fmt.allocPrint(arena, " {d:>5.1}% {s}", .{ sec.weight * 100.0, name }), th.contentStyle());
|
|
}
|
|
}
|
|
}
|
|
|
|
// Col 4: Top holdings
|
|
if (profile.holdings) |holdings| {
|
|
if (holdings.len > 0) {
|
|
try col4.add(arena, "Top Holdings", th.headerStyle());
|
|
const show = @min(holdings.len, 7);
|
|
for (holdings[0..show]) |h| {
|
|
const sym_str = h.symbol orelse "--";
|
|
try col4.add(arena, try std.fmt.allocPrint(arena, " {s:>6} {d:>5.1}%", .{ sym_str, h.weight * 100.0 }), th.contentStyle());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Merge all columns into grapheme-based StyledLines
|
|
const gap: usize = 3;
|
|
const bg_style = vaxis.Style{ .fg = theme_mod.Theme.vcolor(th.text), .bg = theme_mod.Theme.vcolor(th.bg) };
|
|
const cols = [_]*const Column{ &col1, &col2, &col3, &col4 };
|
|
var max_rows: usize = 0;
|
|
for (cols) |col| max_rows = @max(max_rows, col.len());
|
|
|
|
// Total max width for allocation
|
|
const max_width = col1.width + gap + col2.width + gap + col3.width + gap + col4.width + 4;
|
|
|
|
for (0..max_rows) |ri| {
|
|
const graphemes = try arena.alloc([]const u8, max_width);
|
|
const styles = try arena.alloc(vaxis.Style, max_width);
|
|
var pos: usize = 0;
|
|
|
|
for (cols, 0..) |col, ci| {
|
|
if (ci > 0 and col.len() == 0) continue; // skip empty columns entirely
|
|
if (ci > 0) {
|
|
// Gap between columns
|
|
for (0..gap) |_| {
|
|
if (pos < max_width) {
|
|
graphemes[pos] = " ";
|
|
styles[pos] = bg_style;
|
|
pos += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (ri < col.len()) {
|
|
const text = col.texts.items[ri];
|
|
const style = col.styles.items[ri];
|
|
// Write text characters
|
|
for (0..@min(text.len, col.width)) |ci2| {
|
|
if (pos < max_width) {
|
|
graphemes[pos] = glyph(text[ci2]);
|
|
styles[pos] = style;
|
|
pos += 1;
|
|
}
|
|
}
|
|
// Pad to column width
|
|
if (text.len < col.width) {
|
|
for (0..col.width - text.len) |_| {
|
|
if (pos < max_width) {
|
|
graphemes[pos] = " ";
|
|
styles[pos] = bg_style;
|
|
pos += 1;
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// Empty row in this column - pad full width
|
|
for (0..col.width) |_| {
|
|
if (pos < max_width) {
|
|
graphemes[pos] = " ";
|
|
styles[pos] = bg_style;
|
|
pos += 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
try lines.append(arena, .{
|
|
.text = "",
|
|
.style = bg_style,
|
|
.graphemes = graphemes[0..pos],
|
|
.cell_styles = styles[0..pos],
|
|
});
|
|
}
|
|
}
|
|
|
|
// ── Performance tab ──────────────────────────────────────────
|
|
|
|
fn buildPerfStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
|
const th = self.theme;
|
|
var lines: std.ArrayList(StyledLine) = .empty;
|
|
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
|
|
if (self.symbol.len == 0) {
|
|
try lines.append(arena, .{ .text = " No symbol selected. Press / to enter a symbol.", .style = th.mutedStyle() });
|
|
return lines.toOwnedSlice(arena);
|
|
}
|
|
|
|
if (self.candle_last_date) |d| {
|
|
var pdate_buf: [10]u8 = undefined;
|
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Trailing Returns: {s} (as of close on {s})", .{ self.symbol, d.format(&pdate_buf) }), .style = th.headerStyle() });
|
|
} else {
|
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Trailing Returns: {s}", .{self.symbol}), .style = th.headerStyle() });
|
|
}
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
|
|
if (self.candles == null and !self.perf_loaded) self.loadPerfData();
|
|
|
|
if (self.trailing_price == null) {
|
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " No data. Run: zfin perf {s}", .{self.symbol}), .style = th.mutedStyle() });
|
|
return lines.toOwnedSlice(arena);
|
|
}
|
|
|
|
if (self.candle_count > 0) {
|
|
if (self.candle_first_date) |first| {
|
|
if (self.candle_last_date) |last| {
|
|
var fb: [10]u8 = undefined;
|
|
var lb: [10]u8 = undefined;
|
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Data: {d} points ({s} to {s})", .{
|
|
self.candle_count, first.format(&fb), last.format(&lb),
|
|
}), .style = th.mutedStyle() });
|
|
}
|
|
}
|
|
}
|
|
|
|
if (self.candles) |cc| {
|
|
if (cc.len > 0) {
|
|
var close_buf: [24]u8 = undefined;
|
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Latest close: {s}", .{fmt.fmtMoney(&close_buf, cc[cc.len - 1].close)}), .style = th.contentStyle() });
|
|
}
|
|
}
|
|
|
|
const has_total = self.trailing_total != null;
|
|
|
|
if (self.candle_last_date) |last| {
|
|
var db: [10]u8 = undefined;
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " As-of {s}:", .{last.format(&db)}), .style = th.headerStyle() });
|
|
}
|
|
try appendStyledReturnsTable(arena, &lines, self.trailing_price.?, if (has_total) self.trailing_total else null, th);
|
|
|
|
{
|
|
const today = fmt.todayDate();
|
|
const month_end = today.lastDayOfPriorMonth();
|
|
var db: [10]u8 = undefined;
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Month-end ({s}):", .{month_end.format(&db)}), .style = th.headerStyle() });
|
|
}
|
|
if (self.trailing_me_price) |me_price| {
|
|
try appendStyledReturnsTable(arena, &lines, me_price, if (has_total) self.trailing_me_total else null, th);
|
|
}
|
|
|
|
if (!has_total) {
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
try lines.append(arena, .{ .text = " (Set POLYGON_API_KEY for total returns with dividends)", .style = th.dimStyle() });
|
|
}
|
|
|
|
if (self.risk_metrics) |rm| {
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
try lines.append(arena, .{ .text = " Risk Metrics:", .style = th.headerStyle() });
|
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Volatility (ann.): {d:.1}%", .{rm.volatility * 100.0}), .style = th.contentStyle() });
|
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Sharpe Ratio: {d:.2}", .{rm.sharpe}), .style = th.contentStyle() });
|
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Max Drawdown: {d:.1}%", .{rm.max_drawdown * 100.0}), .style = th.negativeStyle() });
|
|
if (rm.drawdown_trough) |dt| {
|
|
var db2: [10]u8 = undefined;
|
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " DD Trough: {s}", .{dt.format(&db2)}), .style = th.mutedStyle() });
|
|
}
|
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Sample Size: {d} days", .{rm.sample_size}), .style = th.mutedStyle() });
|
|
}
|
|
|
|
return lines.toOwnedSlice(arena);
|
|
}
|
|
|
|
fn appendStyledReturnsTable(
|
|
arena: std.mem.Allocator,
|
|
lines: *std.ArrayList(StyledLine),
|
|
price: zfin.performance.TrailingReturns,
|
|
total: ?zfin.performance.TrailingReturns,
|
|
th: theme_mod.Theme,
|
|
) !void {
|
|
const has_total = total != null;
|
|
if (has_total) {
|
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14} {s:>14}", .{ "", "Price Only", "Total Return" }), .style = th.mutedStyle() });
|
|
} else {
|
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14}", .{ "", "Price Only" }), .style = th.mutedStyle() });
|
|
}
|
|
|
|
const price_arr = [4]?zfin.performance.PerformanceResult{ price.one_year, price.three_year, price.five_year, price.ten_year };
|
|
const labels = [4][]const u8{ "1-Year Return:", "3-Year Return:", "5-Year Return:", "10-Year Return:" };
|
|
const annualize = [4]bool{ false, true, true, true };
|
|
|
|
for (0..4) |i| {
|
|
var price_str: [16]u8 = undefined;
|
|
var price_val: f64 = 0;
|
|
const ps = if (price_arr[i]) |r| blk: {
|
|
const val = if (annualize[i]) r.annualized_return orelse r.total_return else r.total_return;
|
|
price_val = val;
|
|
break :blk zfin.performance.formatReturn(&price_str, val);
|
|
} else "N/A";
|
|
|
|
const row_style = if (price_arr[i] != null)
|
|
(if (price_val >= 0) th.positiveStyle() else th.negativeStyle())
|
|
else
|
|
th.mutedStyle();
|
|
|
|
if (has_total) {
|
|
const t = total.?;
|
|
const total_arr = [4]?zfin.performance.PerformanceResult{ t.one_year, t.three_year, t.five_year, t.ten_year };
|
|
var total_str: [16]u8 = undefined;
|
|
const ts = if (total_arr[i]) |r| blk: {
|
|
const val = if (annualize[i]) r.annualized_return orelse r.total_return else r.total_return;
|
|
break :blk zfin.performance.formatReturn(&total_str, val);
|
|
} else "N/A";
|
|
|
|
const suffix: []const u8 = if (annualize[i]) " ann." else "";
|
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14} {s:>14}{s}", .{ labels[i], ps, ts, suffix }), .style = row_style });
|
|
} else {
|
|
const suffix: []const u8 = if (annualize[i]) " ann." else "";
|
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14}{s}", .{ labels[i], ps, suffix }), .style = row_style });
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Options tab ──────────────────────────────────────────────
|
|
|
|
fn buildOptionsStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
|
const th = self.theme;
|
|
var lines: std.ArrayList(StyledLine) = .empty;
|
|
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
|
|
if (self.symbol.len == 0) {
|
|
try lines.append(arena, .{ .text = " No symbol selected. Press / to enter a symbol.", .style = th.mutedStyle() });
|
|
return lines.toOwnedSlice(arena);
|
|
}
|
|
|
|
const chains = self.options_data orelse {
|
|
try lines.append(arena, .{ .text = " Loading options data...", .style = th.mutedStyle() });
|
|
return lines.toOwnedSlice(arena);
|
|
};
|
|
|
|
if (chains.len == 0) {
|
|
try lines.append(arena, .{ .text = " No options data found.", .style = th.mutedStyle() });
|
|
return lines.toOwnedSlice(arena);
|
|
}
|
|
|
|
var opt_ago_buf: [16]u8 = undefined;
|
|
const opt_ago = fmt.fmtTimeAgo(&opt_ago_buf, self.options_timestamp);
|
|
if (opt_ago.len > 0) {
|
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Options: {s} (data {s}, 15 min delay)", .{ self.symbol, opt_ago }), .style = th.headerStyle() });
|
|
} else {
|
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Options: {s}", .{self.symbol}), .style = th.headerStyle() });
|
|
}
|
|
|
|
if (chains[0].underlying_price) |price| {
|
|
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)", .{ fmt.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;
|
|
switch (row.kind) {
|
|
.expiration => {
|
|
if (row.exp_idx < chains.len) {
|
|
const chain = chains[row.exp_idx];
|
|
var db: [10]u8 = undefined;
|
|
const is_expanded = row.exp_idx < self.options_expanded.len and self.options_expanded[row.exp_idx];
|
|
const is_monthly = fmt.isMonthlyExpiration(chain.expiration);
|
|
const arrow: []const u8 = if (is_expanded) "v " else "> ";
|
|
const text = try std.fmt.allocPrint(arena, " {s}{s} ({d} calls, {d} puts)", .{
|
|
arrow,
|
|
chain.expiration.format(&db),
|
|
chain.calls.len,
|
|
chain.puts.len,
|
|
});
|
|
const style = if (is_cursor) th.selectStyle() else if (is_monthly) th.contentStyle() else th.mutedStyle();
|
|
try lines.append(arena, .{ .text = text, .style = style });
|
|
}
|
|
},
|
|
.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 style = if (is_cursor) th.selectStyle() else th.headerStyle();
|
|
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 ";
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
const style = if (is_cursor) th.selectStyle() else th.headerStyle();
|
|
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 text = try fmt.fmtContractLine(arena, prefix, cc);
|
|
const style = if (is_cursor) th.selectStyle() else th.contentStyle();
|
|
try lines.append(arena, .{ .text = text, .style = style });
|
|
}
|
|
},
|
|
.put => {
|
|
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 text = try fmt.fmtContractLine(arena, prefix, p);
|
|
const style = if (is_cursor) th.selectStyle() else th.contentStyle();
|
|
try lines.append(arena, .{ .text = text, .style = style });
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
return lines.toOwnedSlice(arena);
|
|
}
|
|
|
|
// ── Earnings tab ─────────────────────────────────────────────
|
|
|
|
fn buildEarningsStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
|
const th = self.theme;
|
|
var lines: std.ArrayList(StyledLine) = .empty;
|
|
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
|
|
if (self.symbol.len == 0) {
|
|
try lines.append(arena, .{ .text = " No symbol selected. Press / to enter a symbol.", .style = th.mutedStyle() });
|
|
return lines.toOwnedSlice(arena);
|
|
}
|
|
if (self.earnings_disabled) {
|
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Earnings not available for {s} (ETF/index)", .{self.symbol}), .style = th.mutedStyle() });
|
|
return lines.toOwnedSlice(arena);
|
|
}
|
|
|
|
var earn_ago_buf: [16]u8 = undefined;
|
|
const earn_ago = fmt.fmtTimeAgo(&earn_ago_buf, self.earnings_timestamp);
|
|
if (earn_ago.len > 0) {
|
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Earnings: {s} (data {s})", .{ self.symbol, earn_ago }), .style = th.headerStyle() });
|
|
} else {
|
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Earnings: {s}", .{self.symbol}), .style = th.headerStyle() });
|
|
}
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
|
|
const ev = self.earnings_data orelse {
|
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " No data. Run: zfin earnings {s}", .{self.symbol}), .style = th.mutedStyle() });
|
|
return lines.toOwnedSlice(arena);
|
|
};
|
|
if (ev.len == 0) {
|
|
try lines.append(arena, .{ .text = " No earnings events found.", .style = th.mutedStyle() });
|
|
return lines.toOwnedSlice(arena);
|
|
}
|
|
|
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:>12} {s:>4} {s:>12} {s:>12} {s:>12} {s:>10}", .{
|
|
"Date", "Q", "EPS Est", "EPS Act", "Surprise", "Surprise %",
|
|
}), .style = th.mutedStyle() });
|
|
|
|
for (ev) |e| {
|
|
var db: [10]u8 = undefined;
|
|
const date_str = e.date.format(&db);
|
|
|
|
var q_buf: [4]u8 = undefined;
|
|
const q_str = if (e.quarter) |q| std.fmt.bufPrint(&q_buf, "Q{d}", .{q}) catch "--" else "--";
|
|
|
|
var est_buf: [12]u8 = undefined;
|
|
const est_str = if (e.estimate) |est| std.fmt.bufPrint(&est_buf, "${d:.2}", .{est}) catch "--" else "--";
|
|
|
|
var act_buf: [12]u8 = undefined;
|
|
const act_str = if (e.actual) |act| std.fmt.bufPrint(&act_buf, "${d:.2}", .{act}) catch "--" else "--";
|
|
|
|
var surp_buf: [12]u8 = undefined;
|
|
const surp_str = if (e.surpriseAmount()) |s|
|
|
(if (s >= 0) std.fmt.bufPrint(&surp_buf, "+${d:.4}", .{s}) catch "?" else std.fmt.bufPrint(&surp_buf, "-${d:.4}", .{-s}) catch "?")
|
|
else
|
|
@as([]const u8, "--");
|
|
|
|
var surp_pct_buf: [12]u8 = undefined;
|
|
const surp_pct_str = if (e.surprisePct()) |sp|
|
|
(if (sp >= 0) std.fmt.bufPrint(&surp_pct_buf, "+{d:.1}%", .{sp}) catch "?" else std.fmt.bufPrint(&surp_pct_buf, "{d:.1}%", .{sp}) catch "?")
|
|
else
|
|
@as([]const u8, "--");
|
|
|
|
const text = try std.fmt.allocPrint(arena, " {s:>12} {s:>4} {s:>12} {s:>12} {s:>12} {s:>10}", .{
|
|
date_str, q_str, est_str, act_str, surp_str, surp_pct_str,
|
|
});
|
|
|
|
// Color by surprise
|
|
const surprise_positive = if (e.surpriseAmount()) |s| s >= 0 else true;
|
|
const row_style = if (e.isFuture()) th.mutedStyle() else if (surprise_positive) th.positiveStyle() else th.negativeStyle();
|
|
|
|
try lines.append(arena, .{ .text = text, .style = row_style });
|
|
}
|
|
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {d} earnings event(s)", .{ev.len}), .style = th.mutedStyle() });
|
|
|
|
return lines.toOwnedSlice(arena);
|
|
}
|
|
|
|
// ── Help ─────────────────────────────────────────────────────
|
|
|
|
fn buildHelpStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
|
const th = self.theme;
|
|
var lines: std.ArrayList(StyledLine) = .empty;
|
|
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
try lines.append(arena, .{ .text = " zfin TUI -- Keybindings", .style = th.headerStyle() });
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
|
|
const actions = comptime std.enums.values(keybinds.Action);
|
|
const action_labels = [_][]const u8{
|
|
"Quit", "Refresh", "Previous tab", "Next tab",
|
|
"Tab 1", "Tab 2", "Tab 3", "Tab 4",
|
|
"Tab 5", "Scroll down", "Scroll up", "Scroll to top",
|
|
"Scroll to bottom", "Page down", "Page up", "Select next",
|
|
"Select prev", "Expand/collapse", "Select symbol", "Change symbol (search)",
|
|
"This help", "Edit portfolio/watchlist",
|
|
"Toggle all calls (options)", "Toggle all puts (options)",
|
|
"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| {
|
|
var key_strs: [8][]const u8 = undefined;
|
|
var key_count: usize = 0;
|
|
for (self.keymap.bindings) |b| {
|
|
if (b.action == action and key_count < key_strs.len) {
|
|
var key_buf: [32]u8 = undefined;
|
|
if (keybinds.formatKeyCombo(b.key, &key_buf)) |s| {
|
|
key_strs[key_count] = try arena.dupe(u8, s);
|
|
key_count += 1;
|
|
}
|
|
}
|
|
}
|
|
if (key_count == 0) continue;
|
|
|
|
var combined_buf: [128]u8 = undefined;
|
|
var pos: usize = 0;
|
|
for (0..key_count) |ki| {
|
|
if (ki > 0) {
|
|
if (pos + 2 <= combined_buf.len) {
|
|
combined_buf[pos] = ',';
|
|
combined_buf[pos + 1] = ' ';
|
|
pos += 2;
|
|
}
|
|
}
|
|
const ks = key_strs[ki];
|
|
if (pos + ks.len <= combined_buf.len) {
|
|
@memcpy(combined_buf[pos..][0..ks.len], ks);
|
|
pos += ks.len;
|
|
}
|
|
}
|
|
|
|
const label_text = if (ai < action_labels.len) action_labels[ai] else @tagName(action);
|
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s}", .{ combined_buf[0..pos], label_text }), .style = th.contentStyle() });
|
|
}
|
|
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
try lines.append(arena, .{ .text = " Mouse: click tabs, scroll wheel, click rows", .style = th.mutedStyle() });
|
|
try lines.append(arena, .{ .text = " Config: ~/.config/zfin/keys.srf | theme.srf", .style = th.mutedStyle() });
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
try lines.append(arena, .{ .text = " Press any key to close.", .style = th.dimStyle() });
|
|
|
|
return lines.toOwnedSlice(arena);
|
|
}
|
|
|
|
// ── Tab navigation ───────────────────────────────────────────
|
|
|
|
fn nextTab(self: *App) void {
|
|
const idx = @intFromEnum(self.active_tab);
|
|
var next_idx = if (idx + 1 < tabs.len) idx + 1 else 0;
|
|
if (tabs[next_idx] == .earnings and self.earnings_disabled)
|
|
next_idx = if (next_idx + 1 < tabs.len) next_idx + 1 else 0;
|
|
self.active_tab = tabs[next_idx];
|
|
}
|
|
|
|
fn prevTab(self: *App) void {
|
|
const idx = @intFromEnum(self.active_tab);
|
|
var prev_idx = if (idx > 0) idx - 1 else tabs.len - 1;
|
|
if (tabs[prev_idx] == .earnings and self.earnings_disabled)
|
|
prev_idx = if (prev_idx > 0) prev_idx - 1 else tabs.len - 1;
|
|
self.active_tab = tabs[prev_idx];
|
|
}
|
|
};
|
|
|
|
// ── Utility functions ────────────────────────────────────────
|
|
|
|
/// Render a braille sparkline chart from candle close prices into StyledLines.
|
|
/// Uses the shared BrailleChart computation, then wraps results in vaxis styles.
|
|
fn renderBrailleToStyledLines(arena: std.mem.Allocator, lines: *std.ArrayList(StyledLine), data: []const zfin.Candle, th: theme_mod.Theme) !void {
|
|
var chart = fmt.computeBrailleChart(arena, data, 60, 10, th.positive, th.negative) catch return;
|
|
// No deinit needed: arena handles cleanup
|
|
|
|
const bg = th.bg;
|
|
for (0..chart.chart_height) |row| {
|
|
const graphemes = try arena.alloc([]const u8, chart.n_cols + 12); // chart + padding + label
|
|
const styles = try arena.alloc(vaxis.Style, chart.n_cols + 12);
|
|
var gpos: usize = 0;
|
|
|
|
// 2 leading spaces
|
|
graphemes[gpos] = " ";
|
|
styles[gpos] = .{ .fg = theme_mod.Theme.vcolor(th.text_muted), .bg = theme_mod.Theme.vcolor(bg) };
|
|
gpos += 1;
|
|
graphemes[gpos] = " ";
|
|
styles[gpos] = styles[0];
|
|
gpos += 1;
|
|
|
|
// Chart columns
|
|
for (0..chart.n_cols) |col| {
|
|
const pattern = chart.pattern(row, col);
|
|
graphemes[gpos] = fmt.brailleGlyph(pattern);
|
|
if (pattern != 0) {
|
|
styles[gpos] = .{ .fg = theme_mod.Theme.vcolor(chart.col_colors[col]), .bg = theme_mod.Theme.vcolor(bg) };
|
|
} else {
|
|
styles[gpos] = .{ .fg = theme_mod.Theme.vcolor(bg), .bg = theme_mod.Theme.vcolor(bg) };
|
|
}
|
|
gpos += 1;
|
|
}
|
|
|
|
// Right-side price labels
|
|
if (row == 0) {
|
|
const lbl = try std.fmt.allocPrint(arena, " {s}", .{chart.maxLabel()});
|
|
for (lbl) |ch| {
|
|
if (gpos < graphemes.len) {
|
|
graphemes[gpos] = glyph(ch);
|
|
styles[gpos] = .{ .fg = theme_mod.Theme.vcolor(th.text_muted), .bg = theme_mod.Theme.vcolor(bg) };
|
|
gpos += 1;
|
|
}
|
|
}
|
|
} else if (row == chart.chart_height - 1) {
|
|
const lbl = try std.fmt.allocPrint(arena, " {s}", .{chart.minLabel()});
|
|
for (lbl) |ch| {
|
|
if (gpos < graphemes.len) {
|
|
graphemes[gpos] = glyph(ch);
|
|
styles[gpos] = .{ .fg = theme_mod.Theme.vcolor(th.text_muted), .bg = theme_mod.Theme.vcolor(bg) };
|
|
gpos += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
try lines.append(arena, .{
|
|
.text = "",
|
|
.style = .{ .fg = theme_mod.Theme.vcolor(th.text), .bg = theme_mod.Theme.vcolor(bg) },
|
|
.graphemes = graphemes[0..gpos],
|
|
.cell_styles = styles[0..gpos],
|
|
});
|
|
}
|
|
|
|
// Date axis below chart
|
|
{
|
|
var start_buf: [7]u8 = undefined;
|
|
var end_buf: [7]u8 = undefined;
|
|
const start_label = fmt.BrailleChart.fmtShortDate(chart.start_date, &start_buf);
|
|
const end_label = fmt.BrailleChart.fmtShortDate(chart.end_date, &end_buf);
|
|
const muted_style = vaxis.Style{ .fg = theme_mod.Theme.vcolor(th.text_muted), .bg = theme_mod.Theme.vcolor(bg) };
|
|
|
|
const date_graphemes = try arena.alloc([]const u8, chart.n_cols + 12);
|
|
const date_styles = try arena.alloc(vaxis.Style, chart.n_cols + 12);
|
|
var dpos: usize = 0;
|
|
|
|
// 2 leading spaces
|
|
date_graphemes[dpos] = " ";
|
|
date_styles[dpos] = muted_style;
|
|
dpos += 1;
|
|
date_graphemes[dpos] = " ";
|
|
date_styles[dpos] = muted_style;
|
|
dpos += 1;
|
|
|
|
// Start date label
|
|
for (start_label) |ch| {
|
|
if (dpos < date_graphemes.len) {
|
|
date_graphemes[dpos] = glyph(ch);
|
|
date_styles[dpos] = muted_style;
|
|
dpos += 1;
|
|
}
|
|
}
|
|
|
|
// Gap between labels
|
|
const total_width = chart.n_cols;
|
|
if (total_width > start_label.len + end_label.len) {
|
|
const gap = total_width - start_label.len - end_label.len;
|
|
for (0..gap) |_| {
|
|
if (dpos < date_graphemes.len) {
|
|
date_graphemes[dpos] = " ";
|
|
date_styles[dpos] = muted_style;
|
|
dpos += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
// End date label
|
|
for (end_label) |ch| {
|
|
if (dpos < date_graphemes.len) {
|
|
date_graphemes[dpos] = glyph(ch);
|
|
date_styles[dpos] = muted_style;
|
|
dpos += 1;
|
|
}
|
|
}
|
|
|
|
try lines.append(arena, .{
|
|
.text = "",
|
|
.style = .{ .fg = theme_mod.Theme.vcolor(th.text), .bg = theme_mod.Theme.vcolor(bg) },
|
|
.graphemes = date_graphemes[0..dpos],
|
|
.cell_styles = date_styles[0..dpos],
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Load a watchlist from an SRF file.
|
|
fn loadWatchlist(allocator: std.mem.Allocator, path: []const u8) ?[][]const u8 {
|
|
const file_data = std.fs.cwd().readFileAlloc(allocator, path, 1024 * 1024) catch return null;
|
|
defer allocator.free(file_data);
|
|
|
|
var syms: std.ArrayList([]const u8) = .empty;
|
|
var file_lines = std.mem.splitScalar(u8, file_data, '\n');
|
|
while (file_lines.next()) |line| {
|
|
const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace);
|
|
if (trimmed.len == 0 or trimmed[0] == '#') continue;
|
|
if (std.mem.indexOf(u8, trimmed, "symbol::")) |idx| {
|
|
const rest = trimmed[idx + "symbol::".len ..];
|
|
const end = std.mem.indexOfScalar(u8, rest, ',') orelse rest.len;
|
|
const sym = std.mem.trim(u8, rest[0..end], &std.ascii.whitespace);
|
|
if (sym.len > 0 and sym.len <= 10) {
|
|
const duped = allocator.dupe(u8, sym) catch continue;
|
|
syms.append(allocator, duped) catch {
|
|
allocator.free(duped);
|
|
continue;
|
|
};
|
|
}
|
|
}
|
|
}
|
|
if (syms.items.len == 0) {
|
|
syms.deinit(allocator);
|
|
return null;
|
|
}
|
|
return syms.toOwnedSlice(allocator) catch null;
|
|
}
|
|
|
|
fn freeWatchlist(allocator: std.mem.Allocator, watchlist: ?[][]const u8) void {
|
|
if (watchlist) |wl| {
|
|
for (wl) |sym| allocator.free(sym);
|
|
allocator.free(wl);
|
|
}
|
|
}
|
|
|
|
/// Entry point for the interactive TUI.
|
|
pub fn run(allocator: std.mem.Allocator, config: zfin.Config, args: []const []const u8) !void {
|
|
var portfolio_path: ?[]const u8 = null;
|
|
var watchlist_path: ?[]const u8 = null;
|
|
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")) {
|
|
try keybinds.printDefaults();
|
|
return;
|
|
} else if (std.mem.eql(u8, args[i], "--default-theme")) {
|
|
try theme_mod.printDefaults();
|
|
return;
|
|
} else if (std.mem.eql(u8, args[i], "--portfolio") or std.mem.eql(u8, args[i], "-p")) {
|
|
if (i + 1 < args.len) {
|
|
i += 1;
|
|
portfolio_path = args[i];
|
|
}
|
|
} else if (std.mem.eql(u8, args[i], "--watchlist") or std.mem.eql(u8, args[i], "-w")) {
|
|
if (i + 1 < args.len) {
|
|
i += 1;
|
|
watchlist_path = args[i];
|
|
} else {
|
|
watchlist_path = "watchlist.srf";
|
|
}
|
|
} else if (std.mem.eql(u8, args[i], "--symbol") or std.mem.eql(u8, args[i], "-s")) {
|
|
if (i + 1 < args.len) {
|
|
i += 1;
|
|
symbol = args[i];
|
|
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;
|
|
}
|
|
}
|
|
|
|
if (portfolio_path == null and !has_explicit_symbol) {
|
|
if (std.fs.cwd().access("portfolio.srf", .{})) |_| {
|
|
portfolio_path = "portfolio.srf";
|
|
} else |_| {}
|
|
}
|
|
|
|
var keymap = blk: {
|
|
const home = std.posix.getenv("HOME") orelse break :blk keybinds.defaults();
|
|
const keys_path = std.fs.path.join(allocator, &.{ home, ".config", "zfin", "keys.srf" }) catch
|
|
break :blk keybinds.defaults();
|
|
defer allocator.free(keys_path);
|
|
break :blk keybinds.loadFromFile(allocator, keys_path) orelse keybinds.defaults();
|
|
};
|
|
defer keymap.deinit();
|
|
|
|
const theme = blk: {
|
|
const home = std.posix.getenv("HOME") orelse break :blk theme_mod.default_theme;
|
|
const theme_path = std.fs.path.join(allocator, &.{ home, ".config", "zfin", "theme.srf" }) catch
|
|
break :blk theme_mod.default_theme;
|
|
defer allocator.free(theme_path);
|
|
break :blk theme_mod.loadFromFile(allocator, theme_path) orelse theme_mod.default_theme;
|
|
};
|
|
|
|
var svc = try allocator.create(zfin.DataService);
|
|
defer allocator.destroy(svc);
|
|
svc.* = zfin.DataService.init(allocator, config);
|
|
defer svc.deinit();
|
|
|
|
var app_inst = try allocator.create(App);
|
|
defer allocator.destroy(app_inst);
|
|
app_inst.* = .{
|
|
.allocator = allocator,
|
|
.config = config,
|
|
.svc = svc,
|
|
.keymap = keymap,
|
|
.theme = theme,
|
|
.portfolio_path = portfolio_path,
|
|
.symbol = symbol,
|
|
.has_explicit_symbol = has_explicit_symbol,
|
|
.chart_config = chart_config,
|
|
};
|
|
|
|
if (portfolio_path) |path| {
|
|
const file_data = std.fs.cwd().readFileAlloc(allocator, path, 10 * 1024 * 1024) catch null;
|
|
if (file_data) |d| {
|
|
defer allocator.free(d);
|
|
if (zfin.cache.deserializePortfolio(allocator, d)) |pf| {
|
|
app_inst.portfolio = pf;
|
|
} else |_| {}
|
|
}
|
|
}
|
|
|
|
if (!skip_watchlist) {
|
|
const wl_path = watchlist_path orelse blk: {
|
|
std.fs.cwd().access("watchlist.srf", .{}) catch break :blk null;
|
|
break :blk @as(?[]const u8, "watchlist.srf");
|
|
};
|
|
if (wl_path) |path| {
|
|
app_inst.watchlist = loadWatchlist(allocator, path);
|
|
app_inst.watchlist_path = path;
|
|
}
|
|
}
|
|
|
|
if (has_explicit_symbol and symbol.len > 0) {
|
|
app_inst.active_tab = .quote;
|
|
}
|
|
|
|
defer if (app_inst.portfolio) |*pf| pf.deinit();
|
|
defer freeWatchlist(allocator, app_inst.watchlist);
|
|
defer app_inst.deinitData();
|
|
|
|
while (true) {
|
|
{
|
|
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)
|
|
|
|
if (!app_inst.wants_edit) break;
|
|
app_inst.wants_edit = false;
|
|
|
|
launchEditor(allocator, app_inst.portfolio_path, app_inst.watchlist_path);
|
|
app_inst.reloadFiles();
|
|
app_inst.active_tab = .portfolio;
|
|
}
|
|
}
|
|
|
|
/// Launch $EDITOR on the portfolio and/or watchlist files.
|
|
fn launchEditor(allocator: std.mem.Allocator, portfolio_path: ?[]const u8, watchlist_path: ?[]const u8) void {
|
|
const editor = std.posix.getenv("EDITOR") orelse std.posix.getenv("VISUAL") orelse "vi";
|
|
|
|
var argv_buf: [4][]const u8 = undefined;
|
|
var argc: usize = 0;
|
|
argv_buf[argc] = editor;
|
|
argc += 1;
|
|
if (portfolio_path) |p| {
|
|
argv_buf[argc] = p;
|
|
argc += 1;
|
|
}
|
|
if (watchlist_path) |p| {
|
|
argv_buf[argc] = p;
|
|
argc += 1;
|
|
}
|
|
const argv = argv_buf[0..argc];
|
|
|
|
var child = std.process.Child.init(argv, allocator);
|
|
|
|
child.spawn() catch return;
|
|
_ = child.wait() catch {};
|
|
}
|