use priceSymbol() rather than raw symbol when calculating tax percentages

This commit is contained in:
Emil Lerch 2026-06-24 15:56:54 -07:00
parent 7bc19eafb7
commit 972b7436c0
Signed by: lobo
GPG key ID: A7B62D657EF764F8

View file

@ -499,8 +499,10 @@ fn bucketForSymbol(
return "Unclassified";
}
/// Walk the open lots for `symbol` and compute the share of market
/// value held in taxable accounts. Returns null when:
/// Walk the open lots for `symbol` (the allocation's `priceSymbol()`)
/// and compute the share of market value held in taxable accounts.
/// Lots are matched by `priceSymbol()`, so ticker-aliased lots count.
/// Returns null when:
/// - account_map is null (no classification metadata available)
/// - the symbol has no open lots
/// - all lots have unknown accounts (none match in the map)
@ -515,7 +517,12 @@ fn computeTaxPct(
var taxable_shares: f64 = 0;
var classified_shares: f64 = 0;
for (portfolio.lots) |lot| {
if (!std.mem.eql(u8, lot.symbol, symbol)) continue;
// Match on priceSymbol() (the ticker:: alias when set, else
// the raw symbol), because the caller passes the allocation's
// symbol, which is itself priceSymbol(). Matching raw
// lot.symbol would miss ticker-aliased lots (e.g. a CUSIP with
// ticker::VTTHX), yielding a wrong/empty tax%.
if (!std.mem.eql(u8, lot.priceSymbol(), symbol)) continue;
if (!lot.lotIsOpenAsOf(as_of)) continue;
if (lot.security_type != .stock) continue; // options/cash/CDs handled elsewhere
const acct = lot.account orelse continue;
@ -910,6 +917,26 @@ test "computeTaxPct: mixed accounts produces partial tax%" {
try testing.expectApproxEqAbs(@as(f64, 0.6), result.?, 0.001);
}
test "computeTaxPct: matches ticker-aliased lots via priceSymbol()" {
// Regression: the caller passes the allocation's symbol, which is
// priceSymbol(). A CUSIP lot aliased to a ticker must be matched
// by priceSymbol(), not raw lot.symbol, or its shares are dropped
// and the tax% comes out wrong (or null).
var lots = [_]zfin.Lot{
.{ .symbol = "02315N600", .shares = 60, .open_date = Date.fromYmd(2022, 1, 10), .open_price = 200, .account = "Brokerage", .ticker = "VTTHX" },
.{ .symbol = "02315N600", .shares = 40, .open_date = Date.fromYmd(2022, 1, 10), .open_price = 200, .account = "Roth IRA", .ticker = "VTTHX" },
};
const portfolio: zfin.Portfolio = .{ .lots = lots[0..], .allocator = testing.allocator };
var entries = [_]analysis.AccountTaxEntry{
.{ .account = "Brokerage", .tax_type = .taxable },
.{ .account = "Roth IRA", .tax_type = .roth },
};
const am: analysis.AccountMap = .{ .entries = entries[0..], .allocator = testing.allocator };
// Caller passes priceSymbol() ("VTTHX"), not the raw CUSIP.
const result = computeTaxPct("VTTHX", portfolio, am, Date.fromYmd(2026, 1, 1));
try testing.expectApproxEqAbs(@as(f64, 0.6), result.?, 0.001);
}
test "annualizedFromResult: null result returns null" {
try testing.expect(annualizedFromResult(null, false) == null);
try testing.expect(annualizedFromResult(null, true) == null);