# Stock/Ticker Price: REST · WebSocket · Cache Research

_Investigated: 2026-04-28 | Scope: src/features/market/, src/infrastructure/, src/features/stock-detail/_

---

## Data Flow Diagram

```
┌─────────────────────────────────────────────────────────────────┐
│                    UI COMPONENT LAYER                           │
│  (MarketMoverRow, IndexCard, StockDetailScreen, etc.)           │
└──────────────────────┬──────────────────────────────────────────┘
                       │
        ┌──────────────┴──────────────┬─────────────────┐
        ▼                             ▼                 ▼
 useMergedSnapshot()        useSnapshotQuery()  useLivePrice()
        │                             │                 │
        └──────────────┬──────────────┘                 │
                ┌──────┴──────────────────────────────┐ │
          REST + WS Merge              REST Only       │ WS Only
          (Best UX)                    (60s stale)     │ (300ms batched)
                │                                      │
┌───────────────┴──────────────────────────────────────┴────────────┐
│             REACT QUERY CACHE (REST snapshots)                    │
│  staleTime: 60s | gcTime: 5min | key: ['market','snapshot',ticker]│
└──────────────────────────┬────────────────────────────────────────┘
                           ▼
                  ┌────────────────┐
                  │  REST API      │
                  │ /market/snapshots
                  │ /market/gainers│
                  │ /market/losers │
                  │ /market/status │
                  │ /market/indices│
                  └────────┬───────┘
                           ▼
                    [NanoStreet API]

┌──────────────────────────────────────────────────────────────────┐
│                     WEBSOCKET LAYER                              │
│                                                                  │
│ useLiveTickerPrice('AAPL')                                       │
│   → MarketWebSocketProvider.subscribe()  (ref-counted)          │
│   → NanoStreetWebSocketAdapter.subscribe()                       │
│   → WebSocketClient.send({ action:'subscribe', tickers:[...] }) │
│                                ↓                                 │
│              WS message received (stock_aggregate)               │
│   → LivePriceRepositoryImpl parses + fans out                   │
│   → LivePriceStore.ingest() (300ms batched flush)               │
│   → useSyncExternalStore triggers re-render (if changed)        │
└──────────────────────────────────────────────────────────────────┘
```

---

## REST Layer

### Endpoint
`/market/snapshots?tickers=AAPL,MSFT` (batched query)

### Key Files
| File | Purpose |
|---|---|
| `src/features/market/presentation/hooks/useSnapshotQuery.ts:1-29` | React Query wrapper, staleTime 60s |
| `src/features/market/data/repositories/MarketRepositoryImpl.ts:62-145` | REST impl, calls MarketApi |
| `src/features/market/service/api/NanoStreetMarketAdapter.ts:1-80` | HTTP adapter, all market endpoints |

### React Query Config (Snapshot)
- **queryKey**: `['market', 'snapshot', ticker]` — per-ticker, shared across navigations
- **staleTime**: 60 seconds
- **gcTime**: 5 minutes (default)
- **refetchInterval**: on mount only

### Other REST Queries
| Data | staleTime | Key pattern |
|---|---|---|
| Sparkline bars (1D aggs) | 5 min | `['market','stock-aggs',ticker,'1D']` |
| Market status | – | `['market','status']` |
| Indices | – | `['market','overview']` |
| Gainers/Losers | – | `['market','movers',type]` |

---

## WebSocket Layer

### Connection
- URL: `wss://...?token=JWT` (token injected by `NanoStreetWebSocketAdapter`)
- Ping/pong: 30s interval, 10s pong timeout
- Reconnect: exponential backoff 1s → 30s, max 10 attempts

### Subscribe flow
```
useLiveTickerPrice('AAPL')
→ MarketWebSocketProvider.subscribe(['AAPL'], subscriberId, 'stock')
→ NanoStreetWebSocketAdapter.subscribe(['AAPL'], 'stock')
→ WebSocketClient.send({ action: 'subscribe', asset_type: 'stock', tickers: ['AAPL'] })
```

### Inbound message structure
```json
{
  "job_id": "stock-123",
  "status": "success",
  "data": {
    "type": "stock_aggregate",
    "ticker": "AAPL",
    "data": { "open": 150.0, "high": 152.5, "low": 149.8, "close": 151.2,
              "volume": 45000000, "timestamp": 1234567890000 }
  }
}
```

Message kinds: `connected`, `pong`, `subscribed`, `unsubscribed`, `tick`, `error`

