add name to quote command/tab

This commit is contained in:
Emil Lerch 2026-06-24 10:30:26 -07:00
parent 2ad9a0fb40
commit 7e6102cc5f
Signed by: lobo
GPG key ID: A7B62D657EF764F8
4 changed files with 289 additions and 29 deletions

View file

@ -150,23 +150,96 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
};
} else |_| {}
try display(ctx.allocator, candles, quote, parsed.symbol, ctx.today, ctx.color, ctx.out);
// Resolve a human-readable security name the same way the TUI
// 'K' overlay and quote tab do: the curated `metadata.srf`
// `name::` field first, then the ETF profile's fund name. The
// name is copied into `name_buf` so its lifetime is independent
// of the (transient) classification map / ETF fetch result.
var name_buf: [256]u8 = undefined;
var name: ?[]const u8 = null;
{
var cm_opt = loadClassificationMap(ctx);
defer if (cm_opt) |*cm| cm.deinit();
const cm_ptr: ?*const zfin.classification.ClassificationMap = if (cm_opt) |*cm| cm else null;
if (zfin.classification.resolveSecurityName(parsed.symbol, cm_ptr, null)) |nm| {
name = clampName(&name_buf, nm);
}
}
if (name == null) {
// Fallback: the ETF profile's fund name, gated on isEtf() so
// it matches the TUI (which only retains fund-shaped
// profiles in symbol_data). Plain stocks not in metadata.srf
// therefore render symbol-only, exactly like the 'K' overlay.
// Only fetched when metadata yielded nothing, so already-named
// holdings don't pay for an EDGAR round-trip.
if (svc.getEtfProfile(parsed.symbol, opts)) |etf_result| {
defer etf_result.deinit();
if (etf_result.data.isEtf()) {
if (etf_result.data.name) |nm| name = clampName(&name_buf, nm);
}
} else |_| {}
}
try display(ctx.allocator, candles, quote, parsed.symbol, name, ctx.today, ctx.color, ctx.out);
}
pub fn display(allocator: std.mem.Allocator, candles: []const zfin.Candle, quote: ?QuoteData, symbol: []const u8, as_of: zfin.Date, color: bool, out: *std.Io.Writer) !void {
/// Copy `s` (clamped to `buf`'s capacity) into `buf` and return the
/// written slice. Fund/security names fit easily in 256 bytes.
fn clampName(buf: []u8, s: []const u8) []const u8 {
const n = @min(s.len, buf.len);
@memcpy(buf[0..n], s[0..n]);
return buf[0..n];
}
/// Quietly load the `metadata.srf` classification map that sits
/// beside the resolved portfolio anchor. Best-effort: returns null
/// (printing nothing) when there's no portfolio, no `metadata.srf`,
/// or it fails to parse. Unlike `cli.loadPortfolio` this never emits
/// "no portfolio" noise `quote` works fine without one, and the
/// map is only used to enrich the header with a name. Caller owns
/// the returned map and must `deinit()` it.
fn loadClassificationMap(ctx: *framework.RunCtx) ?zfin.classification.ClassificationMap {
var resolved = framework.resolvePatterns(
ctx.io,
ctx.allocator,
ctx.config,
ctx.globals.portfolio_patterns,
) catch return null;
defer resolved.deinit();
if (resolved.paths.len == 0) return null;
// metadata.srf lives in the same directory as the portfolio
// anchor (see AGENTS.md "Portfolio auto-detection").
const anchor_path = resolved.paths[0];
const dir_end = if (std.mem.lastIndexOfScalar(u8, anchor_path, std.fs.path.sep)) |idx| idx + 1 else 0;
const meta_path = std.fmt.allocPrint(ctx.allocator, "{s}metadata.srf", .{anchor_path[0..dir_end]}) catch return null;
defer ctx.allocator.free(meta_path);
const meta_data = std.Io.Dir.cwd().readFileAlloc(ctx.io, meta_path, ctx.allocator, .limited(1024 * 1024)) catch return null;
defer ctx.allocator.free(meta_data);
return zfin.classification.parseClassificationFile(ctx.allocator, meta_data) catch null;
}
pub fn display(allocator: std.mem.Allocator, candles: []const zfin.Candle, quote: ?QuoteData, symbol: []const u8, name: ?[]const u8, as_of: zfin.Date, color: bool, out: *std.Io.Writer) !void {
const has_quote = quote != null;
// Header
// Header. The security name (when resolved) renders between the
// symbol and the price, matching the TUI quote tab.
try cli.setBold(out, color);
try out.print("\n{s}", .{symbol});
if (name) |nm| {
if (nm.len > 0) try out.print(" {s}", .{nm});
}
if (quote) |q| {
try out.print("\n{s} {f}\n", .{ symbol, Money.from(q.price) });
try out.print(" {f}\n", .{Money.from(q.price)});
} else if (candles.len > 0) {
try out.print("\n{s} {f} (close)\n", .{ symbol, Money.from(candles[candles.len - 1].close) });
try out.print(" {f} (close)\n", .{Money.from(candles[candles.len - 1].close)});
} else {
try out.print("\n{s}\n", .{symbol});
try out.print("\n", .{});
}
try cli.reset(out, color);
try out.print("========================================\n", .{});
try out.print("======================================================================\n", .{});
// Quote details
const price = if (quote) |q| q.price else if (candles.len > 0) candles[candles.len - 1].close else @as(f64, 0);
@ -257,7 +330,7 @@ test "display with candles only" {
.{ .date = .{ .days = 20000 }, .open = 150.0, .high = 155.0, .low = 149.0, .close = 153.0, .adj_close = 153.0, .volume = 50_000_000 },
.{ .date = .{ .days = 20001 }, .open = 153.0, .high = 158.0, .low = 152.0, .close = 156.0, .adj_close = 156.0, .volume = 45_000_000 },
};
try display(std.testing.allocator, &candles, null, "AAPL", zfin.Date.fromYmd(2026, 5, 8), false, &w);
try display(std.testing.allocator, &candles, null, "AAPL", null, zfin.Date.fromYmd(2026, 5, 8), false, &w);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "AAPL") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "(close)") != null);
@ -278,7 +351,7 @@ test "display with quote data" {
.prev_close = 172.00,
.date = .{ .days = 20001 },
};
try display(std.testing.allocator, &candles, quote, "AAPL", zfin.Date.fromYmd(2026, 5, 8), false, &w);
try display(std.testing.allocator, &candles, quote, "AAPL", null, zfin.Date.fromYmd(2026, 5, 8), false, &w);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "AAPL") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Change") != null);
@ -286,13 +359,37 @@ test "display with quote data" {
try std.testing.expect(std.mem.indexOf(u8, out, "(close)") == null);
}
test "display renders the security name when provided" {
var buf: [8192]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const candles = [_]zfin.Candle{
.{ .date = .{ .days = 20000 }, .open = 150.0, .high = 155.0, .low = 149.0, .close = 153.0, .adj_close = 153.0, .volume = 50_000_000 },
};
try display(std.testing.allocator, &candles, null, "AAPL", "Apple Inc.", zfin.Date.fromYmd(2026, 5, 8), false, &w);
const out = w.buffered();
// Name appears between the symbol and the price.
try std.testing.expect(std.mem.indexOf(u8, out, "AAPL Apple Inc.") != null);
}
test "display omits an empty name" {
var buf: [8192]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const candles = [_]zfin.Candle{
.{ .date = .{ .days = 20000 }, .open = 150.0, .high = 155.0, .low = 149.0, .close = 153.0, .adj_close = 153.0, .volume = 50_000_000 },
};
try display(std.testing.allocator, &candles, null, "AAPL", "", zfin.Date.fromYmd(2026, 5, 8), false, &w);
const out = w.buffered();
// No double-space orphan where the name would have gone.
try std.testing.expect(std.mem.indexOf(u8, out, "AAPL $") != null);
}
test "display no ANSI without color" {
var buf: [8192]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const candles = [_]zfin.Candle{
.{ .date = .{ .days = 20000 }, .open = 100.0, .high = 105.0, .low = 99.0, .close = 103.0, .adj_close = 103.0, .volume = 1_000_000 },
};
try display(std.testing.allocator, &candles, null, "SPY", zfin.Date.fromYmd(2026, 5, 8), false, &w);
try display(std.testing.allocator, &candles, null, "SPY", null, zfin.Date.fromYmd(2026, 5, 8), false, &w);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
}

