From cd2ccb4c432006e31141b1fb4b24b56a50ac387e Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Wed, 24 Jun 2026 15:36:38 -0700 Subject: [PATCH] add label field, let notes field be strictly for notes --- src/analytics/valuation.zig | 84 +++++++++++++++----------------- src/cache/store.zig | 36 ++++++++++++++ src/commands/import.zig | 1 + src/commands/portfolio.zig | 2 +- src/models/portfolio.zig | 57 +++++++++++++++++++++- src/portfolio_loader.zig | 1 + src/tui/portfolio_tab.zig | 2 +- src/views/portfolio_sections.zig | 4 +- 8 files changed, 136 insertions(+), 51 deletions(-) diff --git a/src/analytics/valuation.zig b/src/analytics/valuation.zig index 5371214..775ff68 100644 --- a/src/analytics/valuation.zig +++ b/src/analytics/valuation.zig @@ -127,8 +127,10 @@ pub const PortfolioSummary = struct { pub const Allocation = struct { /// Ticker symbol or CUSIP identifying this position. symbol: []const u8, - /// Display label for the symbol column. For CUSIPs with notes, this is a - /// short label derived from the note (e.g. "TGT2035"). Otherwise same as symbol. + /// Display label for the symbol column — the position's "human + /// identity": an explicit `label::`, else the economic identity + /// (`priceSymbol()`). Display-only; never note-derived and never a + /// pricing or classification key. See `Position.displaySymbol()`. display_symbol: []const u8, /// Total shares held across all lots for this symbol. shares: f64, @@ -392,15 +394,9 @@ pub fn portfolioSummary( total_cost += pos.total_cost; total_realized += pos.realized_gain_loss; - // For CUSIPs with a note, derive a short display label from the note. - const display = if (portfolio_mod.isCusipLike(pos.symbol) and pos.note != null) - shortLabel(pos.note.?) - else - pos.symbol; - try allocs.append(allocator, .{ .symbol = pos.symbol, - .display_symbol = display, + .display_symbol = pos.displaySymbol(), .shares = pos.shares, .avg_cost = pos.avg_cost, .current_price = price, @@ -652,49 +648,12 @@ pub fn computeHistoricalSnapshots( return result; } -/// Derive a short display label (max 7 chars) from a descriptive note. -/// "VANGUARD TARGET 2035" -> "TGT2035", "LARGE COMPANY STOCK" -> "LRG CO". -/// Falls back to first 7 characters of the note if no pattern matches. -fn shortLabel(note: []const u8) []const u8 { - // Look for "TARGET " pattern (Vanguard Target Retirement funds) - const target_labels = .{ - .{ "2025", "TGT2025" }, - .{ "2030", "TGT2030" }, - .{ "2035", "TGT2035" }, - .{ "2040", "TGT2040" }, - .{ "2045", "TGT2045" }, - .{ "2050", "TGT2050" }, - .{ "2055", "TGT2055" }, - .{ "2060", "TGT2060" }, - .{ "2065", "TGT2065" }, - .{ "2070", "TGT2070" }, - }; - if (std.ascii.indexOfIgnoreCase(note, "target")) |_| { - inline for (target_labels) |entry| { - if (std.mem.indexOf(u8, note, entry[0]) != null) { - return entry[1]; - } - } - } - // Fallback: take up to 7 chars from the note - const max = @min(note.len, 7); - return note[0..max]; -} - // ── Tests ──────────────────────────────────────────────────── fn makeCandle(date: Date, price: f64) Candle { return .{ .date = date, .open = price, .high = price, .low = price, .close = price, .adj_close = price, .volume = 1000 }; } -test "shortLabel" { - try std.testing.expectEqualStrings("TGT2035", shortLabel("VANGUARD TARGET 2035")); - try std.testing.expectEqualStrings("TGT2040", shortLabel("VANGUARD TARGET 2040")); - try std.testing.expectEqualStrings("LARGE C", shortLabel("LARGE COMPANY STOCK")); - try std.testing.expectEqualStrings("SHORT", shortLabel("SHORT")); - try std.testing.expectEqualStrings("TGT2055", shortLabel("TARGET 2055 FUND")); -} - test "findPriceAtDate exact match" { const candles = [_]Candle{ makeCandle(Date.fromYmd(2024, 1, 2), 100), @@ -981,6 +940,39 @@ test "portfolioSummary applies price_ratio" { } } +test "portfolioSummary: display_symbol uses label, else priceSymbol" { + const Position = portfolio_mod.Position; + const alloc = std.testing.allocator; + + var positions = [_]Position{ + // Bare CUSIP with an explicit label → the label shows. + .{ .symbol = "02315N600", .shares = 100, .avg_cost = 140.0, .total_cost = 14000.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0, .label = "TGT2035" }, + // Bare CUSIP without a label → raw CUSIP shows (post-migration default). + .{ .symbol = "02315N709", .shares = 10, .avg_cost = 150.0, .total_cost = 1500.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 }, + }; + + var prices = std.StringHashMap(f64).init(alloc); + defer prices.deinit(); + try prices.put("02315N600", 200.0); + try prices.put("02315N709", 100.0); + + const empty_pf = portfolio_mod.Portfolio{ .lots = &.{}, .allocator = alloc }; + var summary = try portfolioSummary(Date.fromYmd(2026, 5, 8), alloc, empty_pf, &positions, prices, null); + defer summary.deinit(alloc); + + try std.testing.expectEqual(@as(usize, 2), summary.allocations.len); + for (summary.allocations) |a| { + if (std.mem.eql(u8, a.symbol, "02315N600")) { + // symbol (the classification key) is unchanged; display shows the label. + try std.testing.expectEqualStrings("02315N600", a.symbol); + try std.testing.expectEqualStrings("TGT2035", a.display_symbol); + } else { + // No label → display falls back to the symbol (priceSymbol). + try std.testing.expectEqualStrings("02315N709", a.display_symbol); + } + } +} + test "portfolioSummary skips price_ratio for manual/fallback prices" { const Position = portfolio_mod.Position; const alloc = std.testing.allocator; diff --git a/src/cache/store.zig b/src/cache/store.zig index f67fa07..e90b950 100644 --- a/src/cache/store.zig +++ b/src/cache/store.zig @@ -1867,6 +1867,7 @@ pub fn deserializePortfolio(allocator: std.mem.Allocator, data: []const u8) !Por for (lots.items) |lot| { allocator.free(lot.symbol); if (lot.note) |n| allocator.free(n); + if (lot.label) |l| allocator.free(l); if (lot.account) |a| allocator.free(a); if (lot.ticker) |t| allocator.free(t); if (lot.underlying) |u| allocator.free(u); @@ -1890,6 +1891,7 @@ pub fn deserializePortfolio(allocator: std.mem.Allocator, data: []const u8) !Por // Dupe owned strings before iterator.deinit() frees the backing buffer lot.symbol = try allocator.dupe(u8, lot.symbol); if (lot.note) |n| lot.note = try allocator.dupe(u8, n); + if (lot.label) |l| lot.label = try allocator.dupe(u8, l); if (lot.account) |a| lot.account = try allocator.dupe(u8, a); if (lot.ticker) |t| lot.ticker = try allocator.dupe(u8, t); if (lot.underlying) |u| lot.underlying = try allocator.dupe(u8, u); @@ -1903,6 +1905,7 @@ pub fn deserializePortfolio(allocator: std.mem.Allocator, data: []const u8) !Por else => { std.log.warn("portfolio: record at line {d} has no symbol, skipping", .{line}); if (lot.note) |n| allocator.free(n); + if (lot.label) |l| allocator.free(l); if (lot.account) |a| allocator.free(a); if (lot.ticker) |t| allocator.free(t); if (lot.underlying) |u| allocator.free(u); @@ -2919,6 +2922,39 @@ test "portfolio: price_ratio round-trip" { try std.testing.expectApproxEqAbs(@as(f64, 1.0), portfolio2.lots[1].price_ratio, 0.001); } +test "portfolio: label:: round-trip" { + const allocator = std.testing.allocator; + const data = + \\#!srfv1 + \\symbol::02315N600,shares:num:100,open_date::2024-01-15,open_price:num:140.00,note::some annotation,label::TGT2035 + \\symbol::AAPL,shares:num:10,open_date::2024-03-01,open_price:num:150.00 + \\ + ; + + var portfolio = try deserializePortfolio(allocator, data); + defer portfolio.deinit(); + + try std.testing.expectEqual(@as(usize, 2), portfolio.lots.len); + // Label parses and is independent of the note. + try std.testing.expectEqualStrings("TGT2035", portfolio.lots[0].label.?); + try std.testing.expectEqualStrings("some annotation", portfolio.lots[0].note.?); + // displaySymbol() prefers the label over the raw CUSIP. + try std.testing.expectEqualStrings("TGT2035", portfolio.lots[0].displaySymbol()); + // No label: displaySymbol() falls back to priceSymbol(). + try std.testing.expect(portfolio.lots[1].label == null); + try std.testing.expectEqualStrings("AAPL", portfolio.lots[1].displaySymbol()); + + // Round-trip: the label survives serialize -> deserialize. + const reserialized = try serializePortfolio(allocator, portfolio.lots); + defer allocator.free(reserialized); + try std.testing.expect(std.mem.indexOf(u8, reserialized, "label::TGT2035") != null); + + var portfolio2 = try deserializePortfolio(allocator, reserialized); + defer portfolio2.deinit(); + try std.testing.expectEqualStrings("TGT2035", portfolio2.lots[0].label.?); + try std.testing.expect(portfolio2.lots[1].label == null); +} + // ── TTL and Negative Cache Tests ───────────────────────────────── test "TTL constants are reasonable" { diff --git a/src/commands/import.zig b/src/commands/import.zig index cb84d37..df160b0 100644 --- a/src/commands/import.zig +++ b/src/commands/import.zig @@ -812,6 +812,7 @@ fn freeLots(allocator: std.mem.Allocator, lots: []const portfolio_mod.Lot) void fn freeLot(allocator: std.mem.Allocator, lot: portfolio_mod.Lot) void { allocator.free(lot.symbol); if (lot.note) |n| allocator.free(n); + if (lot.label) |l| allocator.free(l); if (lot.account) |a| allocator.free(a); if (lot.ticker) |t| allocator.free(t); if (lot.underlying) |u| allocator.free(u); diff --git a/src/commands/portfolio.zig b/src/commands/portfolio.zig index 6a8d34e..e1f9015 100644 --- a/src/commands/portfolio.zig +++ b/src/commands/portfolio.zig @@ -562,7 +562,7 @@ pub fn display( for (portfolio.lots) |lot| { if (lot.security_type != .illiquid) continue; var il_row_buf: [160]u8 = undefined; - try out.print("{s}\n", .{fmt.fmtIlliquidRow(&il_row_buf, lot.symbol, lot.shares, lot.note)}); + try out.print("{s}\n", .{fmt.fmtIlliquidRow(&il_row_buf, lot.displaySymbol(), lot.shares, lot.note)}); } // Illiquid total var il_sep_buf2: [80]u8 = undefined; diff --git a/src/models/portfolio.zig b/src/models/portfolio.zig index 41ad530..bac1663 100644 --- a/src/models/portfolio.zig +++ b/src/models/portfolio.zig @@ -176,6 +176,12 @@ pub const Lot = struct { close_price: ?f64 = null, /// Optional note/tag for the lot note: ?[]const u8 = null, + /// Optional explicit display label — the lot's "human identity" + /// for the symbol column (e.g. `label::TGT2035` on a target-date + /// CUSIP). When set it overrides the symbol/ticker in display + /// ONLY; it is never a pricing or classification key. The display + /// counterpart to `ticker::`/`priceSymbol()`. See `displaySymbol()`. + label: ?[]const u8 = null, /// Optional account identifier (e.g. "Roth IRA", "Brokerage") account: ?[]const u8 = null, /// Type of holding (stock, option, cd, cash) @@ -209,11 +215,23 @@ pub const Lot = struct { /// Call or put (for option lots). option_type: OptionType = .call, - /// The symbol to use for price fetching (ticker if set, else symbol). + /// The symbol to use for price fetching: the `ticker::` alias + /// when set, else the raw `symbol`. This is the lot's **economic + /// identity** — what the pipeline prices, aggregates, and + /// classifies by. Its display counterpart is `displaySymbol()`. pub fn priceSymbol(self: Lot) []const u8 { return self.ticker orelse self.symbol; } + /// The symbol to show in the display: an explicit `label::` when + /// set, else the economic identity (`priceSymbol()`). This is the + /// lot's **human identity** — purely cosmetic, never a pricing or + /// classification key. The display mirror of `priceSymbol()`; + /// also mirrored by `Position.displaySymbol()`. + pub fn displaySymbol(self: Lot) []const u8 { + return self.label orelse self.priceSymbol(); + } + pub fn isOpen(self: Lot, as_of: Date) bool { return self.lotIsOpenAsOf(as_of); } @@ -301,6 +319,9 @@ pub const Position = struct { account: []const u8 = "", /// Note from the first lot (e.g. "VANGUARD TARGET 2035"). note: ?[]const u8 = null, + /// Explicit display label from the first lot (the lot's `label::`). + /// Drives `displaySymbol()`; display-only, never a key. + label: ?[]const u8 = null, /// Price ratio for institutional share classes (from lot). /// positionsAsOf() groups by (priceSymbol, price_ratio), so lots with /// different ratios sharing the same ticker produce separate positions. @@ -319,6 +340,15 @@ pub const Position = struct { pub fn marketValue(self: Position, raw_price: f64, is_preadjusted: bool) f64 { return self.shares * self.effectivePrice(raw_price, is_preadjusted); } + + /// The symbol to show in the display: an explicit `label` (from + /// the lot's `label::`) when set, else `symbol` — which is + /// already the economic identity (`priceSymbol()`), since + /// positions are keyed by it. The aggregate mirror of + /// `Lot.displaySymbol()`. + pub fn displaySymbol(self: Position) []const u8 { + return self.label orelse self.symbol; + } }; /// A portfolio is a collection of lots. @@ -330,6 +360,7 @@ pub const Portfolio = struct { for (self.lots) |lot| { self.allocator.free(lot.symbol); if (lot.note) |n| self.allocator.free(n); + if (lot.label) |l| self.allocator.free(l); if (lot.account) |a| self.allocator.free(a); if (lot.ticker) |t| self.allocator.free(t); if (lot.underlying) |u| self.allocator.free(u); @@ -456,6 +487,7 @@ pub const Portfolio = struct { .realized_gain_loss = 0, .account = lot.account orelse "", .note = lot.note, + .label = lot.label, .price_ratio = lot.price_ratio, }); found = &result.items[result.items.len - 1]; @@ -527,6 +559,7 @@ pub const Portfolio = struct { .realized_gain_loss = 0, .account = lot_acct, .note = lot.note, + .label = lot.label, .price_ratio = lot.price_ratio, }); found = &result.items[result.items.len - 1]; @@ -834,6 +867,28 @@ test "Lot.priceSymbol" { try std.testing.expectEqualStrings("AAPL", without_ticker.priceSymbol()); } +test "Lot.displaySymbol: label orelse priceSymbol" { + const base = Lot{ .symbol = "02315N600", .shares = 1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100 }; + // No label, no ticker: falls back to the raw symbol (the CUSIP). + try std.testing.expectEqualStrings("02315N600", base.displaySymbol()); + // No label, ticker set: falls back to priceSymbol (the ticker). + var aliased = base; + aliased.ticker = "VTTHX"; + try std.testing.expectEqualStrings("VTTHX", aliased.displaySymbol()); + // Explicit label wins over both symbol and ticker. + var labeled = aliased; + labeled.label = "TGT2035"; + try std.testing.expectEqualStrings("TGT2035", labeled.displaySymbol()); +} + +test "Position.displaySymbol: label orelse symbol" { + // Position.symbol is already priceSymbol(), so symbol is the fallback. + const no_label = Position{ .symbol = "VTTHX", .shares = 1, .avg_cost = 0, .total_cost = 0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 }; + try std.testing.expectEqualStrings("VTTHX", no_label.displaySymbol()); + const labeled = Position{ .symbol = "VTTHX", .shares = 1, .avg_cost = 0, .total_cost = 0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0, .label = "TGT2035" }; + try std.testing.expectEqualStrings("TGT2035", labeled.displaySymbol()); +} + test "Lot.returnPct" { // Open lot: uses current_price param const open_lot = Lot{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100 }; diff --git a/src/portfolio_loader.zig b/src/portfolio_loader.zig index ad71476..a52db97 100644 --- a/src/portfolio_loader.zig +++ b/src/portfolio_loader.zig @@ -393,6 +393,7 @@ fn loadFromBytes( for (merged.items) |lot| { allocator.free(lot.symbol); if (lot.note) |n| allocator.free(n); + if (lot.label) |l| allocator.free(l); if (lot.account) |a| allocator.free(a); if (lot.ticker) |t| allocator.free(t); if (lot.underlying) |u| allocator.free(u); diff --git a/src/tui/portfolio_tab.zig b/src/tui/portfolio_tab.zig index 2c76b45..83183da 100644 --- a/src/tui/portfolio_tab.zig +++ b/src/tui/portfolio_tab.zig @@ -1726,7 +1726,7 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va .illiquid_row => { if (row.lot) |lot| { var illiquid_row_buf: [160]u8 = undefined; - const row_text = fmt.fmtIlliquidRow(&illiquid_row_buf, row.symbol, lot.shares, lot.note); + const row_text = fmt.fmtIlliquidRow(&illiquid_row_buf, lot.displaySymbol(), lot.shares, lot.note); const text = try std.fmt.allocPrint(arena, " {s}", .{row_text}); const row_style7 = if (is_cursor) th.selectStyle() else th.mutedStyle(); try lines.append(arena, .{ .text = text, .style = row_style7 }); diff --git a/src/views/portfolio_sections.zig b/src/views/portfolio_sections.zig index 528e2fc..44ef5d2 100644 --- a/src/views/portfolio_sections.zig +++ b/src/views/portfolio_sections.zig @@ -120,7 +120,7 @@ pub const Options = struct { const acct = lot.account orelse ""; const text = try std.fmt.allocPrint(allocator, OptionsLayout.data_row, .{ - lot.symbol, + lot.displaySymbol(), qty, std.fmt.bufPrint(&cost_buf, "{f}", .{Money.from(cost_per)}) catch "$?", prem_str, @@ -244,7 +244,7 @@ pub const CDs = struct { const acct = lot.account orelse ""; const text = try std.fmt.allocPrint(allocator, CDsLayout.data_row, .{ - lot.symbol, + lot.displaySymbol(), std.fmt.bufPrint(&face_buf, "{f}", .{Money.from(lot.shares)}) catch "$?", rate_str, mat_str,