From b2128bfcddac02ed8ab164cdd04e682227d3192f Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Mon, 29 Jun 2026 19:29:24 -0700 Subject: [PATCH] live quote support (yahoo only) quote tab in tui --- src/tui/quote_tab.zig | 180 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 173 insertions(+), 7 deletions(-) diff --git a/src/tui/quote_tab.zig b/src/tui/quote_tab.zig index 4bbbc93..2056473 100644 --- a/src/tui/quote_tab.zig +++ b/src/tui/quote_tab.zig @@ -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 }), + ); +}