ai: merge multiple positions with price_ratio fields
This commit is contained in:
parent
ee98a2c4ed
commit
77cc69efe0
5 changed files with 458 additions and 95 deletions
8
TODO.md
8
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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue