diff --git a/src/tui.zig b/src/tui.zig index e8e0389..c09d163 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -309,6 +309,7 @@ pub const App = struct { // Account filter state account_filter: ?[]const u8 = null, // active account filter (owned copy; null = all accounts) + filtered_positions: ?[]zfin.Position = null, // positions for filtered account (from positionsForAccount) 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") @@ -684,10 +685,15 @@ pub const App = struct { /// 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 (self.filtered_positions) |fp| self.allocator.free(fp); + self.filtered_positions = null; + if (name) |n| { self.account_filter = self.allocator.dupe(u8, n) catch null; + if (self.portfolio) |pf| { + self.filtered_positions = pf.positionsForAccount(self.allocator, n) catch null; + } } else { self.account_filter = null; } @@ -1347,6 +1353,7 @@ pub const App = struct { self.options_rows.deinit(self.allocator); self.account_list.deinit(self.allocator); if (self.account_filter) |af| self.allocator.free(af); + if (self.filtered_positions) |fp| self.allocator.free(fp); if (self.watchlist_prices) |*wp| wp.deinit(); if (self.analysis_result) |*ar| ar.deinit(self.allocator); if (self.classification_map) |*cm| cm.deinit(); diff --git a/src/tui/portfolio_tab.zig b/src/tui/portfolio_tab.zig index 9f0b85f..9c0af56 100644 --- a/src/tui/portfolio_tab.zig +++ b/src/tui/portfolio_tab.zig @@ -199,6 +199,7 @@ pub fn loadPortfolioData(app: *App) void { sortPortfolioAllocations(app); buildAccountList(app); + recomputeFilteredPositions(app); rebuildPortfolioRows(app); const summary = pf_data.summary; @@ -288,10 +289,14 @@ pub fn rebuildPortfolioRows(app: *App) void { // 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)) { - if (matchesAccountFilter(app, lot.account)) lcount += 1; + if (findFilteredPosition(app, a.symbol)) |pos| { + lcount = pos.open_lots + pos.closed_lots; + } else if (app.account_filter == null) { + if (app.portfolio) |pf| { + for (pf.lots) |lot| { + if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) { + lcount += 1; + } } } } @@ -583,6 +588,26 @@ pub fn buildAccountList(app: *App) void { } } +/// Recompute filtered_positions when portfolio or account filter changes. +fn recomputeFilteredPositions(app: *App) void { + if (app.filtered_positions) |fp| app.allocator.free(fp); + app.filtered_positions = null; + const filter = app.account_filter orelse return; + const pf = app.portfolio orelse return; + app.filtered_positions = pf.positionsForAccount(app.allocator, filter) catch null; +} + +/// Look up a symbol in the pre-computed filtered positions. +/// Returns null if no filter is active or the symbol isn't in the filtered account. +fn findFilteredPosition(app: *const App, symbol: []const u8) ?zfin.Position { + const fps = app.filtered_positions orelse return null; + for (fps) |pos| { + if (std.mem.eql(u8, pos.symbol, symbol) or std.mem.eql(u8, pos.lot_symbol, symbol)) + return pos; + } + return 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 { @@ -592,25 +617,11 @@ fn matchesAccountFilter(app: *const App, account: ?[]const u8) bool { } /// 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. +/// When filtered, checks against pre-computed filtered_positions. 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; + if (app.account_filter == null) return true; + if (app.filtered_positions == null) return false; + return findFilteredPosition(app, a.symbol) != null; } /// Account-filtered view of an allocation. When a position spans multiple accounts, @@ -624,41 +635,24 @@ const FilteredAlloc = struct { /// 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. +/// For filtered views, uses pre-computed filtered_positions. fn filteredAllocValues(app: *const App, a: zfin.valuation.Allocation) FilteredAlloc { - const filter = app.account_filter orelse return .{ + if (app.account_filter == null) 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; - } + if (findFilteredPosition(app, a.symbol)) |pos| { + const mv = pos.shares * a.current_price * pos.price_ratio; + return .{ + .shares = pos.shares, + .cost_basis = pos.total_cost, + .market_value = mv, + .unrealized_gain_loss = mv - pos.total_cost, + }; } - const mv = shares * a.current_price * a.price_ratio; - return .{ - .shares = shares, - .cost_basis = cost, - .market_value = mv, - .unrealized_gain_loss = mv - cost, - }; + return .{ .shares = 0, .cost_basis = 0, .market_value = 0, .unrealized_gain_loss = 0 }; } /// Totals for the filtered account view (stocks + cash + CDs + options). @@ -896,38 +890,20 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width if (app.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 (app.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 (matchesAccountFilter(app, lot.account)) { + 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; } } } - if (!mixed) { - acct_col = common_acct orelse ""; - } else { - acct_col = "Multiple"; - } } + } else if (app.account_filter) |af| { + acct_col = af; + } else { + acct_col = a.account; } const display_weight = if (app.account_filter != null and filtered_total_for_weight > 0) @@ -1254,6 +1230,7 @@ pub fn reloadPortfolioFile(app: *App) void { sortPortfolioAllocations(app); buildAccountList(app); + recomputeFilteredPositions(app); rebuildPortfolioRows(app); // Invalidate analysis data -- it holds pointers into old portfolio memory