zfin/src/tui/quote_tab.zig
Emil Lerch 44f940c882
All checks were successful
Generic zig build / build (push) Successful in 5m18s
Generic zig build / publish-macos (push) Successful in 12s
Generic zig build / deploy (push) Successful in 20s
pass fetch options through TUI
2026-06-30 11:43:40 -07:00

1204 lines
54 KiB
Zig

const std = @import("std");
const vaxis = @import("vaxis");
const zfin = @import("../root.zig");
const fmt = @import("../format.zig");
const Money = @import("../Money.zig");
const theme = @import("theme.zig");
const chart = @import("../charts/chart.zig");
const tui = @import("../tui.zig");
const framework = @import("tab_framework.zig");
const market = @import("../market.zig");
const App = tui.App;
const StyledLine = tui.StyledLine;
const glyph = tui.glyph;
/// Re-fetch the Quote tab's live quote on re-activation once the held
/// quote is older than this many seconds - but only while the market is
/// open (outside regular hours the price is frozen). See
/// `shouldFetchLiveQuote`.
const quote_live_stale_s: i64 = 60;
/// Per-symbol chart state for the quote tab. Tracks the active
/// timeframe, transmitted Kitty image (when supported), cached
/// indicator overlays (SMA/Bollinger/etc), and last-rendered
/// data fingerprints used to decide when to re-render.
pub const ChartState = struct {
timeframe: chart.Timeframe = .@"1Y",
image_id: ?u32 = null, // currently transmitted Kitty image ID
image_width: u16 = 0, // image width in cells
image_height: u16 = 0, // image height in cells
// SAFETY: paired with `symbol_len`; only `symbol[0..symbol_len]` is read.
symbol: [16]u8 = undefined, // symbol the chart was rendered for
symbol_len: usize = 0,
timeframe_rendered: ?chart.Timeframe = null, // timeframe the chart was rendered for
timeframe_row: ?usize = null, // screen row of the timeframe selector (for mouse clicks)
dirty: bool = true, // needs re-render
price_min: f64 = 0,
price_max: f64 = 0,
rsi_latest: ?f64 = null,
// Cached indicator data (persists across frames to avoid recomputation)
cached_indicators: ?chart.CachedIndicators = null,
cache_candle_count: usize = 0, // candle count when cache was computed
cache_timeframe: ?chart.Timeframe = null, // timeframe when cache was computed
cache_last_close: f64 = 0, // last candle's close when cache was computed
/// Free cached indicator memory.
pub fn freeCache(self: *ChartState, alloc: std.mem.Allocator) void {
if (self.cached_indicators) |*cache| {
cache.deinit(alloc);
self.cached_indicators = null;
}
self.cache_candle_count = 0;
self.cache_timeframe = null;
self.cache_last_close = 0;
}
/// Check if cache is valid for the given candle data and timeframe.
pub fn isCacheValid(self: *const ChartState, candles: []const zfin.Candle, timeframe: chart.Timeframe) bool {
if (self.cached_indicators == null) return false;
if (self.cache_timeframe == null or self.cache_timeframe.? != timeframe) return false;
// Slice candles to timeframe (same logic as renderChart)
const max_days = timeframe.tradingDays();
const n = @min(candles.len, max_days);
const data = candles[candles.len - n ..];
if (data.len != self.cache_candle_count) return false;
if (data.len == 0) return false;
// Check if last close changed (detects data refresh)
const last_close = data[data.len - 1].close;
if (@abs(last_close - self.cache_last_close) > 0.0001) return false;
return true;
}
};
// ── Tab-local action enum ─────────────────────────────────────
//
// Quote tab cycles the chart timeframe with `[` and `]` (chart-
// next / chart-prev). These bindings only fire on the quote tab
// today; the surrounding `if (active_tab == .quote)` gate will
// disappear when scoped keymaps land in step 3.
pub const Action = enum {
chart_timeframe_next,
chart_timeframe_prev,
toggle_live,
};
// ── Tab-private state ─────────────────────────────────────────
pub const State = struct {
/// Stored real-time quote. Fetched on tab activation (staleness-
/// gated; see `refreshLiveQuote`) and force-refreshed on r/F5. Null
/// until the first successful fetch, in which case the headline
/// price falls back to the last candle close.
live: ?zfin.Quote = null,
/// Unix-epoch seconds for the live-quote fetch - drives the
/// "data Xs ago" header readout.
timestamp: i64 = 0,
/// Pixel-chart state (Kitty graphics + Bollinger bands +
/// indicator cache + timeframe selection). Lives here because
/// only the quote tab uses it; perf renders its own braille
/// chart from `app.symbol_data.candles` directly.
chart: ChartState = .{},
/// Opt-in live websocket streaming (default off). Toggled with the
/// `toggle_live` action. While true the App polls `tick`, which
/// pulls the latest streamed price into `stream_price`. The stream
/// itself is owned by `DataService` and torn down on `deactivate` /
/// symbol change / `deinit`.
streaming: bool = false,
/// Latest streamed price for the active symbol, or null until the
/// first tick (or when not streaming). When set, it headlines the
/// quote as the most-current mark.
stream_price: ?f64 = null,
};
// ── Tab framework contract ────────────────────────────────────
pub const meta: framework.TabMeta(Action) = .{
.label = "Quote",
.default_bindings = &.{
.{ .action = .chart_timeframe_next, .key = .{ .codepoint = ']' } },
.{ .action = .chart_timeframe_prev, .key = .{ .codepoint = '[' } },
.{ .action = .toggle_live, .key = .{ .codepoint = 'L' } },
},
.action_labels = std.enums.EnumArray(Action, []const u8).init(.{
.chart_timeframe_next = "Chart: next timeframe",
.chart_timeframe_prev = "Chart: previous timeframe",
.toggle_live = "Toggle live stream",
}),
.status_hints = &.{
.chart_timeframe_next,
.toggle_live,
},
};
/// Whether the Quote tab should (re)fetch the live quote on activation.
/// Pure so the policy is unit-testable without a DataService.
///
/// - No quote held yet (cold open / new symbol): always fetch.
/// - Market open: refetch once the held quote is older than the
/// staleness window, so re-entering the tab stays current like the
/// CLI `quote` command; rapid toggling within the window reuses it.
/// - Otherwise (pre/after-hours, weekend, holiday): the last price is
/// frozen, so the single cold-open fetch suffices - don't refetch.
fn shouldFetchLiveQuote(session: market.MarketSession, has_quote: bool, quote_age_s: i64) bool {
if (!has_quote) return true;
return session == .open and quote_age_s > quote_live_stale_s;
}
/// The price to headline for the quote tab, in precedence order: a live
/// streamed tick (the most-current mark), then the REST quote's close,
/// then the latest candle close. Null only when none are available.
/// Pure so the policy is unit-testable.
fn headlinePrice(stream_price: ?f64, quote: ?zfin.Quote, candles: []const zfin.Candle) ?f64 {
if (stream_price) |p| return p;
if (quote) |q| return q.close;
if (candles.len > 0) return candles[candles.len - 1].close;
return null;
}
/// Fetch the live quote into `state` when warranted. `force` (r/F5)
/// always fetches; otherwise `shouldFetchLiveQuote` gates it. This is
/// the same `DataService.getQuote` the CLI `quote` command uses. On
/// failure the prior value is left untouched (the tab falls back to the
/// last candle close) and the error is debug-logged - a missing live
/// quote must never break tab activation.
fn refreshLiveQuote(state: *State, app: *App, force: bool) void {
if (app.symbol.len == 0) return;
// wall-clock required: drives the staleness gate and the
// "refreshed Xs ago" header timestamp.
const now_s = std.Io.Timestamp.now(app.io, .real).toSeconds();
if (!force and !shouldFetchLiveQuote(market.marketSession(now_s), state.live != null, now_s - state.timestamp)) return;
if (app.svc.getQuote(app.symbol, app.fetch_options)) |q| {
state.live = q;
state.timestamp = now_s;
} else |err| std.log.scoped(.quote_tab).debug("{s}: live-quote fetch failed: {t}", .{ app.symbol, err });
}
pub const tab = struct {
pub const ActionT = Action;
pub const StateT = State;
pub fn init(state: *State, app: *App) !void {
_ = app;
state.* = .{};
}
pub fn deinit(state: *State, app: *App) void {
if (state.streaming) app.svc.stopLiveStream();
state.chart.freeCache(app.allocator);
state.* = .{};
}
/// Release the transmitted Kitty chart image before vaxis is torn
/// down. The App calls this across all tabs at teardown while
/// `app.vx_app` is still valid - distinct from `deinit`, which runs
/// after vaxis is already gone.
pub fn releaseGraphics(state: *State, app: *App) void {
if (state.chart.image_id) |id| {
if (app.vx_app) |va| va.vx.freeImage(va.tty.writer(), id);
state.chart.image_id = null;
}
}
/// On activation the Quote tab loads its shared candle data (by
/// delegating to performance, which owns it) and fetches the live
/// quote that drives the headline price/change - the same
/// `DataService.getQuote` the CLI `quote` command uses. EOD candles
/// can't represent the *current* price intraday (today's bar isn't
/// available until after the close), so without the live fetch the
/// tab would show yesterday's close during market hours. The fetch
/// is staleness-gated (see `refreshLiveQuote` / `shouldFetchLiveQuote`)
/// so re-entering the tab refetches when stale while rapid toggling
/// reuses the held quote. This also covers `zfin i <symbol>` startup,
/// which routes through `App.loadTabData` -> activate.
pub fn activate(state: *State, app: *App) !void {
const perf_module = @import("performance_tab.zig");
try perf_module.tab.activate(&app.states.performance, app);
refreshLiveQuote(state, app, false);
}
/// Leaving the tab stops any live stream so it doesn't keep a
/// socket + thread alive in the background (and frees the single
/// shared `DataService` stream for another tab to use). Re-enabled
/// by toggling again on return.
pub fn deactivate(state: *State, app: *App) void {
stopStream(state, app);
}
/// Framework poll-tick gate: true only while streaming, so the App
/// keeps a 100ms tick armed to pull fresh prices; switching away
/// (which stops the stream) lets the poll timer wind down.
pub fn wantsPollTick(state: *State, app: *App) bool {
_ = app;
return state.streaming;
}
/// Refresh (r/F5): reset the quote-only state, delegate to
/// performance.reload (which owns the shared candle/dividend data,
/// svc invalidation, and resetting quote's chart cache), then force
/// a fresh live quote so the headline price is current as of NOW.
pub fn reload(state: *State, app: *App) !void {
state.live = null;
state.timestamp = 0;
const perf_module = @import("performance_tab.zig");
try perf_module.tab.reload(&app.states.performance, app);
refreshLiveQuote(state, app, true);
}
/// Poll hook: while streaming, copy the latest streamed price for
/// the active symbol into `state.stream_price`. Runs ~10x/sec (only
/// while `wantsPollTick` is true), so the headline price tracks the
/// live feed. A symbol with no tick yet leaves `stream_price` as-is.
pub fn tick(state: *State, app: *App, frame: u64) void {
_ = frame;
if (!state.streaming or app.symbol.len == 0) return;
var prices = std.StringHashMap(f64).init(app.allocator);
defer prices.deinit();
const syms = [_][]const u8{app.symbol};
app.svc.liveStreamSnapshot(&syms, &prices);
if (prices.get(app.symbol)) |p| state.stream_price = p;
}
pub fn handleAction(state: *State, app: *App, action: Action) void {
switch (action) {
.chart_timeframe_next => {
state.chart.timeframe = state.chart.timeframe.next();
state.chart.dirty = true;
app.setStatus(state.chart.timeframe.label());
},
.chart_timeframe_prev => {
state.chart.timeframe = state.chart.timeframe.prev();
state.chart.dirty = true;
app.setStatus(state.chart.timeframe.label());
},
.toggle_live => {
if (state.streaming) {
stopStream(state, app);
app.setStatus("Live stream: off");
} else if (app.symbol.len == 0) {
app.setStatus("No symbol to stream");
} else if (startStream(state, app)) {
app.setStatus("Live stream: on (Yahoo)");
} else {
app.setStatus("Live stream: failed to start");
}
},
}
}
/// Mouse handling: clicks on the timeframe selector row switch
/// the chart timeframe. Returns `true` if the click was on a
/// timeframe label (consumed); `false` otherwise (unhandled).
/// The caller (App's mouse dispatcher) handles wheel scroll,
/// tab-bar clicks, and other global mouse semantics before
/// routing here.
pub fn handleMouse(state: *State, app: *App, mouse: vaxis.Mouse) bool {
if (mouse.button != .left) return false;
if (mouse.type != .press) return false;
const tf_row = state.chart.timeframe_row orelse return false;
const content_row = @as(usize, @intCast(mouse.row)) + app.scroll_offset;
if (content_row != tf_row) return false;
// Layout: " Chart: [3M] 6M YTD 1Y 3Y 5Y ([ ] to change)"
// Prefix " Chart: " is 9 chars. Each timeframe label takes
// `label_len + 2` (brackets/spaces around the label) + 1 (gap).
const col: usize = @intCast(mouse.col);
const prefix_len: usize = 9;
if (col < prefix_len) return false;
const timeframes = [_]chart.Timeframe{ .@"3M", .@"6M", .ytd, .@"1Y", .@"3Y", .@"5Y" };
var x: usize = prefix_len;
for (timeframes) |tf| {
const lbl_len = tf.label().len;
const slot_width = lbl_len + 2 + 1;
if (col >= x and col < x + slot_width) {
if (tf != state.chart.timeframe) {
state.chart.timeframe = tf;
app.setStatus(tf.label());
}
return true;
}
x += slot_width;
}
return false;
}
pub fn isDisabled(app: *App) bool {
_ = app;
return false;
}
/// Symbol-change reset. Drops the live quote, resets the
/// fetch timestamp, marks the chart dirty so the next draw
/// re-renders for the new symbol, and frees the indicator
/// cache (Bollinger bands etc. are computed per-symbol).
/// The candle data lives on `app.symbol_data` and is dropped
/// centrally by the App.
pub fn onSymbolChange(state: *State, app: *App) void {
state.live = null;
state.timestamp = 0;
state.chart.dirty = true;
state.chart.freeCache(app.allocator);
// Re-point an active stream at the new symbol (stop + start).
if (state.streaming) {
stopStream(state, app);
if (app.symbol.len > 0) _ = startStream(state, app);
}
}
};
// ── Live-stream helpers ───────────────────────────────────────
/// Start the live stream for the active symbol. Returns true on
/// success. On failure the error is debug-logged and `streaming` stays
/// false so the tab silently falls back to the REST/close price.
fn startStream(state: *State, app: *App) bool {
const syms = [_][]const u8{app.symbol};
app.svc.startLiveStream(&syms) catch |err| {
std.log.scoped(.quote_tab).debug("{s}: live stream start failed: {t}", .{ app.symbol, err });
state.streaming = false;
state.stream_price = null;
return false;
};
state.streaming = true;
state.stream_price = null;
return true;
}
/// Stop the live stream if running and clear the streamed price.
/// Idempotent.
fn stopStream(state: *State, app: *App) void {
if (!state.streaming) return;
app.svc.stopLiveStream();
state.streaming = false;
state.stream_price = null;
}
// ── Rendering ─────────────────────────────────────────────────
/// Draw the quote tab content. Uses Kitty graphics for the chart when available,
/// falling back to braille sparkline otherwise.
pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
_ = state;
// Determine whether to use Kitty graphics
const use_kitty = switch (app.chart_config.mode) {
.braille => false,
.kitty => true,
.auto => if (app.vx_app) |va| va.vx.caps.kitty_graphics else false,
};
if (use_kitty and app.symbol_data.candles != null and app.symbol_data.candles.?.len >= 40) {
drawWithKittyChart(app, arena, buf, width, height) catch {
// On any failure, fall back to braille
try app.drawStyledContent(arena, buf, width, height, try buildStyledLines(app, arena));
};
} else {
// Fallback to styled lines with braille chart
try app.drawStyledContent(arena, buf, width, height, try buildStyledLines(app, arena));
}
}
/// Draw quote tab using Kitty graphics protocol for the chart.
fn drawWithKittyChart(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
const th = app.theme;
const c = app.symbol_data.candles orelse return;
// Build text header (symbol, price, change) - first few lines
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. The headline "Change" reflects the
// selected chart period (the chart's left edge -> now); the less
// relevant 1-day change lives in the detail section below the chart.
const cur_tf = app.states.quote.chart.timeframe;
const sp = app.states.quote.stream_price;
const cur_price: ?f64 = headlinePrice(sp, app.states.quote.live, c);
if (sp) |p| {
const price_str = try std.fmt.allocPrint(arena, " {s} ${d:.2} (live)", .{ sym_label, p });
try lines.append(arena, .{ .text = price_str, .style = th.headerStyle() });
} else if (app.states.quote.live) |q| {
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() });
} else if (c.len > 0) {
const last = c[c.len - 1];
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 (cur_price) |price| {
if (fmt.windowChange(c, cur_tf.tradingDays(), price)) |wc| {
var chg_buf: [64]u8 = undefined;
var lbl_buf: [24]u8 = undefined;
const lbl = std.fmt.bufPrint(&lbl_buf, "Change ({s}):", .{cur_tf.label()}) catch "Change:";
const change_style = if (wc.change >= 0) th.positiveStyle() else th.negativeStyle();
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s} {s}", .{ lbl, fmt.fmtPriceChange(&chg_buf, wc.change, wc.pct) }), .style = change_style });
}
}
// Timeframe selector line
{
var tf_buf: [80]u8 = undefined;
var tf_pos: usize = 0;
const prefix = " Chart: ";
@memcpy(tf_buf[tf_pos..][0..prefix.len], prefix);
tf_pos += prefix.len;
const timeframes = [_]chart.Timeframe{ .@"3M", .@"6M", .ytd, .@"1Y", .@"3Y", .@"5Y" };
for (timeframes) |tf| {
const lbl = tf.label();
if (tf == app.states.quote.chart.timeframe) {
tf_buf[tf_pos] = '[';
tf_pos += 1;
@memcpy(tf_buf[tf_pos..][0..lbl.len], lbl);
tf_pos += lbl.len;
tf_buf[tf_pos] = ']';
tf_pos += 1;
} else {
tf_buf[tf_pos] = ' ';
tf_pos += 1;
@memcpy(tf_buf[tf_pos..][0..lbl.len], lbl);
tf_pos += lbl.len;
tf_buf[tf_pos] = ' ';
tf_pos += 1;
}
tf_buf[tf_pos] = ' ';
tf_pos += 1;
}
const hint = " ([ ] to change)";
@memcpy(tf_buf[tf_pos..][0..hint.len], hint);
tf_pos += hint.len;
app.states.quote.chart.timeframe_row = lines.items.len; // track which row the timeframe line is on
try lines.append(arena, .{ .text = try arena.dupe(u8, tf_buf[0..tf_pos]), .style = th.mutedStyle() });
}
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
// Draw the text header
const header_lines = try lines.toOwnedSlice(arena);
try app.drawStyledContent(arena, buf, width, height, header_lines);
// Calculate chart area (below the header, leaving room for details below)
const header_rows: u16 = @intCast(@min(header_lines.len, height));
const detail_rows: u16 = 10; // reserve rows for quote details below chart
const chart_rows = height -| header_rows -| detail_rows;
if (chart_rows < 8) return; // not enough space
// Compute pixel dimensions from cell size
// cell_size may be 0 if terminal hasn't reported pixel dimensions yet
const cell_size = app.cellPixelSize();
const cell_w: u32 = cell_size.width;
const cell_h: u32 = cell_size.height;
const label_cols: u16 = 10; // columns reserved for axis labels on the right
const chart_cols = width -| 2 -| label_cols; // 1 col left margin + label area on right
if (chart_cols == 0) return;
const px_w: u32 = @as(u32, chart_cols) * cell_w;
const px_h: u32 = @as(u32, chart_rows) * cell_h;
if (px_w < 100 or px_h < 100) return;
// Apply resolution cap from chart config
const capped_w = @min(px_w, app.chart_config.max_width);
const capped_h = @min(px_h, app.chart_config.max_height);
// Check if we need to re-render the chart image
const symbol_changed = app.states.quote.chart.symbol_len != app.symbol.len or
!std.mem.eql(u8, app.states.quote.chart.symbol[0..app.states.quote.chart.symbol_len], app.symbol);
const tf_changed = app.states.quote.chart.timeframe_rendered == null or app.states.quote.chart.timeframe_rendered.? != app.states.quote.chart.timeframe;
if (app.states.quote.chart.dirty or symbol_changed or tf_changed) {
// Free old image
if (app.states.quote.chart.image_id) |old_id| {
if (app.vx_app) |va| {
va.vx.freeImage(va.tty.writer(), old_id);
}
app.states.quote.chart.image_id = null;
}
// If symbol changed, invalidate the indicator cache
if (symbol_changed) {
app.states.quote.chart.freeCache(app.allocator);
}
// Check if we can reuse cached indicators
const cache_valid = app.states.quote.chart.isCacheValid(c, app.states.quote.chart.timeframe);
// If cache is invalid, compute new indicators
if (!cache_valid) {
// Free old cache if it exists
app.states.quote.chart.freeCache(app.allocator);
// Compute and cache new indicators. Use the warmup variant so
// the Bollinger bands / RSI are computed over an extra lookback
// beyond the timeframe window and stay valid from the first
// *visible* candle - no warm-up gap at the chart's left edge.
const new_cache = chart.computeIndicatorsWarmup(
app.allocator,
c,
app.states.quote.chart.timeframe.tradingDays(),
20,
) catch |err| {
app.states.quote.chart.dirty = false;
var err_buf: [128]u8 = undefined;
const msg = std.fmt.bufPrint(&err_buf, "Indicator computation failed: {s}", .{@errorName(err)}) catch "Indicator computation failed";
app.setStatus(msg);
return;
};
app.states.quote.chart.cached_indicators = new_cache;
// Update cache metadata
const max_days = app.states.quote.chart.timeframe.tradingDays();
const n = @min(c.len, max_days);
const data = c[c.len - n ..];
app.states.quote.chart.cache_candle_count = data.len;
app.states.quote.chart.cache_timeframe = app.states.quote.chart.timeframe;
app.states.quote.chart.cache_last_close = if (data.len > 0) data[data.len - 1].close else 0;
}
// Render and transmit - use the app's main allocator, NOT the arena,
// because z2d allocates large pixel buffers that would bloat the arena.
if (app.vx_app) |va| {
// Pass cached indicators to avoid recomputation during rendering
const cached_ptr: ?*const chart.CachedIndicators = if (app.states.quote.chart.cached_indicators) |*ci| ci else null;
const chart_result = chart.renderChart(
app.io,
app.allocator,
c,
app.states.quote.chart.timeframe,
capped_w,
capped_h,
th,
cached_ptr,
) catch |err| {
app.states.quote.chart.dirty = false;
var err_buf: [128]u8 = undefined;
const msg = std.fmt.bufPrint(&err_buf, "Chart render failed: {s}", .{@errorName(err)}) catch "Chart render failed";
app.setStatus(msg);
return;
};
defer app.allocator.free(chart_result.rgb_data);
// Base64-encode and transmit raw RGB data directly via Kitty protocol.
// This avoids the PNG encode -> file write -> file read -> PNG decode roundtrip.
const base64_enc = std.base64.standard.Encoder;
const b64_buf = app.allocator.alloc(u8, base64_enc.calcSize(chart_result.rgb_data.len)) catch {
app.states.quote.chart.dirty = false;
app.setStatus("Chart: base64 alloc failed");
return;
};
defer app.allocator.free(b64_buf);
const encoded = base64_enc.encode(b64_buf, chart_result.rgb_data);
const img = va.vx.transmitPreEncodedImage(
va.tty.writer(),
encoded,
chart_result.width,
chart_result.height,
.rgb,
) catch |err| {
app.states.quote.chart.dirty = false;
var err_buf: [128]u8 = undefined;
const msg = std.fmt.bufPrint(&err_buf, "Image transmit failed: {s}", .{@errorName(err)}) catch "Image transmit failed";
app.setStatus(msg);
return;
};
app.states.quote.chart.image_id = img.id;
app.states.quote.chart.image_width = @intCast(chart_cols);
app.states.quote.chart.image_height = chart_rows;
// Track what we rendered
const sym_len = @min(app.symbol.len, 16);
@memcpy(app.states.quote.chart.symbol[0..sym_len], app.symbol[0..sym_len]);
app.states.quote.chart.symbol_len = sym_len;
app.states.quote.chart.timeframe_rendered = app.states.quote.chart.timeframe;
app.states.quote.chart.price_min = chart_result.price_min;
app.states.quote.chart.price_max = chart_result.price_max;
app.states.quote.chart.rsi_latest = chart_result.rsi_latest;
app.states.quote.chart.dirty = false;
}
}
// Place the image in the cell buffer
if (app.states.quote.chart.image_id) |img_id| {
// Place image at the first cell of the chart area
const chart_row_start: usize = header_rows;
const chart_col_start: usize = 1; // 1 col left margin
const buf_idx = chart_row_start * @as(usize, width) + chart_col_start;
if (buf_idx < buf.len) {
buf[buf_idx] = .{
.char = .{ .grapheme = " " },
.style = th.contentStyle(),
.image = .{
.img_id = img_id,
.options = .{
.size = .{
.rows = app.states.quote.chart.image_height,
.cols = app.states.quote.chart.image_width,
},
.scale = .contain,
},
},
};
}
// ── Axis labels (terminal text in the right margin) ───────────
// The chart image uses layout fractions: price=72%, gap=8%, RSI=20%
// Map these to terminal rows to position labels.
const img_rows = app.states.quote.chart.image_height;
const label_col: usize = @as(usize, chart_col_start) + @as(usize, app.states.quote.chart.image_width) + 1;
const label_style = th.mutedStyle();
if (label_col + 8 <= width and img_rows >= 4 and app.states.quote.chart.price_max > app.states.quote.chart.price_min) {
// Price axis labels - evenly spaced across the price panel (top 72%)
const price_panel_rows = @as(f64, @floatFromInt(img_rows)) * 0.72;
const n_price_labels: usize = 5;
for (0..n_price_labels) |i| {
const frac = @as(f64, @floatFromInt(i)) / @as(f64, @floatFromInt(n_price_labels - 1));
const price_val = app.states.quote.chart.price_max - frac * (app.states.quote.chart.price_max - app.states.quote.chart.price_min);
const row_f = @as(f64, @floatFromInt(chart_row_start)) + frac * price_panel_rows;
const row: usize = @intFromFloat(@round(row_f));
if (row >= height) continue;
var lbl_buf: [16]u8 = undefined;
const lbl = std.fmt.bufPrint(&lbl_buf, "{f}", .{Money.from(price_val)}) catch "$?";
const start_idx = row * @as(usize, width) + label_col;
for (lbl, 0..) |ch, ci| {
const idx = start_idx + ci;
if (idx < buf.len and label_col + ci < width) {
buf[idx] = .{
.char = .{ .grapheme = glyph(ch) },
.style = label_style,
};
}
}
}
// RSI axis labels - positioned within the RSI panel (bottom 20%, after 80% offset)
const rsi_panel_start_f = @as(f64, @floatFromInt(img_rows)) * 0.80;
const rsi_panel_h = @as(f64, @floatFromInt(img_rows)) * 0.20;
const rsi_labels = [_]struct { val: f64, label: []const u8 }{
.{ .val = 70, .label = "70" },
.{ .val = 50, .label = "50" },
.{ .val = 30, .label = "30" },
};
for (rsi_labels) |rl| {
// RSI maps 0-100 top-to-bottom within the RSI panel
const rsi_frac = 1.0 - (rl.val / 100.0);
const row_f = @as(f64, @floatFromInt(chart_row_start)) + rsi_panel_start_f + rsi_frac * rsi_panel_h;
const row: usize = @intFromFloat(@round(row_f));
if (row >= height) continue;
const start_idx = row * @as(usize, width) + label_col;
for (rl.label, 0..) |ch, ci| {
const idx = start_idx + ci;
if (idx < buf.len and label_col + ci < width) {
buf[idx] = .{
.char = .{ .grapheme = glyph(ch) },
.style = label_style,
};
}
}
}
}
// Render quote details below the chart image as styled text
const detail_start_row = header_rows + app.states.quote.chart.image_height;
if (detail_start_row + 8 < height) {
var detail_lines: std.ArrayList(StyledLine) = .empty;
try detail_lines.append(arena, .{ .text = "", .style = th.contentStyle() });
const latest = c[c.len - 1];
const quote_data = app.states.quote.live;
const price = headlinePrice(app.states.quote.stream_price, quote_data, c) orelse latest.close;
const prev_close = if (quote_data) |q| q.previous_close else if (c.len >= 2) c[c.len - 2].close else @as(f64, 0);
// `cur_tf` (the selected timeframe) is computed once at the
// top of this function; the window change reuses it.
const period = fmt.windowChange(c, cur_tf.tradingDays(), price);
try buildDetailColumns(app, arena, &detail_lines, latest, quote_data, price, prev_close, period, cur_tf.label());
// Write detail lines into the buffer below the image
const detail_buf_start = detail_start_row * @as(usize, width);
const remaining_height = height - @as(u16, @intCast(detail_start_row));
const detail_slice = try detail_lines.toOwnedSlice(arena);
if (detail_buf_start < buf.len) {
try app.drawStyledContent(arena, buf[detail_buf_start..], width, remaining_height, detail_slice);
}
}
}
}
/// Source-of-data hint shown in the quote tab's header line.
/// Determines which sub-form of the header is rendered.
pub const QuoteHeaderSource = union(enum) {
/// Live quote with a "refreshed Xs ago" suffix.
live: []const u8,
/// Close-of-day data with a date.
close: zfin.Date,
/// No timing info - just the symbol.
none,
/// Real-time websocket stream (NOT the ~15-min-delayed REST quote).
/// Carries the latest streamed price so the headline shows a live,
/// changing number even for symbols with no cached candles.
streaming: f64,
};
/// Format the quote tab's header line. Pure function over
/// (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}{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 }),
.streaming => |price| std.fmt.allocPrint(arena, " {s}{s} ${d:.2} (live)", .{ symbol, name_part, price }),
};
}
/// 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 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 {
const th = app.theme;
var lines: std.ArrayList(StyledLine) = .empty;
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
if (app.symbol.len == 0) {
try lines.append(arena, .{ .text = " No symbol selected. Press / to enter a symbol.", .style = th.mutedStyle() });
return lines.toOwnedSlice(arena);
}
var ago_buf: [16]u8 = undefined;
const name = quoteTabName(app);
if (app.states.quote.streaming and app.states.quote.stream_price != null) {
try lines.append(arena, .{ .text = try formatQuoteHeader(arena, app.symbol, name, .{ .streaming = app.states.quote.stream_price.? }), .style = th.headerStyle() });
} else 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, name, .{ .live = ago_str }), .style = th.headerStyle() });
} else if (app.symbol_data.candleLastDate()) |d| {
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, name, .none), .style = th.headerStyle() });
}
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
// Use stored real-time quote if available (fetched on manual refresh)
const quote_data = app.states.quote.live;
const c = app.symbol_data.candles orelse {
// Streaming with no cached candle history (e.g. BTC-USD): the
// header already carries the live price, so just note the
// absence of chart/history rather than the misleading "No data".
if (app.states.quote.streaming and app.states.quote.stream_price != null) {
try lines.append(arena, .{ .text = " Live price above. No chart history cached for this symbol.", .style = th.mutedStyle() });
return lines.toOwnedSlice(arena);
}
if (quote_data) |q| {
// No candle data but have a quote - show it
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Price: {f}", .{Money.from(q.close)}), .style = th.contentStyle() });
if (q.previous_close > 0) {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Prev: ${d:.2}", .{q.previous_close}), .style = th.mutedStyle() });
}
{
var chg_buf: [64]u8 = undefined;
const change_style = if (q.change >= 0) th.positiveStyle() else th.negativeStyle();
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change (1D): {s}", .{fmt.fmtPriceChange(&chg_buf, q.change, q.percent_change)}), .style = change_style });
}
return lines.toOwnedSlice(arena);
}
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " No data. Run: zfin perf {s}", .{app.symbol}), .style = th.mutedStyle() });
return lines.toOwnedSlice(arena);
};
if (c.len == 0) {
try lines.append(arena, .{ .text = " No candle data.", .style = th.mutedStyle() });
return lines.toOwnedSlice(arena);
}
// Use real-time quote price if available, otherwise latest candle
const price = headlinePrice(app.states.quote.stream_price, quote_data, c) orelse c[c.len - 1].close;
const prev_close = if (quote_data) |q| q.previous_close else if (c.len >= 2) c[c.len - 2].close else @as(f64, 0);
const latest = c[c.len - 1];
// The braille fallback charts the last 60 candles; the window
// change measures against that same span.
const period = fmt.windowChange(c, 60, price);
try buildDetailColumns(app, arena, &lines, latest, quote_data, price, prev_close, period, "60D");
// Braille sparkline chart of recent 60 trading days
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
const chart_days: usize = @min(c.len, 60);
const chart_data = c[c.len - chart_days ..];
try tui.renderBrailleToStyledLines(arena, &lines, chart_data, th);
// Recent history table
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = " Recent History:", .style = th.headerStyle() });
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:>12} {s:>10} {s:>10} {s:>10} {s:>10} {s:>12}", .{ "Date", "Open", "High", "Low", "Close", "Volume" }), .style = th.mutedStyle() });
const start_idx = if (c.len > 20) c.len - 20 else 0;
for (c[start_idx..]) |candle| {
var row_buf: [128]u8 = undefined;
const day_change = if (candle.close >= candle.open) th.positiveStyle() else th.negativeStyle();
try lines.append(arena, .{ .text = try arena.dupe(u8, fmt.fmtCandleRow(&row_buf, candle)), .style = day_change });
}
return lines.toOwnedSlice(arena);
}
// ── Quote detail columns (price/OHLCV | ETF stats | sectors | holdings) ──
const Column = struct {
texts: std.ArrayList([]const u8),
styles: std.ArrayList(vaxis.Style),
width: usize, // fixed column width for padding
fn init() Column {
return .{
.texts = .empty,
.styles = .empty,
.width = 0,
};
}
fn add(app: *Column, arena: std.mem.Allocator, text: []const u8, style: vaxis.Style) !void {
try app.texts.append(arena, text);
try app.styles.append(arena, style);
}
fn len(app: *const Column) usize {
return app.texts.items.len;
}
};
fn buildDetailColumns(
app: *App,
arena: std.mem.Allocator,
lines: *std.ArrayList(StyledLine),
latest: zfin.Candle,
quote_data: ?zfin.Quote,
price: f64,
prev_close: f64,
period: ?fmt.PctChange,
period_label: []const u8,
) !void {
const th = app.theme;
var vol_buf: [32]u8 = undefined;
// Column 1: Price/OHLCV
var col1 = Column.init();
col1.width = 36;
try col1.add(arena, try std.fmt.allocPrint(arena, " Date: {f}", .{latest.date}), th.contentStyle());
try col1.add(arena, try std.fmt.allocPrint(arena, " Price: {f}", .{Money.from(price)}), th.contentStyle());
try col1.add(arena, try std.fmt.allocPrint(arena, " Open: ${d:.2}", .{if (quote_data) |q| q.open else latest.open}), th.mutedStyle());
try col1.add(arena, try std.fmt.allocPrint(arena, " High: ${d:.2}", .{if (quote_data) |q| q.high else latest.high}), th.mutedStyle());
try col1.add(arena, try std.fmt.allocPrint(arena, " Low: ${d:.2}", .{if (quote_data) |q| q.low else latest.low}), th.mutedStyle());
try col1.add(arena, try std.fmt.allocPrint(arena, " Volume: {s}", .{fmt.fmtIntCommas(&vol_buf, if (quote_data) |q| q.volume else latest.volume)}), th.mutedStyle());
if (prev_close > 0) {
try col1.add(arena, try std.fmt.allocPrint(arena, " Prev: ${d:.2}", .{prev_close}), th.mutedStyle());
}
if (fmt.pctChange(price, prev_close)) |dc| {
var chg_buf: [64]u8 = undefined;
const change_style = if (dc.change >= 0) th.positiveStyle() else th.negativeStyle();
try col1.add(arena, try std.fmt.allocPrint(arena, " {s:<14}{s}", .{ "Change (1D):", fmt.fmtPriceChange(&chg_buf, dc.change, dc.pct) }), change_style);
}
// Change over the displayed chart window (the chart's left edge ->
// now), labeled with the window span so it reads distinctly from the
// 1-day change above. Null when there's no meaningful window.
if (period) |pc| {
var wchg_buf: [64]u8 = undefined;
var lbl_buf: [24]u8 = undefined;
const lbl = std.fmt.bufPrint(&lbl_buf, "Change ({s}):", .{period_label}) catch "Change:";
const wstyle = if (pc.change >= 0) th.positiveStyle() else th.negativeStyle();
try col1.add(arena, try std.fmt.allocPrint(arena, " {s:<14}{s}", .{ lbl, fmt.fmtPriceChange(&wchg_buf, pc.change, pc.pct) }), wstyle);
}
// Columns 2-4: ETF profile (only for actual ETFs)
var col2 = Column.init(); // ETF stats
col2.width = 22;
var col3 = Column.init(); // Sectors
col3.width = 26;
var col4 = Column.init(); // Top holdings
col4.width = 30;
if (app.symbol_data.etf_profile) |profile| {
// Col 2: ETF key stats
try col2.add(arena, "ETF Profile", th.headerStyle());
if (profile.expense_ratio) |er| {
try col2.add(arena, try std.fmt.allocPrint(arena, " Expense: {d:.2}%", .{er * 100.0}), th.contentStyle());
}
if (profile.net_assets) |na| {
try col2.add(arena, try std.fmt.allocPrint(arena, " Assets: ${s}", .{std.mem.trimEnd(u8, &fmt.fmtLargeNum(na), &.{' '})}), th.contentStyle());
}
if (profile.dividend_yield) |dy| {
try col2.add(arena, try std.fmt.allocPrint(arena, " Yield: {d:.2}%", .{dy * 100.0}), th.contentStyle());
}
if (profile.total_holdings) |th_val| {
try col2.add(arena, try std.fmt.allocPrint(arena, " Holdings: {d}", .{th_val}), th.mutedStyle());
}
// Col 3: Sector allocation
if (profile.sectors) |sectors| {
if (sectors.len > 0) {
try col3.add(arena, "Sectors", th.headerStyle());
const show = @min(sectors.len, 7);
for (sectors[0..show]) |sec| {
var title_buf: [64]u8 = undefined;
const title_name = fmt.toTitleCase(&title_buf, sec.name);
const name = if (title_name.len > 20) title_name[0..20] else title_name;
try col3.add(arena, try std.fmt.allocPrint(arena, " {d:>5.1}% {s}", .{ sec.weight * 100.0, name }), th.contentStyle());
}
}
}
// Col 4: Top holdings
if (profile.holdings) |holdings| {
if (holdings.len > 0) {
try col4.add(arena, "Top Holdings", th.headerStyle());
const show = @min(holdings.len, 7);
for (holdings[0..show]) |h| {
const sym_str = h.symbol orelse "--";
try col4.add(arena, try std.fmt.allocPrint(arena, " {s:>6} {d:>5.1}%", .{ sym_str, h.weight * 100.0 }), th.contentStyle());
}
}
}
}
// Merge all columns into grapheme-based StyledLines
const gap: usize = 3;
const bg_style = vaxis.Style{ .fg = theme.Theme.vcolor(th.text), .bg = theme.Theme.vcolor(th.bg) };
const cols = [_]*const Column{ &col1, &col2, &col3, &col4 };
var max_rows: usize = 0;
for (cols) |col| max_rows = @max(max_rows, col.len());
// Total max width for allocation
const max_width = col1.width + gap + col2.width + gap + col3.width + gap + col4.width + 4;
for (0..max_rows) |ri| {
const graphemes = try arena.alloc([]const u8, max_width);
const col_styles = try arena.alloc(vaxis.Style, max_width);
var pos: usize = 0;
for (cols, 0..) |col, ci| {
if (ci > 0 and col.len() == 0) continue; // skip empty columns entirely
if (ci > 0) {
// Gap between columns
for (0..gap) |_| {
if (pos < max_width) {
graphemes[pos] = " ";
col_styles[pos] = bg_style;
pos += 1;
}
}
}
if (ri < col.len()) {
const text = col.texts.items[ri];
const style = col.styles.items[ri];
// Write text characters
for (0..@min(text.len, col.width)) |ci2| {
if (pos < max_width) {
graphemes[pos] = glyph(text[ci2]);
col_styles[pos] = style;
pos += 1;
}
}
// Pad to column width
if (text.len < col.width) {
for (0..col.width - text.len) |_| {
if (pos < max_width) {
graphemes[pos] = " ";
col_styles[pos] = bg_style;
pos += 1;
}
}
}
} else {
// Empty row in this column - pad full width
for (0..col.width) |_| {
if (pos < max_width) {
graphemes[pos] = " ";
col_styles[pos] = bg_style;
pos += 1;
}
}
}
}
try lines.append(arena, .{
.text = "",
.style = bg_style,
.graphemes = graphemes[0..pos],
.cell_styles = col_styles[0..pos],
});
}
}
// ── Tests ─────────────────────────────────────────────────────
const testing = std.testing;
const Date = zfin.Date;
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", null, .{ .live = "5s ago" });
try testing.expectEqualStrings(" AAPL (live, ~15 min delay, refreshed 5s ago)", text);
}
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", null, .{ .close = Date.fromYmd(2024, 3, 15) });
try testing.expectEqualStrings(" VTI (as of close on 2024-03-15)", text);
}
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", 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);
}
test "shouldFetchLiveQuote: with no held quote, always fetches" {
try testing.expect(shouldFetchLiveQuote(.open, false, 0));
try testing.expect(shouldFetchLiveQuote(.closed, false, 0));
try testing.expect(shouldFetchLiveQuote(.premarket, false, 99_999));
try testing.expect(shouldFetchLiveQuote(.afterhours, false, 99_999));
}
test "shouldFetchLiveQuote: market open refetches only once past the staleness window" {
try testing.expect(!shouldFetchLiveQuote(.open, true, 0));
// Boundary: equal-to-window is not yet "older than", so no refetch.
try testing.expect(!shouldFetchLiveQuote(.open, true, quote_live_stale_s));
try testing.expect(shouldFetchLiveQuote(.open, true, quote_live_stale_s + 1));
}
test "shouldFetchLiveQuote: outside regular hours a held quote is never refetched" {
// Price is frozen pre/after-hours and on weekends/holidays, so even a
// very stale held quote should not trigger a refetch.
try testing.expect(!shouldFetchLiveQuote(.premarket, true, 100_000));
try testing.expect(!shouldFetchLiveQuote(.afterhours, true, 100_000));
try testing.expect(!shouldFetchLiveQuote(.closed, true, 100_000));
}
/// A `Quote` with only `close` meaningful; other fields are zeroed.
/// Enough to exercise the price-precedence helpers.
fn testQuote(close: f64) zfin.Quote {
return .{
.symbol = "X",
.exchange = "",
.datetime = "",
.close = close,
.open = 0,
.high = 0,
.low = 0,
.volume = 0,
.previous_close = 0,
.change = 0,
.percent_change = 0,
.average_volume = 0,
.fifty_two_week_low = 0,
.fifty_two_week_high = 0,
};
}
test "headlinePrice: a streamed tick wins over the quote and candle close" {
const candles = [_]zfin.Candle{
.{ .date = .{ .days = 20000 }, .open = 0, .high = 0, .low = 0, .close = 50.0, .adj_close = 50.0, .volume = 0 },
};
try testing.expectEqual(@as(?f64, 282.5), headlinePrice(282.5, testQuote(100.0), &candles));
}
test "headlinePrice: falls back quote -> candle close -> null" {
const candles = [_]zfin.Candle{
.{ .date = .{ .days = 20000 }, .open = 0, .high = 0, .low = 0, .close = 50.0, .adj_close = 50.0, .volume = 0 },
};
// No stream -> REST quote close.
try testing.expectEqual(@as(?f64, 100.0), headlinePrice(null, testQuote(100.0), &candles));
// No stream, no quote -> latest candle close.
try testing.expectEqual(@as(?f64, 50.0), headlinePrice(null, null, &candles));
// Nothing available -> null.
const empty = [_]zfin.Candle{};
try testing.expect(headlinePrice(null, null, &empty) == null);
}
test "formatQuoteHeader: streaming source shows a real-time price and (live) tag" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
try testing.expectEqualStrings(" AAPL $282.01 (live)", try formatQuoteHeader(arena, "AAPL", null, .{ .streaming = 282.01 }));
try testing.expectEqualStrings(
" AAPL Apple Inc. $282.01 (live)",
try formatQuoteHeader(arena, "AAPL", "Apple Inc.", .{ .streaming = 282.01 }),
);
}