add label field, let notes field be strictly for notes

This commit is contained in:
Emil Lerch 2026-06-24 15:36:38 -07:00
parent d301466345
commit cd2ccb4c43
Signed by: lobo
GPG key ID: A7B62D657EF764F8
8 changed files with 136 additions and 51 deletions

View file

@ -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 <year>" 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;

36
src/cache/store.zig vendored
View file

@ -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" {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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,