add name to quote command/tab
This commit is contained in:
parent
2ad9a0fb40
commit
7e6102cc5f
4 changed files with 289 additions and 29 deletions
|
|
@ -150,23 +150,96 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
};
|
};
|
||||||
} else |_| {}
|
} 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;
|
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 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| {
|
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) {
|
} 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 {
|
} else {
|
||||||
try out.print("\n{s}\n", .{symbol});
|
try out.print("\n", .{});
|
||||||
}
|
}
|
||||||
try cli.reset(out, color);
|
try cli.reset(out, color);
|
||||||
try out.print("========================================\n", .{});
|
try out.print("======================================================================\n", .{});
|
||||||
|
|
||||||
// Quote details
|
// Quote details
|
||||||
const price = if (quote) |q| q.price else if (candles.len > 0) candles[candles.len - 1].close else @as(f64, 0);
|
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 = 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 },
|
.{ .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();
|
const out = w.buffered();
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, "AAPL") != null);
|
try std.testing.expect(std.mem.indexOf(u8, out, "AAPL") != null);
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, "(close)") != 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,
|
.prev_close = 172.00,
|
||||||
.date = .{ .days = 20001 },
|
.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();
|
const out = w.buffered();
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, "AAPL") != null);
|
try std.testing.expect(std.mem.indexOf(u8, out, "AAPL") != null);
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, "Change") != 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);
|
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" {
|
test "display no ANSI without color" {
|
||||||
var buf: [8192]u8 = undefined;
|
var buf: [8192]u8 = undefined;
|
||||||
var w: std.Io.Writer = .fixed(&buf);
|
var w: std.Io.Writer = .fixed(&buf);
|
||||||
const candles = [_]zfin.Candle{
|
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 },
|
.{ .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();
|
const out = w.buffered();
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
|
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,43 @@ 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 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" {
|
test "parse classification file" {
|
||||||
const data =
|
const data =
|
||||||
\\#!srfv1
|
\\#!srfv1
|
||||||
|
|
@ -210,6 +247,66 @@ test "parse classification file: bucket round-trips" {
|
||||||
try std.testing.expectEqualStrings("Equity / Corporate", cm.entries[0].sector.?);
|
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" {
|
test "deriveBucket: returns user-curated bucket when set" {
|
||||||
const e: ClassificationEntry = .{
|
const e: ClassificationEntry = .{
|
||||||
.symbol = "SPY",
|
.symbol = "SPY",
|
||||||
|
|
|
||||||
23
src/tui.zig
23
src/tui.zig
|
|
@ -1797,16 +1797,27 @@ pub const App = struct {
|
||||||
break :blk null;
|
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, .{
|
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(),
|
||||||
});
|
});
|
||||||
if (display_name.ptr != sym.ptr and display_name.len > 0) {
|
if (resolved_name) |nm| {
|
||||||
try lines.append(arena, .{
|
if (nm.len > 0 and !std.mem.eql(u8, nm, sym)) {
|
||||||
.text = try std.fmt.allocPrint(arena, " {s}", .{display_name}),
|
try lines.append(arena, .{
|
||||||
.style = th.contentStyle(),
|
.text = try std.fmt.allocPrint(arena, " {s}", .{nm}),
|
||||||
});
|
.style = th.contentStyle(),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -266,9 +266,16 @@ fn drawWithKittyChart(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, wi
|
||||||
var lines: std.ArrayList(StyledLine) = .empty;
|
var lines: std.ArrayList(StyledLine) = .empty;
|
||||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
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
|
// Symbol + price header
|
||||||
if (app.states.quote.live) |q| {
|
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() });
|
try lines.append(arena, .{ .text = price_str, .style = th.headerStyle() });
|
||||||
if (q.previous_close > 0) {
|
if (q.previous_close > 0) {
|
||||||
const change = q.close - q.previous_close;
|
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) {
|
} else if (c.len > 0) {
|
||||||
const last = c[c.len - 1];
|
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() });
|
try lines.append(arena, .{ .text = price_str, .style = th.headerStyle() });
|
||||||
if (c.len >= 2) {
|
if (c.len >= 2) {
|
||||||
const prev_close = c[c.len - 2].close;
|
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
|
/// Format the quote tab's header line. Pure function over
|
||||||
/// (arena, symbol, source). The three branches mirror the live /
|
/// (arena, symbol, name, source). The three branches mirror the
|
||||||
/// close-of-day / no-data paths in the live builder.
|
/// 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(
|
pub fn formatQuoteHeader(
|
||||||
arena: std.mem.Allocator,
|
arena: std.mem.Allocator,
|
||||||
symbol: []const u8,
|
symbol: []const u8,
|
||||||
|
name: ?[]const u8,
|
||||||
source: QuoteHeaderSource,
|
source: QuoteHeaderSource,
|
||||||
) ![]const u8 {
|
) ![]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) {
|
return switch (source) {
|
||||||
.live => |ago| std.fmt.allocPrint(arena, " {s} (live, ~15 min delay, refreshed {s})", .{ symbol, ago }),
|
.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} (as of close on {f})", .{ symbol, date }),
|
.close => |date| std.fmt.allocPrint(arena, " {s}{s} (as of close on {f})", .{ symbol, name_part, date }),
|
||||||
.none => std.fmt.allocPrint(arena, " {s}", .{symbol}),
|
.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 {
|
fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
||||||
const th = app.theme;
|
const th = app.theme;
|
||||||
var lines: std.ArrayList(StyledLine) = .empty;
|
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;
|
var ago_buf: [16]u8 = undefined;
|
||||||
|
const name = quoteTabName(app);
|
||||||
if (app.states.quote.live != null and app.states.quote.timestamp > 0) {
|
if (app.states.quote.live != null and app.states.quote.timestamp > 0) {
|
||||||
// wall-clock required: per-frame "now" for the data-age readout.
|
// wall-clock required: per-frame "now" for the data-age readout.
|
||||||
const now_s = std.Io.Timestamp.now(app.io, .real).toSeconds();
|
const now_s = std.Io.Timestamp.now(app.io, .real).toSeconds();
|
||||||
const ago_str = fmt.fmtTimeAgo(&ago_buf, app.states.quote.timestamp, now_s);
|
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| {
|
} 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 {
|
} 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() });
|
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);
|
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||||||
defer arena_state.deinit();
|
defer arena_state.deinit();
|
||||||
const arena = arena_state.allocator();
|
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);
|
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);
|
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||||||
defer arena_state.deinit();
|
defer arena_state.deinit();
|
||||||
const arena = arena_state.allocator();
|
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);
|
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);
|
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||||||
defer arena_state.deinit();
|
defer arena_state.deinit();
|
||||||
const arena = arena_state.allocator();
|
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);
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue