live quote support (yahoo only) quote tab in tui

This commit is contained in:
Emil Lerch 2026-06-29 19:29:24 -07:00
parent 1bca0d596d
commit b2128bfcdd
Signed by: lobo
GPG key ID: A7B62D657EF764F8

View file

@ -86,6 +86,7 @@ pub const ChartState = struct {
pub const Action = enum {
chart_timeframe_next,
chart_timeframe_prev,
toggle_live,
};
// Tab-private state
@ -104,6 +105,16 @@ pub const State = struct {
/// 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
@ -113,13 +124,16 @@ pub const meta: framework.TabMeta(Action) = .{
.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,
},
};
@ -137,6 +151,17 @@ fn shouldFetchLiveQuote(session: market.MarketSession, has_quote: bool, quote_ag
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
@ -165,6 +190,7 @@ pub const tab = struct {
}
pub fn deinit(state: *State, app: *App) void {
if (state.streaming) app.svc.stopLiveStream();
state.chart.freeCache(app.allocator);
state.* = .{};
}
@ -197,7 +223,21 @@ pub const tab = struct {
refreshLiveQuote(state, app, false);
}
pub const deactivate = framework.noopDeactivate(State);
/// 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,
@ -211,7 +251,19 @@ pub const tab = struct {
refreshLiveQuote(state, app, true);
}
pub const tick = framework.noopTick(State);
/// 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) {
@ -225,6 +277,18 @@ pub const tab = struct {
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");
}
},
}
}
@ -281,9 +345,41 @@ pub const tab = struct {
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,
@ -329,9 +425,13 @@ fn drawWithKittyChart(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, wi
// 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 cur_price: ?f64 = if (app.states.quote.live) |q| q.close else if (c.len > 0) c[c.len - 1].close else null;
const sp = app.states.quote.stream_price;
const cur_price: ?f64 = headlinePrice(sp, app.states.quote.live, c);
if (app.states.quote.live) |q| {
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) {
@ -623,7 +723,7 @@ fn drawWithKittyChart(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, wi
const latest = c[c.len - 1];
const quote_data = app.states.quote.live;
const price = if (quote_data) |q| q.close else latest.close;
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
@ -651,6 +751,10 @@ pub const QuoteHeaderSource = union(enum) {
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
@ -672,6 +776,7 @@ pub fn formatQuoteHeader(
.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 }),
};
}
@ -702,7 +807,9 @@ 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) {
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);
@ -718,6 +825,13 @@ fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
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() });
@ -740,7 +854,7 @@ fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
}
// Use real-time quote price if available, otherwise latest candle
const price = if (quote_data) |q| q.close else c[c.len - 1].close;
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];
@ -1036,3 +1150,55 @@ test "shouldFetchLiveQuote: outside regular hours a held quote is never refetche
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 }),
);
}