From ec3da78241e67588927ac10d0c2324323ba2e4cc Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Thu, 26 Feb 2026 15:32:59 -0800 Subject: [PATCH] ai: display labels from notes if provided on CUSIP securities --- src/analytics/risk.zig | 48 ++++++++++++++++++++++++++++++++++++++++ src/cli/main.zig | 8 +++---- src/format.zig | 23 +++++++++++++++---- src/models/portfolio.zig | 3 +++ src/tui/main.zig | 40 ++++++++++++++++++++------------- 5 files changed, 99 insertions(+), 23 deletions(-) diff --git a/src/analytics/risk.zig b/src/analytics/risk.zig index 6204223..2d6dd10 100644 --- a/src/analytics/risk.zig +++ b/src/analytics/risk.zig @@ -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 " 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; diff --git a/src/cli/main.zig b/src/cli/main.zig index e23f431..56cae8a 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -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; diff --git a/src/format.zig b/src/format.zig index be61b54..f709dfd 100644 --- a/src/format.zig +++ b/src/format.zig @@ -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:14}" + // " {name: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]; } diff --git a/src/models/portfolio.zig b/src/models/portfolio.zig index b8cbb1f..c9fd3b5 100644 --- a/src/models/portfolio.zig +++ b/src/models/portfolio.zig @@ -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" diff --git a/src/tui/main.zig b/src/tui/main.zig index 5bf1d0b..32ee930 100644 --- a/src/tui/main.zig +++ b/src/tui/main.zig @@ -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) - else - break :blk @as([]const u8, "--"); - } else "--"; + const ps: []const u8 = if (self.watchlist_prices) |wp| + (if (wp.get(row.symbol)) |p| fmt.fmtMoney2(&price_str3, p) else "--") + 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 });