diff --git a/src/tui.zig b/src/tui.zig index e78f359..eecb06f 100644 --- a/src/tui.zig +++ b/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 diff --git a/src/tui/quote_tab.zig b/src/tui/quote_tab.zig index 532cf31..0176155 100644 --- a/src/tui/quote_tab.zig +++ b/src/tui/quote_tab.zig @@ -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 ` 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)); +}