fetch live on quote tab
This commit is contained in:
parent
fa4ec246c2
commit
bfabd66866
2 changed files with 86 additions and 47 deletions
32
src/tui.zig
32
src/tui.zig
|
|
@ -1348,40 +1348,16 @@ pub const App = struct {
|
|||
// state, and re-fetch via `loadData` (or whatever the
|
||||
// tab's loader is named). The framework dispatcher routes
|
||||
// to the active tab's reload; tabs that share data
|
||||
// (quote/performance) delegate via their reload bodies.
|
||||
// (quote/performance) delegate via their reload bodies. The
|
||||
// Quote tab's reload also force-refreshes its live quote (the
|
||||
// "refreshed Xs ago" ticker), so there is no tab-specific quote
|
||||
// fetch here - the dispatch stays uniform across tabs.
|
||||
//
|
||||
// Reload is contractually self-completing: when it
|
||||
// returns, the tab's state is fully refreshed. There's no
|
||||
// need for a follow-up `loadTabData` - `activate` would
|
||||
// see `state.loaded = true` and no-op.
|
||||
self.dispatchTry("reload", .{});
|
||||
|
||||
// Live-quote re-fetch: the quote tab's freshness display
|
||||
// ("refreshed Xs ago") is driven by `states.quote.timestamp`,
|
||||
// which is independent of the candles cache. The user's
|
||||
// mental model for `r` includes "and the price ticker is
|
||||
// current as of NOW," so we hit the live-quote endpoint
|
||||
// here. Only on tabs that show a live quote.
|
||||
//
|
||||
// This is the one place where `r` reaches past the cache;
|
||||
// tab-switches that activate quote/performance don't trigger
|
||||
// it (they should keep using whatever cached price the user
|
||||
// last fetched). When live-streaming quotes ship someday,
|
||||
// this block goes away.
|
||||
switch (self.active_tab) {
|
||||
.quote, .performance => {
|
||||
if (self.symbol.len > 0) {
|
||||
if (self.svc.getQuote(self.symbol, .{})) |q| {
|
||||
self.states.quote.live = q;
|
||||
// wall-clock required: records the exact moment
|
||||
// this quote was served so the "refreshed Xs ago"
|
||||
// display is honest about freshness.
|
||||
self.states.quote.timestamp = std.Io.Timestamp.now(self.io, .real).toSeconds();
|
||||
} else |_| {}
|
||||
}
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
|
||||
/// Activate the current tab - load any data it needs, set
|
||||
|
|
|
|||
|
|
@ -7,11 +7,18 @@ 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
|
||||
|
|
@ -84,8 +91,10 @@ pub const Action = enum {
|
|||
// ── Tab-private state ─────────────────────────────────────────
|
||||
|
||||
pub const State = struct {
|
||||
/// Stored real-time quote (only fetched on manual refresh; not
|
||||
/// auto-refetched on every redraw).
|
||||
/// 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.
|
||||
|
|
@ -114,6 +123,38 @@ pub const meta: framework.TabMeta(Action) = .{
|
|||
},
|
||||
};
|
||||
|
||||
/// 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;
|
||||
}
|
||||
|
||||
/// 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, .{})) |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;
|
||||
|
|
@ -128,35 +169,35 @@ pub const tab = struct {
|
|||
state.* = .{};
|
||||
}
|
||||
|
||||
/// Quote loads its own data on activation (the live-quote
|
||||
/// fetch path lives in tui.zig after the tab switches because
|
||||
/// it depends on App.svc); no-op here. Chart redraws are
|
||||
/// triggered by the dirty flag on `state.chart`.
|
||||
/// Quote and performance share `app.symbol_data` (candles +
|
||||
/// dividends). Performance owns the loader; quote piggybacks
|
||||
/// by delegating its activate to performance's. This keeps
|
||||
/// `loadTabData`'s dispatch uniform - every tab activates its
|
||||
/// own state - while preserving the historical "switching to
|
||||
/// quote populates shared candle data" behavior.
|
||||
/// 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 {
|
||||
_ = state;
|
||||
const perf_module = @import("performance_tab.zig");
|
||||
try perf_module.tab.activate(&app.states.performance, app);
|
||||
refreshLiveQuote(state, app, false);
|
||||
}
|
||||
|
||||
pub const deactivate = framework.noopDeactivate(State);
|
||||
|
||||
/// Refresh: delegate to performance.reload, which owns the
|
||||
/// shared candle/dividend data and svc invalidation. Quote's
|
||||
/// chart-state (dirty + freeCache) is also reset by
|
||||
/// performance.reload - see the comment there for why.
|
||||
/// Quote-only state (live quote + timestamp) is reset here
|
||||
/// because performance doesn't know about it.
|
||||
/// 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);
|
||||
}
|
||||
|
||||
pub const tick = framework.noopTick(State);
|
||||
|
|
@ -961,3 +1002,25 @@ test "formatQuoteHeader: empty name is omitted" {
|
|||
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));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue