4086 lines
185 KiB
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);
|
|
}
|