ai: display labels from notes if provided on CUSIP securities

This commit is contained in:
Emil Lerch 2026-02-26 15:32:59 -08:00
parent bbb11c29e1
commit ec3da78241
Signed by: lobo
GPG key ID: A7B62D657EF764F8
5 changed files with 99 additions and 23 deletions

View file

@ -1,6 +1,7 @@
const std = @import("std");
const Candle = @import("../models/candle.zig").Candle;
const Date = @import("../models/date.zig").Date;
const OpenFigi = @import("../providers/openfigi.zig");
/// Daily return series statistics.
pub const RiskMetrics = struct {
@ -98,6 +99,9 @@ pub const PortfolioSummary = struct {
pub const Allocation = struct {
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_symbol: []const u8,
shares: f64,
avg_cost: f64,
current_price: f64,
@ -136,8 +140,15 @@ pub fn portfolioSummary(
total_cost += pos.total_cost;
total_realized += pos.realized_pnl;
// For CUSIPs with a note, derive a short display label from the note.
const display = if (OpenFigi.isCusipLike(pos.symbol) and pos.note != null)
shortLabel(pos.note.?)
else
pos.symbol;
try allocs.append(allocator, .{
.symbol = pos.symbol,
.display_symbol = display,
.shares = pos.shares,
.avg_cost = pos.avg_cost,
.current_price = price,
@ -168,6 +179,43 @@ pub fn portfolioSummary(
};
}
/// 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];
}
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 "risk metrics basic" {
// Construct a simple price series: $100 going up $1/day for 60 days
var candles: [60]Candle = undefined;

View file

@ -1163,7 +1163,7 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.Da
// Sort allocations alphabetically by symbol
std.mem.sort(zfin.risk.Allocation, summary.allocations, {}, struct {
fn f(_: void, a: zfin.risk.Allocation, b: zfin.risk.Allocation) bool {
return std.mem.lessThan(u8, a.symbol, b.symbol);
return std.mem.lessThan(u8, a.display_symbol, b.display_symbol);
}
}.f);
@ -1269,7 +1269,7 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.Da
}
try out.print(" " ++ fmt.sym_col_spec ++ " {d:>8.1} {s:>10} ", .{
a.symbol, a.shares, fmt.fmtMoney2(&cost_buf2, a.avg_cost),
a.display_symbol, a.shares, fmt.fmtMoney2(&cost_buf2, a.avg_cost),
});
if (a.is_manual_price) try setFg(out, color, CLR_YELLOW);
try out.print("{s:>10}", .{fmt.fmtMoney2(&price_buf2, a.current_price)});
@ -1531,8 +1531,8 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.Da
for (portfolio.lots) |lot| {
if (lot.lot_type != .cash) continue;
const acct2: []const u8 = lot.account orelse "Unknown";
var row_buf: [80]u8 = undefined;
try out.print("{s}\n", .{fmt.fmtCashRow(&row_buf, acct2, lot.shares)});
var row_buf: [160]u8 = undefined;
try out.print("{s}\n", .{fmt.fmtCashRow(&row_buf, acct2, lot.shares, lot.note)});
}
// Cash total
var sep_buf: [80]u8 = undefined;

View file

@ -19,7 +19,7 @@ pub const sym_col_spec = std.fmt.comptimePrint("{{s:<{d}}}", .{sym_col_width});
/// Width of the account name column in cash section (CLI + TUI).
pub const cash_acct_width = 30;
/// Format the cash section column header: " Account Balance"
/// Format the cash section column header: " Account Balance Note"
pub fn fmtCashHeader(buf: []u8) []const u8 {
const w = cash_acct_width;
var pos: usize = 0;
@ -37,16 +37,21 @@ pub fn fmtCashHeader(buf: []u8) []const u8 {
pos += bal_pad;
@memcpy(buf[pos..][0..bal_label.len], bal_label);
pos += bal_label.len;
@memcpy(buf[pos..][0..2], " ");
pos += 2;
const note_label = "Note";
@memcpy(buf[pos..][0..note_label.len], note_label);
pos += note_label.len;
return buf[0..pos];
}
/// Format a cash row: " account_name $1,234.56"
/// Format a cash row: " account_name $1,234.56 some note"
/// Returns a slice of `buf`.
pub fn fmtCashRow(buf: []u8, account: []const u8, amount: f64) []const u8 {
pub fn fmtCashRow(buf: []u8, account: []const u8, amount: f64, note: ?[]const u8) []const u8 {
var money_buf: [24]u8 = undefined;
const money = fmtMoney(&money_buf, amount);
const w = cash_acct_width;
// " {name:<w} {money:>14}"
// " {name:<w} {money:>14} {note}"
const prefix = " ";
var pos: usize = 0;
@memcpy(buf[pos..][0..prefix.len], prefix);
@ -63,6 +68,16 @@ pub fn fmtCashRow(buf: []u8, account: []const u8, amount: f64) []const u8 {
pos += money_pad;
@memcpy(buf[pos..][0..money.len], money);
pos += money.len;
// Append note if present
if (note) |n| {
if (n.len > 0) {
@memcpy(buf[pos..][0..2], " ");
pos += 2;
const note_len = @min(n.len, buf.len - pos);
@memcpy(buf[pos..][0..note_len], n[0..note_len]);
pos += note_len;
}
}
return buf[0..pos];
}

View file

@ -110,6 +110,8 @@ pub const Position = struct {
realized_pnl: f64,
/// Account name (shared across lots, or "Multiple" if mixed).
account: []const u8 = "",
/// Note from the first lot (e.g. "VANGUARD TARGET 2035").
note: ?[]const u8 = null,
};
/// A portfolio is a collection of lots.
@ -214,6 +216,7 @@ pub const Portfolio = struct {
.closed_lots = 0,
.realized_pnl = 0,
.account = lot.account orelse "",
.note = lot.note,
};
} else {
// Track account: if lots have different accounts, mark as "Multiple"

View file

@ -230,6 +230,7 @@ const App = struct {
portfolio_line_count: usize = 0, // total styled lines in portfolio view
portfolio_sort_field: PortfolioSortField = .symbol, // current sort column
portfolio_sort_dir: SortDirection = .asc, // current sort direction
watchlist_prices: ?std.StringHashMap(f64) = null, // cached watchlist prices (no disk I/O during render)
// Options navigation (inline expand/collapse like portfolio)
options_cursor: usize = 0, // selected row in flattened options view
@ -972,17 +973,28 @@ const App = struct {
// Fetch data for watchlist symbols so they have prices to display
// (from both the separate watchlist file and watch lots in the portfolio)
if (self.watchlist_prices) |*wp| wp.clearRetainingCapacity() else {
self.watchlist_prices = std.StringHashMap(f64).init(self.allocator);
}
var wp = &(self.watchlist_prices.?);
if (self.watchlist) |wl| {
for (wl) |sym| {
const result = self.svc.getCandles(sym) catch continue;
self.allocator.free(result.data);
defer self.allocator.free(result.data);
if (result.data.len > 0) {
wp.put(sym, result.data[result.data.len - 1].close) catch {};
}
}
}
if (self.portfolio) |pf| {
for (pf.lots) |lot| {
if (lot.lot_type == .watch) {
const result = self.svc.getCandles(lot.priceSymbol()) catch continue;
self.allocator.free(result.data);
const sym = lot.priceSymbol();
const result = self.svc.getCandles(sym) catch continue;
defer self.allocator.free(result.data);
if (result.data.len > 0) {
wp.put(sym, result.data[result.data.len - 1].close) catch {};
}
}
}
}
@ -1139,7 +1151,7 @@ const App = struct {
const lhs = if (ctx.dir == .asc) a else b;
const rhs = if (ctx.dir == .asc) b else a;
return switch (ctx.field) {
.symbol => std.mem.lessThan(u8, lhs.symbol, rhs.symbol),
.symbol => std.mem.lessThan(u8, lhs.display_symbol, rhs.display_symbol),
.shares => lhs.shares < rhs.shares,
.avg_cost => lhs.avg_cost < rhs.avg_cost,
.price => lhs.current_price < rhs.current_price,
@ -1565,6 +1577,7 @@ const App = struct {
self.freePortfolioSummary();
self.portfolio_rows.deinit(self.allocator);
self.options_rows.deinit(self.allocator);
if (self.watchlist_prices) |*wp| wp.deinit();
}
fn reloadFiles(self: *App) void {
@ -2056,13 +2069,13 @@ const App = struct {
}
const text = try std.fmt.allocPrint(arena, "{s}{s}" ++ fmt.sym_col_spec ++ " {d:>8.1} {s:>10} {s:>10} {s:>16} {s:>14} {d:>7.1}% {s:>13} {s}", .{
arrow, star, a.symbol, a.shares, cost_str, price_str, mv_str, pnl_str, a.weight * 100.0, date_col, acct_col,
arrow, star, a.display_symbol, a.shares, cost_str, price_str, mv_str, pnl_str, a.weight * 100.0, date_col, acct_col,
});
// base: neutral text for main cols, green/red only for gain/loss col
// Manual-price positions use warning color to indicate stale/estimated price
const base_style = if (is_cursor) th.selectStyle() else if (a.is_manual_price) th.warningStyle() else th.contentStyle();
const gl_style = if (is_cursor) th.selectStyle() else if (a.is_manual_price) th.warningStyle() else if (pnl_pct >= 0) th.positiveStyle() else th.negativeStyle();
const gl_style = if (is_cursor) th.selectStyle() else if (pnl_pct >= 0) th.positiveStyle() else th.negativeStyle();
// The gain/loss column starts after market value
// prefix(4) + sym(6+1) + shares(8+1) + avgcost(10+1) + price(10+1) + mv(16+1) = 59
@ -2120,13 +2133,10 @@ const App = struct {
},
.watchlist => {
var price_str3: [16]u8 = undefined;
const ps = if (self.svc.getCachedCandles(row.symbol)) |candles_slice| blk: {
defer self.allocator.free(candles_slice);
if (candles_slice.len > 0)
break :blk fmt.fmtMoney2(&price_str3, candles_slice[candles_slice.len - 1].close)
const ps: []const u8 = if (self.watchlist_prices) |wp|
(if (wp.get(row.symbol)) |p| fmt.fmtMoney2(&price_str3, p) else "--")
else
break :blk @as([]const u8, "--");
} else "--";
"--";
const star2: []const u8 = if (is_active_sym) "* " else " ";
const text = try std.fmt.allocPrint(arena, " {s}" ++ fmt.sym_col_spec ++ " {s:>8} {s:>10} {s:>10} {s:>16} {s:>14} {s:>8} {s:>13}", .{
star2, row.symbol, "--", "--", ps, "--", "--", "watch", "",
@ -2215,8 +2225,8 @@ const App = struct {
},
.cash_row => {
if (row.lot) |lot| {
var cash_row_buf: [80]u8 = undefined;
const row_text = fmt.fmtCashRow(&cash_row_buf, row.symbol, lot.shares);
var cash_row_buf: [160]u8 = undefined;
const row_text = fmt.fmtCashRow(&cash_row_buf, row.symbol, lot.shares, lot.note);
const text = try std.fmt.allocPrint(arena, " {s}", .{row_text});
const row_style5 = if (is_cursor) th.selectStyle() else th.mutedStyle();
try lines.append(arena, .{ .text = text, .style = row_style5 });