ai: merge multiple positions with price_ratio fields
All checks were successful
Generic zig build / build (push) Successful in 1m48s
Generic zig build / deploy (push) Successful in 15s

This commit is contained in:
Emil Lerch 2026-04-25 11:15:41 -07:00
parent ee98a2c4ed
commit 77cc69efe0
Signed by: lobo
GPG key ID: A7B62D657EF764F8
5 changed files with 458 additions and 95 deletions

View file

@ -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

View file

@ -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);
}
}
}

View file

@ -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);
}

View file

@ -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;

View file

@ -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).