View file

@ -146,6 +146,43 @@ pub fn deriveBucket(entry: ClassificationEntry, allocator: std.mem.Allocator) ![
return try allocator.dupe(u8, "Unclassified");
}
/// Resolve a human-readable security name for `symbol`, applying
/// the project-wide name-source policy:
/// 1. The curated `name::` field from `metadata.srf` (via the
/// classification map) the same source the TUI 'K' overlay
/// uses. Wins whenever present.
/// 2. `fallback_name` (typically the ETF profile's fund name)
/// when metadata has no name for the symbol.
///
/// Returns a slice borrowed from `cm` or `fallback_name` (the
/// caller must keep those alive for as long as the result is used),
/// or null when neither source yields a name.
///
/// This is the single source of truth for the security name shown
/// by the 'K' overlay, the quote tab header, and the CLI `quote`
/// command. Symbol comparison is exact (`std.mem.eql`); every caller
/// normalizes symbols to upper-case before calling, matching the
/// upper-case symbols `metadata.srf` carries.
pub fn resolveSecurityName(
symbol: []const u8,
cm: ?*const ClassificationMap,
fallback_name: ?[]const u8,
) ?[]const u8 {
if (cm) |m| {
for (m.entries) |*e| {
if (std.mem.eql(u8, e.symbol, symbol)) {
// First matching entry that actually carries a name
// wins. Blended-fund symbols repeat across rows with
// the same name, so this is well-defined.
if (e.name) |n| {
if (n.len > 0) return n;
}
}
}
}
return fallback_name;
}
test "parse classification file" {
const data =
\\#!srfv1
@ -210,6 +247,66 @@ test "parse classification file: bucket round-trips" {
try std.testing.expectEqualStrings("Equity / Corporate", cm.entries[0].sector.?);
}
test "resolveSecurityName: metadata name wins" {
var entries = [_]ClassificationEntry{
.{ .symbol = "AMZN", .name = "Amazon" },
.{ .symbol = "VTI", .name = "Vanguard Total Stock Market ETF" },
};
const cm: ClassificationMap = .{ .entries = &entries, .allocator = std.testing.allocator };
// Metadata hit; fallback ignored.
try std.testing.expectEqualStrings(
"Amazon",
resolveSecurityName("AMZN", &cm, "ignored fallback").?,
);
try std.testing.expectEqualStrings(
"Vanguard Total Stock Market ETF",
resolveSecurityName("VTI", &cm, null).?,
);
}
test "resolveSecurityName: falls back when entry has no name" {
var entries = [_]ClassificationEntry{
.{ .symbol = "SOXX", .name = null }, // pre-name metadata row
};
const cm: ClassificationMap = .{ .entries = &entries, .allocator = std.testing.allocator };
try std.testing.expectEqualStrings(
"iShares Semiconductor ETF",
resolveSecurityName("SOXX", &cm, "iShares Semiconductor ETF").?,
);
// No fallback either null.
try std.testing.expect(resolveSecurityName("SOXX", &cm, null) == null);
}
test "resolveSecurityName: falls back when symbol absent from map" {
var entries = [_]ClassificationEntry{
.{ .symbol = "AMZN", .name = "Amazon" },
};
const cm: ClassificationMap = .{ .entries = &entries, .allocator = std.testing.allocator };
try std.testing.expectEqualStrings(
"SPDR S&P 500 ETF Trust",
resolveSecurityName("SPY", &cm, "SPDR S&P 500 ETF Trust").?,
);
}
test "resolveSecurityName: null map uses fallback, null everything is null" {
try std.testing.expectEqualStrings(
"Apple Inc.",
resolveSecurityName("AAPL", null, "Apple Inc.").?,
);
try std.testing.expect(resolveSecurityName("AAPL", null, null) == null);
}
test "resolveSecurityName: empty metadata name treated as absent" {
var entries = [_]ClassificationEntry{
.{ .symbol = "AAPL", .name = "" },
};
const cm: ClassificationMap = .{ .entries = &entries, .allocator = std.testing.allocator };
try std.testing.expectEqualStrings(
"Apple Inc.",
resolveSecurityName("AAPL", &cm, "Apple Inc.").?,
);
}
test "deriveBucket: returns user-curated bucket when set" {
const e: ClassificationEntry = .{
.symbol = "SPY",

View file

@ -1797,16 +1797,27 @@ pub const App = struct {
break :blk null;
};
const display_name: []const u8 = if (ce_opt) |ce| (ce.name orelse sym) else sym;
// Name: same policy as the quote tab and CLI `quote`
// metadata.srf `name::` first, then the ETF profile's fund
// name. The ETF fallback only applies when the overlay
// targets the active symbol, since `symbol_data` holds the
// profile for `self.symbol`, not necessarily `sym`.
const etf_fallback: ?[]const u8 = if (std.mem.eql(u8, sym, self.symbol))
(if (self.symbol_data.etf_profile) |p| p.name else null)
else
null;
const resolved_name = zfin.classification.resolveSecurityName(sym, cm_opt, etf_fallback);
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " {s}", .{sym}),
.style = th.headerStyle(),
});
if (display_name.ptr != sym.ptr and display_name.len > 0) {
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " {s}", .{display_name}),
.style = th.contentStyle(),
});
if (resolved_name) |nm| {
if (nm.len > 0 and !std.mem.eql(u8, nm, sym)) {
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " {s}", .{nm}),
.style = th.contentStyle(),
});
}
}
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });

