From 8ca673c8e3da95add4fe6e6fabab3afd8f05c84d Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Sat, 27 Jun 2026 09:49:32 -0700 Subject: [PATCH] better quote name resolution --- src/commands/quote.zig | 50 ++++++++------ src/models/classification.zig | 121 +++++++++++++++++++++++++--------- src/models/quote.zig | 25 ++++++- src/providers/twelvedata.zig | 29 +++++++- src/providers/yahoo.zig | 97 ++++++++++++++++++++++++++- src/tui.zig | 2 +- src/tui/quote_tab.zig | 19 +++--- 7 files changed, 278 insertions(+), 65 deletions(-) diff --git a/src/commands/quote.zig b/src/commands/quote.zig index 08b9801..d6ec55b 100644 --- a/src/commands/quote.zig +++ b/src/commands/quote.zig @@ -159,6 +159,12 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { // Fetch real-time quote via DataService var quote: ?QuoteData = null; + // Live security name (Yahoo `longName`) captured out of the + // transient Quote into a function-scope buffer. Shares the price's + // source, so the displayed name and price always describe the same + // security - even after a ticker is recycled. + var live_name_buf: [256]u8 = undefined; + var live_name: ?[]const u8 = null; if (svc.getQuote(parsed.symbol, opts)) |q| { quote = .{ .price = q.close, @@ -169,37 +175,43 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { .prev_close = q.previous_close, .date = if (candles.len > 0) candles[candles.len - 1].date else ctx.today, }; + if (q.name().len > 0) live_name = clampName(&live_name_buf, q.name()); } else |_| {} // 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. + // 'K' overlay and quote tab do, via the shared policy: the curated + // `metadata.srf` `name::` field first, then the live quote name, + // then the ETF profile's fund name. The result 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| { + // metadata > live quote name. Both are already in hand, so no + // EDGAR round-trip happens unless these come up empty. + if (zfin.classification.resolveSecurityName(parsed.symbol, cm_ptr, .{ .live_quote = live_name })) |nm| { name = clampName(&name_buf, nm); + } else { + // 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 + // and without a live name render symbol-only, exactly like + // the 'K' overlay. Only fetched when the cheap sources + // 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 (zfin.classification.resolveSecurityName(parsed.symbol, cm_ptr, .{ .live_quote = live_name, .etf_profile = etf_result.data.name })) |nm| { + name = clampName(&name_buf, nm); + } + } + } else |_| {} } } - 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 |_| {} - } const k: KittyChart = .{ .io = ctx.io, .caps = ctx.graphics_caps }; const chart_render: ChartRender = switch (ctx.globals.chart_config.mode) { diff --git a/src/models/classification.zig b/src/models/classification.zig index 62ee393..cbc9fa4 100644 --- a/src/models/classification.zig +++ b/src/models/classification.zig @@ -146,41 +146,66 @@ 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. +/// The candidate name sources for a security, other than the curated +/// `metadata.srf` name (which `resolveSecurityName` looks up from the +/// classification map and ranks above all of these). Callers fill in +/// whatever they have on hand and leave the rest null; the precedence +/// *between* these sources is decided by `resolveSecurityName`, not by +/// the caller - that's the whole point of routing every surface +/// through one function. +pub const NameSources = struct { + /// The live quote provider's name (Yahoo `longName`), read from the + /// same response as the price. Ranked above the ETF profile because + /// it reflects what currently trades under the ticker and so + /// self-heals when a ticker is recycled (e.g. SPCX: SpaceX vs. the + /// defunct "SPAC and New Issue ETF"). + live_quote: ?[]const u8 = null, + /// The ETF/fund profile name (EDGAR series name or Wikidata name). + etf_profile: ?[]const u8 = null, +}; + +/// Resolve a human-readable security name for `symbol`. This is the +/// single home for the name-precedence *policy*; every surface (the +/// CLI `quote` command, the TUI quote tab, the 'K' overlay) calls it +/// so they cannot drift. The policy, highest priority first: /// -/// 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. +/// 1. The curated `name::` from `metadata.srf` (via `cm`) - the +/// user's explicit override. +/// 2. `sources.live_quote` - the live quote provider name. +/// 3. `sources.etf_profile` - the ETF/fund profile 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. +/// Empty names are skipped at every level. Returns a slice borrowed +/// from `cm` or one of `sources` (keep them alive while the result is +/// used), or null when no source yields a name. Symbol comparison is +/// exact (`std.mem.eql`); every caller upper-cases symbols first, +/// matching the upper-case symbols `metadata.srf` carries. pub fn resolveSecurityName( symbol: []const u8, cm: ?*const ClassificationMap, - fallback_name: ?[]const u8, + sources: NameSources, ) ?[]const u8 { + // 1. Curated metadata name wins. First matching entry that carries + // a name wins; blended-fund symbols repeat across rows with the + // same name, so this is well-defined. 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; + // 2. then the live quote name, 3. then the ETF profile name. + if (nonEmptyName(sources.live_quote)) |n| return n; + if (nonEmptyName(sources.etf_profile)) |n| return n; + return null; +} + +/// An optional name, treating empty as absent. +fn nonEmptyName(s: ?[]const u8) ?[]const u8 { + const v = s orelse return null; + return if (v.len > 0) v else null; } test "parse classification file" { @@ -253,14 +278,14 @@ test "resolveSecurityName: metadata name wins" { .{ .symbol = "VTI", .name = "Vanguard Total Stock Market ETF" }, }; const cm: ClassificationMap = .{ .entries = &entries, .allocator = std.testing.allocator }; - // Metadata hit; fallback ignored. + // Metadata hit; lower-priority sources ignored. try std.testing.expectEqualStrings( "Amazon", - resolveSecurityName("AMZN", &cm, "ignored fallback").?, + resolveSecurityName("AMZN", &cm, .{ .live_quote = "ignored", .etf_profile = "ignored" }).?, ); try std.testing.expectEqualStrings( "Vanguard Total Stock Market ETF", - resolveSecurityName("VTI", &cm, null).?, + resolveSecurityName("VTI", &cm, .{}).?, ); } @@ -271,10 +296,10 @@ test "resolveSecurityName: falls back when entry has no name" { const cm: ClassificationMap = .{ .entries = &entries, .allocator = std.testing.allocator }; try std.testing.expectEqualStrings( "iShares Semiconductor ETF", - resolveSecurityName("SOXX", &cm, "iShares Semiconductor ETF").?, + resolveSecurityName("SOXX", &cm, .{ .etf_profile = "iShares Semiconductor ETF" }).?, ); - // No fallback either -> null. - try std.testing.expect(resolveSecurityName("SOXX", &cm, null) == null); + // No other source either -> null. + try std.testing.expect(resolveSecurityName("SOXX", &cm, .{}) == null); } test "resolveSecurityName: falls back when symbol absent from map" { @@ -284,16 +309,16 @@ test "resolveSecurityName: falls back when symbol absent from map" { 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").?, + resolveSecurityName("SPY", &cm, .{ .etf_profile = "SPDR S&P 500 ETF Trust" }).?, ); } -test "resolveSecurityName: null map uses fallback, null everything is null" { +test "resolveSecurityName: null map uses a source, null everything is null" { try std.testing.expectEqualStrings( "Apple Inc.", - resolveSecurityName("AAPL", null, "Apple Inc.").?, + resolveSecurityName("AAPL", null, .{ .etf_profile = "Apple Inc." }).?, ); - try std.testing.expect(resolveSecurityName("AAPL", null, null) == null); + try std.testing.expect(resolveSecurityName("AAPL", null, .{}) == null); } test "resolveSecurityName: empty metadata name treated as absent" { @@ -303,10 +328,44 @@ test "resolveSecurityName: empty metadata name treated as absent" { const cm: ClassificationMap = .{ .entries = &entries, .allocator = std.testing.allocator }; try std.testing.expectEqualStrings( "Apple Inc.", - resolveSecurityName("AAPL", &cm, "Apple Inc.").?, + resolveSecurityName("AAPL", &cm, .{ .etf_profile = "Apple Inc." }).?, ); } +test "resolveSecurityName: precedence is metadata > live quote > etf profile" { + // No metadata: the live quote name beats the ETF profile name. This + // is the SPCX case - the live Yahoo name wins over the stale + // recycled-ticker ETF name. The ordering lives in the function, not + // in the caller, so this test pins the policy itself. + try std.testing.expectEqualStrings( + "Space Exploration Technologies Corp.", + resolveSecurityName("SPCX", null, .{ + .live_quote = "Space Exploration Technologies Corp.", + .etf_profile = "The SPAC and New Issue ETF", + }).?, + ); + // An empty live quote name is skipped, so the ETF profile shows. + try std.testing.expectEqualStrings( + "The SPAC and New Issue ETF", + resolveSecurityName("SPCX", null, .{ + .live_quote = "", + .etf_profile = "The SPAC and New Issue ETF", + }).?, + ); + // Metadata still outranks both lower-priority sources. + var entries = [_]ClassificationEntry{.{ .symbol = "SPCX", .name = "My Override" }}; + const cm: ClassificationMap = .{ .entries = &entries, .allocator = std.testing.allocator }; + try std.testing.expectEqualStrings( + "My Override", + resolveSecurityName("SPCX", &cm, .{ + .live_quote = "Space Exploration Technologies Corp.", + .etf_profile = "The SPAC and New Issue ETF", + }).?, + ); + // No sources at all -> null. + try std.testing.expect(resolveSecurityName("SPCX", null, .{}) == null); +} + test "deriveBucket: returns user-curated bucket when set" { const e: ClassificationEntry = .{ .symbol = "SPY", diff --git a/src/models/quote.zig b/src/models/quote.zig index 8917261..ec7870a 100644 --- a/src/models/quote.zig +++ b/src/models/quote.zig @@ -1,7 +1,16 @@ /// Real-time (or near-real-time) quote snapshot for a symbol. pub const Quote = struct { symbol: []const u8, - name: []const u8, + /// Display-name storage. Inline (not a slice) so the name travels + /// with the value: the TUI stores a `Quote` by value in tab state, + /// and provider parsers copy the name out of transient JSON before + /// that JSON is freed. Read via `name()`, write via `setName()`; + /// never touch these two fields directly. The buffer is zero-filled + /// (not `undefined`) so a Quote constructed without a name - the + /// providers only call `setName` when one is present - still yields + /// an empty `name()` and can never expose uninitialized bytes. + name_buf: [256]u8 = @splat(0), + name_len: usize = 0, exchange: []const u8, datetime: []const u8, close: f64, @@ -15,4 +24,18 @@ pub const Quote = struct { average_volume: u64, fifty_two_week_low: f64, fifty_two_week_high: f64, + + /// The display name, or "" when unset. Borrowed from the Quote; + /// valid for as long as the Quote value is alive. + pub fn name(self: *const Quote) []const u8 { + return self.name_buf[0..self.name_len]; + } + + /// Copy `s` into the inline name buffer, truncating to capacity. + /// Callers should pass an already-trimmed string. + pub fn setName(self: *Quote, s: []const u8) void { + const n = @min(s.len, self.name_buf.len); + @memcpy(self.name_buf[0..n], s[0..n]); + self.name_len = n; + } }; diff --git a/src/providers/twelvedata.zig b/src/providers/twelvedata.zig index 57f5bc4..e7e319a 100644 --- a/src/providers/twelvedata.zig +++ b/src/providers/twelvedata.zig @@ -184,9 +184,8 @@ fn parseQuoteResponse(allocator: std.mem.Allocator, body: []const u8, symbol: [] const ftw = root.get("fifty_two_week"); - return .{ + var quote: Quote = .{ .symbol = symbol, - .name = symbol, .exchange = "", .datetime = "", .close = parseJsonFloat(root.get("close")), @@ -207,6 +206,14 @@ fn parseQuoteResponse(allocator: std.mem.Allocator, body: []const u8, symbol: [] else => 0, } else 0, }; + // TwelveData's /quote payload carries the security name in `name`. + // Copy it before the parsed JSON is freed on return so the name + // rides with the quote (same contract as the Yahoo path). + if (json_utils.jsonStr(root.get("name"))) |raw| { + const trimmed = std.mem.trim(u8, raw, " \t\r\n"); + if (trimmed.len > 0) quote.setName(trimmed); + } + return quote; } // -- Tests -- @@ -348,6 +355,24 @@ test "parseQuoteResponse basic" { try std.testing.expectEqual(@as(u64, 55000000), quote.average_volume); try std.testing.expectApproxEqAbs(@as(f64, 140.0), quote.fifty_two_week_low, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 200.0), quote.fifty_two_week_high, 0.01); + try std.testing.expectEqualStrings("Apple Inc", quote.name()); +} + +test "parseQuoteResponse: missing name field leaves the name empty" { + // `name` is optional in TwelveData's payload. When absent, setName + // is never called, so name() must return "" - never the buffer's + // uninitialized tail. + const body = + \\{ + \\ "symbol": "FOO", + \\ "close": "10.00", + \\ "previous_close": "9.50" + \\} + ; + const allocator = std.testing.allocator; + const quote = try parseQuoteResponse(allocator, body, "FOO"); + try std.testing.expectEqualStrings("", quote.name()); + try std.testing.expectApproxEqAbs(@as(f64, 10.00), quote.close, 0.01); } test "parseQuoteResponse error response" { diff --git a/src/providers/yahoo.zig b/src/providers/yahoo.zig index 2762bc6..d927d93 100644 --- a/src/providers/yahoo.zig +++ b/src/providers/yahoo.zig @@ -14,6 +14,7 @@ const Candle = @import("../models/candle.zig").Candle; const Quote = @import("../models/quote.zig").Quote; const parseJsonFloat = @import("json_utils.zig").parseJsonFloat; const optFloat = @import("json_utils.zig").optFloat; +const jsonStr = @import("json_utils.zig").jsonStr; const base_url = "https://query1.finance.yahoo.com/v8/finance/chart"; @@ -247,9 +248,8 @@ fn parseChartQuote(allocator: std.mem.Allocator, body: []const u8, symbol: []con const change = price - prev_close; const pct = if (prev_close != 0) (change / prev_close) * 100.0 else 0; - return .{ + var quote: Quote = .{ .symbol = symbol, - .name = symbol, .exchange = "", .datetime = "", .close = price, @@ -264,6 +264,16 @@ fn parseChartQuote(allocator: std.mem.Allocator, body: []const u8, symbol: []con .fifty_two_week_low = parseJsonFloat(m.get("fiftyTwoWeekLow")), .fifty_two_week_high = parseJsonFloat(m.get("fiftyTwoWeekHigh")), }; + // Yahoo's chart `meta` carries the security's display name in + // `longName` (preferred) / `shortName`. Copy it into the quote's + // inline buffer before the parsed JSON is freed on return. This is + // the same live source as the price, so the name and price always + // describe the same security even after a ticker is recycled. + if (jsonStr(m.get("longName")) orelse jsonStr(m.get("shortName"))) |raw| { + const trimmed = std.mem.trim(u8, raw, " \t\r\n"); + if (trimmed.len > 0) quote.setName(trimmed); + } + return quote; } fn getFloatArray(val: ?std.json.Value) ?[]const std.json.Value { @@ -603,3 +613,86 @@ test "parseChartQuote falls back to price when indicators are absent" { try std.testing.expectEqual(@as(u64, 0), quote.volume); try std.testing.expectApproxEqAbs(@as(f64, 495.00), quote.previous_close, 0.01); } + +test "parseChartQuote: prefers meta.longName for the display name" { + // The chart meta carries the real security name. longName is + // preferred over shortName (which Yahoo often truncates / pads). + const body = + \\{ + \\ "chart": { + \\ "result": [{ + \\ "meta": { + \\ "symbol": "SPCX", + \\ "longName": "Space Exploration Technologies Corp.", + \\ "shortName": "Space Exploration Technologies ", + \\ "regularMarketPrice": 153.23, + \\ "chartPreviousClose": 153.00 + \\ }, + \\ "timestamp": [1782480600], + \\ "indicators": {"quote": [{ + \\ "open": [150.62], "high": [158.40], "low": [148.51], + \\ "close": [153.23], "volume": [126431973] + \\ }]} + \\ }], + \\ "error": null + \\ } + \\} + ; + const allocator = std.testing.allocator; + const quote = try parseChartQuote(allocator, body, "SPCX"); + try std.testing.expectEqualStrings("Space Exploration Technologies Corp.", quote.name()); +} + +test "parseChartQuote: falls back to trimmed shortName when longName absent" { + // No longName -> use shortName, trimming Yahoo's trailing padding. + const body = + \\{ + \\ "chart": { + \\ "result": [{ + \\ "meta": { + \\ "symbol": "FOO", + \\ "shortName": "Foo Industries ", + \\ "regularMarketPrice": 10.00, + \\ "chartPreviousClose": 9.50 + \\ }, + \\ "timestamp": [1782480600], + \\ "indicators": {"quote": [{ + \\ "open": [9.6], "high": [10.2], "low": [9.4], + \\ "close": [10.0], "volume": [1000] + \\ }]} + \\ }], + \\ "error": null + \\ } + \\} + ; + const allocator = std.testing.allocator; + const quote = try parseChartQuote(allocator, body, "FOO"); + try std.testing.expectEqualStrings("Foo Industries", quote.name()); +} + +test "parseChartQuote: no name fields leaves the name empty" { + // When the meta carries neither longName nor shortName, the name + // stays empty (callers fall back to symbol-only / other sources). + const body = + \\{ + \\ "chart": { + \\ "result": [{ + \\ "meta": { + \\ "symbol": "BAR", + \\ "regularMarketPrice": 42.00, + \\ "chartPreviousClose": 41.00 + \\ }, + \\ "timestamp": [1782480600], + \\ "indicators": {"quote": [{ + \\ "open": [41.5], "high": [42.5], "low": [41.0], + \\ "close": [42.0], "volume": [500] + \\ }]} + \\ }], + \\ "error": null + \\ } + \\} + ; + const allocator = std.testing.allocator; + const quote = try parseChartQuote(allocator, body, "BAR"); + try std.testing.expectEqualStrings("", quote.name()); +} diff --git a/src/tui.zig b/src/tui.zig index e243e0e..d3e0313 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -1893,7 +1893,7 @@ pub const App = struct { (if (self.symbol_data.etf_profile) |p| p.name else null) else null; - const resolved_name = zfin.classification.resolveSecurityName(sym, cm_opt, etf_fallback); + const resolved_name = zfin.classification.resolveSecurityName(sym, cm_opt, .{ .etf_profile = etf_fallback }); try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s}", .{sym}), .style = th.headerStyle(), diff --git a/src/tui/quote_tab.zig b/src/tui/quote_tab.zig index fc3a759..4bbbc93 100644 --- a/src/tui/quote_tab.zig +++ b/src/tui/quote_tab.zig @@ -675,17 +675,18 @@ pub fn formatQuoteHeader( }; } -/// 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. +/// Resolve the active symbol's display name using the shared policy +/// (`resolveSecurityName`): the curated `metadata.srf` `name::` field +/// first, then the live quote name (Yahoo `longName`, the same source +/// as the price), then the ETF profile's fund name. Mirrors the CLI +/// `quote` command so the two quote surfaces always agree. The 'K' +/// overlay uses the same helper but, lacking a per-symbol live quote, +/// resolves metadata -> ETF name only. 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); + const live: ?[]const u8 = if (app.states.quote.live) |*q| q.name() else null; + const etf: ?[]const u8 = if (app.symbol_data.etf_profile) |p| p.name else null; + return zfin.classification.resolveSecurityName(app.symbol, cm, .{ .live_quote = live, .etf_profile = etf }); } fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {