fetch live on quote tab

This commit is contained in:
Emil Lerch 2026-06-27 00:04:25 -07:00
parent fa4ec246c2
commit bfabd66866
Signed by: lobo
GPG key ID: A7B62D657EF764F8
2 changed files with 86 additions and 47 deletions

View file

@ -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

View file

@ -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));
}