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
|
// Fetch real-time quote via DataService
|
||||||
var quote: ?QuoteData = null;
|
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| {
|
if (svc.getQuote(parsed.symbol, opts)) |q| {
|
||||||
quote = .{
|
quote = .{
|
||||||
.price = q.close,
|
.price = q.close,
|
||||||
|
|
@ -169,37 +175,43 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
.prev_close = q.previous_close,
|
.prev_close = q.previous_close,
|
||||||
.date = if (candles.len > 0) candles[candles.len - 1].date else ctx.today,
|
.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 |_| {}
|
} else |_| {}
|
||||||
|
|
||||||
// Resolve a human-readable security name the same way the TUI
|
// Resolve a human-readable security name the same way the TUI
|
||||||
// 'K' overlay and quote tab do: the curated `metadata.srf`
|
// 'K' overlay and quote tab do, via the shared policy: the curated
|
||||||
// `name::` field first, then the ETF profile's fund name. The
|
// `metadata.srf` `name::` field first, then the live quote name,
|
||||||
// name is copied into `name_buf` so its lifetime is independent
|
// then the ETF profile's fund name. The result is copied into
|
||||||
// of the (transient) classification map / ETF fetch result.
|
// `name_buf` so its lifetime is independent of the (transient)
|
||||||
|
// classification map / ETF fetch result.
|
||||||
var name_buf: [256]u8 = undefined;
|
var name_buf: [256]u8 = undefined;
|
||||||
var name: ?[]const u8 = null;
|
var name: ?[]const u8 = null;
|
||||||
{
|
{
|
||||||
var cm_opt = loadClassificationMap(ctx);
|
var cm_opt = loadClassificationMap(ctx);
|
||||||
defer if (cm_opt) |*cm| cm.deinit();
|
defer if (cm_opt) |*cm| cm.deinit();
|
||||||
const cm_ptr: ?*const zfin.classification.ClassificationMap = if (cm_opt) |*cm| cm else null;
|
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);
|
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 k: KittyChart = .{ .io = ctx.io, .caps = ctx.graphics_caps };
|
||||||
const chart_render: ChartRender = switch (ctx.globals.chart_config.mode) {
|
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");
|
return try allocator.dupe(u8, "Unclassified");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve a human-readable security name for `symbol`, applying
|
/// The candidate name sources for a security, other than the curated
|
||||||
/// the project-wide name-source policy:
|
/// `metadata.srf` name (which `resolveSecurityName` looks up from the
|
||||||
/// 1. The curated `name::` field from `metadata.srf` (via the
|
/// classification map and ranks above all of these). Callers fill in
|
||||||
/// classification map) - the same source the TUI 'K' overlay
|
/// whatever they have on hand and leave the rest null; the precedence
|
||||||
/// uses. Wins whenever present.
|
/// *between* these sources is decided by `resolveSecurityName`, not by
|
||||||
/// 2. `fallback_name` (typically the ETF profile's fund name)
|
/// the caller - that's the whole point of routing every surface
|
||||||
/// when metadata has no name for the symbol.
|
/// 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
|
/// 1. The curated `name::` from `metadata.srf` (via `cm`) - the
|
||||||
/// caller must keep those alive for as long as the result is used),
|
/// user's explicit override.
|
||||||
/// or null when neither source yields a name.
|
/// 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
|
/// Empty names are skipped at every level. Returns a slice borrowed
|
||||||
/// by the 'K' overlay, the quote tab header, and the CLI `quote`
|
/// from `cm` or one of `sources` (keep them alive while the result is
|
||||||
/// command. Symbol comparison is exact (`std.mem.eql`); every caller
|
/// used), or null when no source yields a name. Symbol comparison is
|
||||||
/// normalizes symbols to upper-case before calling, matching the
|
/// exact (`std.mem.eql`); every caller upper-cases symbols first,
|
||||||
/// upper-case symbols `metadata.srf` carries.
|
/// matching the upper-case symbols `metadata.srf` carries.
|
||||||
pub fn resolveSecurityName(
|
pub fn resolveSecurityName(
|
||||||
symbol: []const u8,
|
symbol: []const u8,
|
||||||
cm: ?*const ClassificationMap,
|
cm: ?*const ClassificationMap,
|
||||||
fallback_name: ?[]const u8,
|
sources: NameSources,
|
||||||
) ?[]const u8 {
|
) ?[]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| {
|
if (cm) |m| {
|
||||||
for (m.entries) |*e| {
|
for (m.entries) |*e| {
|
||||||
if (std.mem.eql(u8, e.symbol, symbol)) {
|
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 (e.name) |n| {
|
||||||
if (n.len > 0) return 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" {
|
test "parse classification file" {
|
||||||
|
|
@ -253,14 +278,14 @@ test "resolveSecurityName: metadata name wins" {
|
||||||
.{ .symbol = "VTI", .name = "Vanguard Total Stock Market ETF" },
|
.{ .symbol = "VTI", .name = "Vanguard Total Stock Market ETF" },
|
||||||
};
|
};
|
||||||
const cm: ClassificationMap = .{ .entries = &entries, .allocator = std.testing.allocator };
|
const cm: ClassificationMap = .{ .entries = &entries, .allocator = std.testing.allocator };
|
||||||
// Metadata hit; fallback ignored.
|
// Metadata hit; lower-priority sources ignored.
|
||||||
try std.testing.expectEqualStrings(
|
try std.testing.expectEqualStrings(
|
||||||
"Amazon",
|
"Amazon",
|
||||||
resolveSecurityName("AMZN", &cm, "ignored fallback").?,
|
resolveSecurityName("AMZN", &cm, .{ .live_quote = "ignored", .etf_profile = "ignored" }).?,
|
||||||
);
|
);
|
||||||
try std.testing.expectEqualStrings(
|
try std.testing.expectEqualStrings(
|
||||||
"Vanguard Total Stock Market ETF",
|
"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 };
|
const cm: ClassificationMap = .{ .entries = &entries, .allocator = std.testing.allocator };
|
||||||
try std.testing.expectEqualStrings(
|
try std.testing.expectEqualStrings(
|
||||||
"iShares Semiconductor ETF",
|
"iShares Semiconductor ETF",
|
||||||
resolveSecurityName("SOXX", &cm, "iShares Semiconductor ETF").?,
|
resolveSecurityName("SOXX", &cm, .{ .etf_profile = "iShares Semiconductor ETF" }).?,
|
||||||
);
|
);
|
||||||
// No fallback either -> null.
|
// No other source either -> null.
|
||||||
try std.testing.expect(resolveSecurityName("SOXX", &cm, null) == null);
|
try std.testing.expect(resolveSecurityName("SOXX", &cm, .{}) == null);
|
||||||
}
|
}
|
||||||
|
|
||||||
test "resolveSecurityName: falls back when symbol absent from map" {
|
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 };
|
const cm: ClassificationMap = .{ .entries = &entries, .allocator = std.testing.allocator };
|
||||||
try std.testing.expectEqualStrings(
|
try std.testing.expectEqualStrings(
|
||||||
"SPDR S&P 500 ETF Trust",
|
"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(
|
try std.testing.expectEqualStrings(
|
||||||
"Apple Inc.",
|
"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" {
|
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 };
|
const cm: ClassificationMap = .{ .entries = &entries, .allocator = std.testing.allocator };
|
||||||
try std.testing.expectEqualStrings(
|
try std.testing.expectEqualStrings(
|
||||||
"Apple Inc.",
|
"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" {
|
test "deriveBucket: returns user-curated bucket when set" {
|
||||||
const e: ClassificationEntry = .{
|
const e: ClassificationEntry = .{
|
||||||
.symbol = "SPY",
|
.symbol = "SPY",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,16 @@
|
||||||
/// Real-time (or near-real-time) quote snapshot for a symbol.
|
/// Real-time (or near-real-time) quote snapshot for a symbol.
|
||||||
pub const Quote = struct {
|
pub const Quote = struct {
|
||||||
symbol: []const u8,
|
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,
|
exchange: []const u8,
|
||||||
datetime: []const u8,
|
datetime: []const u8,
|
||||||
close: f64,
|
close: f64,
|
||||||
|
|
@ -15,4 +24,18 @@ pub const Quote = struct {
|
||||||
average_volume: u64,
|
average_volume: u64,
|
||||||
fifty_two_week_low: f64,
|
fifty_two_week_low: f64,
|
||||||
fifty_two_week_high: 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");
|
const ftw = root.get("fifty_two_week");
|
||||||
|
|
||||||
return .{
|
var quote: Quote = .{
|
||||||
.symbol = symbol,
|
.symbol = symbol,
|
||||||
.name = symbol,
|
|
||||||
.exchange = "",
|
.exchange = "",
|
||||||
.datetime = "",
|
.datetime = "",
|
||||||
.close = parseJsonFloat(root.get("close")),
|
.close = parseJsonFloat(root.get("close")),
|
||||||
|
|
@ -207,6 +206,14 @@ fn parseQuoteResponse(allocator: std.mem.Allocator, body: []const u8, symbol: []
|
||||||
else => 0,
|
else => 0,
|
||||||
} 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 --
|
// -- Tests --
|
||||||
|
|
@ -348,6 +355,24 @@ test "parseQuoteResponse basic" {
|
||||||
try std.testing.expectEqual(@as(u64, 55000000), quote.average_volume);
|
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, 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.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" {
|
test "parseQuoteResponse error response" {
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ const Candle = @import("../models/candle.zig").Candle;
|
||||||
const Quote = @import("../models/quote.zig").Quote;
|
const Quote = @import("../models/quote.zig").Quote;
|
||||||
const parseJsonFloat = @import("json_utils.zig").parseJsonFloat;
|
const parseJsonFloat = @import("json_utils.zig").parseJsonFloat;
|
||||||
const optFloat = @import("json_utils.zig").optFloat;
|
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";
|
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 change = price - prev_close;
|
||||||
const pct = if (prev_close != 0) (change / prev_close) * 100.0 else 0;
|
const pct = if (prev_close != 0) (change / prev_close) * 100.0 else 0;
|
||||||
|
|
||||||
return .{
|
var quote: Quote = .{
|
||||||
.symbol = symbol,
|
.symbol = symbol,
|
||||||
.name = symbol,
|
|
||||||
.exchange = "",
|
.exchange = "",
|
||||||
.datetime = "",
|
.datetime = "",
|
||||||
.close = price,
|
.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_low = parseJsonFloat(m.get("fiftyTwoWeekLow")),
|
||||||
.fifty_two_week_high = parseJsonFloat(m.get("fiftyTwoWeekHigh")),
|
.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 {
|
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.expectEqual(@as(u64, 0), quote.volume);
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 495.00), quote.previous_close, 0.01);
|
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)
|
(if (self.symbol_data.etf_profile) |p| p.name else null)
|
||||||
else
|
else
|
||||||
null;
|
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, .{
|
try lines.append(arena, .{
|
||||||
.text = try std.fmt.allocPrint(arena, " {s}", .{sym}),
|
.text = try std.fmt.allocPrint(arena, " {s}", .{sym}),
|
||||||
.style = th.headerStyle(),
|
.style = th.headerStyle(),
|
||||||
|
|
|
||||||
|
|
@ -675,17 +675,18 @@ pub fn formatQuoteHeader(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the active symbol's display name using the shared
|
/// Resolve the active symbol's display name using the shared policy
|
||||||
/// policy (metadata.srf `name::`, then the ETF profile's fund
|
/// (`resolveSecurityName`): the curated `metadata.srf` `name::` field
|
||||||
/// name). Mirrors the 'K' overlay and the CLI `quote` command, so
|
/// first, then the live quote name (Yahoo `longName`, the same source
|
||||||
/// the three surfaces always agree on the name they show. The ETF
|
/// as the price), then the ETF profile's fund name. Mirrors the CLI
|
||||||
/// profile is loaded lazily on the performance tab, so the fallback
|
/// `quote` command so the two quote surfaces always agree. The 'K'
|
||||||
/// only kicks in once that data is present; the metadata name is
|
/// overlay uses the same helper but, lacking a per-symbol live quote,
|
||||||
/// always available when enriched.
|
/// resolves metadata -> ETF name only.
|
||||||
fn quoteTabName(app: *App) ?[]const u8 {
|
fn quoteTabName(app: *App) ?[]const u8 {
|
||||||
const cm = app.portfolio.classificationMap();
|
const cm = app.portfolio.classificationMap();
|
||||||
const fallback: ?[]const u8 = if (app.symbol_data.etf_profile) |p| p.name else null;
|
const live: ?[]const u8 = if (app.states.quote.live) |*q| q.name() else null;
|
||||||
return zfin.classification.resolveSecurityName(app.symbol, cm, fallback);
|
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 {
|
fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue