reuse common account filtering in portfolio

This commit is contained in:
Emil Lerch 2026-04-09 17:41:25 -07:00
parent f55af318f2
commit 9337c198f4
Signed by: lobo
GPG key ID: A7B62D657EF764F8
2 changed files with 63 additions and 79 deletions

View file

@ -309,6 +309,7 @@ pub const App = struct {
// Account filter state // Account filter state
account_filter: ?[]const u8 = null, // active account filter (owned copy; null = all accounts) 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_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") 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. /// Set or clear the account filter. Owns the string via allocator.
pub fn setAccountFilter(self: *App, name: ?[]const u8) void { 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.account_filter) |old| self.allocator.free(old);
if (self.filtered_positions) |fp| self.allocator.free(fp);
self.filtered_positions = null;
if (name) |n| { if (name) |n| {
self.account_filter = self.allocator.dupe(u8, n) catch null; 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 { } else {
self.account_filter = null; self.account_filter = null;
} }
@ -1347,6 +1353,7 @@ pub const App = struct {
self.options_rows.deinit(self.allocator); self.options_rows.deinit(self.allocator);
self.account_list.deinit(self.allocator); self.account_list.deinit(self.allocator);
if (self.account_filter) |af| self.allocator.free(af); 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.watchlist_prices) |*wp| wp.deinit();
if (self.analysis_result) |*ar| ar.deinit(self.allocator); if (self.analysis_result) |*ar| ar.deinit(self.allocator);
if (self.classification_map) |*cm| cm.deinit(); if (self.classification_map) |*cm| cm.deinit();

View file

@ -199,6 +199,7 @@ pub fn loadPortfolioData(app: *App) void {
sortPortfolioAllocations(app); sortPortfolioAllocations(app);
buildAccountList(app); buildAccountList(app);
recomputeFilteredPositions(app);
rebuildPortfolioRows(app); rebuildPortfolioRows(app);
const summary = pf_data.summary; 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) // Count lots for this symbol (filtered by account when filter is active)
var lcount: usize = 0; var lcount: usize = 0;
if (findFilteredPosition(app, a.symbol)) |pos| {
lcount = pos.open_lots + pos.closed_lots;
} else if (app.account_filter == null) {
if (app.portfolio) |pf| { if (app.portfolio) |pf| {
for (pf.lots) |lot| { for (pf.lots) |lot| {
if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) { if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) {
if (matchesAccountFilter(app, lot.account)) lcount += 1; 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. /// Check if a lot matches the active account filter.
/// Returns true if no filter is active or the lot's account matches. /// Returns true if no filter is active or the lot's account matches.
fn matchesAccountFilter(app: *const App, account: ?[]const u8) bool { 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. /// Check if an allocation matches the active account filter.
/// Uses the allocation's account field (which is "Multiple" for mixed-account positions). /// When filtered, checks against pre-computed filtered_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 { fn allocationMatchesFilter(app: *const App, a: zfin.valuation.Allocation) bool {
const filter = app.account_filter orelse return true; if (app.account_filter == null) return true;
// Simple case: allocation has a single account if (app.filtered_positions == null) return false;
if (!std.mem.eql(u8, a.account, "Multiple")) { return findFilteredPosition(app, a.symbol) != null;
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, /// 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. /// Compute account-filtered values for an allocation.
/// For single-account positions (or no filter), returns the allocation's own values. /// 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 { 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, .shares = a.shares,
.cost_basis = a.cost_basis, .cost_basis = a.cost_basis,
.market_value = a.market_value, .market_value = a.market_value,
.unrealized_gain_loss = a.unrealized_gain_loss, .unrealized_gain_loss = a.unrealized_gain_loss,
}; };
if (!std.mem.eql(u8, a.account, "Multiple")) return .{ if (findFilteredPosition(app, a.symbol)) |pos| {
.shares = a.shares, const mv = pos.shares * a.current_price * pos.price_ratio;
.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 .{ return .{
.shares = shares, .shares = pos.shares,
.cost_basis = cost, .cost_basis = pos.total_cost,
.market_value = mv, .market_value = mv,
.unrealized_gain_loss = mv - cost, .unrealized_gain_loss = mv - pos.total_cost,
}; };
}
return .{ .shares = 0, .cost_basis = 0, .market_value = 0, .unrealized_gain_loss = 0 };
} }
/// Totals for the filtered account view (stocks + cash + CDs + options). /// Totals for the filtered account view (stocks + cash + CDs + options).
@ -896,6 +890,7 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width
if (app.portfolio) |pf| { if (app.portfolio) |pf| {
for (pf.lots) |lot| { for (pf.lots) |lot| {
if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) { if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) {
if (matchesAccountFilter(app, lot.account)) {
const ds = lot.open_date.format(&pos_date_buf); const ds = lot.open_date.format(&pos_date_buf);
const indicator = fmt.capitalGainsIndicator(lot.open_date); const indicator = fmt.capitalGainsIndicator(lot.open_date);
date_col = std.fmt.allocPrint(arena, "{s} {s}", .{ ds, indicator }) catch ds; date_col = std.fmt.allocPrint(arena, "{s} {s}", .{ ds, indicator }) catch ds;
@ -904,30 +899,11 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width
} }
} }
} }
}
} else if (app.account_filter) |af| {
acct_col = af;
} else { } else {
// Multi-lot: show account if all lots share the same one acct_col = a.account;
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 (!mixed) {
acct_col = common_acct orelse "";
} else {
acct_col = "Multiple";
}
}
} }
const display_weight = if (app.account_filter != null and filtered_total_for_weight > 0) 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); sortPortfolioAllocations(app);
buildAccountList(app); buildAccountList(app);
recomputeFilteredPositions(app);
rebuildPortfolioRows(app); rebuildPortfolioRows(app);
// Invalidate analysis data -- it holds pointers into old portfolio memory // Invalidate analysis data -- it holds pointers into old portfolio memory