zfin/src/tui.zig

4086 lines
185 KiB
Zig

const std = @import("std");
const vaxis = @import("vaxis");
const zfin = @import("root.zig");
const fmt = zfin.format;
const keybinds = @import("tui/keybinds.zig");
const theme_mod = @import("tui/theme.zig");
const chart_mod = @import("tui/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;
};
/// Build a fixed-display-width column header label with optional sort indicator.
/// The indicator (▲/▼, 3 bytes, 1 display column) replaces a padding space so total
/// display width stays constant. Indicator always appears on the left side.
/// `left` controls text alignment (left-aligned vs right-aligned).
fn colLabel(buf: []u8, name: []const u8, comptime col_width: usize, left: bool, indicator: ?[]const u8) []const u8 {
const ind = indicator orelse {
// No indicator: plain padded label
if (left) {
@memset(buf[0..col_width], ' ');
@memcpy(buf[0..name.len], name);
return buf[0..col_width];
} else {
@memset(buf[0..col_width], ' ');
const offset = col_width - name.len;
@memcpy(buf[offset..][0..name.len], name);
return buf[0..col_width];
}
};
// Indicator always on the left, replacing one padding space.
// total display cols = col_width, byte length = col_width - 1 + ind.len
const total_bytes = col_width - 1 + ind.len;
if (total_bytes > buf.len) return name;
if (left) {
// "▲Name " — indicator, text, then spaces
@memcpy(buf[0..ind.len], ind);
@memcpy(buf[ind.len..][0..name.len], name);
const content_len = ind.len + name.len;
if (content_len < total_bytes) @memset(buf[content_len..total_bytes], ' ');
} else {
// " ▲Name" — spaces, indicator, then text
const pad = col_width - name.len - 1;
@memset(buf[0..pad], ' ');
@memcpy(buf[pad..][0..ind.len], ind);
@memcpy(buf[pad + ind.len ..][0..name.len], name);
}
return buf[0..total_bytes];
}
// Portfolio column layout: gain/loss column start position (display columns).
// prefix(4) + sym(sym_col_width+1) + shares(9) + avgcost(11) + price(11) + mv(17) = 4 + sym_col_width + 49
const gl_col_start: usize = 4 + fmt.sym_col_width + 49;
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,
analysis,
fn label(self: Tab) []const u8 {
return switch (self) {
.portfolio => " 1:Portfolio ",
.quote => " 2:Quote ",
.performance => " 3:Performance ",
.options => " 4:Options ",
.earnings => " 5:Earnings ",
.analysis => " 6:Analysis ",
};
}
};
const tabs = [_]Tab{ .portfolio, .quote, .performance, .options, .earnings, .analysis };
const InputMode = enum {
normal,
symbol_input,
help,
};
/// Sort field for portfolio columns.
const PortfolioSortField = enum {
symbol,
shares,
avg_cost,
price,
market_value,
gain_loss,
weight,
account,
pub fn label(self: PortfolioSortField) []const u8 {
return switch (self) {
.symbol => "Symbol",
.shares => "Shares",
.avg_cost => "Avg Cost",
.price => "Price",
.market_value => "Market Value",
.gain_loss => "Gain/Loss",
.weight => "Weight",
.account => "Account",
};
}
pub fn next(self: PortfolioSortField) ?PortfolioSortField {
const fields = std.meta.fields(PortfolioSortField);
const idx: usize = @intFromEnum(self);
if (idx + 1 >= fields.len) return null;
return @enumFromInt(idx + 1);
}
pub fn prev(self: PortfolioSortField) ?PortfolioSortField {
const idx: usize = @intFromEnum(self);
if (idx == 0) return null;
return @enumFromInt(idx - 1);
}
};
const SortDirection = enum {
asc,
desc,
pub fn flip(self: SortDirection) SortDirection {
return if (self == .asc) .desc else .asc;
}
pub fn indicator(self: SortDirection) []const u8 {
return if (self == .asc) "" else "";
}
};
/// 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,
/// DRIP summary data (for drip_summary rows)
drip_is_lt: bool = false, // true = LT summary, false = ST summary
drip_lot_count: usize = 0,
drip_shares: f64 = 0,
drip_avg_cost: f64 = 0,
drip_date_first: ?zfin.Date = null,
drip_date_last: ?zfin.Date = null,
const Kind = enum { position, lot, watchlist, section_header, option_row, cd_row, cash_row, cash_total, illiquid_row, illiquid_total, drip_summary };
};
/// 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
illiquid_expanded: bool = false, // whether illiquid section is expanded to show per-asset
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
portfolio_sort_field: PortfolioSortField = .symbol, // current sort column
portfolio_sort_dir: SortDirection = .asc, // current sort direction
watchlist_prices: ?std.StringHashMap(f64) = null, // cached watchlist prices (no disk I/O during render)
// 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,
historical_snapshots: ?[zfin.risk.HistoricalPeriod.all.len]zfin.risk.HistoricalSnapshot = 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,
// Analysis tab state
analysis_result: ?zfin.analysis.AnalysisResult = null,
analysis_loaded: bool = false,
classification_map: ?zfin.classification.ClassificationMap = null,
account_map: ?zfin.analysis.AccountMap = null,
// 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_timeframe_row: ?usize = null, // screen row of the timeframe selector (for mouse clicks)
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;
// Click on column header row -> sort by that column
if (self.portfolio_header_lines > 0 and content_row == self.portfolio_header_lines - 1) {
// Column boundaries derived from sym_col_width (sw).
// prefix(4) + Symbol(sw+1) + Shares(8+1) + AvgCost(10+1) + Price(10+1) + MV(16+1) + G/L(14+1) + Weight(8)
const sw = fmt.sym_col_width;
const col = @as(usize, @intCast(mouse.col));
const new_field: ?PortfolioSortField =
if (col < 4 + sw + 1) .symbol else if (col < 4 + sw + 10) .shares else if (col < 4 + sw + 21) .avg_cost else if (col < 4 + sw + 32) .price else if (col < 4 + sw + 49) .market_value else if (col < 4 + sw + 64) .gain_loss else if (col < 4 + sw + 73) .weight else if (col < 4 + sw + 87) null // Date (not sortable)
else .account;
if (new_field) |nf| {
if (nf == self.portfolio_sort_field) {
self.portfolio_sort_dir = self.portfolio_sort_dir.flip();
} else {
self.portfolio_sort_field = nf;
self.portfolio_sort_dir = if (nf == .symbol or nf == .account) .asc else .desc;
}
self.sortPortfolioAllocations();
self.rebuildPortfolioRows();
return ctx.consumeAndRedraw();
}
}
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();
}
}
}
}
// Quote tab: click on timeframe selector to switch timeframes
if (self.active_tab == .quote and mouse.row > 0) {
if (self.chart_timeframe_row) |tf_row| {
const content_row = @as(usize, @intCast(mouse.row)) + self.scroll_offset;
if (content_row == tf_row) {
// " Chart: [6M] YTD 1Y 3Y 5Y ([ ] to change)"
// Prefix " Chart: " = 9 chars, then each TF takes label_len+2 (brackets/spaces) + 1 gap
const col = @as(usize, @intCast(mouse.col));
const prefix_len: usize = 9; // " Chart: "
if (col >= prefix_len) {
const timeframes = [_]chart_mod.Timeframe{ .@"6M", .ytd, .@"1Y", .@"3Y", .@"5Y" };
var x: usize = prefix_len;
for (timeframes) |tf| {
const lbl_len = tf.label().len;
const slot_width = lbl_len + 2 + 1; // [XX] + space or XX + space
if (col >= x and col < x + slot_width) {
if (tf != self.chart_timeframe) {
self.chart_timeframe = tf;
self.setStatus(tf.label());
return ctx.consumeAndRedraw();
}
break;
}
x += slot_width;
}
}
}
}
}
// 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, .tab_6 => {
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();
}
},
.reload_portfolio => {
self.reloadPortfolioFile();
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();
}
},
.sort_col_next => {
if (self.active_tab == .portfolio) {
if (self.portfolio_sort_field.next()) |new_field| {
self.portfolio_sort_field = new_field;
self.portfolio_sort_dir = if (new_field == .symbol or new_field == .account) .asc else .desc;
self.sortPortfolioAllocations();
self.rebuildPortfolioRows();
}
return ctx.consumeAndRedraw();
}
},
.sort_col_prev => {
if (self.active_tab == .portfolio) {
if (self.portfolio_sort_field.prev()) |new_field| {
self.portfolio_sort_field = new_field;
self.portfolio_sort_dir = if (new_field == .symbol or new_field == .account) .asc else .desc;
self.sortPortfolioAllocations();
self.rebuildPortfolioRows();
}
return ctx.consumeAndRedraw();
}
},
.sort_reverse => {
if (self.active_tab == .portfolio) {
self.portfolio_sort_dir = self.portfolio_sort_dir.flip();
self.sortPortfolioAllocations();
self.rebuildPortfolioRows();
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, .illiquid_row, .section_header, .drip_summary => {},
.cash_total => {
self.cash_expanded = !self.cash_expanded;
self.rebuildPortfolioRows();
},
.illiquid_total => {
self.illiquid_expanded = !self.illiquid_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, .analysis => {},
}
}
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();
},
.analysis => {
self.analysis_loaded = false;
if (self.analysis_result) |*ar| ar.deinit(self.allocator);
self.analysis_result = null;
if (self.account_map) |*am| am.deinit();
self.account_map = null;
},
}
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();
},
.analysis => {
if (!self.analysis_loaded) self.loadAnalysisData();
},
}
}
fn loadPortfolioData(self: *App) void {
self.portfolio_loaded = true;
self.freePortfolioSummary();
// Fetch data for watchlist symbols so they have prices to display
// (from both the separate watchlist file and watch lots in the portfolio)
if (self.watchlist_prices) |*wp| wp.clearRetainingCapacity() else {
self.watchlist_prices = std.StringHashMap(f64).init(self.allocator);
}
var wp = &(self.watchlist_prices.?);
if (self.watchlist) |wl| {
for (wl) |sym| {
const result = self.svc.getCandles(sym) catch continue;
defer self.allocator.free(result.data);
if (result.data.len > 0) {
wp.put(sym, result.data[result.data.len - 1].close) catch {};
}
}
}
if (self.portfolio) |pf| {
for (pf.lots) |lot| {
if (lot.lot_type == .watch) {
const sym = lot.priceSymbol();
const result = self.svc.getCandles(sym) catch continue;
defer self.allocator.free(result.data);
if (result.data.len > 0) {
wp.put(sym, result.data[result.data.len - 1].close) catch {};
}
}
}
}
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;
var fetch_count: usize = 0;
var stale_count: usize = 0;
var failed_syms: [8][]const u8 = undefined;
{
const TuiProgress = struct {
app: *App,
failed: *[8][]const u8,
fail_n: usize = 0,
fn onProgress(ctx: *anyopaque, _: usize, _: usize, symbol: []const u8, status: zfin.DataService.SymbolStatus) void {
const s: *@This() = @ptrCast(@alignCast(ctx));
switch (status) {
.fetching => {
var buf: [64]u8 = undefined;
const msg = std.fmt.bufPrint(&buf, "Loading {s}...", .{symbol}) catch "Loading...";
s.app.setStatus(msg);
},
.failed, .failed_used_stale => {
if (s.fail_n < s.failed.len) {
s.failed[s.fail_n] = symbol;
s.fail_n += 1;
}
},
else => {},
}
}
fn callback(s: *@This()) zfin.DataService.ProgressCallback {
return .{
.context = @ptrCast(s),
.on_progress = onProgress,
};
}
};
var tui_progress = TuiProgress{ .app = self, .failed = &failed_syms };
const load_result = self.svc.loadPrices(syms, &prices, false, tui_progress.callback());
latest_date = load_result.latest_date;
fail_count = load_result.fail_count;
fetch_count = load_result.fetched_count;
stale_count = load_result.stale_count;
}
self.candle_last_date = latest_date;
// Build fallback prices for symbols that failed API fetch
var manual_price_set = zfin.risk.buildFallbackPrices(self.allocator, pf.lots, positions, &prices) catch {
self.setStatus("Error building fallback prices");
return;
};
defer manual_price_set.deinit();
var summary = zfin.risk.portfolioSummary(self.allocator, positions, prices, manual_price_set) 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
summary.adjustForNonStockAssets(pf);
self.portfolio_summary = summary;
// Compute historical portfolio snapshots from cached candle data
{
var candle_map = std.StringHashMap([]const zfin.Candle).init(self.allocator);
defer {
var it = candle_map.valueIterator();
while (it.next()) |v| self.allocator.free(v.*);
candle_map.deinit();
}
for (syms) |sym| {
if (self.svc.getCachedCandles(sym)) |cs| {
candle_map.put(sym, cs) catch {};
}
}
self.historical_snapshots = zfin.risk.computeHistoricalSnapshots(
fmt.todayDate(),
positions,
prices,
candle_map,
);
}
self.sortPortfolioAllocations();
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: [256]u8 = undefined;
if (fail_count <= 3) {
// Show actual symbol names for easier debugging
var sym_buf: [128]u8 = undefined;
var sym_len: usize = 0;
const show = @min(fail_count, failed_syms.len);
for (0..show) |fi| {
if (sym_len > 0) {
if (sym_len + 2 < sym_buf.len) {
sym_buf[sym_len] = ',';
sym_buf[sym_len + 1] = ' ';
sym_len += 2;
}
}
const s = failed_syms[fi];
const copy_len = @min(s.len, sym_buf.len - sym_len);
@memcpy(sym_buf[sym_len..][0..copy_len], s[0..copy_len]);
sym_len += copy_len;
}
if (stale_count > 0) {
const warn_msg = std.fmt.bufPrint(&warn_buf, "Failed to refresh: {s} (using stale cache)", .{sym_buf[0..sym_len]}) catch "Warning: some securities failed";
self.setStatus(warn_msg);
} else {
const warn_msg = std.fmt.bufPrint(&warn_buf, "Failed to load: {s}", .{sym_buf[0..sym_len]}) catch "Warning: some securities failed";
self.setStatus(warn_msg);
}
} else {
if (stale_count > 0 and stale_count == fail_count) {
const warn_msg = std.fmt.bufPrint(&warn_buf, "{d} symbols failed to refresh (using stale cache) | r/F5 to retry", .{fail_count}) catch "Warning: some securities used stale cache";
self.setStatus(warn_msg);
} else {
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 if (fetch_count > 0) {
var info_buf: [128]u8 = undefined;
const info_msg = std.fmt.bufPrint(&info_buf, "Loaded {d} symbols ({d} fetched) | r/F5 to refresh", .{ syms.len, fetch_count }) catch "Loaded | r/F5 to refresh";
self.setStatus(info_msg);
} else {
self.setStatus("j/k navigate | Enter expand | s select symbol | / search | ? help");
}
}
fn sortPortfolioAllocations(self: *App) void {
if (self.portfolio_summary) |s| {
const SortCtx = struct {
field: PortfolioSortField,
dir: SortDirection,
fn lessThan(ctx: @This(), a: zfin.risk.Allocation, b: zfin.risk.Allocation) bool {
const lhs = if (ctx.dir == .asc) a else b;
const rhs = if (ctx.dir == .asc) b else a;
return switch (ctx.field) {
.symbol => std.mem.lessThan(u8, lhs.display_symbol, rhs.display_symbol),
.shares => lhs.shares < rhs.shares,
.avg_cost => lhs.avg_cost < rhs.avg_cost,
.price => lhs.current_price < rhs.current_price,
.market_value => lhs.market_value < rhs.market_value,
.gain_loss => lhs.unrealized_pnl < rhs.unrealized_pnl,
.weight => lhs.weight < rhs.weight,
.account => std.mem.lessThan(u8, lhs.account, rhs.account),
};
}
};
std.mem.sort(zfin.risk.Allocation, s.allocations, SortCtx{ .field = self.portfolio_sort_field, .dir = self.portfolio_sort_dir }, SortCtx.lessThan);
}
}
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.priceSymbol(), 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.priceSymbol(), a.symbol)) {
matching.append(self.allocator, lot) catch continue;
}
}
std.mem.sort(zfin.Lot, matching.items, {}, fmt.lotSortFn);
// Check if any lots are DRIP
var has_drip = false;
for (matching.items) |lot| {
if (lot.drip) {
has_drip = true;
break;
}
}
if (!has_drip) {
// No DRIP lots: show all individually
for (matching.items) |lot| {
self.portfolio_rows.append(self.allocator, .{
.kind = .lot,
.symbol = lot.symbol,
.pos_idx = i,
.lot = lot,
}) catch continue;
}
} else {
// Has DRIP lots: show non-DRIP individually, summarize DRIP as ST/LT
for (matching.items) |lot| {
if (!lot.drip) {
self.portfolio_rows.append(self.allocator, .{
.kind = .lot,
.symbol = lot.symbol,
.pos_idx = i,
.lot = lot,
}) catch continue;
}
}
// Build ST and LT DRIP summaries
const drip = fmt.aggregateDripLots(matching.items);
if (!drip.st.isEmpty()) {
self.portfolio_rows.append(self.allocator, .{
.kind = .drip_summary,
.symbol = a.symbol,
.pos_idx = i,
.drip_is_lt = false,
.drip_lot_count = drip.st.lot_count,
.drip_shares = drip.st.shares,
.drip_avg_cost = drip.st.avgCost(),
.drip_date_first = drip.st.first_date,
.drip_date_last = drip.st.last_date,
}) catch {};
}
if (!drip.lt.isEmpty()) {
self.portfolio_rows.append(self.allocator, .{
.kind = .drip_summary,
.symbol = a.symbol,
.pos_idx = i,
.drip_is_lt = true,
.drip_lot_count = drip.lt.lot_count,
.drip_shares = drip.lt.shares,
.drip_avg_cost = drip.lt.avgCost(),
.drip_date_first = drip.lt.first_date,
.drip_date_last = drip.lt.last_date,
}) catch {};
}
}
}
}
}
}
// Add watchlist items from both the separate watchlist file and
// watch lots embedded in the portfolio. Skip symbols already in allocations.
var watch_seen = std.StringHashMap(void).init(self.allocator);
defer watch_seen.deinit();
// Mark all portfolio position symbols as seen
if (self.portfolio_summary) |s| {
for (s.allocations) |a| {
watch_seen.put(a.symbol, {}) catch {};
}
}
// Watch lots from portfolio file
if (self.portfolio) |pf| {
for (pf.lots) |lot| {
if (lot.lot_type == .watch) {
if (watch_seen.contains(lot.priceSymbol())) continue;
watch_seen.put(lot.priceSymbol(), {}) catch {};
self.portfolio_rows.append(self.allocator, .{
.kind = .watchlist,
.symbol = lot.symbol,
}) catch continue;
}
}
}
// Separate watchlist file (backward compat)
if (self.watchlist) |wl| {
for (wl) |sym| {
if (watch_seen.contains(sym)) continue;
watch_seen.put(sym, {}) catch {};
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, {}, fmt.lotMaturitySortFn);
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;
}
}
}
}
// Illiquid assets section (similar to cash: total row, expandable)
if (pf.hasType(.illiquid)) {
self.portfolio_rows.append(self.allocator, .{
.kind = .section_header,
.symbol = "Illiquid Assets",
}) catch {};
// Total illiquid row
self.portfolio_rows.append(self.allocator, .{
.kind = .illiquid_total,
.symbol = "ILLIQUID",
}) catch {};
// Per-asset rows (expanded when illiquid_total is toggled)
if (self.illiquid_expanded) {
for (pf.lots) |lot| {
if (lot.lot_type == .illiquid) {
self.portfolio_rows.append(self.allocator, .{
.kind = .illiquid_row,
.symbol = lot.symbol,
.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);
if (self.watchlist_prices) |*wp| wp.deinit();
if (self.analysis_result) |*ar| ar.deinit(self.allocator);
if (self.classification_map) |*cm| cm.deinit();
if (self.account_map) |*am| am.deinit();
}
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();
}
/// Reload portfolio file from disk without re-fetching prices.
/// Uses cached candle data to recompute summary.
fn reloadPortfolioFile(self: *App) void {
// Re-read the portfolio file
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 {
self.setStatus("Error reading portfolio file");
return;
};
defer self.allocator.free(file_data);
if (zfin.cache.deserializePortfolio(self.allocator, file_data)) |pf| {
self.portfolio = pf;
} else |_| {
self.setStatus("Error parsing portfolio file");
return;
}
} else {
self.setStatus("No portfolio file to reload");
return;
}
// Reload watchlist file too (if separate)
freeWatchlist(self.allocator, self.watchlist);
self.watchlist = null;
if (self.watchlist_path) |path| {
self.watchlist = loadWatchlist(self.allocator, path);
}
// Recompute summary using cached prices (no network)
self.freePortfolioSummary();
self.expanded = [_]bool{false} ** 64;
self.cash_expanded = false;
self.illiquid_expanded = false;
self.cursor = 0;
self.scroll_offset = 0;
self.portfolio_rows.clearRetainingCapacity();
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();
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 missing: usize = 0;
for (syms) |sym| {
// Cache only — no network
const candles_slice = self.svc.getCachedCandles(sym);
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;
}
} else {
missing += 1;
}
}
self.candle_last_date = latest_date;
// Build fallback prices for reload path
var manual_price_set = zfin.risk.buildFallbackPrices(self.allocator, pf.lots, positions, &prices) catch {
self.setStatus("Error building fallback prices");
return;
};
defer manual_price_set.deinit();
var summary = zfin.risk.portfolioSummary(self.allocator, positions, prices, manual_price_set) catch {
self.setStatus("Error computing portfolio summary");
return;
};
if (summary.allocations.len == 0) {
summary.deinit(self.allocator);
self.setStatus("No cached prices available");
return;
}
// Include non-stock assets
summary.adjustForNonStockAssets(pf);
self.portfolio_summary = summary;
// Compute historical snapshots from cache (reload path)
{
var candle_map = std.StringHashMap([]const zfin.Candle).init(self.allocator);
defer {
var it = candle_map.valueIterator();
while (it.next()) |v| self.allocator.free(v.*);
candle_map.deinit();
}
for (syms) |sym| {
if (self.svc.getCachedCandles(sym)) |cs| {
candle_map.put(sym, cs) catch {};
}
}
self.historical_snapshots = zfin.risk.computeHistoricalSnapshots(
fmt.todayDate(),
positions,
prices,
candle_map,
);
}
self.sortPortfolioAllocations();
self.rebuildPortfolioRows();
// Invalidate analysis data -- it holds pointers into old portfolio memory
if (self.analysis_result) |*ar| ar.deinit(self.allocator);
self.analysis_result = null;
self.analysis_loaded = false;
// If currently on the analysis tab, eagerly recompute so the user
// doesn't see an error message before switching away and back.
if (self.active_tab == .analysis) {
self.loadAnalysisData();
}
if (missing > 0) {
var warn_buf: [128]u8 = undefined;
const warn_msg = std.fmt.bufPrint(&warn_buf, "Reloaded. {d} symbols missing cached prices", .{missing}) catch "Reloaded (some prices missing)";
self.setStatus(warn_msg);
} else {
self.setStatus("Portfolio reloaded from disk");
}
}
// ── 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)),
.analysis => try self.drawStyledContent(ctx.arena, buf, width, height, try self.buildAnalysisStyledLines(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 {
// UTF-8 aware rendering: byte index and column index tracked separately
var col: usize = 0;
var bi: usize = 0;
while (bi < line.text.len and col < width) {
var s = line.style;
if (line.alt_style) |alt| {
if (col >= line.alt_start and col < line.alt_end) s = alt;
}
const byte = line.text[bi];
if (byte < 0x80) {
// ASCII: single byte, single column
buf[row * width + col] = .{ .char = .{ .grapheme = ascii_g[byte] }, .style = s };
bi += 1;
} else {
// Multi-byte UTF-8: determine sequence length
const seq_len: usize = if (byte >= 0xF0) 4 else if (byte >= 0xE0) 3 else if (byte >= 0xC0) 2 else 1;
const end = @min(bi + seq_len, line.text.len);
buf[row * width + col] = .{ .char = .{ .grapheme = line.text[bi..end] }, .style = s };
bi = end;
}
col += 1;
}
}
}
}
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() });
}
// Net Worth line (only if portfolio has illiquid assets)
if (self.portfolio) |pf| {
if (pf.hasType(.illiquid)) {
const illiquid_total = pf.totalIlliquid();
const net_worth = s.total_value + illiquid_total;
var nw_buf: [24]u8 = undefined;
var il_buf: [24]u8 = undefined;
const nw_text = try std.fmt.allocPrint(arena, " Net Worth: {s} (Liquid: {s} Illiquid: {s})", .{
fmt.fmtMoney(&nw_buf, net_worth),
val_str,
fmt.fmtMoney(&il_buf, illiquid_total),
});
try lines.append(arena, .{ .text = nw_text, .style = th.headerStyle() });
}
}
// Historical portfolio value snapshots
if (self.historical_snapshots) |snapshots| {
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
// Build a single-line summary: " Historical: 1M: +3.2% 3M: +8.1% 1Y: +22.4% 3Y: +45.1% 5Y: -- 10Y: --"
var hist_parts: [6][]const u8 = undefined;
for (zfin.risk.HistoricalPeriod.all, 0..) |period, pi| {
const snap = snapshots[pi];
var hbuf: [16]u8 = undefined;
const change_str = fmt.fmtHistoricalChange(&hbuf, snap.position_count, snap.changePct());
hist_parts[pi] = try std.fmt.allocPrint(arena, "{s}: {s}", .{ period.label(), change_str });
}
const hist_text = try std.fmt.allocPrint(arena, " Historical: {s} {s} {s} {s} {s} {s}", .{
hist_parts[0], hist_parts[1], hist_parts[2], hist_parts[3], hist_parts[4], hist_parts[5],
});
try lines.append(arena, .{ .text = hist_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)
// Active sort column gets a sort indicator within the column width
const sf = self.portfolio_sort_field;
const si = self.portfolio_sort_dir.indicator();
// Build column labels with indicator embedded in padding
// Left-aligned cols: "Name▲ " Right-aligned cols: " ▼Price"
var sym_hdr_buf: [16]u8 = undefined;
var shr_hdr_buf: [16]u8 = undefined;
var avg_hdr_buf: [16]u8 = undefined;
var prc_hdr_buf: [16]u8 = undefined;
var mv_hdr_buf: [24]u8 = undefined;
var gl_hdr_buf: [24]u8 = undefined;
var wt_hdr_buf: [16]u8 = undefined;
const sym_hdr = colLabel(&sym_hdr_buf, "Symbol", fmt.sym_col_width, true, if (sf == .symbol) si else null);
const shr_hdr = colLabel(&shr_hdr_buf, "Shares", 8, false, if (sf == .shares) si else null);
const avg_hdr = colLabel(&avg_hdr_buf, "Avg Cost", 10, false, if (sf == .avg_cost) si else null);
const prc_hdr = colLabel(&prc_hdr_buf, "Price", 10, false, if (sf == .price) si else null);
const mv_hdr = colLabel(&mv_hdr_buf, "Market Value", 16, false, if (sf == .market_value) si else null);
const gl_hdr = colLabel(&gl_hdr_buf, "Gain/Loss", 14, false, if (sf == .gain_loss) si else null);
const wt_hdr = colLabel(&wt_hdr_buf, "Weight", 8, false, if (sf == .weight) si else null);
const acct_ind: []const u8 = if (sf == .account) si else "";
const hdr = try std.fmt.allocPrint(arena, " {s} {s} {s} {s} {s} {s} {s} {s:>13} {s}{s}", .{
sym_hdr, shr_hdr, avg_hdr, prc_hdr, mv_hdr, gl_hdr, wt_hdr, "Date", acct_ind, "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.priceSymbol(), 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.priceSymbol(), 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}" ++ fmt.sym_col_spec ++ " {d:>8.1} {s:>10} {s:>10} {s:>16} {s:>14} {d:>7.1}% {s:>13} {s}", .{
arrow, star, a.display_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
// Manual-price positions use warning color to indicate stale/estimated price
const base_style = if (is_cursor) th.selectStyle() else if (a.is_manual_price) th.warningStyle() 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 = gl_col_start,
.alt_end = gl_col_start + 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, " " ++ fmt.sym_col_spec ++ " {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 = gl_col_start,
.alt_end = gl_col_start + 14,
});
}
},
.watchlist => {
var price_str3: [16]u8 = undefined;
const ps: []const u8 = if (self.watchlist_prices) |wp|
(if (wp.get(row.symbol)) |p| fmt.fmtMoney2(&price_str3, p) else "--")
else
"--";
const star2: []const u8 = if (is_active_sym) "* " else " ";
const text = try std.fmt.allocPrint(arena, " {s}" ++ fmt.sym_col_spec ++ " {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_row_buf: [160]u8 = undefined;
const row_text = fmt.fmtCashRow(&cash_row_buf, row.symbol, lot.shares, lot.note);
const text = try std.fmt.allocPrint(arena, " {s}", .{row_text});
const row_style5 = if (is_cursor) th.selectStyle() else th.mutedStyle();
try lines.append(arena, .{ .text = text, .style = row_style5 });
}
},
.illiquid_total => {
if (self.portfolio) |pf| {
const total_illiquid = pf.totalIlliquid();
var illiquid_buf: [24]u8 = undefined;
const arrow4: []const u8 = if (self.illiquid_expanded) "v " else "> ";
const text = try std.fmt.allocPrint(arena, " {s}Total Illiquid {s:>14}", .{
arrow4,
fmt.fmtMoney(&illiquid_buf, total_illiquid),
});
const row_style6 = if (is_cursor) th.selectStyle() else th.contentStyle();
try lines.append(arena, .{ .text = text, .style = row_style6 });
}
},
.illiquid_row => {
if (row.lot) |lot| {
var illiquid_row_buf: [160]u8 = undefined;
const row_text = fmt.fmtIlliquidRow(&illiquid_row_buf, row.symbol, lot.shares, lot.note);
const text = try std.fmt.allocPrint(arena, " {s}", .{row_text});
const row_style7 = if (is_cursor) th.selectStyle() else th.mutedStyle();
try lines.append(arena, .{ .text = text, .style = row_style7 });
}
},
.drip_summary => {
const label_str: []const u8 = if (row.drip_is_lt) "LT" else "ST";
var drip_avg_buf: [24]u8 = undefined;
var drip_d1_buf: [10]u8 = undefined;
var drip_d2_buf: [10]u8 = undefined;
const drip_d1: []const u8 = if (row.drip_date_first) |d| d.format(&drip_d1_buf)[0..7] else "?";
const drip_d2: []const u8 = if (row.drip_date_last) |d| d.format(&drip_d2_buf)[0..7] else "?";
const text = try std.fmt.allocPrint(arena, " {s}: {d} DRIP lots, {d:.1} shares, avg {s} ({s} to {s})", .{
label_str,
row.drip_lot_count,
row.drip_shares,
fmt.fmtMoney2(&drip_avg_buf, row.drip_avg_cost),
drip_d1,
drip_d2,
});
const drip_style = if (is_cursor) th.selectStyle() else th.mutedStyle();
try lines.append(arena, .{ .text = text, .style = drip_style });
},
}
// 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;
var chg_buf: [64]u8 = undefined;
const change_style = if (change >= 0) th.positiveStyle() else th.negativeStyle();
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: {s}", .{fmt.fmtPriceChange(&chg_buf, 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;
var chg_buf: [64]u8 = undefined;
const change_style = if (change >= 0) th.positiveStyle() else th.negativeStyle();
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: {s}", .{fmt.fmtPriceChange(&chg_buf, 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;
self.chart_timeframe_row = lines.items.len; // track which row the timeframe line is on
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() });
{
var chg_buf: [64]u8 = undefined;
const change_style = if (q.change >= 0) th.positiveStyle() else th.negativeStyle();
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: {s}", .{fmt.fmtPriceChange(&chg_buf, q.change, q.percent_change)}), .style = change_style });
}
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 row_buf: [128]u8 = undefined;
const day_change = if (candle.close >= candle.open) th.positiveStyle() else th.negativeStyle();
try lines.append(arena, .{ .text = try arena.dupe(u8, fmt.fmtCandleRow(&row_buf, candle)), .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;
var chg_buf: [64]u8 = undefined;
const change_style = if (change >= 0) th.positiveStyle() else th.negativeStyle();
try col1.add(arena, try std.fmt.allocPrint(arena, " Change: {s}", .{fmt.fmtPriceChange(&chg_buf, 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 total_arr_vals: [4]?zfin.performance.PerformanceResult = if (total) |t|
.{ t.one_year, t.three_year, t.five_year, t.ten_year }
else
.{ null, null, null, null };
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_buf: [32]u8 = undefined;
var total_buf: [32]u8 = undefined;
const row = fmt.fmtReturnsRow(
&price_buf,
&total_buf,
price_arr[i],
if (has_total) total_arr_vals[i] else null,
annualize[i],
);
const row_style = if (price_arr[i] != null)
(if (row.price_positive) th.positiveStyle() else th.negativeStyle())
else
th.mutedStyle();
if (has_total) {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14} {s:>14}{s}", .{ labels[i], row.price_str, row.total_str orelse "N/A", row.suffix }), .style = row_style });
} else {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14}{s}", .{ labels[i], row.price_str, row.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 {
return renderEarningsLines(arena, self.theme, self.symbol, self.earnings_disabled, self.earnings_data, self.earnings_timestamp);
}
// ── Analysis tab ────────────────────────────────────────────
fn loadAnalysisData(self: *App) void {
self.analysis_loaded = true;
// Ensure portfolio is loaded first
if (!self.portfolio_loaded) self.loadPortfolioData();
const pf = self.portfolio orelse return;
const summary = self.portfolio_summary orelse return;
// Load classification metadata file
if (self.classification_map == null) {
// Look for metadata.srf next to the portfolio file
if (self.portfolio_path) |ppath| {
// Derive metadata path: same directory as portfolio, named "metadata.srf"
const dir_end = if (std.mem.lastIndexOfScalar(u8, ppath, '/')) |idx| idx + 1 else 0;
const meta_path = std.fmt.allocPrint(self.allocator, "{s}metadata.srf", .{ppath[0..dir_end]}) catch return;
defer self.allocator.free(meta_path);
const file_data = std.fs.cwd().readFileAlloc(self.allocator, meta_path, 1024 * 1024) catch {
self.setStatus("No metadata.srf found. Run: zfin enrich <portfolio.srf> > metadata.srf");
return;
};
defer self.allocator.free(file_data);
self.classification_map = zfin.classification.parseClassificationFile(self.allocator, file_data) catch {
self.setStatus("Error parsing metadata.srf");
return;
};
}
}
// Load account tax type metadata file (optional)
if (self.account_map == null) {
if (self.portfolio_path) |ppath| {
const dir_end = if (std.mem.lastIndexOfScalar(u8, ppath, '/')) |idx| idx + 1 else 0;
const acct_path = std.fmt.allocPrint(self.allocator, "{s}accounts.srf", .{ppath[0..dir_end]}) catch {
self.loadAnalysisDataFinish(pf, summary);
return;
};
defer self.allocator.free(acct_path);
if (std.fs.cwd().readFileAlloc(self.allocator, acct_path, 1024 * 1024)) |acct_data| {
defer self.allocator.free(acct_data);
self.account_map = zfin.analysis.parseAccountsFile(self.allocator, acct_data) catch null;
} else |_| {
// accounts.srf is optional -- analysis works without it
}
}
}
self.loadAnalysisDataFinish(pf, summary);
}
fn loadAnalysisDataFinish(self: *App, pf: zfin.Portfolio, summary: zfin.risk.PortfolioSummary) void {
const cm = self.classification_map orelse {
self.setStatus("No classification data. Run: zfin enrich <portfolio.srf> > metadata.srf");
return;
};
// Free previous result
if (self.analysis_result) |*ar| ar.deinit(self.allocator);
self.analysis_result = zfin.analysis.analyzePortfolio(
self.allocator,
summary.allocations,
cm,
pf,
summary.total_value,
self.account_map,
) catch {
self.setStatus("Error computing analysis");
return;
};
}
fn buildAnalysisStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine {
return renderAnalysisLines(arena, self.theme, self.analysis_result);
}
fn fmtBreakdownLine(arena: std.mem.Allocator, item: zfin.analysis.BreakdownItem, bar_width: usize, label_width: usize) ![]const u8 {
var val_buf: [24]u8 = undefined;
const pct = item.weight * 100.0;
const bar = try buildBlockBar(arena, item.weight, bar_width);
// Build label padded to label_width
const lbl = item.label;
const lbl_len = @min(lbl.len, label_width);
const padded_label = try arena.alloc(u8, label_width);
@memcpy(padded_label[0..lbl_len], lbl[0..lbl_len]);
if (lbl_len < label_width) @memset(padded_label[lbl_len..], ' ');
return std.fmt.allocPrint(arena, " {s} {s} {d:>5.1}% {s}", .{
padded_label, bar, pct, fmt.fmtMoney(&val_buf, item.value),
});
}
/// Build a bar using Unicode block elements for sub-character precision.
/// Wraps fmt.buildBlockBar into arena-allocated memory.
fn buildBlockBar(arena: std.mem.Allocator, weight: f64, total_chars: usize) ![]const u8 {
var buf: [256]u8 = undefined;
const result = fmt.buildBlockBar(&buf, weight, total_chars);
return arena.dupe(u8, result);
}
// ── 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", "Tab 6", "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", "Reload portfolio from disk",
"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", "Sort: next column", "Sort: prev column", "Sort: reverse order",
};
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);
}
}
// Force test discovery for imported TUI sub-modules
comptime {
_ = keybinds;
_ = theme_mod;
}
/// 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 {};
}
// ── Standalone render functions (testable without App) ────────────────
/// Render earnings tab content. Pure function — no App dependency.
fn renderEarningsLines(
arena: std.mem.Allocator,
th: theme_mod.Theme,
symbol: []const u8,
earnings_disabled: bool,
earnings_data: ?[]const zfin.EarningsEvent,
earnings_timestamp: i64,
) ![]const StyledLine {
var lines: std.ArrayList(StyledLine) = .empty;
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
if (symbol.len == 0) {
try lines.append(arena, .{ .text = " No symbol selected. Press / to enter a symbol.", .style = th.mutedStyle() });
return lines.toOwnedSlice(arena);
}
if (earnings_disabled) {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Earnings not available for {s} (ETF/index)", .{symbol}), .style = th.mutedStyle() });
return lines.toOwnedSlice(arena);
}
var earn_ago_buf: [16]u8 = undefined;
const earn_ago = fmt.fmtTimeAgo(&earn_ago_buf, earnings_timestamp);
if (earn_ago.len > 0) {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Earnings: {s} (data {s})", .{ symbol, earn_ago }), .style = th.headerStyle() });
} else {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Earnings: {s}", .{symbol}), .style = th.headerStyle() });
}
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
const ev = earnings_data orelse {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " No data. Run: zfin earnings {s}", .{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 row_buf: [128]u8 = undefined;
const row = fmt.fmtEarningsRow(&row_buf, e);
const text = try std.fmt.allocPrint(arena, " {s}", .{row.text});
const row_style = if (row.is_future) th.mutedStyle() else if (row.is_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);
}
/// Render analysis tab content. Pure function — no App dependency.
fn renderAnalysisLines(
arena: std.mem.Allocator,
th: theme_mod.Theme,
analysis_result: ?zfin.analysis.AnalysisResult,
) ![]const StyledLine {
var lines: std.ArrayList(StyledLine) = .empty;
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = " Portfolio Analysis", .style = th.headerStyle() });
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
const result = analysis_result orelse {
try lines.append(arena, .{ .text = " No analysis data. Ensure metadata.srf exists alongside portfolio.", .style = th.mutedStyle() });
try lines.append(arena, .{ .text = " Run: zfin enrich <portfolio.srf> > metadata.srf", .style = th.mutedStyle() });
return lines.toOwnedSlice(arena);
};
const bar_width: usize = 30;
const label_width: usize = 24;
const sections = [_]struct { items: []const zfin.analysis.BreakdownItem, title: []const u8 }{
.{ .items = result.asset_class, .title = " Asset Class" },
.{ .items = result.sector, .title = " Sector (Equities)" },
.{ .items = result.geo, .title = " Geographic" },
.{ .items = result.account, .title = " By Account" },
.{ .items = result.tax_type, .title = " By Tax Type" },
};
for (sections, 0..) |sec, si| {
if (si > 0 and sec.items.len == 0) continue;
if (si > 0) try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = sec.title, .style = th.headerStyle() });
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
for (sec.items) |item| {
const text = try App.fmtBreakdownLine(arena, item, bar_width, label_width);
try lines.append(arena, .{ .text = text, .style = th.contentStyle(), .alt_text = null, .alt_style = th.barFillStyle(), .alt_start = 2 + label_width + 1, .alt_end = 2 + label_width + 1 + bar_width });
}
}
if (result.unclassified.len > 0) {
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = " Unclassified (not in metadata.srf)", .style = th.warningStyle() });
for (result.unclassified) |sym| {
const text = try std.fmt.allocPrint(arena, " {s}", .{sym});
try lines.append(arena, .{ .text = text, .style = th.mutedStyle() });
}
}
return lines.toOwnedSlice(arena);
}
// ── Tests ─────────────────────────────────────────────────────────────
const testing = std.testing;
test "colLabel plain left-aligned" {
var buf: [32]u8 = undefined;
const result = colLabel(&buf, "Name", 10, true, null);
try testing.expectEqualStrings("Name ", result);
try testing.expectEqual(@as(usize, 10), result.len);
}
test "colLabel plain right-aligned" {
var buf: [32]u8 = undefined;
const result = colLabel(&buf, "Price", 10, false, null);
try testing.expectEqualStrings(" Price", result);
}
test "colLabel with indicator left-aligned" {
var buf: [64]u8 = undefined;
const result = colLabel(&buf, "Name", 10, true, "\xe2\x96\xb2"); // ▲ = 3 bytes
// Indicator + text + padding. Display width is 10, byte length is 10 - 1 + 3 = 12
try testing.expectEqual(@as(usize, 12), result.len);
try testing.expect(std.mem.startsWith(u8, result, "\xe2\x96\xb2")); // starts with ▲
try testing.expect(std.mem.indexOf(u8, result, "Name") != null);
}
test "colLabel with indicator right-aligned" {
var buf: [64]u8 = undefined;
const result = colLabel(&buf, "Price", 10, false, "\xe2\x96\xbc"); // ▼
try testing.expectEqual(@as(usize, 12), result.len);
try testing.expect(std.mem.endsWith(u8, result, "Price"));
}
test "glyph ASCII returns single-char slice" {
try testing.expectEqualStrings("A", glyph('A'));
try testing.expectEqualStrings(" ", glyph(' '));
try testing.expectEqualStrings("0", glyph('0'));
}
test "glyph non-ASCII returns space" {
try testing.expectEqualStrings(" ", glyph(200));
}
test "PortfolioSortField next/prev" {
// next from first field
try testing.expectEqual(PortfolioSortField.shares, PortfolioSortField.symbol.next().?);
// next from last field returns null
try testing.expectEqual(@as(?PortfolioSortField, null), PortfolioSortField.account.next());
// prev from first returns null
try testing.expectEqual(@as(?PortfolioSortField, null), PortfolioSortField.symbol.prev());
// prev from last
try testing.expectEqual(PortfolioSortField.weight, PortfolioSortField.account.prev().?);
}
test "PortfolioSortField label" {
try testing.expectEqualStrings("Symbol", PortfolioSortField.symbol.label());
try testing.expectEqualStrings("Market Value", PortfolioSortField.market_value.label());
}
test "SortDirection flip and indicator" {
try testing.expectEqual(SortDirection.desc, SortDirection.asc.flip());
try testing.expectEqual(SortDirection.asc, SortDirection.desc.flip());
try testing.expectEqualStrings("\xe2\x96\xb2", SortDirection.asc.indicator()); // ▲
try testing.expectEqualStrings("\xe2\x96\xbc", SortDirection.desc.indicator()); // ▼
}
test "buildBlockBar empty" {
const bar = try App.buildBlockBar(testing.allocator, 0, 10);
defer testing.allocator.free(bar);
// All spaces
try testing.expectEqual(@as(usize, 10), bar.len);
try testing.expectEqualStrings(" ", bar);
}
test "buildBlockBar full" {
const bar = try App.buildBlockBar(testing.allocator, 1.0, 5);
defer testing.allocator.free(bar);
// 5 full blocks, each 3 bytes UTF-8 (█ = E2 96 88)
try testing.expectEqual(@as(usize, 15), bar.len);
// Verify first block is █
try testing.expectEqualStrings("\xe2\x96\x88", bar[0..3]);
}
test "buildBlockBar partial" {
const bar = try App.buildBlockBar(testing.allocator, 0.5, 10);
defer testing.allocator.free(bar);
// 50% of 10 chars = 5 full blocks (no partial)
// 5 full blocks (15 bytes) + 5 spaces = 20 bytes
try testing.expectEqual(@as(usize, 20), bar.len);
}
test "fmtBreakdownLine formats correctly" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const item = zfin.analysis.BreakdownItem{
.label = "US Stock",
.weight = 0.65,
.value = 130000,
};
const line = try App.fmtBreakdownLine(arena, item, 10, 12);
// Should contain the label, percentage, and dollar amount
try testing.expect(std.mem.indexOf(u8, line, "US Stock") != null);
try testing.expect(std.mem.indexOf(u8, line, "65.0%") != null);
try testing.expect(std.mem.indexOf(u8, line, "$130,000") != null);
}
test "Tab label" {
try testing.expectEqualStrings(" 1:Portfolio ", Tab.portfolio.label());
try testing.expectEqualStrings(" 6:Analysis ", Tab.analysis.label());
}
test "renderEarningsLines with earnings data" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const th = theme_mod.default_theme;
const events = [_]zfin.EarningsEvent{.{
.symbol = "AAPL",
.date = try zfin.Date.parse("2025-01-15"),
.quarter = 4,
.estimate = 1.50,
.actual = 1.65,
}};
const lines = try renderEarningsLines(arena, th, "AAPL", false, &events, 0);
// blank + header + blank + col_header + data_row + blank + count = 7
try testing.expectEqual(@as(usize, 7), lines.len);
try testing.expect(std.mem.indexOf(u8, lines[1].text, "AAPL") != null);
try testing.expect(std.mem.indexOf(u8, lines[3].text, "EPS Est") != null);
// Data row should contain the date
try testing.expect(std.mem.indexOf(u8, lines[4].text, "2025-01-15") != null);
}
test "renderEarningsLines no symbol" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const th = theme_mod.default_theme;
const lines = try renderEarningsLines(arena, th, "", false, null, 0);
try testing.expectEqual(@as(usize, 2), lines.len);
try testing.expect(std.mem.indexOf(u8, lines[1].text, "No symbol") != null);
}
test "renderEarningsLines disabled" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const th = theme_mod.default_theme;
const lines = try renderEarningsLines(arena, th, "VTI", true, null, 0);
try testing.expectEqual(@as(usize, 2), lines.len);
try testing.expect(std.mem.indexOf(u8, lines[1].text, "ETF/index") != null);
}
test "renderEarningsLines no data" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const th = theme_mod.default_theme;
const lines = try renderEarningsLines(arena, th, "AAPL", false, null, 0);
try testing.expectEqual(@as(usize, 4), lines.len);
try testing.expect(std.mem.indexOf(u8, lines[3].text, "No data") != null);
}
test "renderAnalysisLines with data" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const th = theme_mod.default_theme;
var asset_class = [_]zfin.analysis.BreakdownItem{
.{ .label = "US Stock", .weight = 0.60, .value = 120000 },
.{ .label = "Int'l Stock", .weight = 0.40, .value = 80000 },
};
const result = zfin.analysis.AnalysisResult{
.asset_class = &asset_class,
.sector = &.{},
.geo = &.{},
.account = &.{},
.tax_type = &.{},
.unclassified = &.{},
.total_value = 200000,
};
const lines = try renderAnalysisLines(arena, th, result);
// Should have header section + asset class items
try testing.expect(lines.len >= 5);
// Find "Portfolio Analysis" header
var found_header = false;
for (lines) |l| {
if (std.mem.indexOf(u8, l.text, "Portfolio Analysis") != null) found_header = true;
}
try testing.expect(found_header);
// Find asset class data
var found_us = false;
for (lines) |l| {
if (std.mem.indexOf(u8, l.text, "US Stock") != null) found_us = true;
}
try testing.expect(found_us);
}
test "renderAnalysisLines no data" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const th = theme_mod.default_theme;
const lines = try renderAnalysisLines(arena, th, null);
try testing.expectEqual(@as(usize, 5), lines.len);
try testing.expect(std.mem.indexOf(u8, lines[3].text, "No analysis data") != null);
}