# NFLX $0.00 pre-market — diagnosis

Date: 2026-04-22 05:18 ET (pre-market)
Status: READ-ONLY diagnosis, no code changed.

## (a) Backend — live response

`GET /market/snapshots?tickers=NFLX` (auth: beginner test account, server_time `2026-04-22T05:18:05-04:00`, status `pre_market`):

```json
{
  "snapshots": [{
    "ticker": "NFLX",
    "price": 93.14,
    "change": 0.56,
    "change_percent": 0.6048822639879048,
    "open": 0.0,
    "high": 0.0,
    "low": 0.0,
    "close": 0.0,
    "volume": 0.0,
    "vwap": 0.0,
    "prev_close": 92.58,
    "prev_open": 94.0
  }]
}
```

`GET /market/status` → `{ "status": "pre_market", ... }`.

Key observation: even with `price=93.14` (non-zero), the backend returns `open/high/low/close/volume/vwap = 0.0` rather than `null`. That is, the backend uses `0` to mean "no current-session aggregate bar yet", not `null`. This is the pivot for the mobile-side bug.

## (b) Mobile mapper trace — `mapTickerSnapshotDtoToDomain`

`src/features/market/data/mappers/marketMapper.ts:107-131`

```ts
const price = dto.price ?? 0;               // 108
const prevClose = dto.prev_close ?? 0;      // 109
const prevOpen = dto.prev_open ?? 0;        // 110

const displayPrice = price !== 0 ? price : prevClose;                         // 112
const displayPrevPrice = price !== 0 ? prevClose : (prevOpen !== 0 ? prevOpen : prevClose); // 113
```

With **today's** values (price=93.14, prev_close=92.58) → `displayPrice = 93.14`, `displayPrevPrice = 92.58`. Renders correctly.

**Failure case that explains the user's screenshot** — backend returns `price=0` and `prev_close=null` (or `0`):

| Scenario | `dto.price` | `dto.prev_close` | mapper `displayPrice` |
|---|---|---|---|
| A: no pre-market trade + missing prev | `0` | `null` | `0` → renders `$0.00` |
| B: no pre-market trade + valid prev | `0` | `92.58` | `92.58` → correct |
| C: explicit null price + missing prev | `null` | `null` | `0` → renders `$0.00` |
| D: current live state | `93.14` | `92.58` | `93.14` → correct |

Scenarios A/C collapse to `0` because (i) `?? 0` converts `null` to `0`, and (ii) the `price !== 0 ? price : prevClose` fallback chain has no further branch when `prevClose` is also `0`.

### Display chain after the mapper

`src/features/stock-detail/presentation/components/ExpandedHeader.tsx:103-115` — `useLiveSnapshot`:

```ts
if (!snapshot || !live || marketStatus !== 'open') return snapshot; // 109
```

Pre-market → returns raw snapshot unchanged. So `CompanyInfoRow` reads `snapshot.displayPrice` directly.

`ExpandedHeader.tsx:91-101` — `useChangeValues`:

```ts
const price = snapshot?.displayPrice ?? null;   // 92
```

`displayPrice` is **always a number** (never `null`) by mapper contract, so the `price == null → '--'` branch on line 134 is unreachable for this bug. `displayPrice = 0` flows into `LivePriceText` and renders as `$0.00` via `formatDollar`.

Also note `mergeTickIntoSnapshot` in `src/features/market/business/utils/livePriceUtils.ts:164-184` runs unconditionally via `useMergedSnapshot`. If a WS tick arrives with `tick.close = 0` it falls back to `snapshot.prevClose` (correct), but if `snapshot.prevClose` itself is `0` the output is still `0`. Same class of bug.

## (c) Root-cause verdict

**Primary: mobile mapper.** The bug is a latent fallback-chain collapse in `mapTickerSnapshotDtoToDomain`. The mapper treats `price=0` as the "no data" sentinel and falls back to `prevClose`, but if `prevClose` is also `0`/`null` it silently yields `0`, which renders as `$0.00` instead of `'--'`. `displayPrice` is typed `number` so the downstream UI has no way to express "unknown price" — every fallback must succeed or we render a misleading zero.

**Secondary: backend contract.** Backend returns `0.0` (not `null`) for missing aggregates (current response shows `open/high/low/close/volume/vwap = 0.0` while `price=93.14`). This forces clients to distinguish "$0.00 is a real price" from "0 means missing", which is a footgun. At the moment the user captured the screenshot (earlier pre-market window, possibly before polygon published prev_close), the server likely emitted `price=0` or `price=null` together with `prev_close=null`, hitting scenarios A/C above.

## (d) Fix proposal

### Mobile fix (primary, small)

**Option 1 — make `displayPrice` nullable and propagate `--`** (preferred, matches existing intent on `ExpandedHeader.tsx:134-136`):

In `TickerSnapshot` model:
- `displayPrice: number | null`
- `displayPrevPrice: number | null`

In `mapTickerSnapshotDtoToDomain` (`marketMapper.ts:107-131`):

```ts
const price = dto.price != null && dto.price !== 0 ? dto.price : null;
const prevClose = dto.prev_close != null && dto.prev_close !== 0 ? dto.prev_close : null;
const prevOpen = dto.prev_open != null && dto.prev_open !== 0 ? dto.prev_open : null;

const displayPrice = price ?? prevClose ?? prevOpen;       // null → UI shows '--'
const displayPrevPrice = price != null ? prevClose : (prevOpen ?? prevClose);

const changePercent =
  displayPrice != null && displayPrevPrice != null && displayPrevPrice !== 0
    ? ((displayPrice - displayPrevPrice) / displayPrevPrice) * 100
    : (dto.change_percent ?? null);
```

Downstream changes:
- `mergeTickIntoSnapshot` (`livePriceUtils.ts:164-184`) — same null-aware pattern.
- `useLiveSnapshot` in `ExpandedHeader.tsx:103-115` — handle `displayPrice = null`.
- `useChangeValues` (`ExpandedHeader.tsx:91-101`) already handles `price == null`.
- Other `displayPrice` consumers: `CollapsedHeader`, `FABOverlay`, any market list cards — quick grep confirms the null path reaches them but they mostly format via `formatDollar(displayPrice)` and would need the same `== null ? '--' : …` branch.

**Option 2 — minimal patch, single line** (if full nullable refactor is too big for a P0):

Extend the fallback in `marketMapper.ts:112`:

```ts
const displayPrice =
  price !== 0 ? price :
  prevClose !== 0 ? prevClose :
  prevOpen;   // still a number, but $0.00 only when truly all three are zero
```

This fixes scenario A (`price=0, prev_close=null, prev_open=94.0` → shows $94.00) but not the pathological case where the backend returns all zeros for everything. Acceptable band-aid; still renders `$0.00` when the dataset is genuinely empty, which is the edge the backend should eliminate.

### Backend fix (secondary, contract)

