diff --git a/src/commands/quote.zig b/src/commands/quote.zig index a5d2250..9b05143 100644 --- a/src/commands/quote.zig +++ b/src/commands/quote.zig @@ -150,23 +150,96 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { }; } else |_| {} - try display(ctx.allocator, candles, quote, parsed.symbol, ctx.today, ctx.color, ctx.out); + // Resolve a human-readable security name the same way the TUI + // 'K' overlay and quote tab do: the curated `metadata.srf` + // `name::` field first, then the ETF profile's fund name. The + // name is copied into `name_buf` so its lifetime is independent + // of the (transient) classification map / ETF fetch result. + var name_buf: [256]u8 = undefined; + var name: ?[]const u8 = null; + { + var cm_opt = loadClassificationMap(ctx); + defer if (cm_opt) |*cm| cm.deinit(); + const cm_ptr: ?*const zfin.classification.ClassificationMap = if (cm_opt) |*cm| cm else null; + if (zfin.classification.resolveSecurityName(parsed.symbol, cm_ptr, null)) |nm| { + name = clampName(&name_buf, nm); + } + } + if (name == null) { + // Fallback: the ETF profile's fund name, gated on isEtf() so + // it matches the TUI (which only retains fund-shaped + // profiles in symbol_data). Plain stocks not in metadata.srf + // therefore render symbol-only, exactly like the 'K' overlay. + // Only fetched when metadata yielded nothing, so already-named + // holdings don't pay for an EDGAR round-trip. + if (svc.getEtfProfile(parsed.symbol, opts)) |etf_result| { + defer etf_result.deinit(); + if (etf_result.data.isEtf()) { + if (etf_result.data.name) |nm| name = clampName(&name_buf, nm); + } + } else |_| {} + } + + try display(ctx.allocator, candles, quote, parsed.symbol, name, ctx.today, ctx.color, ctx.out); } -pub fn display(allocator: std.mem.Allocator, candles: []const zfin.Candle, quote: ?QuoteData, symbol: []const u8, as_of: zfin.Date, color: bool, out: *std.Io.Writer) !void { +/// Copy `s` (clamped to `buf`'s capacity) into `buf` and return the +/// written slice. Fund/security names fit easily in 256 bytes. +fn clampName(buf: []u8, s: []const u8) []const u8 { + const n = @min(s.len, buf.len); + @memcpy(buf[0..n], s[0..n]); + return buf[0..n]; +} + +/// Quietly load the `metadata.srf` classification map that sits +/// beside the resolved portfolio anchor. Best-effort: returns null +/// (printing nothing) when there's no portfolio, no `metadata.srf`, +/// or it fails to parse. Unlike `cli.loadPortfolio` this never emits +/// "no portfolio" noise — `quote` works fine without one, and the +/// map is only used to enrich the header with a name. Caller owns +/// the returned map and must `deinit()` it. +fn loadClassificationMap(ctx: *framework.RunCtx) ?zfin.classification.ClassificationMap { + var resolved = framework.resolvePatterns( + ctx.io, + ctx.allocator, + ctx.config, + ctx.globals.portfolio_patterns, + ) catch return null; + defer resolved.deinit(); + if (resolved.paths.len == 0) return null; + + // metadata.srf lives in the same directory as the portfolio + // anchor (see AGENTS.md "Portfolio auto-detection"). + const anchor_path = resolved.paths[0]; + const dir_end = if (std.mem.lastIndexOfScalar(u8, anchor_path, std.fs.path.sep)) |idx| idx + 1 else 0; + const meta_path = std.fmt.allocPrint(ctx.allocator, "{s}metadata.srf", .{anchor_path[0..dir_end]}) catch return null; + defer ctx.allocator.free(meta_path); + + const meta_data = std.Io.Dir.cwd().readFileAlloc(ctx.io, meta_path, ctx.allocator, .limited(1024 * 1024)) catch return null; + defer ctx.allocator.free(meta_data); + + return zfin.classification.parseClassificationFile(ctx.allocator, meta_data) catch null; +} + +pub fn display(allocator: std.mem.Allocator, candles: []const zfin.Candle, quote: ?QuoteData, symbol: []const u8, name: ?[]const u8, as_of: zfin.Date, color: bool, out: *std.Io.Writer) !void { const has_quote = quote != null; - // Header + // Header. The security name (when resolved) renders between the + // symbol and the price, matching the TUI quote tab. try cli.setBold(out, color); + try out.print("\n{s}", .{symbol}); + if (name) |nm| { + if (nm.len > 0) try out.print(" {s}", .{nm}); + } if (quote) |q| { - try out.print("\n{s} {f}\n", .{ symbol, Money.from(q.price) }); + try out.print(" {f}\n", .{Money.from(q.price)}); } else if (candles.len > 0) { - try out.print("\n{s} {f} (close)\n", .{ symbol, Money.from(candles[candles.len - 1].close) }); + try out.print(" {f} (close)\n", .{Money.from(candles[candles.len - 1].close)}); } else { - try out.print("\n{s}\n", .{symbol}); + try out.print("\n", .{}); } try cli.reset(out, color); - try out.print("========================================\n", .{}); + try out.print("======================================================================\n", .{}); // Quote details const price = if (quote) |q| q.price else if (candles.len > 0) candles[candles.len - 1].close else @as(f64, 0); @@ -257,7 +330,7 @@ test "display with candles only" { .{ .date = .{ .days = 20000 }, .open = 150.0, .high = 155.0, .low = 149.0, .close = 153.0, .adj_close = 153.0, .volume = 50_000_000 }, .{ .date = .{ .days = 20001 }, .open = 153.0, .high = 158.0, .low = 152.0, .close = 156.0, .adj_close = 156.0, .volume = 45_000_000 }, }; - try display(std.testing.allocator, &candles, null, "AAPL", zfin.Date.fromYmd(2026, 5, 8), false, &w); + try display(std.testing.allocator, &candles, null, "AAPL", null, zfin.Date.fromYmd(2026, 5, 8), false, &w); const out = w.buffered(); try std.testing.expect(std.mem.indexOf(u8, out, "AAPL") != null); try std.testing.expect(std.mem.indexOf(u8, out, "(close)") != null); @@ -278,7 +351,7 @@ test "display with quote data" { .prev_close = 172.00, .date = .{ .days = 20001 }, }; - try display(std.testing.allocator, &candles, quote, "AAPL", zfin.Date.fromYmd(2026, 5, 8), false, &w); + try display(std.testing.allocator, &candles, quote, "AAPL", null, zfin.Date.fromYmd(2026, 5, 8), false, &w); const out = w.buffered(); try std.testing.expect(std.mem.indexOf(u8, out, "AAPL") != null); try std.testing.expect(std.mem.indexOf(u8, out, "Change") != null); @@ -286,13 +359,37 @@ test "display with quote data" { try std.testing.expect(std.mem.indexOf(u8, out, "(close)") == null); } +test "display renders the security name when provided" { + var buf: [8192]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + const candles = [_]zfin.Candle{ + .{ .date = .{ .days = 20000 }, .open = 150.0, .high = 155.0, .low = 149.0, .close = 153.0, .adj_close = 153.0, .volume = 50_000_000 }, + }; + try display(std.testing.allocator, &candles, null, "AAPL", "Apple Inc.", zfin.Date.fromYmd(2026, 5, 8), false, &w); + const out = w.buffered(); + // Name appears between the symbol and the price. + try std.testing.expect(std.mem.indexOf(u8, out, "AAPL Apple Inc.") != null); +} + +test "display omits an empty name" { + var buf: [8192]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + const candles = [_]zfin.Candle{ + .{ .date = .{ .days = 20000 }, .open = 150.0, .high = 155.0, .low = 149.0, .close = 153.0, .adj_close = 153.0, .volume = 50_000_000 }, + }; + try display(std.testing.allocator, &candles, null, "AAPL", "", zfin.Date.fromYmd(2026, 5, 8), false, &w); + const out = w.buffered(); + // No double-space orphan where the name would have gone. + try std.testing.expect(std.mem.indexOf(u8, out, "AAPL $") != null); +} + test "display no ANSI without color" { var buf: [8192]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); const candles = [_]zfin.Candle{ .{ .date = .{ .days = 20000 }, .open = 100.0, .high = 105.0, .low = 99.0, .close = 103.0, .adj_close = 103.0, .volume = 1_000_000 }, }; - try display(std.testing.allocator, &candles, null, "SPY", zfin.Date.fromYmd(2026, 5, 8), false, &w); + try display(std.testing.allocator, &candles, null, "SPY", null, zfin.Date.fromYmd(2026, 5, 8), false, &w); const out = w.buffered(); try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null); } diff --git a/src/models/classification.zig b/src/models/classification.zig index 138d298..febd526 100644 --- a/src/models/classification.zig +++ b/src/models/classification.zig @@ -146,6 +146,43 @@ pub fn deriveBucket(entry: ClassificationEntry, allocator: std.mem.Allocator) ![ return try allocator.dupe(u8, "Unclassified"); } +/// Resolve a human-readable security name for `symbol`, applying +/// the project-wide name-source policy: +/// 1. The curated `name::` field from `metadata.srf` (via the +/// classification map) — the same source the TUI 'K' overlay +/// uses. Wins whenever present. +/// 2. `fallback_name` (typically the ETF profile's fund name) +/// when metadata has no name for the symbol. +/// +/// Returns a slice borrowed from `cm` or `fallback_name` (the +/// caller must keep those alive for as long as the result is used), +/// or null when neither source yields a name. +/// +/// This is the single source of truth for the security name shown +/// by the 'K' overlay, the quote tab header, and the CLI `quote` +/// command. Symbol comparison is exact (`std.mem.eql`); every caller +/// normalizes symbols to upper-case before calling, matching the +/// upper-case symbols `metadata.srf` carries. +pub fn resolveSecurityName( + symbol: []const u8, + cm: ?*const ClassificationMap, + fallback_name: ?[]const u8, +) ?[]const u8 { + if (cm) |m| { + for (m.entries) |*e| { + if (std.mem.eql(u8, e.symbol, symbol)) { + // First matching entry that actually carries a name + // wins. Blended-fund symbols repeat across rows with + // the same name, so this is well-defined. + if (e.name) |n| { + if (n.len > 0) return n; + } + } + } + } + return fallback_name; +} + test "parse classification file" { const data = \\#!srfv1 @@ -210,6 +247,66 @@ test "parse classification file: bucket round-trips" { try std.testing.expectEqualStrings("Equity / Corporate", cm.entries[0].sector.?); } +test "resolveSecurityName: metadata name wins" { + var entries = [_]ClassificationEntry{ + .{ .symbol = "AMZN", .name = "Amazon" }, + .{ .symbol = "VTI", .name = "Vanguard Total Stock Market ETF" }, + }; + const cm: ClassificationMap = .{ .entries = &entries, .allocator = std.testing.allocator }; + // Metadata hit; fallback ignored. + try std.testing.expectEqualStrings( + "Amazon", + resolveSecurityName("AMZN", &cm, "ignored fallback").?, + ); + try std.testing.expectEqualStrings( + "Vanguard Total Stock Market ETF", + resolveSecurityName("VTI", &cm, null).?, + ); +} + +test "resolveSecurityName: falls back when entry has no name" { + var entries = [_]ClassificationEntry{ + .{ .symbol = "SOXX", .name = null }, // pre-name metadata row + }; + const cm: ClassificationMap = .{ .entries = &entries, .allocator = std.testing.allocator }; + try std.testing.expectEqualStrings( + "iShares Semiconductor ETF", + resolveSecurityName("SOXX", &cm, "iShares Semiconductor ETF").?, + ); + // No fallback either → null. + try std.testing.expect(resolveSecurityName("SOXX", &cm, null) == null); +} + +test "resolveSecurityName: falls back when symbol absent from map" { + var entries = [_]ClassificationEntry{ + .{ .symbol = "AMZN", .name = "Amazon" }, + }; + const cm: ClassificationMap = .{ .entries = &entries, .allocator = std.testing.allocator }; + try std.testing.expectEqualStrings( + "SPDR S&P 500 ETF Trust", + resolveSecurityName("SPY", &cm, "SPDR S&P 500 ETF Trust").?, + ); +} + +test "resolveSecurityName: null map uses fallback, null everything is null" { + try std.testing.expectEqualStrings( + "Apple Inc.", + resolveSecurityName("AAPL", null, "Apple Inc.").?, + ); + try std.testing.expect(resolveSecurityName("AAPL", null, null) == null); +} + +test "resolveSecurityName: empty metadata name treated as absent" { + var entries = [_]ClassificationEntry{ + .{ .symbol = "AAPL", .name = "" }, + }; + const cm: ClassificationMap = .{ .entries = &entries, .allocator = std.testing.allocator }; + try std.testing.expectEqualStrings( + "Apple Inc.", + resolveSecurityName("AAPL", &cm, "Apple Inc.").?, + ); +} + test "deriveBucket: returns user-curated bucket when set" { const e: ClassificationEntry = .{ .symbol = "SPY", diff --git a/src/tui.zig b/src/tui.zig index 34835e1..e40b257 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -1797,16 +1797,27 @@ pub const App = struct { break :blk null; }; - const display_name: []const u8 = if (ce_opt) |ce| (ce.name orelse sym) else sym; + // Name: same policy as the quote tab and CLI `quote` — + // metadata.srf `name::` first, then the ETF profile's fund + // name. The ETF fallback only applies when the overlay + // targets the active symbol, since `symbol_data` holds the + // profile for `self.symbol`, not necessarily `sym`. + const etf_fallback: ?[]const u8 = if (std.mem.eql(u8, sym, self.symbol)) + (if (self.symbol_data.etf_profile) |p| p.name else null) + else + null; + const resolved_name = zfin.classification.resolveSecurityName(sym, cm_opt, etf_fallback); try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s}", .{sym}), .style = th.headerStyle(), }); - if (display_name.ptr != sym.ptr and display_name.len > 0) { - try lines.append(arena, .{ - .text = try std.fmt.allocPrint(arena, " {s}", .{display_name}), - .style = th.contentStyle(), - }); + if (resolved_name) |nm| { + if (nm.len > 0 and !std.mem.eql(u8, nm, sym)) { + try lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, " {s}", .{nm}), + .style = th.contentStyle(), + }); + } } try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); diff --git a/src/tui/quote_tab.zig b/src/tui/quote_tab.zig index e125325..704d77d 100644 --- a/src/tui/quote_tab.zig +++ b/src/tui/quote_tab.zig @@ -266,9 +266,16 @@ fn drawWithKittyChart(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, wi var lines: std.ArrayList(StyledLine) = .empty; try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + // Symbol (+ resolved name) label, shared by the live and + // close-of-day header variants below. + const sym_label: []const u8 = if (quoteTabName(app)) |n| + try std.fmt.allocPrint(arena, "{s} {s}", .{ app.symbol, n }) + else + app.symbol; + // Symbol + price header if (app.states.quote.live) |q| { - const price_str = try std.fmt.allocPrint(arena, " {s} ${d:.2}", .{ app.symbol, q.close }); + const price_str = try std.fmt.allocPrint(arena, " {s} ${d:.2}", .{ sym_label, q.close }); try lines.append(arena, .{ .text = price_str, .style = th.headerStyle() }); if (q.previous_close > 0) { const change = q.close - q.previous_close; @@ -279,7 +286,7 @@ fn drawWithKittyChart(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, wi } } else if (c.len > 0) { const last = c[c.len - 1]; - const price_str = try std.fmt.allocPrint(arena, " {s} ${d:.2} (close)", .{ app.symbol, last.close }); + const price_str = try std.fmt.allocPrint(arena, " {s} ${d:.2} (close)", .{ sym_label, last.close }); try lines.append(arena, .{ .text = price_str, .style = th.headerStyle() }); if (c.len >= 2) { const prev_close = c[c.len - 2].close; @@ -588,20 +595,40 @@ pub const QuoteHeaderSource = union(enum) { }; /// Format the quote tab's header line. Pure function over -/// (arena, symbol, source). The three branches mirror the live / -/// close-of-day / no-data paths in the live builder. +/// (arena, symbol, name, source). The three branches mirror the +/// live / close-of-day / no-data paths in the live builder. When +/// `name` is non-null and non-empty, it's rendered between the +/// symbol and the timing suffix (e.g. "AAPL Apple Inc. (live …)"). pub fn formatQuoteHeader( arena: std.mem.Allocator, symbol: []const u8, + name: ?[]const u8, source: QuoteHeaderSource, ) ![]const u8 { + const name_part: []const u8 = if (name) |n| + (if (n.len > 0) try std.fmt.allocPrint(arena, " {s}", .{n}) else "") + else + ""; return switch (source) { - .live => |ago| std.fmt.allocPrint(arena, " {s} (live, ~15 min delay, refreshed {s})", .{ symbol, ago }), - .close => |date| std.fmt.allocPrint(arena, " {s} (as of close on {f})", .{ symbol, date }), - .none => std.fmt.allocPrint(arena, " {s}", .{symbol}), + .live => |ago| std.fmt.allocPrint(arena, " {s}{s} (live, ~15 min delay, refreshed {s})", .{ symbol, name_part, ago }), + .close => |date| std.fmt.allocPrint(arena, " {s}{s} (as of close on {f})", .{ symbol, name_part, date }), + .none => std.fmt.allocPrint(arena, " {s}{s}", .{ symbol, name_part }), }; } +/// Resolve the active symbol's display name using the shared +/// policy (metadata.srf `name::`, then the ETF profile's fund +/// name). Mirrors the 'K' overlay and the CLI `quote` command, so +/// the three surfaces always agree on the name they show. The ETF +/// profile is loaded lazily on the performance tab, so the fallback +/// only kicks in once that data is present; the metadata name is +/// always available when enriched. +fn quoteTabName(app: *App) ?[]const u8 { + const cm = app.portfolio.classificationMap(); + const fallback: ?[]const u8 = if (app.symbol_data.etf_profile) |p| p.name else null; + return zfin.classification.resolveSecurityName(app.symbol, cm, fallback); +} + fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine { const th = app.theme; var lines: std.ArrayList(StyledLine) = .empty; @@ -614,15 +641,16 @@ fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine { } var ago_buf: [16]u8 = undefined; + const name = quoteTabName(app); if (app.states.quote.live != null and app.states.quote.timestamp > 0) { // wall-clock required: per-frame "now" for the data-age readout. const now_s = std.Io.Timestamp.now(app.io, .real).toSeconds(); const ago_str = fmt.fmtTimeAgo(&ago_buf, app.states.quote.timestamp, now_s); - try lines.append(arena, .{ .text = try formatQuoteHeader(arena, app.symbol, .{ .live = ago_str }), .style = th.headerStyle() }); + try lines.append(arena, .{ .text = try formatQuoteHeader(arena, app.symbol, name, .{ .live = ago_str }), .style = th.headerStyle() }); } else if (app.symbol_data.candleLastDate()) |d| { - try lines.append(arena, .{ .text = try formatQuoteHeader(arena, app.symbol, .{ .close = d }), .style = th.headerStyle() }); + try lines.append(arena, .{ .text = try formatQuoteHeader(arena, app.symbol, name, .{ .close = d }), .style = th.headerStyle() }); } else { - try lines.append(arena, .{ .text = try formatQuoteHeader(arena, app.symbol, .none), .style = th.headerStyle() }); + try lines.append(arena, .{ .text = try formatQuoteHeader(arena, app.symbol, name, .none), .style = th.headerStyle() }); } try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); @@ -860,7 +888,7 @@ test "formatQuoteHeader: live source includes refreshed-ago string" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); - const text = try formatQuoteHeader(arena, "AAPL", .{ .live = "5s ago" }); + const text = try formatQuoteHeader(arena, "AAPL", null, .{ .live = "5s ago" }); try testing.expectEqualStrings(" AAPL (live, ~15 min delay, refreshed 5s ago)", text); } @@ -868,7 +896,7 @@ test "formatQuoteHeader: close source includes ISO date" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); - const text = try formatQuoteHeader(arena, "VTI", .{ .close = Date.fromYmd(2024, 3, 15) }); + const text = try formatQuoteHeader(arena, "VTI", null, .{ .close = Date.fromYmd(2024, 3, 15) }); try testing.expectEqualStrings(" VTI (as of close on 2024-03-15)", text); } @@ -876,6 +904,33 @@ test "formatQuoteHeader: none source renders just the symbol" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); - const text = try formatQuoteHeader(arena, "BRK.B", .none); + const text = try formatQuoteHeader(arena, "BRK.B", null, .none); try testing.expectEqualStrings(" BRK.B", text); } + +test "formatQuoteHeader: name renders between symbol and suffix" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + try testing.expectEqualStrings( + " AAPL Apple Inc. (live, ~15 min delay, refreshed 5s ago)", + try formatQuoteHeader(arena, "AAPL", "Apple Inc.", .{ .live = "5s ago" }), + ); + try testing.expectEqualStrings( + " VTI Vanguard Total Stock Market ETF (as of close on 2024-03-15)", + try formatQuoteHeader(arena, "VTI", "Vanguard Total Stock Market ETF", .{ .close = Date.fromYmd(2024, 3, 15) }), + ); + try testing.expectEqualStrings( + " BRK.B Berkshire Hathaway", + try formatQuoteHeader(arena, "BRK.B", "Berkshire Hathaway", .none), + ); +} + +test "formatQuoteHeader: empty name is omitted" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const text = try formatQuoteHeader(arena, "AAPL", "", .none); + try testing.expectEqualStrings(" AAPL", text); +}