match only on the calculated symbol, never on display (which could pick up notes)
This commit is contained in:
parent
be888069c0
commit
d301466345
1 changed files with 65 additions and 5 deletions
|
|
@ -605,13 +605,21 @@ pub fn analyzePortfolio(
|
||||||
const mv = alloc.market_value;
|
const mv = alloc.market_value;
|
||||||
if (mv <= 0) continue;
|
if (mv <= 0) continue;
|
||||||
|
|
||||||
// Find classification entries for this symbol
|
// Find classification entries for this symbol.
|
||||||
// Try both the raw symbol and display_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;
|
var found = false;
|
||||||
for (classifications.entries) |entry| {
|
for (classifications.entries) |entry| {
|
||||||
if (std.mem.eql(u8, entry.symbol, alloc.symbol) or
|
if (std.mem.eql(u8, entry.symbol, alloc.symbol)) {
|
||||||
std.mem.eql(u8, entry.symbol, alloc.display_symbol))
|
|
||||||
{
|
|
||||||
found = true;
|
found = true;
|
||||||
const frac = entry.pct / 100.0;
|
const frac = entry.pct / 100.0;
|
||||||
const portion = mv * frac;
|
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);
|
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" {
|
test "analyzePortfolio: empty portfolio produces empty asset_category" {
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
const cm = ClassificationMap{ .entries = &.{}, .allocator = allocator };
|
const cm = ClassificationMap{ .entries = &.{}, .allocator = allocator };
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue