diff --git a/src/net/http.zig b/src/net/http.zig index 8942fad..c2e3b10 100644 --- a/src/net/http.zig +++ b/src/net/http.zig @@ -48,7 +48,7 @@ pub const Client = struct { return self.request(.POST, url, body, extra_headers); } - fn request(self: *Client, method: std.http.Method, url: []const u8, body: ?[]const u8, extra_headers: []const std.http.Header) HttpError!Response { + pub fn request(self: *Client, method: std.http.Method, url: []const u8, body: ?[]const u8, extra_headers: []const std.http.Header) HttpError!Response { var attempt: u8 = 0; while (true) : (attempt += 1) { const response = self.doRequest(method, url, body, extra_headers) catch { diff --git a/src/providers/cboe.zig b/src/providers/cboe.zig index e5ad4bc..c6354e7 100644 --- a/src/providers/cboe.zig +++ b/src/providers/cboe.zig @@ -47,7 +47,9 @@ pub const Cboe = struct { const url = try buildCboeUrl(allocator, symbol); defer allocator.free(url); - var response = try self.client.get(url); + // Request with Accept-Encoding: identity to avoid Zig 0.15 stdlib deflate panic + // on malformed compressed responses from CBOE's CDN. + var response = try self.client.request(.GET, url, null, &.{.{ .name = "Accept-Encoding", .value = "identity" }}); defer response.deinit(); return parseResponse(allocator, response.body, symbol); diff --git a/src/tui.zig b/src/tui.zig index 84688a0..86d8188 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -6,6 +6,12 @@ const cli = @import("commands/common.zig"); const keybinds = @import("tui/keybinds.zig"); const theme_mod = @import("tui/theme.zig"); const chart_mod = @import("tui/chart.zig"); +const portfolio_tab = @import("tui/portfolio_tab.zig"); +const quote_tab = @import("tui/quote_tab.zig"); +const perf_tab = @import("tui/perf_tab.zig"); +const options_tab = @import("tui/options_tab.zig"); +const earnings_tab = @import("tui/earnings_tab.zig"); +const analysis_tab = @import("tui/analysis_tab.zig"); /// Comptime-generated table of single-character grapheme slices with static lifetime. /// This avoids dangling pointers from stack-allocated temporaries in draw functions. @@ -22,7 +28,7 @@ const ascii_g = blk: { /// 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 { +pub 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) { @@ -58,21 +64,14 @@ fn colLabel(buf: []u8, name: []const u8, comptime col_width: usize, left: bool, // 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; +pub const gl_col_start: usize = 4 + fmt.sym_col_width + 49; -fn glyph(ch: u8) []const u8 { +pub 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 { +pub const Tab = enum { portfolio, quote, performance, @@ -94,14 +93,13 @@ const Tab = enum { const tabs = [_]Tab{ .portfolio, .quote, .performance, .options, .earnings, .analysis }; -const InputMode = enum { +pub const InputMode = enum { normal, symbol_input, help, }; -/// Sort field for portfolio columns. -const PortfolioSortField = enum { +pub const PortfolioSortField = enum { symbol, shares, avg_cost, @@ -138,7 +136,7 @@ const PortfolioSortField = enum { } }; -const SortDirection = enum { +pub const SortDirection = enum { asc, desc, @@ -151,8 +149,7 @@ const SortDirection = enum { } }; -/// A row in the portfolio view -- position header, lot detail, or special sections. -const PortfolioRow = struct { +pub const PortfolioRow = struct { kind: Kind, symbol: []const u8, /// For position rows: index into allocations; for lot rows: lot data. @@ -171,8 +168,7 @@ const PortfolioRow = struct { 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 { +pub const StyledLine = struct { text: []const u8, style: vaxis.Style, // Optional per-character style override ranges (for mixed-color lines) @@ -187,16 +183,15 @@ const StyledLine = struct { cell_styles: ?[]const vaxis.Style = null, }; -const OptionsRowKind = enum { expiration, calls_header, puts_header, call, put }; +pub 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 { +pub const OptionsRow = struct { kind: OptionsRowKind, exp_idx: usize = 0, // index into options_data chains contract: ?zfin.OptionContract = null, }; -const App = struct { +pub const App = struct { allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataService, @@ -728,7 +723,7 @@ const App = struct { } fn ensureCursorVisible(self: *App) void { - const cursor_row = self.cursor + 4; // 4 header lines + const cursor_row = self.cursor + self.portfolio_header_lines; if (cursor_row < self.scroll_offset) { self.scroll_offset = cursor_row; } @@ -886,7 +881,7 @@ const App = struct { } fn ensureOptionsCursorVisible(self: *App) void { - const cursor_row = self.options_cursor + 5; // 5 header lines in options content + const cursor_row = self.options_cursor + self.options_header_lines; if (cursor_row < self.scroll_offset) { self.scroll_offset = cursor_row; } @@ -896,7 +891,7 @@ const App = struct { } } - fn setActiveSymbol(self: *App, sym: []const u8) void { + pub 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]); for (self.symbol_buf[0..len]) |*c| c.* = std.ascii.toUpper(c.*); @@ -1021,474 +1016,19 @@ const App = struct { } } - fn loadPortfolioData(self: *App) void { - self.portfolio_loaded = true; - self.freePortfolioSummary(); - - 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; - - if (self.prefetched_prices) |*pp| { - // Use pre-fetched prices from before TUI started (first load only) - // Move stock prices into the working map - for (syms) |sym| { - if (pp.get(sym)) |price| { - prices.put(sym, price) catch {}; - } - } - - // Extract watchlist prices - if (self.watchlist_prices) |*wp| wp.clearRetainingCapacity() else { - self.watchlist_prices = std.StringHashMap(f64).init(self.allocator); - } - var wp = &(self.watchlist_prices.?); - var pp_iter = pp.iterator(); - while (pp_iter.next()) |entry| { - if (!prices.contains(entry.key_ptr.*)) { - wp.put(entry.key_ptr.*, entry.value_ptr.*) catch {}; - } - } - - pp.deinit(); - self.prefetched_prices = null; - } else { - // Live fetch (refresh path) — fetch watchlist first, then stock prices - 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 {}; - } - } - } - for (pf.lots) |lot| { - if (lot.security_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 {}; - } - } - } - - // Fetch stock prices with TUI status-bar progress - 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.valuation.buildFallbackPrices(self.allocator, pf.lots, positions, &prices) catch { - self.setStatus("Error building fallback prices"); - return; - }; - defer manual_price_set.deinit(); - - var summary = zfin.valuation.portfolioSummary(self.allocator, pf, 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 first"); - return; - } - - 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.valuation.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"); - } + pub fn loadPortfolioData(self: *App) void { + portfolio_tab.loadPortfolioData(self); } fn sortPortfolioAllocations(self: *App) void { - if (self.portfolio_summary) |s| { - const SortCtx = struct { - field: PortfolioSortField, - dir: SortDirection, - - fn lessThan(ctx: @This(), a: zfin.valuation.Allocation, b: zfin.valuation.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_gain_loss < rhs.unrealized_gain_loss, - .weight => lhs.weight < rhs.weight, - .account => std.mem.lessThan(u8, lhs.account, rhs.account), - }; - } - }; - std.mem.sort(zfin.valuation.Allocation, s.allocations, SortCtx{ .field = self.portfolio_sort_field, .dir = self.portfolio_sort_dir }, SortCtx.lessThan); - } + portfolio_tab.sortPortfolioAllocations(self); } 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.security_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.security_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.security_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.security_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.security_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.security_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.security_type == .illiquid) { - self.portfolio_rows.append(self.allocator, .{ - .kind = .illiquid_row, - .symbol = lot.symbol, - .lot = lot, - }) catch continue; - } - } - } - } - } + portfolio_tab.rebuildPortfolioRows(self); } - fn loadPerfData(self: *App) void { + pub fn loadPerfData(self: *App) void { self.perf_loaded = true; self.freeCandles(); self.freeDividends(); @@ -1592,7 +1132,7 @@ const App = struct { 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 { + pub 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; @@ -1603,22 +1143,22 @@ const App = struct { return self.status_msg[0..self.status_len]; } - fn freeCandles(self: *App) void { + pub fn freeCandles(self: *App) void { if (self.candles) |c| self.allocator.free(c); self.candles = null; } - fn freeDividends(self: *App) void { + pub fn freeDividends(self: *App) void { if (self.dividends) |d| zfin.Dividend.freeSlice(self.allocator, d); self.dividends = null; } - fn freeEarnings(self: *App) void { + pub fn freeEarnings(self: *App) void { if (self.earnings_data) |e| self.allocator.free(e); self.earnings_data = null; } - fn freeOptions(self: *App) void { + pub fn freeOptions(self: *App) void { if (self.options_data) |chains| { for (chains) |chain| { self.allocator.free(chain.calls); @@ -1630,7 +1170,7 @@ const App = struct { self.options_data = null; } - fn freeEtfProfile(self: *App) void { + pub fn freeEtfProfile(self: *App) void { if (self.etf_profile) |profile| { if (profile.holdings) |h| { for (h) |holding| { @@ -1648,7 +1188,7 @@ const App = struct { self.etf_loaded = false; } - fn freePortfolioSummary(self: *App) void { + pub fn freePortfolioSummary(self: *App) void { if (self.portfolio_summary) |*s| s.deinit(self.allocator); self.portfolio_summary = null; } @@ -1701,138 +1241,7 @@ const App = struct { /// 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.valuation.buildFallbackPrices(self.allocator, pf.lots, positions, &prices) catch { - self.setStatus("Error building fallback prices"); - return; - }; - defer manual_price_set.deinit(); - - var summary = zfin.valuation.portfolioSummary(self.allocator, pf, 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; - } - - 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.valuation.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"); - } + portfolio_tab.reloadPortfolioFile(self); } // ── Drawing ────────────────────────────────────────────────── @@ -1934,7 +1343,7 @@ const App = struct { 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 { + pub 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 @@ -2020,1356 +1429,43 @@ const App = struct { // ── 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.fmtMoneyAbs(&val_buf, s.total_value); - const cost_str = fmt.fmtMoneyAbs(&cost_buf, s.total_cost); - const gl_abs = if (s.unrealized_gain_loss >= 0) s.unrealized_gain_loss else -s.unrealized_gain_loss; - const gl_str = fmt.fmtMoneyAbs(&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_gain_loss >= 0) @as([]const u8, "+") else @as([]const u8, "-"), gl_str, s.unrealized_return * 100.0, - }); - const summary_style = if (s.unrealized_gain_loss >= 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.fmtMoneyAbs(&nw_buf, net_worth), - val_str, - fmt.fmtMoneyAbs(&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.valuation.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 ' 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_gain_loss / a.cost_basis) * 100.0 else @as(f64, 0); - var gl_val_buf: [24]u8 = undefined; - const gl_abs = if (a.unrealized_gain_loss >= 0) a.unrealized_gain_loss else -a.unrealized_gain_loss; - const gl_money = fmt.fmtMoneyAbs(&gl_val_buf, gl_abs); - var pnl_buf: [20]u8 = undefined; - const pnl_str = if (a.unrealized_gain_loss >= 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.fmtMoneyAbs(&mv_buf, a.market_value); - var cost_buf2: [24]u8 = undefined; - const cost_str = fmt.fmtMoneyAbs(&cost_buf2, a.avg_cost); - var price_buf2: [24]u8 = undefined; - const price_str = fmt.fmtMoneyAbs(&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.security_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.security_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 and market value if we have a price - var lot_gl_str: []const u8 = ""; - var lot_mv_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.fmtMoneyAbs(&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 lot_mv_buf: [24]u8 = undefined; - lot_mv_str = try std.fmt.allocPrint(arena, "{s}", .{fmt.fmtMoneyAbs(&lot_mv_buf, lot.shares * use_price)}); - } - } - - var price_str2: [24]u8 = undefined; - const lot_price_str = fmt.fmtMoneyAbs(&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_mv_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.fmtMoneyAbs(&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.fmtMoneyAbs(&cost_buf3, cost_per), - fmt.fmtMoneyAbs(&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.fmtMoneyAbs(&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.fmtMoneyAbs(&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.fmtMoneyAbs(&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.fmtMoneyAbs(&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); + return portfolio_tab.drawContent(self, arena, buf, width, height); } // ── 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 styled_lines = try options_tab.buildStyledLines(self, 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.fmtMoneyAbs(&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.fmtMoneyAbs(&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.fmtMoneyAbs(&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| { - var title_buf: [64]u8 = undefined; - const title_name = fmt.toTitleCase(&title_buf, sec.name); - const name = if (title_name.len > 20) title_name[0..20] else title_name; - 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], - }); - } + return quote_tab.drawContent(self, ctx, buf, width, height); } // ── 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.fmtMoneyAbs(&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) |tr| { - try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); - try lines.append(arena, .{ .text = " Risk Metrics (monthly returns):", .style = th.headerStyle() }); - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14} {s:>14} {s:>14}", .{ "", "Volatility", "Sharpe", "Max DD" }), .style = th.mutedStyle() }); - - const risk_arr = [4]?zfin.risk.RiskMetrics{ tr.one_year, tr.three_year, tr.five_year, tr.ten_year }; - const risk_labels = [4][]const u8{ "1-Year:", "3-Year:", "5-Year:", "10-Year:" }; - - for (0..4) |i| { - if (risk_arr[i]) |rm| { - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {d:>13.1}% {d:>14.2} {d:>13.1}%", .{ - risk_labels[i], rm.volatility * 100.0, rm.sharpe, rm.max_drawdown * 100.0, - }), .style = th.contentStyle() }); - } else { - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14} {s:>14} {s:>14}", .{ - risk_labels[i], "—", "—", "—", - }), .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.fmtMoneyAbs(&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 " "; - var contract_buf: [128]u8 = undefined; - const text = try arena.dupe(u8, fmt.fmtContractLine(&contract_buf, 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 " "; - var contract_buf: [128]u8 = undefined; - const text = try arena.dupe(u8, fmt.fmtContractLine(&contract_buf, prefix, p)); - const style = if (is_cursor) th.selectStyle() else th.contentStyle(); - try lines.append(arena, .{ .text = text, .style = style }); - } - }, - } - } - - return lines.toOwnedSlice(arena); + return perf_tab.buildStyledLines(self, 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); + return earnings_tab.buildStyledLines(self, arena); } // ── 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 > 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.valuation.PortfolioSummary) void { - const cm = self.classification_map orelse { - self.setStatus("No classification data. Run: zfin enrich > 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; - }; + pub fn loadAnalysisData(self: *App) void { + analysis_tab.loadData(self); } 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.fmtMoneyAbs(&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); + return analysis_tab.buildStyledLines(self, arena); } // ── Help ───────────────────────────────────────────────────── @@ -3461,9 +1557,7 @@ const App = struct { // ── 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 { +pub 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 @@ -3582,8 +1676,7 @@ fn renderBrailleToStyledLines(arena: std.mem.Allocator, lines: *std.ArrayList(St } } -/// Load a watchlist from an SRF file. -fn loadWatchlist(allocator: std.mem.Allocator, path: []const u8) ?[][]const u8 { +pub 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); @@ -3612,7 +1705,7 @@ fn loadWatchlist(allocator: std.mem.Allocator, path: []const u8) ?[][]const u8 { return syms.toOwnedSlice(allocator) catch null; } -fn freeWatchlist(allocator: std.mem.Allocator, watchlist: ?[][]const u8) void { +pub fn freeWatchlist(allocator: std.mem.Allocator, watchlist: ?[][]const u8) void { if (watchlist) |wl| { for (wl) |sym| allocator.free(sym); allocator.free(wl); @@ -3623,6 +1716,12 @@ fn freeWatchlist(allocator: std.mem.Allocator, watchlist: ?[][]const u8) void { comptime { _ = keybinds; _ = theme_mod; + _ = portfolio_tab; + _ = quote_tab; + _ = perf_tab; + _ = options_tab; + _ = earnings_tab; + _ = analysis_tab; } /// Entry point for the interactive TUI. @@ -3861,120 +1960,6 @@ fn launchEditor(allocator: std.mem.Allocator, portfolio_path: ?[]const u8, watch _ = 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 > 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; @@ -4041,151 +2026,7 @@ test "SortDirection flip and 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); -} diff --git a/src/tui/analysis_tab.zig b/src/tui/analysis_tab.zig new file mode 100644 index 0000000..04a5a6e --- /dev/null +++ b/src/tui/analysis_tab.zig @@ -0,0 +1,258 @@ +const std = @import("std"); +const vaxis = @import("vaxis"); +const zfin = @import("../root.zig"); +const fmt = @import("../format.zig"); +const theme_mod = @import("theme.zig"); +const tui = @import("../tui.zig"); +const App = tui.App; +const StyledLine = tui.StyledLine; + +// ── Data loading ────────────────────────────────────────────── + +pub fn loadData(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 > 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 { + loadDataFinish(self, 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 + } + } + } + + loadDataFinish(self, pf, summary); +} + +fn loadDataFinish(self: *App, pf: zfin.Portfolio, summary: zfin.valuation.PortfolioSummary) void { + const cm = self.classification_map orelse { + self.setStatus("No classification data. Run: zfin enrich > 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; + }; +} + +// ── Rendering ───────────────────────────────────────────────── + +pub fn buildStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine { + return renderAnalysisLines(arena, self.theme, self.analysis_result); +} + +/// Render analysis tab content. Pure function — no App dependency. +pub 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 > 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 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); +} + +pub 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.fmtMoneyAbs(&val_buf, item.value), + }); +} + +/// Build a bar using Unicode block elements for sub-character precision. +/// Wraps fmt.buildBlockBar into arena-allocated memory. +pub 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); +} + +// ── Tests ───────────────────────────────────────────────────────────── + +const testing = std.testing; + +test "buildBlockBar empty" { + const bar = try 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 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 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 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 "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); +} diff --git a/src/tui/earnings_tab.zig b/src/tui/earnings_tab.zig new file mode 100644 index 0000000..8e53be5 --- /dev/null +++ b/src/tui/earnings_tab.zig @@ -0,0 +1,161 @@ +const std = @import("std"); +const vaxis = @import("vaxis"); +const zfin = @import("../root.zig"); +const fmt = @import("../format.zig"); +const theme_mod = @import("theme.zig"); +const tui = @import("../tui.zig"); +const App = tui.App; +const StyledLine = tui.StyledLine; + +// ── Data loading ────────────────────────────────────────────── + +pub fn loadData(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"); +} + +// ── Rendering ───────────────────────────────────────────────── + +pub fn buildStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine { + return renderEarningsLines(arena, self.theme, self.symbol, self.earnings_disabled, self.earnings_data, self.earnings_timestamp); +} + +/// Render earnings tab content. Pure function — no App dependency. +pub 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); +} + +// ── Tests ───────────────────────────────────────────────────────────── + +const testing = std.testing; + +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); +} diff --git a/src/tui/options_tab.zig b/src/tui/options_tab.zig new file mode 100644 index 0000000..3870924 --- /dev/null +++ b/src/tui/options_tab.zig @@ -0,0 +1,116 @@ +const std = @import("std"); +const vaxis = @import("vaxis"); +const zfin = @import("../root.zig"); +const fmt = @import("../format.zig"); +const theme_mod = @import("theme.zig"); +const tui = @import("../tui.zig"); + +const App = tui.App; +const StyledLine = tui.StyledLine; + +// ── Rendering ───────────────────────────────────────────────── + +pub fn buildStyledLines(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.fmtMoneyAbs(&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 " "; + var contract_buf: [128]u8 = undefined; + const text = try arena.dupe(u8, fmt.fmtContractLine(&contract_buf, 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 " "; + var contract_buf: [128]u8 = undefined; + const text = try arena.dupe(u8, fmt.fmtContractLine(&contract_buf, prefix, p)); + const style = if (is_cursor) th.selectStyle() else th.contentStyle(); + try lines.append(arena, .{ .text = text, .style = style }); + } + }, + } + } + + return lines.toOwnedSlice(arena); +} diff --git a/src/tui/perf_tab.zig b/src/tui/perf_tab.zig new file mode 100644 index 0000000..afb10f3 --- /dev/null +++ b/src/tui/perf_tab.zig @@ -0,0 +1,151 @@ +const std = @import("std"); +const vaxis = @import("vaxis"); +const zfin = @import("../root.zig"); +const fmt = @import("../format.zig"); +const theme_mod = @import("theme.zig"); +const tui = @import("../tui.zig"); + +const App = tui.App; +const StyledLine = tui.StyledLine; + +// ── Rendering ───────────────────────────────────────────────── + +pub fn buildStyledLines(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.fmtMoneyAbs(&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) |tr| { + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + try lines.append(arena, .{ .text = " Risk Metrics (monthly returns):", .style = th.headerStyle() }); + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14} {s:>14} {s:>14}", .{ "", "Volatility", "Sharpe", "Max DD" }), .style = th.mutedStyle() }); + + const risk_arr = [4]?zfin.risk.RiskMetrics{ tr.one_year, tr.three_year, tr.five_year, tr.ten_year }; + const risk_labels = [4][]const u8{ "1-Year:", "3-Year:", "5-Year:", "10-Year:" }; + + for (0..4) |i| { + if (risk_arr[i]) |rm| { + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {d:>13.1}% {d:>14.2} {d:>13.1}%", .{ + risk_labels[i], rm.volatility * 100.0, rm.sharpe, rm.max_drawdown * 100.0, + }), .style = th.contentStyle() }); + } else { + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14} {s:>14} {s:>14}", .{ + risk_labels[i], "—", "—", "—", + }), .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 }); + } + } +} diff --git a/src/tui/portfolio_tab.zig b/src/tui/portfolio_tab.zig new file mode 100644 index 0000000..e2ec60a --- /dev/null +++ b/src/tui/portfolio_tab.zig @@ -0,0 +1,1053 @@ +const std = @import("std"); +const vaxis = @import("vaxis"); +const zfin = @import("../root.zig"); +const fmt = @import("../format.zig"); +const cli = @import("../commands/common.zig"); +const theme_mod = @import("theme.zig"); +const tui = @import("../tui.zig"); + +const App = tui.App; +const StyledLine = tui.StyledLine; +const PortfolioRow = tui.PortfolioRow; +const PortfolioSortField = tui.PortfolioSortField; +const colLabel = tui.colLabel; +const glyph = tui.glyph; + +// ── Data loading ────────────────────────────────────────────── + +pub fn loadPortfolioData(self: *App) void { + self.portfolio_loaded = true; + self.freePortfolioSummary(); + + 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; + + if (self.prefetched_prices) |*pp| { + // Use pre-fetched prices from before TUI started (first load only) + // Move stock prices into the working map + for (syms) |sym| { + if (pp.get(sym)) |price| { + prices.put(sym, price) catch {}; + } + } + + // Extract watchlist prices + if (self.watchlist_prices) |*wp| wp.clearRetainingCapacity() else { + self.watchlist_prices = std.StringHashMap(f64).init(self.allocator); + } + var wp = &(self.watchlist_prices.?); + var pp_iter = pp.iterator(); + while (pp_iter.next()) |entry| { + if (!prices.contains(entry.key_ptr.*)) { + wp.put(entry.key_ptr.*, entry.value_ptr.*) catch {}; + } + } + + pp.deinit(); + self.prefetched_prices = null; + } else { + // Live fetch (refresh path) — fetch watchlist first, then stock prices + 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 {}; + } + } + } + for (pf.lots) |lot| { + if (lot.security_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 {}; + } + } + } + + // Fetch stock prices with TUI status-bar progress + 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.valuation.buildFallbackPrices(self.allocator, pf.lots, positions, &prices) catch { + self.setStatus("Error building fallback prices"); + return; + }; + defer manual_price_set.deinit(); + + var summary = zfin.valuation.portfolioSummary(self.allocator, pf, 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 first"); + return; + } + + 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.valuation.computeHistoricalSnapshots( + fmt.todayDate(), + positions, + prices, + candle_map, + ); + } + + sortPortfolioAllocations(self); + rebuildPortfolioRows(self); + + 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"); + } +} + +pub fn sortPortfolioAllocations(self: *App) void { + if (self.portfolio_summary) |s| { + const SortCtx = struct { + field: PortfolioSortField, + dir: tui.SortDirection, + + fn lessThan(ctx: @This(), a: zfin.valuation.Allocation, b: zfin.valuation.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_gain_loss < rhs.unrealized_gain_loss, + .weight => lhs.weight < rhs.weight, + .account => std.mem.lessThan(u8, lhs.account, rhs.account), + }; + } + }; + std.mem.sort(zfin.valuation.Allocation, s.allocations, SortCtx{ .field = self.portfolio_sort_field, .dir = self.portfolio_sort_dir }, SortCtx.lessThan); + } +} + +pub 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.security_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.security_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.security_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.security_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.security_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.security_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.security_type == .illiquid) { + self.portfolio_rows.append(self.allocator, .{ + .kind = .illiquid_row, + .symbol = lot.symbol, + .lot = lot, + }) catch continue; + } + } + } + } + } +} + +// ── Rendering ───────────────────────────────────────────────── + +pub fn drawContent(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 drawWelcomeScreen(self, 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.fmtMoneyAbs(&val_buf, s.total_value); + const cost_str = fmt.fmtMoneyAbs(&cost_buf, s.total_cost); + const gl_abs = if (s.unrealized_gain_loss >= 0) s.unrealized_gain_loss else -s.unrealized_gain_loss; + const gl_str = fmt.fmtMoneyAbs(&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_gain_loss >= 0) @as([]const u8, "+") else @as([]const u8, "-"), gl_str, s.unrealized_return * 100.0, + }); + const summary_style = if (s.unrealized_gain_loss >= 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.fmtMoneyAbs(&nw_buf, net_worth), + val_str, + fmt.fmtMoneyAbs(&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.valuation.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 ' 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_gain_loss / a.cost_basis) * 100.0 else @as(f64, 0); + var gl_val_buf: [24]u8 = undefined; + const gl_abs = if (a.unrealized_gain_loss >= 0) a.unrealized_gain_loss else -a.unrealized_gain_loss; + const gl_money = fmt.fmtMoneyAbs(&gl_val_buf, gl_abs); + var pnl_buf: [20]u8 = undefined; + const pnl_str = if (a.unrealized_gain_loss >= 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.fmtMoneyAbs(&mv_buf, a.market_value); + var cost_buf2: [24]u8 = undefined; + const cost_str = fmt.fmtMoneyAbs(&cost_buf2, a.avg_cost); + var price_buf2: [24]u8 = undefined; + const price_str = fmt.fmtMoneyAbs(&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.security_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.security_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 = tui.gl_col_start, + .alt_end = tui.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 and market value if we have a price + var lot_gl_str: []const u8 = ""; + var lot_mv_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.fmtMoneyAbs(&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 lot_mv_buf: [24]u8 = undefined; + lot_mv_str = try std.fmt.allocPrint(arena, "{s}", .{fmt.fmtMoneyAbs(&lot_mv_buf, lot.shares * use_price)}); + } + } + + var price_str2: [24]u8 = undefined; + const lot_price_str = fmt.fmtMoneyAbs(&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_mv_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 = tui.gl_col_start, + .alt_end = tui.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.fmtMoneyAbs(&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.fmtMoneyAbs(&cost_buf3, cost_per), + fmt.fmtMoneyAbs(&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.fmtMoneyAbs(&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.fmtMoneyAbs(&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.fmtMoneyAbs(&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.fmtMoneyAbs(&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); +} + +/// Reload portfolio file from disk without re-fetching prices. +/// Uses cached candle data to recompute summary. +pub 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) + tui.freeWatchlist(self.allocator, self.watchlist); + self.watchlist = null; + if (self.watchlist_path) |path| { + self.watchlist = tui.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.valuation.buildFallbackPrices(self.allocator, pf.lots, positions, &prices) catch { + self.setStatus("Error building fallback prices"); + return; + }; + defer manual_price_set.deinit(); + + var summary = zfin.valuation.portfolioSummary(self.allocator, pf, 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; + } + + 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.valuation.computeHistoricalSnapshots( + fmt.todayDate(), + positions, + prices, + candle_map, + ); + } + + sortPortfolioAllocations(self); + rebuildPortfolioRows(self); + + // 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"); + } +} diff --git a/src/tui/quote_tab.zig b/src/tui/quote_tab.zig new file mode 100644 index 0000000..1d27aaf --- /dev/null +++ b/src/tui/quote_tab.zig @@ -0,0 +1,567 @@ +const std = @import("std"); +const vaxis = @import("vaxis"); +const zfin = @import("../root.zig"); +const fmt = @import("../format.zig"); +const theme_mod = @import("theme.zig"); +const chart_mod = @import("chart.zig"); +const tui = @import("../tui.zig"); + +const App = tui.App; +const StyledLine = tui.StyledLine; +const glyph = tui.glyph; + +// ── Rendering ───────────────────────────────────────────────── + +/// Draw the quote tab content. Uses Kitty graphics for the chart when available, +/// falling back to braille sparkline otherwise. +pub fn drawContent(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) { + drawWithKittyChart(self, ctx, buf, width, height) catch { + // On any failure, fall back to braille + try self.drawStyledContent(arena, buf, width, height, try buildStyledLines(self, arena)); + }; + } else { + // Fallback to styled lines with braille chart + try self.drawStyledContent(arena, buf, width, height, try buildStyledLines(self, arena)); + } +} + +/// Draw quote tab using Kitty graphics protocol for the chart. +fn drawWithKittyChart(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.fmtMoneyAbs(&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 buildDetailColumns(self, 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 buildStyledLines(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.fmtMoneyAbs(&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 buildDetailColumns(self, 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 tui.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.fmtMoneyAbs(&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| { + var title_buf: [64]u8 = undefined; + const title_name = fmt.toTitleCase(&title_buf, sec.name); + const name = if (title_name.len > 20) title_name[0..20] else title_name; + 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 col_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] = " "; + col_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]); + col_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] = " "; + col_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] = " "; + col_styles[pos] = bg_style; + pos += 1; + } + } + } + } + + try lines.append(arena, .{ + .text = "", + .style = bg_style, + .graphemes = graphemes[0..pos], + .cell_styles = col_styles[0..pos], + }); + } +}