### Key Files
| File | Purpose |
|---|---|
| `src/infrastructure/websocket/WebSocketClient.ts:1-203` | Low-level WS, backoff, ping/pong |
| `src/infrastructure/websocket/LivePriceStore.ts:1-129` | In-memory store, 300ms flush, dedup |
| `src/features/market/service/api/NanoStreetWebSocketAdapter.ts:1-83` | Wraps WsClient, epoch tracking |
| `src/features/market/data/repositories/LivePriceRepositoryImpl.ts:1-75` | Parses WS msgs → LiveTickData |
| `src/features/market/presentation/providers/MarketWebSocketProvider.tsx:1-207` | Ref-counted subscriptions, lifecycle |
| `src/features/market/presentation/hooks/useLiveTickerPrice.ts:1-44` | WS subscribe + price read |
| `src/features/market/presentation/hooks/useLivePrice.ts:1-25` | useSyncExternalStore on LivePriceStore |

### Flush / dedup
- `LivePriceStore` buffers ticks and flushes every **300ms**
- `sameTick(a, b)` checks full OHLCV + timestamp equality — no render if unchanged
- Ticker can have multiple asset types (e.g. SPY = stock + index); store uses double-map `ticker → Map<assetType, PriceTick>`

---

## Cache Strategy

### Coexistence: REST bootstrap + WS overlay
- `useSnapshotQuery` fetches base snapshot (price, prevClose, change%)
- `useLivePrice` / `useLiveTickerPrice` reads latest WS tick from `LivePriceStore`
- **`useMergedSnapshot`** (`src/features/market/presentation/hooks/useMergedSnapshot.ts:1-21`) merges both via `mergeTickIntoSnapshot()` — **this is the recommended hook for displaying live prices**

### No React Query mutation from WS
- WS ticks go into `LivePriceStore` only — they do NOT invalidate or update the React Query cache
- REST snapshot stays static until 60s stale time expires
- Impact: users on `useSnapshotQuery` directly see stale prices; only `useMergedSnapshot` consumers get live updates

### Market-hours gating
- `useWsMarketStatusTeardown()` (`MarketWebSocketProvider.tsx:80-101`) monitors market status
- Disconnects WS 10s after market closes; reconnects on next status change to open/pre-market/after-hours
- App background → disconnect, foreground → reconnect (`lines 103-120`)

### Resilient snapshot (stock detail)
- `src/features/stock-detail/presentation/hooks/useResilientSnapshot.ts:1-79`
- Workaround for backend returning `price=0 && prevClose=null`
- Falls back to `useSparklineQuery` 1D bars, derives price + change% from last two RTH closes
- Reuses existing cache — no extra network call

### Zero-price sentinel
- WS ticks with `close === 0` are ignored in `mergeTickIntoSnapshot()` (livePriceUtils.ts:218-219)
- Prevents zeroed cache-gap responses from overwriting valid prices

---

## Key Utilities

`src/features/market/business/utils/livePriceUtils.ts`
- `mergeTickIntoSnapshot()` (line 244) — overlays WS close onto REST snapshot
- `deriveMoverDisplay()` (line 212) — computes live change% with fallbacks
- `computeLiveChangePercent()` (line 176) — back-solves prevClose from static snapshot
- `mergeTickIntoSparkline()` (line 281) — merges tick into bars, merges same-minute buckets (not yet used by any consumer)
- `filterRolling24hBars()` (line 142) — 24h rolling window for sparkline

---

## Known Gaps & Inconsistencies

1. **No cache invalidation on market status change** — stale snapshot from closed session persists until 60s expires. Fix: `queryClient.invalidateQueries` in `useWsMarketStatusTeardown` on market-open event.

2. **WS does not write to React Query cache** — two separate price stores (`LivePriceStore` + React Query) must be merged manually. Opportunity: use `queryClient.setQueryData` on tick to unify.

3. **`mergeTickIntoSparkline()` has no consumer** — logic exists in livePriceUtils but no hook calls it. Sparkline is static between REST refetches. Real-time chart updates are not wired up.

4. **App state reconnect doesn't re-check market status** — foreground reconnect fires unconditionally even if market is closed.

5. **Fixed 300ms flush (no jitter)** — all tickers flush simultaneously. Low impact now (<5 active tickers typical), but could cause listener spikes at scale.

6. **Asset type tracking cleared on disconnect** — `typesByTicker` reset in `disconnect()`. Repopulated by `resubscribeAll()` on reconnect, but a race window exists if a subscriber unmounts during reconnect.

---

## Recommended Improvements

1. **Invalidate REST cache on market open** — hook `useWsMarketStatusTeardown` to call `queryClient.invalidateQueries` on status → open transition.
2. **Write WS ticks to React Query cache** — `queryClient.setQueryData(['market','snapshot',ticker], mergeTickIntoSnapshot(cached, tick))` — eliminates `useMergedSnapshot` workaround.
3. **Wire `mergeTickIntoSparkline`** — add a `useLiveSparkline` hook for real-time chart bars.
4. **Explicit WS health UI** — expose disconnected state to user ("Price updates paused") using existing state enum.
5. **Jitter flush interval** — randomise 300±50ms to spread callbacks under high volume.
