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; // 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; // ── 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 = 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); } /// 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"); } }