diff --git a/src/analytics/analysis.zig b/src/analytics/analysis.zig index 75767e3..4a66fec 100644 --- a/src/analytics/analysis.zig +++ b/src/analytics/analysis.zig @@ -605,13 +605,21 @@ pub fn analyzePortfolio( const mv = alloc.market_value; if (mv <= 0) continue; - // Find classification entries for this symbol - // Try both the raw symbol and display_symbol + // Find classification entries for this symbol. + // + // Match on `alloc.symbol` only — the canonical economic + // identity (priceSymbol(): the `ticker::` alias when set, + // else the raw symbol/CUSIP). `display_symbol` is a + // display-only concern (an explicit `label::`, else + // priceSymbol) and must NEVER be a classification key: + // a free-text annotation that silently changed what + // classifies would be a footgun. Keying on `alloc.symbol` + // keeps this engine, review's `bucketForSymbol`, and + // doctor's `classifiableSymbols` all matching on + // priceSymbol(). var found = false; for (classifications.entries) |entry| { - if (std.mem.eql(u8, entry.symbol, alloc.symbol) or - std.mem.eql(u8, entry.symbol, alloc.display_symbol)) - { + if (std.mem.eql(u8, entry.symbol, alloc.symbol)) { found = true; const frac = entry.pct / 100.0; const portion = mv * frac; @@ -1848,6 +1856,58 @@ test "analyzePortfolio: GICS-sectored stock lands in Equity bucket" { try std.testing.expectApproxEqAbs(@as(f64, 50_000), result.asset_category[0].value, 1.0); } +test "analyzePortfolio: display_symbol is never a classification key" { + // Regression: a note/label-derived `display_symbol` must not + // classify. The engine keys on `alloc.symbol` (priceSymbol()) + // only, so a metadata entry written against the display label + // classifies nothing — editing a label or note can't move a + // single breakdown dollar. + const allocator = std.testing.allocator; + const allocations = [_]Allocation{ + .{ + .symbol = "02315N600", // bare CUSIP: the economic identity + .display_symbol = "TGT2035", // human label: the would-be footgun + .shares = 1, + .avg_cost = 50_000, + .current_price = 50_000, + .market_value = 50_000, + .cost_basis = 50_000, + .weight = 1.0, + .unrealized_gain_loss = 0.0, + .unrealized_return = 0.0, + }, + }; + const portfolio = Portfolio{ .lots = &.{}, .allocator = allocator }; + + // Metadata keyed on the display label must NOT classify; the + // holding falls through to unclassified (where display_symbol is + // still the friendly label shown to the user). + { + var entries = [_]ClassificationEntry{ + .{ .symbol = "TGT2035", .sector = "Technology" }, + }; + const cm = ClassificationMap{ .entries = &entries, .allocator = allocator }; + var result = try analyzePortfolio(allocator, &allocations, cm, portfolio, 50_000, null, Date.fromYmd(2024, 6, 1)); + defer result.deinit(allocator); + try std.testing.expectEqual(@as(usize, 0), result.asset_category.len); + try std.testing.expectEqual(@as(usize, 1), result.unclassified.len); + try std.testing.expectEqualStrings("TGT2035", result.unclassified[0]); + } + + // Metadata keyed on the CUSIP (the economic identity) DOES classify. + { + var entries = [_]ClassificationEntry{ + .{ .symbol = "02315N600", .sector = "Technology" }, + }; + const cm = ClassificationMap{ .entries = &entries, .allocator = allocator }; + var result = try analyzePortfolio(allocator, &allocations, cm, portfolio, 50_000, null, Date.fromYmd(2024, 6, 1)); + defer result.deinit(allocator); + try std.testing.expectEqual(@as(usize, 0), result.unclassified.len); + try std.testing.expectEqual(@as(usize, 1), result.asset_category.len); + try std.testing.expectEqualStrings(bucket_equity, result.asset_category[0].label); + } +} + test "analyzePortfolio: empty portfolio produces empty asset_category" { const allocator = std.testing.allocator; const cm = ClassificationMap{ .entries = &.{}, .allocator = allocator };