better quote name resolution
This commit is contained in:
parent
3abce454dc
commit
8ca673c8e3
7 changed files with 278 additions and 65 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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" {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue