diff --git a/src/views/review.zig b/src/views/review.zig index 2fe8b73..28d631f 100644 --- a/src/views/review.zig +++ b/src/views/review.zig @@ -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);