zfin/src/tui/main.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 {};
}