- Return `null` (not `0.0`) for unavailable fields (`open/high/low/close/volume/vwap/price`) so clients can distinguish missing from zero without resorting to `!== 0` checks. Current response shows `price=93.14` while `open=0, high=0, ...` — that `0` is clearly sentinel-for-missing.
- Guarantee `prev_close` is present any time a ticker is listed (fall back to polygon's prior-day aggregate server-side). If polygon hasn't delivered it yet, prefer holding the previous `prev_close` from cache rather than emitting `null`/`0`.

### Suggested order

1. Ship mobile Option 2 (single-line fallback extension) as the P0 — unblocks user now, low risk, ~5 lines of code.
2. Follow up with mobile Option 1 (nullable `displayPrice`) as a proper fix — this also eliminates the symmetric bug in `mergeTickIntoSnapshot` and all other callers.
3. Backend contract cleanup on a separate PR — returns `null` for missing fields, guarantees `prev_close` persistence.

## Cited files

- `src/features/market/data/mappers/marketMapper.ts:107-131` — primary bug site
- `src/features/market/data/dto/marketDto.ts:112-125` — DTO schema (all fields nullable)
- `src/features/market/business/utils/livePriceUtils.ts:164-184` — `mergeTickIntoSnapshot` (same class of bug, WS path)
- `src/features/market/presentation/hooks/useMergedSnapshot.ts:12-21` — unconditional merge entry point
- `src/features/stock-detail/presentation/components/ExpandedHeader.tsx:91-147` — `useChangeValues` + `CompanyInfoRow` rendering path
- `src/features/stock-detail/presentation/components/ExtendedHoursRow.tsx:17-51` — pre-market row (already null-safe, OK)

---

# Round 2 — deeper fix (Options C / D / E)

User rejected Options A (prev_open — nonsense, it's last session's open, not a price) and B (null → '--' — still doesn't render a number). The real requirement: **always render a sensible number**. The true defect is upstream — `prev_close` must never be `null`/`0` once we've ever known it for this ticker.

## Evidence gathered in Round 2

### E1. Backend snapshot pipeline (`backend/services/main/app/repositories/massive_client/`)

- `_client.py:60-79` — `_ticker_snapshot_to_dict`: `price = last_trade.price if last_trade else day.close if day else None`; `prev_close = prev.close if prev else None`. Both rely 1:1 on polygon's payload — no server-side fallback, no historical aggregation fallback.
- `market_data.py:279-285` — `_snapshot_row_valid` returns `True` only when `row["price"] != 0`.
- `market_data.py:406-420` — `_apply_snapshot_row`: **invalid rows (price=0) do NOT hit the DB** — only extend the existing memory-cache TTL. **If a ticker has no prior cache entry AND upstream returns `price=0` on first fetch, the zero row is cached briefly with `SNAPSHOTS_CACHE_TTL=60s`**. That 60-second window is exactly when the UI renders `$0.00`. DB write never fires, so the next cold restart reproduces the bug.
- `market_data.py:306-403` — `get_snapshots` does SWR with DB→memory hydration. DB row is only ever written from a "valid" upstream fetch. **If a ticker has never been fetched during a live session since the last DB flush, there is no durable prev_close.**
- Daily-close cron: `routers/cron.py` has `/screener/daily` and `/screener/weekly`, but no dedicated cron that persists `prev_close` per universe ticker at 4:01pm ET. Snapshot DB rows only accumulate opportunistically from client traffic.

So the backend-side root cause is: **`prev_close` has no independent source of truth**. It's whatever polygon's `TickerSnapshot.prev_day.close` happened to return on the last valid fetch — and that field can be `null` during polygon's own transition windows.

### E2. Mobile 1D aggs path — does it have data during pre-market?

Yes, reliably.

Sampled at 2026-04-22 05:18 ET (pre-market) with the beginner test account:

- `GET /market/stocks/NFLX/aggs?timeframe=1D` → **70 bars** across 2 ET dates:
  - 2026-04-21: 64 bars, close range 95.19 → 92.89 (yesterday's session; last 5-min bar close `92.89`)
  - 2026-04-22: 6 bars, close range 93.03 → 93.21 (today's pre-market; last bar close `93.21`)
- AAPL / TSLA / MSFT / NFLX all return non-empty `results` at this hour. Not ticker-specific.

Caveat: the previous session's **last 5-min bar close (92.89) is not identical to polygon's official 4pm `prev_day.close` (92.58)** — the official close is an auction print that can differ from the last aggregate. Drift is typically <0.5% but visible.

### E3. React Query cache sharing — snapshot vs aggs

- `useSnapshotQuery` (`src/features/market/presentation/hooks/useSnapshotQuery.ts:10-19`) — key `marketQueryKeys.snapshots([ticker])`, TTL 60s.
- `useSparklineQuery` (`src/features/market/presentation/hooks/useSparklineQuery.ts:23-29`) — key `['market', 'stock-aggs', ticker, '1D']`, TTL 5min.
- `useTodayRegularClose` (`src/features/stock-detail/presentation/hooks/useTodayRegularClose.ts:14-24`) — **same key** as sparkline; shares cache.
- Independent queries, independent cache entries. On the stock detail screen, both fire in parallel and the sparkline data is in-cache by the time the price renders. On market-list / watchlist / portfolio screens, only the snapshot runs — no aggs available there.

### E4. Where does `$0.00` render vs where does it not?

| Screen | Consumes | Can fall back to bars? |
|---|---|---|
| `StockDetailScreen` — main price | `useMergedSnapshot` → `displayPrice` | **Yes** — 1D aggs already in cache via `useSparklineQuery` |
| `StockDetailScreen` — extended hours row | `snapshot.prevClose` baseline | Yes — same aggs |
| Market list cards (gainers/losers/52w) | `MarketMoverDto.price` (different endpoint, different cache) | No (bars not fetched) |
| Watchlist / portfolio rows | `snapshot.displayPrice` | No (bars not fetched) |
| CollapsedHeader on stock-detail | `snapshot.displayPrice` | Yes (same screen) |

So a mobile-only fallback via bars fixes the visible screen but leaves the latent bug on every other screen that shows `snapshot.displayPrice` with zero aggs nearby.

## Option C — Backend guarantees `prev_close` (recommended primary)

**Goal**: `snapshot.prev_close` is never `null`/`0` for any actively-listed US ticker.

### C1. Fallback chain inside `_ticker_snapshot_to_dict`

When `prev.close` is `None`, call polygon's daily aggregates endpoint for the most recent 2 closed sessions and use the newest closed session's close. Cache the daily-close map with a 1-hour TTL keyed per ticker. Near-free: polygon caches daily aggs aggressively, and each ticker needs one lookup per stale SWR refresh.

```python
# _client.py — sketch
def _ticker_snapshot_to_dict(snap):
    prev_close = snap.prev_day.close if snap.prev_day else None
    if not prev_close:
        prev_close = _last_daily_close(snap.ticker)  # polygon /v2/aggs/.../day/prev
    ...
```

### C2. Pre-market / end-of-day warming cron

Add a cron at 16:05 ET (1 minute after NYSE close) that calls polygon's batch daily aggregates and writes `{"ticker": T, "prev_close": C, "captured_at": ...}` rows to Supabase for every ticker in the hot universe (Watchlist ∪ Portfolio ∪ Gainers/Losers ∪ Top-Volume ∪ 52w). Cron does not need to be exhaustive — just the set that mobile clients actually query. `get_snapshots` hydrates from this DB row on cold miss, guaranteeing `prev_close` for pre-market hours even before any client has warmed the SWR cache.

### C3. Never cache a zero-prev row

Tighten `_snapshot_row_valid` to **also** require non-null `prev_close`. A row that lacks both a live price AND a prev_close is structurally unusable — better to extend the prior cache entry (even if stale for 5+ minutes) than to serve zero. Combined with C2 this means the DB row always has a valid `prev_close` and the stale-extend branch has something to lean on.

### C4. Ensure `price` field is never `0`

In `_ticker_snapshot_to_dict`, when both `last_trade.price` and `day.close` are missing, set `price = prev_close`. Add `"price_is_stale": True` to the payload so the mobile UI can optionally dim or annotate. Mobile mapper already handles price=prev_close correctly (displayPrice falls through to prev_close).

**Result with C**: Mobile mapper on line `marketMapper.ts:112` works as-designed. Every screen — not just stock detail — renders a sensible number. Zero mobile code change.

## Option D — Mobile stock-detail safety net (belt-and-braces)

**Goal**: Even if backend regresses, the stock detail screen can never render `$0.00` because it already has 1D aggs in React Query cache.

### D1. Extract a fallback accessor

Add a helper on `useMergedSnapshot` or a new `useResilientSnapshot` that reads from the aggs cache when `snapshot.displayPrice === 0`:

```ts
// pseudocode — new hook that composes useMergedSnapshot + aggs cache
function useResilientSnapshot(ticker: string): TickerSnapshot | null {
  const merged = useMergedSnapshot(ticker);
  const { data } = useQuery({ queryKey: ['market','stock-aggs',ticker,'1D'] }); // read-only
  if (!merged || merged.displayPrice !== 0) return merged;
  const bars = data?.bars ?? [];
  if (bars.length === 0) return merged;
  const fallbackPrice = bars[bars.length - 1]!.close;
  // For prevClose: find last bar whose ET date differs from today
  const todayKey = etDateKey(Date.now());
  const yesterdayBar = [...bars].reverse().find(b => etDateKey(b.timestamp) !== todayKey);
  const fallbackPrev = yesterdayBar?.close ?? merged.prevClose;
  return { ...merged, displayPrice: fallbackPrice, prevClose: fallbackPrev, displayPrevPrice: fallbackPrev };
}
```

Accept the known ~0.5% drift between last 5-min bar and the official 4pm auction close — it's a fallback, not the primary path.

### D2. Scope

D is only wired into `StockDetailScreen` (where aggs are already loaded). Market-list / watchlist screens stay on raw snapshot. Acceptable because those screens are less prominent and the user's screenshot showed stock-detail.

### Why D alone is not enough

D masks the symptom on one screen. Market list cards (Top Gainers, 52w Highs, etc.) hit different endpoints and cards, with no aggs in cache and no universal fallback. Those can and will show `$0.00` from the same class of upstream gap until the backend is fixed.

## Option E — Recommended: **C + D together** (cheap D first, then proper C)

1. **Day-0 hotfix (mobile, D1)**: Ship the `useResilientSnapshot` fallback for `StockDetailScreen` only. ~40 LOC, no model changes, no backend dependency. Unblocks user within one PR on the visible screen.
2. **Proper fix (backend, C2 + C3 + C1 in that order)**:
   - C2 cron (biggest single win — one scheduled job populates durable DB rows for hot universe).
   - C3 cache-validity tightening (prevents regressions).
   - C1 inline daily-close fallback (catches tickers outside the hot universe).
3. **After C lands**: D stays as dead-code belt-and-braces; can be removed once C is validated across a few market-closed cycles, or kept permanently (cost: zero — it's a gated check).

### Split

| Work unit | Repo | Est |
|---|---|---|
| D1 — `useResilientSnapshot` + wire into `StockDetailScreen` | mobile | S |
| C2 — 16:05 ET cron: fetch polygon daily aggs for hot universe, DB-write `prev_close` rows | backend | M |
| C3 — `_snapshot_row_valid` + `_apply_snapshot_row`: require non-null `prev_close` to DB-write; stale-extend otherwise | backend | S |
| C1 — `_ticker_snapshot_to_dict`: polygon daily-close fallback when `prev.close` is None; cached 1h | backend | S |
| C4 — `price = prev_close` when both live sources missing; `price_is_stale` flag | backend | XS |
| Optional: remove D1 once C is validated | mobile | XS |

No mobile mapper changes needed. No `displayPrice: number | null` refactor. No forced `'--'` rendering anywhere.

## Why this avoids the earlier rejection

- Not Option A: doesn't use `prev_open` (which is yesterday's 9:30 open, not a meaningful price for change calc).
- Not Option B: `displayPrice` stays `number`, UI always renders a dollar figure. The `'--'` branch that exists in `CompanyInfoRow:134-136` stays as dead-code for the `!snapshot` case only.
- The number rendered is **yesterday's official 4pm close** (from backend) or **yesterday's last 5-min bar close** (mobile safety net). Both are real, interpretable numbers — the change% just reads `0.00%` until live data resumes, which is correct ("flat vs last known close") and what users intuitively expect.

