# WS + live-price consumer audit (for TV-convention broader scope)

**Date:** 2026-04-22  
**Context:** Post PR #179, user directive — only ExtendedHoursRow may reflect extended-hours data; every other consumer must render regular-session-close data off-hours.

---

## Upstream hook pre-gating (important — determines blast radius)

### `useLiveTickerPrice`
- **Currently gated on `marketStatus === 'open'`?** NO
- **Evidence:** `/src/features/market/presentation/hooks/useLiveTickerPrice.ts` (line 16) — returns `LiveTickData | null` unconditionally. Does NOT check marketStatus. Subscribes to WS and reads from `container.livePriceStore.getPrice(ticker)` directly.
- **Risk:** ALL callers receive WS ticks 24/7, whether market is open or closed. No upstream gating.

### `useLivePrice`
- **Currently gated on `marketStatus === 'open'`?** NO
- **Evidence:** `/src/features/market/presentation/hooks/useLivePrice.ts` (line 11) — identical to `useLiveTickerPrice`, just without the subscribe/unsubscribe side effect. Pulls from store without gating.
- **Risk:** Same as above.

### `useThrottledLiveTick` (custom in useChartData)
- **Currently gated on `marketStatus === 'open'`?** PARTIALLY (call-site only)
- **Evidence:** `/src/features/stock-detail/presentation/hooks/useChartData.ts` (line 159) — hook itself is ungated, but it's only called when `shouldMergeLive = LIVE_TIMERANGES.has(timeRange) && usingPrimaryBars` (line 158). The caller (`useChartData`) does NOT gate on marketStatus; instead, `mergeTickIntoSparkline` is called only if `sampledLive && shouldMergeLive && displayBars.length > 0` (line 162).
- **Risk:** Chart 1D data IS scoped to session via `filterTodayBars(bars, { marketStatus })` (line 143), so off-hours the chart shows prior-session bars. Live ticks, if present, would merge into stale bars. **But** `filterTodayBars` returns empty bars on closed market with no data, so `mergeTickIntoSparkline` never runs. Accidental safety, not explicit gating.

### `useSnapshotQuery` (REST)
- **REST price field includes extended-hours last-trade?** YES (per PR #179 investigation)
- **Evidence:** Backend mapper returns `TickerSnapshot.price = market data's "last trade"`, which includes extended-hours fills. `/src/features/market/presentation/hooks/useSnapshotQuery.ts` (line 10) fetches REST snapshots with stale time 60s.
- **Risk:** VERY HIGH. `snapshot.price` is extended-hours data even off-market. Every consumer of `useSnapshotQuery(...).price` shows extended ticks during pre/after-market and weekends.

### `useMergedSnapshot`
- **Gating?** NO
- **Evidence:** `/src/features/market/presentation/hooks/useMergedSnapshot.ts` (line 12-21) — merges REST snapshot + WS live. Returns snapshot if no live, returns merged if both exist. **Does NOT gate on marketStatus.**
- **Risk:** HIGH. Merges extended-hours REST price with WS live price. Off-hours, snapshot alone is extended (per REST), and live would also be extended.

### `useLiveSparkline`
- **Gating?** NO (hook itself)
- **Evidence:** `/src/features/market/presentation/hooks/useLiveSparkline.ts` (line 36-46) — calls `filterTodayBars(data?.bars ?? [], { marketStatus })` to scope the primary bars, then merges live tick if available. Hook reads marketStatus correctly for bar filtering, but **does not gate the `useLiveTickerPrice` call itself** (line 36). Off-hours, bars are scoped to prior session, but live tick is still current (extended-hours).
- **Risk:** MEDIUM. Sparkline itself safe (prior-session data), but if displayed alongside a price label, that label would flash with live extended-hours tick while sparkline is stale.

---

## Consumer inventory

### 1. search/SearchResultRow
- **Hook/source:** `useMergedSnapshot` (line 23)
- **Gate on marketStatus?:** NO
- **Renders:** `snapshot.displayPrice` (line 45), `snapshot.changePercent` (line 51)
- **Off-hours bug risk:** YES — REST snapshot includes extended-hours last-trade price, and WS live is unfiltered. Row shows extended prices during pre/after-market and weekends.
- **Recommended fix:** Gate `useMergedSnapshot` call on `marketStatus === 'open'`, or derive a gated variant that returns null off-hours.

### 2. search/SearchScreen (PriceCol, RecentRow, RecentsCard)
- **Hook/source:** `useLiveTickerPrice` (line 26) + `snapshot.displayPrice` (line 27)
- **Gate on marketStatus?:** NO
- **Renders:** Live close price (line 27), computed change% vs displayPrevPrice (lines 28-30)
- **Off-hours bug risk:** YES — WS tick drives price directly. Snapshot's REST price also extended. Recents card shows live extended ticks off-hours.
- **Recommended fix:** Gate on `marketStatus === 'open'`, or use snapshot-only when market closed.

### 3. watchlist/WatchlistRow
- **Hook/source:** `useLiveTickerPrice` (line 21), `useLiveSparkline` (line 37)
- **Gate on marketStatus?:** NO
- **Renders:** `displayPrice = live?.close ?? snapshot.displayPrice` (line 23), computed `changePercent` (lines 24-26), sparkline (line 50)
- **Off-hours bug risk:** YES — Live price overwrites snapshot; sparkline is scoped but price label is not.
- **Recommended fix:** Gate live price merge on `marketStatus === 'open'`. Sparkline already safe via `filterTodayBars`.

### 4. market/MarketMoverRow
- **Hook/source:** `useLiveTickerPrice` (line 28)
- **Gate on marketStatus?:** NO
- **Renders:** `price = live?.close ?? mover.price` (line 30), computed change% (lines 31-33)
- **Off-hours bug risk:** YES — Live tick overwrites static price, showing extended fills.
- **Recommended fix:** Gate on `marketStatus === 'open'`.

### 5. market/MarketMoverStockItem / PriceChange
- **Hook/source:** `useLiveTickerPrice` (line 27)
- **Gate on marketStatus?:** NO
- **Renders:** `price = live?.close ?? stock.price` (line 29), computed change% (line 31)
- **Off-hours bug risk:** YES — Same as MarketMoverRow.
- **Recommended fix:** Gate on `marketStatus === 'open'`.

### 6. stock-detail/ExpandedHeader / CompanyInfoRow / useLiveSnapshot
- **Hook/source:** `useLiveTickerPrice` (line 108)
- **Gate on marketStatus?:** YES — `if (!snapshot || !live || marketStatus !== 'open') return snapshot;` (line 109)
- **Renders:** `displayPrice`, `changePercent` (lines 110-113)
- **Off-hours bug risk:** NO — Already gated on `marketStatus === 'open'`. Returns unmerged snapshot off-hours.
- **Recommended fix:** None; this is the correct pattern from PR #179.

### 7. stock-detail/ExpandedHeader / ExtendedHoursSlot
- **Hook/source:** `useLiveTickerPrice` (line 67), `useTodayRegularClose` (line 66)
- **Gate on marketStatus?:** YES — Only renders ExtendedHoursRow if `marketStatus === 'pre-market' || 'after-hours'` (lines 70, 79)
- **Renders:** Extended-hours row with live price (line 75, 84)
- **Off-hours bug risk:** NO — INTENTIONALLY ungated, as designed. This is the ONE blessed consumer.
- **Recommended fix:** None; keep as-is.

### 8. stock-detail/CollapsedHeader
- **Hook/source:** `useLiveTickerPrice` (line 83), `useLiveSparkline` (line 82)
- **Gate on marketStatus?:** NO
- **Renders:** `displayPrice = live?.close ?? snapshot?.displayPrice` (line 86), computed `changePercent` (lines 87-89), sparkline (via useLiveSparkline)
- **Off-hours bug risk:** YES — Live price unconditional; sparkline is scoped but price label is not.
- **Recommended fix:** Gate on `marketStatus === 'open'`.

### 9. stock-detail/SimilarStocksCard / StockItem / useLiveStockPrice
- **Hook/source:** `useLiveTickerPrice` (line 25)
- **Gate on marketStatus?:** NO
- **Renders:** `price = live?.close ?? stock.price` (line 26), computed `changePercent` (line 28)
- **Off-hours bug risk:** YES — Live tick overwrites static price.
- **Recommended fix:** Gate on `marketStatus === 'open'`.

### 10. stock-detail/hooks/useChartData (chart merge)
- **Hook/source:** `useThrottledLiveTick` (line 159), `mergeTickIntoSparkline` (line 165)
- **Gate on marketStatus?:** PARTIAL — bars are scoped via `filterTodayBars(bars, { marketStatus })` (line 143), so 1D chart is prior-session off-market. Merge only happens if bars exist (line 162).
- **Renders:** Chart bars with live tick merged into latest bar.
- **Off-hours bug risk:** NO (accidental) — Off-hours, `filterTodayBars` with `marketStatus !== 'open'` returns prior-session bars, and `mergeTickIntoSparkline` is only called if bars exist and live tick is present. On weekends with no bars, merge is skipped. But this is brittle; should be explicit.
- **Recommended fix:** Add explicit `marketStatus === 'open'` guard around the merge, not just bar existence check.

### 11. home/WatchlistCard / WatchlistRow
- **Hook/source:** `useLiveTickerPrice` (line 51), `useLiveSparkline` (line 50)
- **Gate on marketStatus?:** NO
- **Renders:** `displayPrice = live?.close ?? snapshot.displayPrice` (line 52), computed `changePercent` (lines 53-55), sparkline (line 63)
- **Off-hours bug risk:** YES — Live price unconditional; sparkline safe but price label not.
- **Recommended fix:** Gate on `marketStatus === 'open'`.

### 12. home/EarningsSpotlight / EarningsStockItem
- **Hook/source:** `useMergedSnapshot` (line 54), `useLiveSparkline` (line 55)
- **Gate on marketStatus?:** NO
- **Renders:** `snapshot?.displayPrice` (line 78), `snapshot?.changePercent` (line 79), sparkline
- **Off-hours bug risk:** YES — `useMergedSnapshot` merges extended-hours REST price with unfiltered WS live.
- **Recommended fix:** Gate `useMergedSnapshot` on `marketStatus === 'open'`.

### 13. earnings/EarningsSpotlightRow
- **Hook/source:** `useMergedSnapshot` (line 48), `useLiveSparkline` (line 49)
- **Gate on marketStatus?:** NO
- **Renders:** `snapshot?.displayPrice` (line 51), `snapshot?.changePercent` (line 52), sparkline
- **Off-hours bug risk:** YES — Same as #12.
- **Recommended fix:** Gate `useMergedSnapshot` on `marketStatus === 'open'`.

### 14. simtrade/HoldingsList / HoldingRowPrice
- **Hook/source:** `useLiveTickerPrice` (line 47), `computeLiveChangePercent` (line 54)
- **Gate on marketStatus?:** NO
- **Renders:** `price = live?.close ?? position.currentPrice` (line 48), computed `changePct` (line 54)
- **Off-hours bug risk:** YES — Live price unconditional; portfolio P&L would be updated by extended-hours fills.
- **Recommended fix:** Gate on `marketStatus === 'open'`.

### 15. simtrade/SelectedStockCard
- **Hook/source:** `useMergedSnapshot` (line 31)
- **Gate on marketStatus?:** NO
- **Renders:** `snapshot?.displayPrice` (line 34), `snapshot?.changePercent` (line 35)
- **Off-hours bug risk:** YES — `useMergedSnapshot` unfiltered.
- **Recommended fix:** Gate on `marketStatus === 'open'`.

### 16. simtrade/SimilarStocksCarousel / StockItemPrice
- **Hook/source:** `useLiveTickerPrice` (line 34), `computeLiveChangePercent` (line 38)
- **Gate on marketStatus?:** NO
- **Renders:** `price = live?.close ?? stock.price` (line 35), computed `changePct` (line 38)
- **Off-hours bug risk:** YES — Live price unconditional.
- **Recommended fix:** Gate on `marketStatus === 'open'`.

### 17. market/SectorStockRow
- **Hook/source:** `useLiveTickerPrice` (line 62), `computeLiveChangePercent` (line 65)
- **Gate on marketStatus?:** NO
- **Renders:** `price = live?.close ?? stock.price` (line 63), computed `changePercent` (line 65)
- **Off-hours bug risk:** YES — Live price unconditional.
- **Recommended fix:** Gate on `marketStatus === 'open'`.

### 18. stock-ideas/IdeaStockRow / useLiveIdeaPrice
- **Hook/source:** `useLiveTickerPrice` (line 44), `computeLiveChangePercent` (line 53)
- **Gate on marketStatus?:** NO
- **Renders:** `price = live.close` (line 58), computed `changePercent` (line 58)
- **Off-hours bug risk:** YES — Live price unconditional.
- **Recommended fix:** Gate on `marketStatus === 'open'`.

### 19. screener/ScreenerResultRow
- **Hook/source:** `useLiveTickerPrice` (line 37)
- **Gate on marketStatus?:** NO
- **Renders:** `price = live?.close ?? snapshot?.displayPrice` (line 39), computed `changePercent` (lines 40-42)
- **Off-hours bug risk:** YES — Live price unconditional; also snapshot.prevClose may not be session-aware.
- **Recommended fix:** Gate on `marketStatus === 'open'`.

### 20. news/TickerTag
- **Hook/source:** `useMergedSnapshot` (line 18)
- **Gate on marketStatus?:** NO
- **Renders:** `snapshot?.changePercent` (line 20)
- **Off-hours bug risk:** YES — `useMergedSnapshot` unfiltered.
- **Recommended fix:** Gate on `marketStatus === 'open'`.

### 21. news/ArticleTickerTag
- **Hook/source:** `useMergedSnapshot` (line 20)
- **Gate on marketStatus?:** NO
- **Renders:** `snapshot?.changePercent` (line 21)
- **Off-hours bug risk:** YES — `useMergedSnapshot` unfiltered.
- **Recommended fix:** Gate on `marketStatus === 'open'`.

---

## Summary

| Metric | Count |
|--------|-------|
| **Total consumers audited** | 21 |
| **At-risk (need gating)** | 19 |
| **Safe (already gated or upstream pre-gated)** | 1 |
| **Intentionally ungated** | 1 (ExtendedHoursRow) |

### At-risk consumers (need fix):
1. SearchResultRow
2. SearchScreen (PriceCol/RecentRow/RecentsCard)
3. WatchlistRow
4. MarketMoverRow
5. MarketMoverStockItem
6. CollapsedHeader
7. SimilarStocksCard
8. EarningsStockItem
9. EarningsSpotlightRow
10. HoldingsList
11. SelectedStockCard
12. SimilarStocksCarousel
13. SectorStockRow
14. IdeaStockRow
15. ScreenerResultRow
16. TickerTag
17. ArticleTickerTag

### Safe consumers:
- **ExpandedHeader** (already gated in PR #179)

### Intentionally ungated:
- **ExtendedHoursRow** (correct by design)

---

## Recommended fix strategy

### **Option A: Centralized fix in upstream hooks (strongest)**
Create gated versions:
- `useLiveTickerPriceIfOpen(ticker: string, marketStatus: MarketStatus | null)` — returns null off-hours
- `useMergedSnapshotIfOpen(ticker: string, marketStatus: MarketStatus | null)` — returns unmerged snapshot off-hours

**Tradeoff:** Requires ALL 19 consumers to:
1. Import `useMarketStatusQuery`
2. Pass it to the new hooks

**Benefit:** Single source of truth; automatic for future consumers.

### **Option B: Per-consumer gate at call site (quicker, less invasive)**
Each at-risk consumer:
```typescript
const marketStatus = useMarketStatusQuery();
const snapshot = useMergedSnapshot(ticker);
// Only use live-merged snapshot when market is open
const safe = marketStatus === 'open' ? snapshot : { ...snapshot, displayPrice: snapshot.displayPrice, changePercent: snapshot.changePercent };
```

**Tradeoff:** 19 separate edits; easy to miss or get inconsistent.

**Benefit:** Minimal hook changes; clear per-component intent.

### **Option C: Hybrid (recommended)**
1. Create a **gated variant** of `useMergedSnapshot`: `useMergedSnapshotIfOpen(ticker, marketStatus)`
2. Refactor the **19 consumers** to use it (1–2 line change per file)
3. Keep `useLiveTickerPrice` ungated for consumers that need it (chart, etc.) and gate at their call site
4. Add explicit check in `useChartData` merge: `if (marketStatus === 'open' && sampledLive) { ... }`

**Tradeoff:** Moderate refactor (~30 edits)

**Benefit:** Clearest intent; scales to new features; PR reviewable in 1–2 reviews.

---

## Surprising findings

1. **REST snapshot is the hidden vector.** `useSnapshotQuery` returns `price` that includes extended-hours last-trade (per PR #179). This alone causes risk in SearchResultRow, which calls `useMergedSnapshot` without gating.

2. **`useLiveSparkline` is accidentally safe.** It calls `filterTodayBars(bars, { marketStatus })` correctly, so sparklines show prior-session data off-hours. But the pattern is inconsistent with price labels in the same component, which are unfiltered.

3. **CollapsedHeader and WatchlistCard have mismatched gates.** Sparkline is scoped (safe), but price label is live and unscoped (at risk). Looks intentional but probably not.

4. **Accidental safety in useChartData.** The merge only runs if bars exist (`if (sampledLive && shouldMergeLive && displayBars.length > 0)`), so off-hours with no bars, the merge never happens. But this is brittle—an explicit `marketStatus === 'open'` check would be clearer and survive future bar-sourcing changes.

---

