add label field, let notes field be strictly for notes
This commit is contained in:
parent
d301466345
commit
cd2ccb4c43
8 changed files with 136 additions and 51 deletions
|
|
@ -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
36
src/cache/store.zig
vendored
|
|
@ -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" {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue