match only on the calculated symbol, never on display (which could pick up notes)

This commit is contained in:
Emil Lerch 2026-06-24 15:15:48 -07:00
parent be888069c0
commit d301466345
Signed by: lobo
GPG key ID: A7B62D657EF764F8

View file

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