diff --git a/TODO.md b/TODO.md index 9081bb8..cdc2c9f 100644 --- a/TODO.md +++ b/TODO.md @@ -138,14 +138,6 @@ all lots to find matching option contracts. O(N*M) is fine for personal portfolios (<1000 lots). Pre-indexing options by underlying would help if someone had a very large options-heavy portfolio. -## Mixed price_ratio grouping - -`Position` grouping in `portfolio.zig` keys on `priceSymbol` alone. Lots with -different `price_ratio` values sharing the same `priceSymbol` get incorrectly -merged (e.g. investor vs institutional shares of the same fund). Should key -on `(priceSymbol, price_ratio)` tuple. Edge case — most people don't hold -both share classes simultaneously. - ## HTTP connection pooling Parallel server sync in `loadAllPrices` spawns up to 8 threads, each with its diff --git a/src/analytics/valuation.zig b/src/analytics/valuation.zig index 9212a81..b4c05f1 100644 --- a/src/analytics/valuation.zig +++ b/src/analytics/valuation.zig @@ -244,6 +244,109 @@ pub fn indexAtOrBefore( return lo - 1; } +/// Merge allocations that share the same ticker symbol but have different +/// price_ratio values into a single rolled-up allocation with normalized +/// (base-ticker-equivalent) shares. This lets the portfolio view show a +/// combined weight for related positions (e.g. direct SPY + institutional +/// CIT using ticker::SPY). +/// +/// For groups with a single allocation, no changes are made. +/// For groups with multiple allocations: +/// - shares are normalized to base-ticker units (shares * price_ratio) +/// - avg_cost is recomputed from total cost / normalized shares +/// - current_price is the raw ticker price (market_value / normalized shares) +/// - market_value, cost_basis, gain/loss, weight are summed +/// - price_ratio is set to 1.0 (shares are now in base units) +/// - account is set to "Multiple" +fn mergeAllocsBySymbol(allocs: *std.ArrayList(Allocation), allocator: std.mem.Allocator) !void { + if (allocs.items.len <= 1) return; + + // Identify symbols that appear more than once + var counts = std.StringHashMap(u32).init(allocator); + defer counts.deinit(); + for (allocs.items) |a| { + const entry = try counts.getOrPut(a.symbol); + if (!entry.found_existing) { + entry.value_ptr.* = 1; + } else { + entry.value_ptr.* += 1; + } + } + + // Check if any merges are needed + var needs_merge = false; + var count_iter = counts.valueIterator(); + while (count_iter.next()) |v| { + if (v.* > 1) { + needs_merge = true; + break; + } + } + if (!needs_merge) return; + + // Build merged result + var merged = std.ArrayList(Allocation).empty; + errdefer merged.deinit(allocator); + + // Track which symbols we've already merged + var done = std.StringHashMap(void).init(allocator); + defer done.deinit(); + + for (allocs.items) |a| { + if (counts.get(a.symbol).? <= 1) { + // Single allocation for this symbol — pass through + try merged.append(allocator, a); + continue; + } + + if (done.contains(a.symbol)) continue; + try done.put(a.symbol, {}); + + // Merge all allocations for this symbol + var total_mv: f64 = 0; + var total_cost: f64 = 0; + var total_weight: f64 = 0; + var norm_shares: f64 = 0; + var is_manual = false; + + for (allocs.items) |b| { + if (!std.mem.eql(u8, b.symbol, a.symbol)) continue; + total_mv += b.market_value; + total_cost += b.cost_basis; + total_weight += b.weight; + // Normalize: convert each lot's shares to base-ticker units + norm_shares += b.shares * b.price_ratio; + if (b.is_manual_price) is_manual = true; + } + + const raw_price = if (norm_shares > 0) total_mv / norm_shares else 0; + const avg_cost = if (norm_shares > 0) total_cost / norm_shares else 0; + + try merged.append(allocator, .{ + .symbol = a.symbol, + .display_symbol = a.symbol, // ticker, not CUSIP + .shares = norm_shares, + .avg_cost = avg_cost, + .current_price = raw_price, + .market_value = total_mv, + .cost_basis = total_cost, + .weight = total_weight, + .unrealized_gain_loss = total_mv - total_cost, + .unrealized_return = if (total_cost > 0) (total_mv / total_cost) - 1.0 else 0, + .is_manual_price = is_manual, + .account = "Multiple", + .price_ratio = 1.0, // normalized to base units + }); + } + + // Replace the allocations list + allocs.clearRetainingCapacity(); + for (merged.items) |a| { + try allocs.append(allocator, a); + } + merged.deinit(allocator); +} + /// Compute portfolio summary given positions and current prices. /// `prices` maps symbol -> current price. /// `manual_prices` optionally marks symbols whose price came from manual override (not live API). @@ -303,6 +406,12 @@ pub fn portfolioSummary( } } + // Roll up allocations that share the same ticker but have different + // price_ratios (e.g. direct SPY + institutional CIT using ticker::SPY). + // Normalize shares to base-ticker units so the header row shows + // meaningful aggregates (SPY-equivalent shares * SPY price = total value). + try mergeAllocsBySymbol(&allocs, allocator); + var summary = PortfolioSummary{ .total_value = total_value, .total_cost = total_cost, @@ -1062,3 +1171,89 @@ test "netWorth / netWorthAsOf: illiquid respects target date" { 0.01, ); } + +test "mergeAllocsBySymbol rolls up same-ticker different-ratio allocations" { + const allocator = std.testing.allocator; + + var allocs = std.ArrayList(Allocation).empty; + defer allocs.deinit(allocator); + + // Direct SPY: 717 shares at $713.94, ratio 1.0 + try allocs.append(allocator, .{ + .symbol = "SPY", + .display_symbol = "SPY", + .shares = 717.34, + .avg_cost = 461.24, + .current_price = 713.94, + .market_value = 717.34 * 713.94, // $512,064 + .cost_basis = 717.34 * 461.24, // $330,877 + .weight = 0.37, + .unrealized_gain_loss = (717.34 * 713.94) - (717.34 * 461.24), + .unrealized_return = (713.94 / 461.24) - 1.0, + .account = "Tax Loss", + .price_ratio = 1.0, + }); + + // Institutional CIT: 5070.866 shares at $169.97 (713.94 * 0.2381), ratio 0.2381 + try allocs.append(allocator, .{ + .symbol = "SPY", + .display_symbol = "S&P500", + .shares = 5070.866, + .avg_cost = 97.24, + .current_price = 713.94 * 0.2381, // effective price + .market_value = 5070.866 * 713.94 * 0.2381, // $861,893 + .cost_basis = 5070.866 * 97.24, // $493,093 + .weight = 0.63, + .unrealized_gain_loss = (5070.866 * 713.94 * 0.2381) - (5070.866 * 97.24), + .unrealized_return = 0, + .account = "Fidelity Kelly 401(k)", + .price_ratio = 0.2381, + }); + + // A non-SPY allocation that should pass through unchanged + try allocs.append(allocator, .{ + .symbol = "AAPL", + .display_symbol = "AAPL", + .shares = 100, + .avg_cost = 150.0, + .current_price = 200.0, + .market_value = 20000.0, + .cost_basis = 15000.0, + .weight = 0, + .unrealized_gain_loss = 5000.0, + .unrealized_return = 0.333, + .account = "Brokerage", + .price_ratio = 1.0, + }); + + try mergeAllocsBySymbol(&allocs, allocator); + + // Should produce 2 allocations: merged SPY + unchanged AAPL + try std.testing.expectEqual(@as(usize, 2), allocs.items.len); + + for (allocs.items) |a| { + if (std.mem.eql(u8, a.symbol, "SPY")) { + // Normalized shares: 717.34 * 1.0 + 5070.866 * 0.2381 ≈ 1924.22 + const expected_norm = 717.34 + 5070.866 * 0.2381; + try std.testing.expectApproxEqAbs(expected_norm, a.shares, 0.1); + + // Market value: sum of both + const expected_mv = (717.34 * 713.94) + (5070.866 * 713.94 * 0.2381); + try std.testing.expectApproxEqAbs(expected_mv, a.market_value, 1.0); + + // price_ratio should be normalized to 1.0 + try std.testing.expectApproxEqAbs(@as(f64, 1.0), a.price_ratio, 0.001); + + // current_price should be raw SPY price (market_value / normalized_shares) + try std.testing.expectApproxEqAbs(@as(f64, 713.94), a.current_price, 0.1); + + // Account should be "Multiple" + try std.testing.expectEqualStrings("Multiple", a.account); + } else { + // AAPL passes through unchanged + try std.testing.expectEqualStrings("AAPL", a.symbol); + try std.testing.expectApproxEqAbs(@as(f64, 100.0), a.shares, 0.01); + try std.testing.expectApproxEqAbs(@as(f64, 1.0), a.price_ratio, 0.001); + } + } +} diff --git a/src/commands/audit.zig b/src/commands/audit.zig index 3387cec..a60fcf4 100644 --- a/src/commands/audit.zig +++ b/src/commands/audit.zig @@ -1932,3 +1932,116 @@ test "fidelityOptionMatchesLot basic call" { const stock_lot = portfolio_mod.Lot{ .symbol = "AMZN", .security_type = .stock, .shares = 100, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 100 }; try std.testing.expect(!fidelityOptionMatchesLot("-AMZN260515C220", stock_lot)); } + +test "fidelityOptionMatchesLot put option and decimal strike" { + const lot = portfolio_mod.Lot{ + .symbol = "AAPL 06/20/2026 220.50 P", + .security_type = .option, + .underlying = "AAPL", + .strike = 220.50, + .option_type = .put, + .maturity_date = Date.fromYmd(2026, 6, 20), + .shares = -1, + .open_date = Date.fromYmd(2025, 1, 1), + .open_price = 5.0, + }; + + try std.testing.expect(fidelityOptionMatchesLot("-AAPL260620P220.50", lot)); + try std.testing.expect(fidelityOptionMatchesLot("AAPL260620P220.50", lot)); + // Call doesn't match put + try std.testing.expect(!fidelityOptionMatchesLot("-AAPL260620C220.50", lot)); +} + +test "fidelityOptionMatchesLot single-char underlying" { + const lot = portfolio_mod.Lot{ + .symbol = "A 03/20/2026 150.00 C", + .security_type = .option, + .underlying = "A", + .strike = 150.0, + .option_type = .call, + .maturity_date = Date.fromYmd(2026, 3, 20), + .shares = -2, + .open_date = Date.fromYmd(2025, 1, 1), + .open_price = 3.0, + }; + + try std.testing.expect(fidelityOptionMatchesLot("-A260320C150", lot)); + try std.testing.expect(!fidelityOptionMatchesLot("-A260320P150", lot)); +} + +test "option delta tracking in compareAccounts" { + const allocator = std.testing.allocator; + + // Build a minimal portfolio with an option lot + var lots = [_]portfolio_mod.Lot{ + .{ + .symbol = "MSFT 05/15/2026 400.00 C", + .security_type = .option, + .underlying = "MSFT", + .strike = 400.0, + .option_type = .call, + .maturity_date = Date.fromYmd(2026, 5, 15), + .shares = -2, + .open_date = Date.fromYmd(2025, 1, 1), + .open_price = 6.68, + .multiplier = 100, + .account = "Emil IRA", + }, + }; + const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; + + // Brokerage shows the option at different (mark-to-market) value + var brokerage = [_]BrokeragePosition{ + .{ + .account_number = "1234", + .account_name = "SCHWAB 1234", + .symbol = "MSFT 05/15/2026 400.00 C", + .description = "MSFT CALL", + .quantity = -2, + .current_value = -6511.20, + .cost_basis = -1336.0, + .is_cash = false, + }, + }; + + // Account map: map schwab account 1234 -> portfolio "Emil IRA" + var entries = [_]analysis.AccountTaxEntry{ + .{ + .account = "Emil IRA", + .tax_type = .roth, + .institution = "schwab", + .account_number = "1234", + }, + }; + const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; + + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + + const results = try compareAccounts(allocator, portfolio, &brokerage, acct_map, "schwab", prices); + defer { + for (results) |r| allocator.free(r.comparisons); + allocator.free(results); + } + + try std.testing.expectEqual(@as(usize, 1), results.len); + const acct = results[0]; + + // Option should be matched, with option_value_delta tracking the difference + try std.testing.expect(@abs(acct.option_value_delta) > 1.0); + // Option value mismatch should NOT set has_discrepancies + try std.testing.expect(!acct.has_discrepancies); + + // The comparison should be flagged as is_option + var found_option = false; + for (acct.comparisons) |cmp| { + if (cmp.is_option) { + found_option = true; + // Shares should match (-2 vs -2) + if (cmp.shares_delta) |d| { + try std.testing.expect(@abs(d) < 0.01); + } + } + } + try std.testing.expect(found_option); +} diff --git a/src/models/portfolio.zig b/src/models/portfolio.zig index 1a10d6a..a8407ef 100644 --- a/src/models/portfolio.zig +++ b/src/models/portfolio.zig @@ -298,11 +298,10 @@ pub const Position = struct { /// Note from the first lot (e.g. "VANGUARD TARGET 2035"). note: ?[]const u8 = null, /// Price ratio for institutional share classes (from lot). - /// NOTE: If lots with different price_ratios (or a mix of ratio/no-ratio) - /// share the same priceSymbol(), the position grouping would be incorrect. - /// Currently positions() takes the ratio from the first lot that has one. - /// Supporting dual-holding of investor + institutional shares of the same - /// ticker would require a different grouping key in positions(). + /// positionsAsOf() groups by (priceSymbol, price_ratio), so lots with + /// different ratios sharing the same ticker produce separate positions. + /// portfolioSummary() then merges them back into a single rolled-up + /// allocation with normalized (base-ticker-equivalent) shares. price_ratio: f64 = 1.0, /// Apply the share-class `price_ratio` to `raw_price` — the @@ -422,15 +421,27 @@ pub const Portfolio = struct { /// after `as_of` still contributes its shares on that date, and /// a lot opened after `as_of` does not. pub fn positionsAsOf(self: Portfolio, allocator: std.mem.Allocator, as_of: Date) ![]Position { - var map = std.StringHashMap(Position).init(allocator); - defer map.deinit(); + var result = std.ArrayList(Position).empty; + errdefer result.deinit(allocator); for (self.lots) |lot| { if (lot.security_type != .stock) continue; const sym = lot.priceSymbol(); - const entry = try map.getOrPut(sym); - if (!entry.found_existing) { - entry.value_ptr.* = .{ + + // Find existing position matching both symbol AND price_ratio. + // Lots with different ratios (e.g. direct SPY vs institutional CIT + // using ticker::SPY) must produce separate positions to ensure + // correct valuation. + var found: ?*Position = null; + for (result.items) |*pos| { + if (std.mem.eql(u8, pos.symbol, sym) and pos.price_ratio == lot.price_ratio) { + found = pos; + break; + } + } + + if (found == null) { + try result.append(allocator, .{ .symbol = sym, .lot_symbol = lot.symbol, .shares = 0, @@ -442,51 +453,38 @@ pub const Portfolio = struct { .account = lot.account orelse "", .note = lot.note, .price_ratio = lot.price_ratio, - }; + }); + found = &result.items[result.items.len - 1]; } else { // Track account: if lots have different accounts, mark as "Multiple" - const existing = entry.value_ptr.account; + const existing = found.?.account; const new_acct = lot.account orelse ""; if (existing.len > 0 and !std.mem.eql(u8, existing, "Multiple") and !std.mem.eql(u8, existing, new_acct)) { - entry.value_ptr.account = "Multiple"; - } - // Propagate price_ratio from the first lot that has one - if (entry.value_ptr.price_ratio == 1.0 and lot.price_ratio != 1.0) { - entry.value_ptr.price_ratio = lot.price_ratio; + found.?.account = "Multiple"; } } + + const pos = found.?; if (lot.lotIsOpenAsOf(as_of)) { - entry.value_ptr.shares += lot.shares; - entry.value_ptr.total_cost += lot.costBasis(); - entry.value_ptr.open_lots += 1; + pos.shares += lot.shares; + pos.total_cost += lot.costBasis(); + pos.open_lots += 1; } else { - // Closed-as-of: contributes realized gain IF the close - // happened on/before as_of. Lots not yet opened as of - // the target date shouldn't contribute anything — they - // didn't exist. const not_yet_opened = as_of.lessThan(lot.open_date); if (!not_yet_opened) { - entry.value_ptr.closed_lots += 1; - entry.value_ptr.realized_gain_loss += lot.realizedGainLoss() orelse 0; + pos.closed_lots += 1; + pos.realized_gain_loss += lot.realizedGainLoss() orelse 0; } } } // Compute avg_cost - var iter = map.valueIterator(); - while (iter.next()) |pos| { + for (result.items) |*pos| { if (pos.shares > 0) { pos.avg_cost = pos.total_cost / pos.shares; } } - var result = std.ArrayList(Position).empty; - errdefer result.deinit(allocator); - - var viter = map.valueIterator(); - while (viter.next()) |pos| { - try result.append(allocator, pos.*); - } return result.toOwnedSlice(allocator); } @@ -494,8 +492,8 @@ pub const Portfolio = struct { /// Same logic as positions() but filtered to lots matching `account_name`. /// Only includes positions with at least one open lot (closed-only symbols are excluded). pub fn positionsForAccount(self: Portfolio, allocator: std.mem.Allocator, account_name: []const u8) ![]Position { - var map = std.StringHashMap(Position).init(allocator); - defer map.deinit(); + var result = std.ArrayList(Position).empty; + errdefer result.deinit(allocator); for (self.lots) |lot| { if (lot.security_type != .stock) continue; @@ -503,9 +501,18 @@ pub const Portfolio = struct { if (!std.mem.eql(u8, lot_acct, account_name)) continue; const sym = lot.priceSymbol(); - const entry = try map.getOrPut(sym); - if (!entry.found_existing) { - entry.value_ptr.* = .{ + + // Find existing position matching both symbol AND price_ratio. + var found: ?*Position = null; + for (result.items) |*pos| { + if (std.mem.eql(u8, pos.symbol, sym) and pos.price_ratio == lot.price_ratio) { + found = pos; + break; + } + } + + if (found == null) { + try result.append(allocator, .{ .symbol = sym, .lot_symbol = lot.symbol, .shares = 0, @@ -517,38 +524,34 @@ pub const Portfolio = struct { .account = lot_acct, .note = lot.note, .price_ratio = lot.price_ratio, - }; - } else { - if (entry.value_ptr.price_ratio == 1.0 and lot.price_ratio != 1.0) { - entry.value_ptr.price_ratio = lot.price_ratio; - } + }); + found = &result.items[result.items.len - 1]; } + + const pos = found.?; if (lot.isOpen()) { - entry.value_ptr.shares += lot.shares; - entry.value_ptr.total_cost += lot.costBasis(); - entry.value_ptr.open_lots += 1; + pos.shares += lot.shares; + pos.total_cost += lot.costBasis(); + pos.open_lots += 1; } else { - entry.value_ptr.closed_lots += 1; - entry.value_ptr.realized_gain_loss += lot.realizedGainLoss() orelse 0; + pos.closed_lots += 1; + pos.realized_gain_loss += lot.realizedGainLoss() orelse 0; } } - var iter = map.valueIterator(); - while (iter.next()) |pos| { + // Compute avg_cost and filter to open-only + var final = std.ArrayList(Position).empty; + errdefer final.deinit(allocator); + + for (result.items) |*pos| { + if (pos.open_lots == 0) continue; if (pos.shares > 0) { pos.avg_cost = pos.total_cost / pos.shares; } + try final.append(allocator, pos.*); } - - var result = std.ArrayList(Position).empty; - errdefer result.deinit(allocator); - - var viter = map.valueIterator(); - while (viter.next()) |pos| { - if (pos.open_lots == 0) continue; - try result.append(allocator, pos.*); - } - return result.toOwnedSlice(allocator); + result.deinit(allocator); + return final.toOwnedSlice(allocator); } /// Total cash for a single account. @@ -1069,6 +1072,41 @@ test "positions propagates price_ratio from lot" { } } +test "positions separates lots with different price_ratio" { + const allocator = std.testing.allocator; + + var lots = [_]Lot{ + // Direct SPY holding, price_ratio = 1.0 (default) + .{ .symbol = "SPY", .shares = 717.34, .open_date = Date.fromYmd(2025, 2, 25), .open_price = 461.24, .account = "Tax Loss" }, + // Institutional S&P 500 CIT, uses SPY as ticker with a ratio + .{ .symbol = "NON40OR52", .shares = 5070.866, .open_date = Date.fromYmd(2026, 2, 26), .open_price = 97.24, .ticker = "SPY", .price_ratio = 0.2381, .account = "Fidelity Kelly 401(k)" }, + }; + + var portfolio = Portfolio{ .lots = &lots, .allocator = allocator }; + const pos = try portfolio.positions(allocator); + defer allocator.free(pos); + + // Should produce 2 separate positions, not 1 merged position + try std.testing.expectEqual(@as(usize, 2), pos.len); + + var found_direct = false; + var found_institutional = false; + for (pos) |p| { + if (p.price_ratio == 1.0) { + found_direct = true; + try std.testing.expectApproxEqAbs(@as(f64, 717.34), p.shares, 0.01); + try std.testing.expectEqualStrings("SPY", p.lot_symbol); + } else { + found_institutional = true; + try std.testing.expectApproxEqAbs(@as(f64, 5070.866), p.shares, 0.01); + try std.testing.expectApproxEqAbs(@as(f64, 0.2381), p.price_ratio, 0.0001); + try std.testing.expectEqualStrings("NON40OR52", p.lot_symbol); + } + } + try std.testing.expect(found_direct); + try std.testing.expect(found_institutional); +} + test "positionsForAccount excludes closed-only symbols" { const allocator = std.testing.allocator; diff --git a/src/tui/portfolio_tab.zig b/src/tui/portfolio_tab.zig index d1ebc44..c8c868c 100644 --- a/src/tui/portfolio_tab.zig +++ b/src/tui/portfolio_tab.zig @@ -289,8 +289,12 @@ pub fn rebuildPortfolioRows(app: *App) void { // Count lots for this symbol (filtered by account when filter is active) var lcount: usize = 0; - if (findFilteredPosition(app, a.symbol)) |pos| { - lcount = pos.open_lots + pos.closed_lots; + if (app.filtered_positions) |fps| { + for (fps) |pos| { + if (std.mem.eql(u8, pos.symbol, a.symbol) or std.mem.eql(u8, pos.lot_symbol, a.symbol)) { + lcount += pos.open_lots + pos.closed_lots; + } + } } else if (app.account_filter == null) { if (app.portfolio) |pf| { for (pf.lots) |lot| { @@ -652,17 +656,6 @@ fn recomputeFilteredPositions(app: *App) void { 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 { @@ -675,8 +668,12 @@ fn matchesAccountFilter(app: *const App, account: ?[]const u8) bool { /// When filtered, checks against pre-computed filtered_positions. fn allocationMatchesFilter(app: *const App, a: zfin.valuation.Allocation) bool { if (app.account_filter == null) return true; - if (app.filtered_positions == null) return false; - return findFilteredPosition(app, a.symbol) != null; + const fps = app.filtered_positions orelse return false; + for (fps) |pos| { + if (std.mem.eql(u8, pos.symbol, a.symbol) or std.mem.eql(u8, pos.lot_symbol, a.symbol)) + return true; + } + return false; } /// Account-filtered view of an allocation. When a position spans multiple accounts, @@ -690,7 +687,9 @@ const FilteredAlloc = struct { /// Compute account-filtered values for an allocation. /// For single-account positions (or no filter), returns the allocation's own values. -/// For filtered views, uses pre-computed filtered_positions. +/// For filtered views, sums across all matching positions for the symbol. +/// This handles rolled-up allocations where multiple positions with different +/// price_ratios share the same ticker (e.g. direct SPY + institutional CIT). fn filteredAllocValues(app: *const App, a: zfin.valuation.Allocation) FilteredAlloc { if (app.account_filter == null) return .{ .shares = a.shares, @@ -698,16 +697,42 @@ fn filteredAllocValues(app: *const App, a: zfin.valuation.Allocation) FilteredAl .market_value = a.market_value, .unrealized_gain_loss = a.unrealized_gain_loss, }; - 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 fps = app.filtered_positions orelse return .{ + .shares = 0, + .cost_basis = 0, + .market_value = 0, + .unrealized_gain_loss = 0, + }; + + // Sum across all filtered positions matching this symbol. + // For rolled-up allocations, the raw ticker price is used with each + // position's own price_ratio to compute correct per-position values. + var total_shares: f64 = 0; + var total_cost: f64 = 0; + var total_mv: f64 = 0; + var found = false; + + for (fps) |pos| { + if (std.mem.eql(u8, pos.symbol, a.symbol) or std.mem.eql(u8, pos.lot_symbol, a.symbol)) { + found = true; + total_shares += pos.shares * pos.price_ratio; // normalize to base units + total_cost += pos.total_cost; + total_mv += pos.shares * a.current_price * pos.price_ratio; + } } - return .{ .shares = 0, .cost_basis = 0, .market_value = 0, .unrealized_gain_loss = 0 }; + + if (!found) return .{ + .shares = 0, + .cost_basis = 0, + .market_value = 0, + .unrealized_gain_loss = 0, + }; + return .{ + .shares = total_shares, + .cost_basis = total_cost, + .market_value = total_mv, + .unrealized_gain_loss = total_mv - total_cost, + }; } /// Totals for the filtered account view (stocks + cash + CDs + options).