diff --git a/src/tui.zig b/src/tui.zig index 75c22ff..961d921 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -93,6 +93,7 @@ pub const InputMode = enum { normal, symbol_input, help, + account_picker, }; pub const StyledLine = struct { @@ -296,6 +297,11 @@ pub const App = struct { watchlist_prices: ?std.StringHashMap(f64) = null, // cached watchlist prices (no disk I/O during render) prefetched_prices: ?std.StringHashMap(f64) = null, // prices loaded before TUI starts (with stderr progress) + // Account filter state + account_filter: ?[]const u8 = null, // active account filter (owned copy; null = all accounts) + account_list: std.ArrayList([]const u8) = .empty, // distinct accounts from portfolio lots (borrowed from portfolio) + account_picker_cursor: usize = 0, // cursor position in picker (0 = "All accounts") + // Options navigation (inline expand/collapse like portfolio) options_cursor: usize = 0, // selected row in flattened options view options_expanded: [64]bool = [_]bool{false} ** 64, // which expirations are expanded @@ -368,6 +374,9 @@ pub const App = struct { if (self.mode == .symbol_input) { return self.handleInputKey(ctx, key); } + if (self.mode == .account_picker) { + return self.handleAccountPickerKey(ctx, key); + } if (self.mode == .help) { self.mode = .normal; return ctx.consumeAndRedraw(); @@ -385,6 +394,42 @@ pub const App = struct { } fn handleMouse(self: *App, ctx: *vaxis.vxfw.EventContext, mouse: vaxis.Mouse) void { + // Account picker mouse handling + if (self.mode == .account_picker) { + const total_items = self.account_list.items.len + 1; + switch (mouse.button) { + .wheel_up => { + if (self.shouldDebounceWheel()) return; + if (self.account_picker_cursor > 0) + self.account_picker_cursor -= 1; + return ctx.consumeAndRedraw(); + }, + .wheel_down => { + if (self.shouldDebounceWheel()) return; + if (total_items > 0 and self.account_picker_cursor < total_items - 1) + self.account_picker_cursor += 1; + return ctx.consumeAndRedraw(); + }, + .left => { + if (mouse.type != .press) return; + // Map click row to picker item index. + // mouse.row maps directly to content line index + // (same convention as portfolio click handling). + const content_row = @as(usize, @intCast(mouse.row)); + if (content_row >= portfolio_tab.account_picker_header_lines) { + const item_idx = content_row - portfolio_tab.account_picker_header_lines; + if (item_idx < total_items) { + self.account_picker_cursor = item_idx; + self.applyAccountPickerSelection(); + return ctx.consumeAndRedraw(); + } + } + }, + else => {}, + } + return; + } + switch (mouse.button) { .wheel_up => { self.moveBy(-3); @@ -560,9 +605,95 @@ pub const App = struct { } } - fn handleNormalKey(self: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) void { - // Escape: no special behavior needed (options is now inline) + /// Handles keypresses in account_picker mode. + fn handleAccountPickerKey(self: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) void { + const total_items = self.account_list.items.len + 1; // +1 for "All accounts" + + // Cancel: return to normal mode without changing the filter if (key.codepoint == vaxis.Key.escape) { + self.mode = .normal; + return ctx.consumeAndRedraw(); + } + + // Confirm: apply the selected account filter + if (key.codepoint == vaxis.Key.enter) { + self.applyAccountPickerSelection(); + return ctx.consumeAndRedraw(); + } + + // Use the keymap for navigation actions + const action = self.keymap.matchAction(key) orelse return; + switch (action) { + .select_next => { + if (total_items > 0 and self.account_picker_cursor < total_items - 1) + self.account_picker_cursor += 1; + return ctx.consumeAndRedraw(); + }, + .select_prev => { + if (self.account_picker_cursor > 0) + self.account_picker_cursor -= 1; + return ctx.consumeAndRedraw(); + }, + .scroll_top => { + self.account_picker_cursor = 0; + return ctx.consumeAndRedraw(); + }, + .scroll_bottom => { + if (total_items > 0) + self.account_picker_cursor = total_items - 1; + return ctx.consumeAndRedraw(); + }, + else => {}, + } + } + + /// Apply the current account picker selection and return to normal mode. + fn applyAccountPickerSelection(self: *App) void { + if (self.account_picker_cursor == 0) { + // "All accounts" — clear filter + self.setAccountFilter(null); + } else { + const idx = self.account_picker_cursor - 1; + if (idx < self.account_list.items.len) { + self.setAccountFilter(self.account_list.items[idx]); + } + } + self.mode = .normal; + self.cursor = 0; + self.scroll_offset = 0; + portfolio_tab.rebuildPortfolioRows(self); + + if (self.account_filter) |af| { + var tmp_buf: [256]u8 = undefined; + const msg = std.fmt.bufPrint(&tmp_buf, "Filtered: {s}", .{af}) catch "Filtered"; + self.setStatus(msg); + } else { + self.setStatus("Filter cleared: showing all accounts"); + } + } + + /// Set or clear the account filter. Owns the string via allocator. + pub fn setAccountFilter(self: *App, name: ?[]const u8) void { + // Free the old owned copy + if (self.account_filter) |old| self.allocator.free(old); + if (name) |n| { + self.account_filter = self.allocator.dupe(u8, n) catch null; + } else { + self.account_filter = null; + } + } + + fn handleNormalKey(self: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) void { + // Escape: clear account filter on portfolio tab, no-op otherwise + if (key.codepoint == vaxis.Key.escape) { + if (self.active_tab == .portfolio and self.account_filter != null) { + self.setAccountFilter(null); + self.cursor = 0; + self.scroll_offset = 0; + portfolio_tab.rebuildPortfolioRows(self); + self.setStatus("Filter cleared: showing all accounts"); + return ctx.consumeAndRedraw(); + } return; } @@ -754,18 +885,40 @@ pub const App = struct { return ctx.consumeAndRedraw(); } }, + .account_filter => { + if (self.active_tab == .portfolio and self.portfolio != null) { + self.mode = .account_picker; + // Position cursor on the currently-active filter (or 0 for "All") + self.account_picker_cursor = 0; + if (self.account_filter) |af| { + for (self.account_list.items, 0..) |acct, ai| { + if (std.mem.eql(u8, acct, af)) { + self.account_picker_cursor = ai + 1; // +1 because 0 = "All accounts" + break; + } + } + } + return ctx.consumeAndRedraw(); + } + }, } } + /// Returns true if this wheel event should be suppressed (too close to the last one). + fn shouldDebounceWheel(self: *App) bool { + const now = std.time.nanoTimestamp(); + if (now - self.last_wheel_ns < 1 * std.time.ns_per_ms) return true; + self.last_wheel_ns = now; + return false; + } + /// Move cursor/scroll. Positive = down, negative = up. /// For portfolio and options tabs, moves the row cursor by 1 with /// debounce to absorb duplicate events from mouse wheel ticks. /// For other tabs, adjusts scroll_offset by |n|. fn moveBy(self: *App, n: isize) void { if (self.active_tab == .portfolio or self.active_tab == .options) { - const now = std.time.nanoTimestamp(); - if (now - self.last_wheel_ns < 1 * std.time.ns_per_ms) return; - self.last_wheel_ns = now; + if (self.shouldDebounceWheel()) return; if (self.active_tab == .portfolio) { stepCursor(&self.cursor, self.portfolio_rows.items.len, n); self.ensureCursorVisible(); @@ -1174,6 +1327,8 @@ pub const App = struct { self.freePortfolioSummary(); self.portfolio_rows.deinit(self.allocator); self.options_rows.deinit(self.allocator); + self.account_list.deinit(self.allocator); + if (self.account_filter) |af| self.allocator.free(af); if (self.watchlist_prices) |*wp| wp.deinit(); if (self.analysis_result) |*ar| ar.deinit(self.allocator); if (self.classification_map) |*cm| cm.deinit(); @@ -1275,6 +1430,8 @@ pub const App = struct { if (self.mode == .help) { try self.drawStyledContent(ctx.arena, buf, width, height, try self.buildHelpStyledLines(ctx.arena)); + } else if (self.mode == .account_picker) { + try portfolio_tab.drawAccountPicker(self, ctx.arena, buf, width, height); } else { switch (self.active_tab) { .portfolio => try self.drawPortfolioContent(ctx.arena, buf, width, height), @@ -1360,12 +1517,29 @@ pub const App = struct { buf[hint_start + i] = .{ .char = .{ .grapheme = glyph(hint[i]) }, .style = hint_style }; } } + } else if (self.mode == .account_picker) { + const prompt_style = t.inputStyle(); + @memset(buf, .{ .char = .{ .grapheme = " " }, .style = prompt_style }); + const hint = " j/k=navigate Enter=select Esc=cancel Click=select "; + for (0..@min(hint.len, width)) |i| { + buf[i] = .{ .char = .{ .grapheme = glyph(hint[i]) }, .style = prompt_style }; + } } else { const status_style = t.statusStyle(); @memset(buf, .{ .char = .{ .grapheme = " " }, .style = status_style }); - const msg = self.getStatus(); - for (0..@min(msg.len, width)) |i| { - buf[i] = .{ .char = .{ .grapheme = glyph(msg[i]) }, .style = status_style }; + // Show account filter indicator when active, appended to status message + if (self.account_filter != null and self.active_tab == .portfolio) { + const af = self.account_filter.?; + const msg = self.getStatus(); + const filter_text = std.fmt.allocPrint(ctx.arena, "{s} [Account: {s}]", .{ msg, af }) catch msg; + for (0..@min(filter_text.len, width)) |i| { + buf[i] = .{ .char = .{ .grapheme = glyph(filter_text[i]) }, .style = status_style }; + } + } else { + const msg = self.getStatus(); + for (0..@min(msg.len, width)) |i| { + buf[i] = .{ .char = .{ .grapheme = glyph(msg[i]) }, .style = status_style }; + } } } @@ -1435,7 +1609,7 @@ pub const App = struct { "Toggle all puts (options)", "Filter +/- 1 NTM", "Filter +/- 2 NTM", "Filter +/- 3 NTM", "Filter +/- 4 NTM", "Filter +/- 5 NTM", "Filter +/- 6 NTM", "Filter +/- 7 NTM", "Filter +/- 8 NTM", "Filter +/- 9 NTM", "Chart: next timeframe", "Chart: prev timeframe", - "Sort: next column", "Sort: prev column", "Sort: reverse order", + "Sort: next column", "Sort: prev column", "Sort: reverse order", "Account filter (portfolio)", }; for (actions, 0..) |action, ai| { diff --git a/src/tui/keybinds.zig b/src/tui/keybinds.zig index 915c631..d6916a6 100644 --- a/src/tui/keybinds.zig +++ b/src/tui/keybinds.zig @@ -42,6 +42,7 @@ pub const Action = enum { sort_col_next, sort_col_prev, sort_reverse, + account_filter, }; pub const KeyCombo = struct { @@ -124,6 +125,7 @@ const default_bindings = [_]Binding{ .{ .action = .sort_col_next, .key = .{ .codepoint = '>' } }, .{ .action = .sort_col_prev, .key = .{ .codepoint = '<' } }, .{ .action = .sort_reverse, .key = .{ .codepoint = 'o' } }, + .{ .action = .account_filter, .key = .{ .codepoint = 'a' } }, }; pub fn defaults() KeyMap { diff --git a/src/tui/portfolio_tab.zig b/src/tui/portfolio_tab.zig index 530003a..9d9db77 100644 --- a/src/tui/portfolio_tab.zig +++ b/src/tui/portfolio_tab.zig @@ -187,6 +187,7 @@ pub fn loadPortfolioData(app: *App) void { } sortPortfolioAllocations(app); + buildAccountList(app); rebuildPortfolioRows(app); const summary = pf_data.summary; @@ -270,11 +271,16 @@ pub fn rebuildPortfolioRows(app: *App) void { if (app.portfolio_summary) |s| { for (s.allocations, 0..) |a, i| { - // Count lots for this symbol + // Skip allocations that don't match account filter + if (!allocationMatchesFilter(app, a)) continue; + + // Count lots for this symbol (filtered by account when filter is active) var lcount: usize = 0; if (app.portfolio) |pf| { for (pf.lots) |lot| { - if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) lcount += 1; + if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) { + if (matchesAccountFilter(app, lot.account)) lcount += 1; + } } } @@ -293,7 +299,8 @@ pub fn rebuildPortfolioRows(app: *App) void { defer matching.deinit(app.allocator); for (pf.lots) |lot| { if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) { - matching.append(app.allocator, lot) catch continue; + if (matchesAccountFilter(app, lot.account)) + matching.append(app.allocator, lot) catch continue; } } std.mem.sort(zfin.Lot, matching.items, {}, fmt.lotSortFn); @@ -367,125 +374,68 @@ pub fn rebuildPortfolioRows(app: *App) void { // 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(app.allocator); - defer watch_seen.deinit(); + // Hide watchlist entirely when account filter is active (watchlist items don't belong to accounts). + if (app.account_filter == null) { + var watch_seen = std.StringHashMap(void).init(app.allocator); + defer watch_seen.deinit(); - // Mark all portfolio position symbols as seen - if (app.portfolio_summary) |s| { - for (s.allocations) |a| { - watch_seen.put(a.symbol, {}) catch {}; - } - } - - // Watch lots from portfolio file - if (app.portfolio) |pf| { - for (pf.lots) |lot| { - if (lot.security_type == .watch) { - if (watch_seen.contains(lot.priceSymbol())) continue; - watch_seen.put(lot.priceSymbol(), {}) catch {}; - app.portfolio_rows.append(app.allocator, .{ - .kind = .watchlist, - .symbol = lot.symbol, - }) catch continue; + // Mark all portfolio position symbols as seen + if (app.portfolio_summary) |s| { + for (s.allocations) |a| { + watch_seen.put(a.symbol, {}) catch {}; } } - } - // Separate watchlist file (backward compat) - if (app.watchlist) |wl| { - for (wl) |sym| { - if (watch_seen.contains(sym)) continue; - watch_seen.put(sym, {}) catch {}; - app.portfolio_rows.append(app.allocator, .{ - .kind = .watchlist, - .symbol = sym, - }) catch continue; - } - } - - // Options section - if (app.portfolio) |pf| { - if (pf.hasType(.option)) { - app.portfolio_rows.append(app.allocator, .{ - .kind = .section_header, - .symbol = "Options", - }) catch {}; + // Watch lots from portfolio file + if (app.portfolio) |pf| { for (pf.lots) |lot| { - if (lot.security_type == .option) { + if (lot.security_type == .watch) { + if (watch_seen.contains(lot.priceSymbol())) continue; + watch_seen.put(lot.priceSymbol(), {}) catch {}; app.portfolio_rows.append(app.allocator, .{ - .kind = .option_row, + .kind = .watchlist, .symbol = lot.symbol, - .lot = lot, }) catch continue; } } } - // CDs section (sorted by maturity date, earliest first) - if (pf.hasType(.cd)) { - app.portfolio_rows.append(app.allocator, .{ - .kind = .section_header, - .symbol = "Certificates of Deposit", - }) catch {}; - var cd_lots: std.ArrayList(zfin.Lot) = .empty; - defer cd_lots.deinit(app.allocator); - for (pf.lots) |lot| { - if (lot.security_type == .cd) { - cd_lots.append(app.allocator, lot) catch continue; - } - } - std.mem.sort(zfin.Lot, cd_lots.items, {}, fmt.lotMaturitySortFn); - for (cd_lots.items) |lot| { + // Separate watchlist file (backward compat) + if (app.watchlist) |wl| { + for (wl) |sym| { + if (watch_seen.contains(sym)) continue; + watch_seen.put(sym, {}) catch {}; app.portfolio_rows.append(app.allocator, .{ - .kind = .cd_row, - .symbol = lot.symbol, - .lot = lot, + .kind = .watchlist, + .symbol = sym, }) catch continue; } } + } - // Cash section (single total row, expandable to show per-account) - if (pf.hasType(.cash)) { - app.portfolio_rows.append(app.allocator, .{ - .kind = .section_header, - .symbol = "Cash", - }) catch {}; - // Total cash row - app.portfolio_rows.append(app.allocator, .{ - .kind = .cash_total, - .symbol = "CASH", - }) catch {}; - // Per-account cash rows (expanded when cash_total is toggled) - if (app.cash_expanded) { + // Options section (filtered by account when filter is active) + if (app.portfolio) |pf| { + if (pf.hasType(.option)) { + var has_matching = false; + if (app.account_filter != null) { for (pf.lots) |lot| { - if (lot.security_type == .cash) { - app.portfolio_rows.append(app.allocator, .{ - .kind = .cash_row, - .symbol = lot.account orelse "Unknown", - .lot = lot, - }) catch continue; + if (lot.security_type == .option and matchesAccountFilter(app, lot.account)) { + has_matching = true; + break; } } + } else { + has_matching = true; } - } - - // Illiquid assets section (similar to cash: total row, expandable) - if (pf.hasType(.illiquid)) { - app.portfolio_rows.append(app.allocator, .{ - .kind = .section_header, - .symbol = "Illiquid Assets", - }) catch {}; - // Total illiquid row - app.portfolio_rows.append(app.allocator, .{ - .kind = .illiquid_total, - .symbol = "ILLIQUID", - }) catch {}; - // Per-asset rows (expanded when illiquid_total is toggled) - if (app.illiquid_expanded) { + if (has_matching) { + app.portfolio_rows.append(app.allocator, .{ + .kind = .section_header, + .symbol = "Options", + }) catch {}; for (pf.lots) |lot| { - if (lot.security_type == .illiquid) { + if (lot.security_type == .option and matchesAccountFilter(app, lot.account)) { app.portfolio_rows.append(app.allocator, .{ - .kind = .illiquid_row, + .kind = .option_row, .symbol = lot.symbol, .lot = lot, }) catch continue; @@ -493,9 +443,270 @@ pub fn rebuildPortfolioRows(app: *App) void { } } } + + // CDs section (sorted by maturity date, earliest first; filtered by account) + if (pf.hasType(.cd)) { + var cd_lots: std.ArrayList(zfin.Lot) = .empty; + defer cd_lots.deinit(app.allocator); + for (pf.lots) |lot| { + if (lot.security_type == .cd and matchesAccountFilter(app, lot.account)) { + cd_lots.append(app.allocator, lot) catch continue; + } + } + if (cd_lots.items.len > 0) { + app.portfolio_rows.append(app.allocator, .{ + .kind = .section_header, + .symbol = "Certificates of Deposit", + }) catch {}; + std.mem.sort(zfin.Lot, cd_lots.items, {}, fmt.lotMaturitySortFn); + for (cd_lots.items) |lot| { + app.portfolio_rows.append(app.allocator, .{ + .kind = .cd_row, + .symbol = lot.symbol, + .lot = lot, + }) catch continue; + } + } + } + + // Cash section (filtered by account when filter is active) + if (pf.hasType(.cash)) { + // When filtered, only show cash lots matching the account + if (app.account_filter != null) { + var cash_lots: std.ArrayList(zfin.Lot) = .empty; + defer cash_lots.deinit(app.allocator); + for (pf.lots) |lot| { + if (lot.security_type == .cash and matchesAccountFilter(app, lot.account)) { + cash_lots.append(app.allocator, lot) catch continue; + } + } + if (cash_lots.items.len > 0) { + app.portfolio_rows.append(app.allocator, .{ + .kind = .section_header, + .symbol = "Cash", + }) catch {}; + for (cash_lots.items) |lot| { + app.portfolio_rows.append(app.allocator, .{ + .kind = .cash_row, + .symbol = lot.account orelse "Unknown", + .lot = lot, + }) catch continue; + } + } + } else { + // Unfiltered: show total + expandable per-account rows + app.portfolio_rows.append(app.allocator, .{ + .kind = .section_header, + .symbol = "Cash", + }) catch {}; + app.portfolio_rows.append(app.allocator, .{ + .kind = .cash_total, + .symbol = "CASH", + }) catch {}; + if (app.cash_expanded) { + for (pf.lots) |lot| { + if (lot.security_type == .cash) { + app.portfolio_rows.append(app.allocator, .{ + .kind = .cash_row, + .symbol = lot.account orelse "Unknown", + .lot = lot, + }) catch continue; + } + } + } + } + } + + // Illiquid assets section (hidden when account filter is active) + if (app.account_filter == null) { + if (pf.hasType(.illiquid)) { + app.portfolio_rows.append(app.allocator, .{ + .kind = .section_header, + .symbol = "Illiquid Assets", + }) catch {}; + app.portfolio_rows.append(app.allocator, .{ + .kind = .illiquid_total, + .symbol = "ILLIQUID", + }) catch {}; + if (app.illiquid_expanded) { + for (pf.lots) |lot| { + if (lot.security_type == .illiquid) { + app.portfolio_rows.append(app.allocator, .{ + .kind = .illiquid_row, + .symbol = lot.symbol, + .lot = lot, + }) catch continue; + } + } + } + } + } } } +/// Build the sorted list of distinct account names from portfolio lots. +/// Called after portfolio data is loaded or reloaded. +pub fn buildAccountList(app: *App) void { + app.account_list.clearRetainingCapacity(); + + const pf = app.portfolio orelse return; + + // Use a set to deduplicate + var seen = std.StringHashMap(void).init(app.allocator); + defer seen.deinit(); + + for (pf.lots) |lot| { + if (lot.account) |acct| { + if (acct.len > 0 and !seen.contains(acct)) { + seen.put(acct, {}) catch continue; + app.account_list.append(app.allocator, acct) catch continue; + } + } + } + + // Sort alphabetically + std.mem.sort([]const u8, app.account_list.items, {}, struct { + fn lessThan(_: void, a: []const u8, b: []const u8) bool { + return std.mem.lessThan(u8, a, b); + } + }.lessThan); + + // If the current filter no longer exists in the new list, clear it + if (app.account_filter) |af| { + var found = false; + for (app.account_list.items) |acct| { + if (std.mem.eql(u8, acct, af)) { + found = true; + break; + } + } + if (!found) app.setAccountFilter(null); + } +} + +/// Check if a lot matches the active account filter. +/// Returns true if no filter is active or the lot's account matches. +fn matchesAccountFilter(app: *const App, account: ?[]const u8) bool { + const filter = app.account_filter orelse return true; + const acct = account orelse return false; + return std.mem.eql(u8, acct, filter); +} + +/// Check if an allocation matches the active account filter. +/// Uses the allocation's account field (which is "Multiple" for mixed-account positions). +/// For "Multiple" accounts, we need to check if any lot with this symbol belongs to the filtered account. +fn allocationMatchesFilter(app: *const App, a: zfin.valuation.Allocation) bool { + const filter = app.account_filter orelse return true; + // Simple case: allocation has a single account + if (!std.mem.eql(u8, a.account, "Multiple")) { + return std.mem.eql(u8, a.account, filter); + } + // "Multiple" account: check if any stock lot for this symbol belongs to the filtered account + if (app.portfolio) |pf| { + for (pf.lots) |lot| { + if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) { + if (lot.account) |la| { + if (std.mem.eql(u8, la, filter)) return true; + } + } + } + } + return false; +} + +/// Account-filtered view of an allocation. When a position spans multiple accounts, +/// this holds the values for only the lots matching the active account filter. +const FilteredAlloc = struct { + shares: f64, + cost_basis: f64, + market_value: f64, + unrealized_gain_loss: f64, +}; + +/// Compute account-filtered values for an allocation. +/// For single-account positions (or no filter), returns the allocation's own values. +/// For "Multiple"-account positions with a filter, sums only the matching lots. +fn filteredAllocValues(app: *const App, a: zfin.valuation.Allocation) FilteredAlloc { + const filter = app.account_filter orelse return .{ + .shares = a.shares, + .cost_basis = a.cost_basis, + .market_value = a.market_value, + .unrealized_gain_loss = a.unrealized_gain_loss, + }; + if (!std.mem.eql(u8, a.account, "Multiple")) return .{ + .shares = a.shares, + .cost_basis = a.cost_basis, + .market_value = a.market_value, + .unrealized_gain_loss = a.unrealized_gain_loss, + }; + // Sum values from only the lots matching the filter + var shares: f64 = 0; + var cost: f64 = 0; + if (app.portfolio) |pf| { + for (pf.lots) |lot| { + if (lot.security_type != .stock) continue; + if (!std.mem.eql(u8, lot.priceSymbol(), a.symbol)) continue; + if (!lot.isOpen()) continue; + const la = lot.account orelse ""; + if (!std.mem.eql(u8, la, filter)) continue; + shares += lot.shares; + cost += lot.shares * lot.open_price; + } + } + const mv = shares * a.current_price * a.price_ratio; + return .{ + .shares = shares, + .cost_basis = cost, + .market_value = mv, + .unrealized_gain_loss = mv - cost, + }; +} + +/// Totals for the filtered account view (stocks + cash + CDs + options). +const FilteredTotals = struct { + value: f64, + cost: f64, +}; + +/// Compute total value and cost across all asset types for the active account filter. +/// Returns {0, 0} if no filter is active. +fn computeFilteredTotals(app: *const App) FilteredTotals { + if (app.account_filter == null) return .{ .value = 0, .cost = 0 }; + var value: f64 = 0; + var cost: f64 = 0; + if (app.portfolio_summary) |s| { + for (s.allocations) |a| { + if (allocationMatchesFilter(app, a)) { + const fa = filteredAllocValues(app, a); + value += fa.market_value; + cost += fa.cost_basis; + } + } + } + if (app.portfolio) |pf| { + for (pf.lots) |lot| { + if (!matchesAccountFilter(app, lot.account)) continue; + switch (lot.security_type) { + .cash => { + value += lot.shares; + cost += lot.shares; + }, + .cd => { + value += lot.shares; + cost += lot.shares; + }, + .option => { + const opt_cost = @abs(lot.shares) * lot.open_price; + value += opt_cost; + cost += opt_cost; + }, + else => {}, + } + } + } + return .{ .value = value, .cost = cost }; +} + // ── Rendering ───────────────────────────────────────────────── pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void { @@ -510,57 +721,90 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); if (app.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 }); + if (app.account_filter) |af| { + // Filtered mode: compute account-specific totals + const ft = computeFilteredTotals(app); + const filtered_value = ft.value; + const filtered_cost = ft.cost; + const filtered_gl = filtered_value - filtered_cost; + const filtered_return = if (filtered_cost > 0) (filtered_gl / filtered_cost) else @as(f64, 0); - // "as of" date indicator - if (app.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() }); - } + // Account name line + const acct_text = try std.fmt.allocPrint(arena, " Account: {s}", .{af}); + try lines.append(arena, .{ .text = acct_text, .style = th.headerStyle() }); - // Net Worth line (only if portfolio has illiquid assets) - if (app.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 (app.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], + 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, filtered_value); + const cost_str = fmt.fmtMoneyAbs(&cost_buf, filtered_cost); + const gl_abs = if (filtered_gl >= 0) filtered_gl else -filtered_gl; + 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 (filtered_gl >= 0) @as([]const u8, "+") else @as([]const u8, "-"), gl_str, filtered_return * 100.0, }); - try lines.append(arena, .{ .text = hist_text, .style = th.mutedStyle() }); + const summary_style = if (filtered_gl >= 0) th.positiveStyle() else th.negativeStyle(); + try lines.append(arena, .{ .text = summary_text, .style = summary_style }); + + if (app.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() }); + } + // No historical snapshots or net worth when filtered + } else { + // Unfiltered mode: use portfolio_summary totals directly + 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 (app.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 (app.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 (app.historical_snapshots) |snapshots| { + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + 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 (app.portfolio != null) { try lines.append(arena, .{ .text = " No cached prices. Run 'zfin perf ' for each holding.", .style = th.mutedStyle() }); @@ -602,6 +846,12 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width app.portfolio_header_lines = lines.items.len; app.portfolio_line_count = 0; + // Compute filtered total value for account-relative weight calculation + const filtered_total_for_weight: f64 = if (app.account_filter != null) + computeFilteredTotals(app).value + else + 0; + // Data rows for (app.portfolio_rows.items, 0..) |row, ri| { const lines_before = lines.items.len; @@ -612,23 +862,30 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width if (app.portfolio_summary) |s| { if (row.pos_idx < s.allocations.len) { const a = s.allocations[row.pos_idx]; + // Use account-filtered values for multi-account positions + const fa = filteredAllocValues(app, a); + const display_shares = fa.shares; + const display_avg_cost = if (fa.shares > 0) fa.cost_basis / fa.shares else a.avg_cost; + const display_mv = fa.market_value; + const display_gl = fa.unrealized_gain_loss; + const is_multi = row.lot_count > 1; const is_expanded = is_multi and row.pos_idx < app.expanded.len and app.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); + const pnl_pct = if (fa.cost_basis > 0) (display_gl / fa.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_abs = if (display_gl >= 0) display_gl else -display_gl; 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) + const pnl_str = if (display_gl >= 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); + const mv_str = fmt.fmtMoneyAbs(&mv_buf, display_mv); var cost_buf2: [24]u8 = undefined; - const cost_str = fmt.fmtMoneyAbs(&cost_buf2, a.avg_cost); + const cost_str = fmt.fmtMoneyAbs(&cost_buf2, display_avg_cost); var price_buf2: [24]u8 = undefined; const price_str = fmt.fmtMoneyAbs(&price_buf2, a.current_price); @@ -674,8 +931,13 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width } } + const display_weight = if (app.account_filter != null and filtered_total_for_weight > 0) + (display_mv / filtered_total_for_weight) + else + a.weight; + 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, + arrow, star, a.display_symbol, display_shares, cost_str, price_str, mv_str, pnl_str, display_weight * 100.0, date_col, acct_col, }); // base: neutral text for main cols, green/red only for gain/loss col @@ -925,6 +1187,11 @@ fn drawWelcomeScreen(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, wid /// Reload portfolio file from disk without re-fetching prices. /// Uses cached candle data to recompute summary. pub fn reloadPortfolioFile(app: *App) void { + // Save the account filter name before freeing the old portfolio. + // account_filter is an owned copy so it survives the portfolio free, + // but account_list entries borrow from the portfolio and will dangle. + app.account_list.clearRetainingCapacity(); + // Re-read the portfolio file if (app.portfolio) |*pf| pf.deinit(); app.portfolio = null; @@ -1019,6 +1286,7 @@ pub fn reloadPortfolioFile(app: *App) void { } sortPortfolioAllocations(app); + buildAccountList(app); rebuildPortfolioRows(app); // Invalidate analysis data -- it holds pointers into old portfolio memory @@ -1041,3 +1309,42 @@ pub fn reloadPortfolioFile(app: *App) void { app.setStatus("Portfolio reloaded from disk"); } } + +// ── Account picker ──────────────────────────────────────────── + +/// Number of header lines in the account picker before the list items start. +/// Used for mouse click hit-testing. +pub const account_picker_header_lines: usize = 3; + +/// Draw the account picker overlay (replaces portfolio content). +pub fn drawAccountPicker(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void { + const th = app.theme; + var lines: std.ArrayList(tui.StyledLine) = .empty; + + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + try lines.append(arena, .{ .text = " Filter by Account", .style = th.headerStyle() }); + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + + // Item 0 = "All accounts" (clears filter) + const total_items = app.account_list.items.len + 1; + for (0..total_items) |i| { + const is_selected = i == app.account_picker_cursor; + const marker: []const u8 = if (is_selected) " > " else " "; + const label: []const u8 = if (i == 0) "All accounts" else app.account_list.items[i - 1]; + const text = try std.fmt.allocPrint(arena, "{s}{s}", .{ marker, label }); + const style = if (is_selected) th.selectStyle() else th.contentStyle(); + try lines.append(arena, .{ .text = text, .style = style }); + } + + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + + // Scroll so cursor is visible + const cursor_line = app.account_picker_cursor + account_picker_header_lines; + var start: usize = 0; + if (cursor_line >= height) { + start = cursor_line - height + 2; // keep one line of padding below + } + start = @min(start, if (lines.items.len > 0) lines.items.len - 1 else 0); + + try app.drawStyledContent(arena, buf, width, height, lines.items[start..]); +}