View file

@ -266,9 +266,16 @@ fn drawWithKittyChart(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, wi
var lines: std.ArrayList(StyledLine) = .empty;
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
// Symbol (+ resolved name) label, shared by the live and
// close-of-day header variants below.
const sym_label: []const u8 = if (quoteTabName(app)) |n|
try std.fmt.allocPrint(arena, "{s} {s}", .{ app.symbol, n })
else
app.symbol;
// Symbol + price header
if (app.states.quote.live) |q| {
const price_str = try std.fmt.allocPrint(arena, " {s} ${d:.2}", .{ app.symbol, q.close });
const price_str = try std.fmt.allocPrint(arena, " {s} ${d:.2}", .{ sym_label, q.close });
try lines.append(arena, .{ .text = price_str, .style = th.headerStyle() });
if (q.previous_close > 0) {
const change = q.close - q.previous_close;
@ -279,7 +286,7 @@ fn drawWithKittyChart(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, wi
}
} else if (c.len > 0) {
const last = c[c.len - 1];
const price_str = try std.fmt.allocPrint(arena, " {s} ${d:.2} (close)", .{ app.symbol, last.close });
const price_str = try std.fmt.allocPrint(arena, " {s} ${d:.2} (close)", .{ sym_label, last.close });
try lines.append(arena, .{ .text = price_str, .style = th.headerStyle() });
if (c.len >= 2) {
const prev_close = c[c.len - 2].close;
@ -588,20 +595,40 @@ pub const QuoteHeaderSource = union(enum) {
};
/// Format the quote tab's header line. Pure function over
/// (arena, symbol, source). The three branches mirror the live /
/// close-of-day / no-data paths in the live builder.
/// (arena, symbol, name, source). The three branches mirror the
/// live / close-of-day / no-data paths in the live builder. When
/// `name` is non-null and non-empty, it's rendered between the
/// symbol and the timing suffix (e.g. "AAPL Apple Inc. (live …)").
pub fn formatQuoteHeader(
arena: std.mem.Allocator,
symbol: []const u8,
name: ?[]const u8,
source: QuoteHeaderSource,
) ![]const u8 {
const name_part: []const u8 = if (name) |n|
(if (n.len > 0) try std.fmt.allocPrint(arena, " {s}", .{n}) else "")
else
"";
return switch (source) {
.live => |ago| std.fmt.allocPrint(arena, " {s} (live, ~15 min delay, refreshed {s})", .{ symbol, ago }),
.close => |date| std.fmt.allocPrint(arena, " {s} (as of close on {f})", .{ symbol, date }),
.none => std.fmt.allocPrint(arena, " {s}", .{symbol}),
.live => |ago| std.fmt.allocPrint(arena, " {s}{s} (live, ~15 min delay, refreshed {s})", .{ symbol, name_part, ago }),
.close => |date| std.fmt.allocPrint(arena, " {s}{s} (as of close on {f})", .{ symbol, name_part, date }),
.none => std.fmt.allocPrint(arena, " {s}{s}", .{ symbol, name_part }),
};
}
/// Resolve the active symbol's display name using the shared
/// policy (metadata.srf `name::`, then the ETF profile's fund
/// name). Mirrors the 'K' overlay and the CLI `quote` command, so
/// the three surfaces always agree on the name they show. The ETF
/// profile is loaded lazily on the performance tab, so the fallback
/// only kicks in once that data is present; the metadata name is
/// always available when enriched.
fn quoteTabName(app: *App) ?[]const u8 {
const cm = app.portfolio.classificationMap();
const fallback: ?[]const u8 = if (app.symbol_data.etf_profile) |p| p.name else null;
return zfin.classification.resolveSecurityName(app.symbol, cm, fallback);
}
fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
const th = app.theme;
var lines: std.ArrayList(StyledLine) = .empty;
@ -614,15 +641,16 @@ fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
}
var ago_buf: [16]u8 = undefined;
const name = quoteTabName(app);
if (app.states.quote.live != null and app.states.quote.timestamp > 0) {
// wall-clock required: per-frame "now" for the data-age readout.
const now_s = std.Io.Timestamp.now(app.io, .real).toSeconds();
const ago_str = fmt.fmtTimeAgo(&ago_buf, app.states.quote.timestamp, now_s);
try lines.append(arena, .{ .text = try formatQuoteHeader(arena, app.symbol, .{ .live = ago_str }), .style = th.headerStyle() });
try lines.append(arena, .{ .text = try formatQuoteHeader(arena, app.symbol, name, .{ .live = ago_str }), .style = th.headerStyle() });
} else if (app.symbol_data.candleLastDate()) |d| {
try lines.append(arena, .{ .text = try formatQuoteHeader(arena, app.symbol, .{ .close = d }), .style = th.headerStyle() });
try lines.append(arena, .{ .text = try formatQuoteHeader(arena, app.symbol, name, .{ .close = d }), .style = th.headerStyle() });
} else {
try lines.append(arena, .{ .text = try formatQuoteHeader(arena, app.symbol, .none), .style = th.headerStyle() });
try lines.append(arena, .{ .text = try formatQuoteHeader(arena, app.symbol, name, .none), .style = th.headerStyle() });
}
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
@ -860,7 +888,7 @@ test "formatQuoteHeader: live source includes refreshed-ago string" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const text = try formatQuoteHeader(arena, "AAPL", .{ .live = "5s ago" });
const text = try formatQuoteHeader(arena, "AAPL", null, .{ .live = "5s ago" });
try testing.expectEqualStrings(" AAPL (live, ~15 min delay, refreshed 5s ago)", text);
}
@ -868,7 +896,7 @@ test "formatQuoteHeader: close source includes ISO date" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const text = try formatQuoteHeader(arena, "VTI", .{ .close = Date.fromYmd(2024, 3, 15) });
const text = try formatQuoteHeader(arena, "VTI", null, .{ .close = Date.fromYmd(2024, 3, 15) });
try testing.expectEqualStrings(" VTI (as of close on 2024-03-15)", text);
}
@ -876,6 +904,33 @@ test "formatQuoteHeader: none source renders just the symbol" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const text = try formatQuoteHeader(arena, "BRK.B", .none);
const text = try formatQuoteHeader(arena, "BRK.B", null, .none);
try testing.expectEqualStrings(" BRK.B", text);
}
test "formatQuoteHeader: name renders between symbol and suffix" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
try testing.expectEqualStrings(
" AAPL Apple Inc. (live, ~15 min delay, refreshed 5s ago)",
try formatQuoteHeader(arena, "AAPL", "Apple Inc.", .{ .live = "5s ago" }),
);
try testing.expectEqualStrings(
" VTI Vanguard Total Stock Market ETF (as of close on 2024-03-15)",
try formatQuoteHeader(arena, "VTI", "Vanguard Total Stock Market ETF", .{ .close = Date.fromYmd(2024, 3, 15) }),
);
try testing.expectEqualStrings(
" BRK.B Berkshire Hathaway",
try formatQuoteHeader(arena, "BRK.B", "Berkshire Hathaway", .none),
);
}
test "formatQuoteHeader: empty name is omitted" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const text = try formatQuoteHeader(arena, "AAPL", "", .none);
try testing.expectEqualStrings(" AAPL", text);
}