better quote name resolution

This commit is contained in:
Emil Lerch 2026-06-27 09:49:32 -07:00
parent 3abce454dc
commit 8ca673c8e3
Signed by: lobo
GPG key ID: A7B62D657EF764F8
7 changed files with 278 additions and 65 deletions

View file

@ -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) {

View file

@ -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",

View file

@ -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;
}
};

View file

@ -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" {

View file

@ -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());
}

View file

@ -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(),

View file

@ -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 {