live quote support (yahoo only) quote tab in tui
This commit is contained in:
parent
1bca0d596d
commit
b2128bfcdd
1 changed files with 173 additions and 7 deletions
|
|
@ -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 }),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue