# Chart market-open gating — current behavior summary

**Author:** chart-horizontal-scroll (read-only investigation)
**Date:** 2026-04-22
**Branch:** `feat/stock-detail-chart-horizontal-scroll` (post-rebase, includes #179)

## Code citation — the gate

`src/features/stock-detail/presentation/hooks/useChartData.ts:168-170`:

```ts
const shouldMergeLive =
  LIVE_TIMERANGES.has(timeRange) && usingPrimaryBars && marketStatus === 'open';
```

Three conditions must all be true to merge a live WS tick into the chart:
1. `LIVE_TIMERANGES.has(timeRange)` — only `1D` is in the set (`useChartData.ts:15`).
2. `usingPrimaryBars` — primary 1D fetch returned ≥1 bar (not the 1W closed-market fallback path).
3. `marketStatus === 'open'` — strictly regular session (9:30-16:00 ET).

The merge call on line 176 is `mergeTickIntoSparkline(displayBars, sampledLive)`. When `shouldMergeLive` is false, the chart renders unmerged `displayBars` (line 174).

## Commit that shipped the gate

**SHA:** `9f8b0383` — `fix(stock-detail): gate 1D live-tick merge on market open`

Rationale verbatim from commit body:
> Off-hours the extended-hours WS tick would splice into the 1D timeline — violating TV convention (chart is regular-session-only). `useLiveTickerPrice` is already gated on `'open'` upstream so in practice the tick never arrives off-hours, but this belt-and-braces gate inside `useChartData` keeps the rule local and self-documenting: if the upstream gate ever changes, the chart still stays regular-session-only.
>
> At 9:30 AM open, `filterTodayBars` returns empty briefly until the first 15-min bar lands; the closed-market fallback is suppressed at `'open'` — so the chart shows empty state for ~15 min then fills. **Intended behavior.**

## 4-phase scenario matrix — current behavior

| Phase | `marketStatus` | Chart bars source | Live WS merge? | What user sees on 1D chart |
|---|---|---|---|---|
| **OPEN** (9:30-16:00 ET) | `'open'` | `filterTodayBars(primary 1D bars)` | YES — gate passes | Today's regular-session 5-min bars, last bar updating live (~1 Hz from `useThrottledLiveTick`) |
| **AFTER-HOURS** (16:00-20:00 ET) | `'after-hours'` | `filterTodayBars(primary 1D bars)` — today's full regular session | NO — gate blocks (`marketStatus !== 'open'`) | Today's regular-session bars, FROZEN at 4:00 PM close. Extended-hours ticks do NOT splice in. |
| **PRE-MARKET** (4:00-9:30 ET) | `'pre-market'` | If primary 1D empty → `useClosedMarketFallback` fetches 1W and returns `filterTodayBars(1W bars)` = most-recent prior-session group | NO — gate blocks | Yesterday's (or last trading day's) regular session, hourly granularity (lower density than 5-min). Static. |
| **WEEKEND / HOLIDAY** | `'closed'` (or null) | Same fallback path as pre-market | NO — gate blocks | Last trading day's regular session via 1W walk-back. Static. |

For ranges other than 1D (`1W`/`1M`/`1Y`/`5Y`), `LIVE_TIMERANGES.has` is false → no live merge in any phase. Bars come straight from primary fetch.

## Edge cases the current implementation handles

- **9:30 AM open empty-chart window** (~15 min): `filterTodayBars` filters by ET date and returns empty until the first 15-min bar lands. Closed-market fallback is suppressed (`marketStatus !== 'open'` is false at open). Chart shows empty/loading state then fills. **User accepted this on msg 658** — flagged below as TL-relevant.
- **Closed-market 1D empty fallback** (`useClosedMarketFallback`, `useChartData.ts:109-135`): when 1D returns no bars and market is not open, fetches 1W (hourly bars × 5 trading days) and re-runs `filterTodayBars` to surface the most-recent session. Self-disables when market is open.
- **1W fallback never gets live merge** (`useChartData.ts:159, 162-167`): `usingPrimaryBars = primaryBars.length > 0`; the gate requires this be true, so 1W-fallback bars never receive a tick splice (would be 5-min tick into hourly bars otherwise).
- **`useThrottledLiveTick` does not open its own WS** (`useChartData.ts:79-98`): polls the shared `livePriceStore` at 1 Hz; depends on an upstream subscriber (`LiveSnapshotProvider`, etc.). When `shouldMergeLive` is false, this hook is invoked with empty ticker and no-ops.
- **Belt-and-braces with upstream**: `useLiveTickerPrice` is already gated on `'open'` per the commit message; the `useChartData` gate is a defense-in-depth so the chart's TV-convention rule is local.

## TL-decision-relevant flags

1. **9:30 open empty-chart window (~15 min) was explicitly accepted by user (msg 658)** — if any new TL direction wants that to fill faster (e.g., synthesize a single bar from prevClose, scavenge 1W bars at open too, or show prior-session greyed-out until the first today bar), the current code would need to relax the `marketStatus !== 'open'` suppression in `useClosedMarketFallback` (line 118) and decide a transition rule.
2. **Frozen chart in after-hours / pre-market**: the current TV-convention freeze means the chart and the extended-hours row in `ExpandedHeader` tell different stories — chart shows regular-session close, row shows extended-hours delta. If TL wants the chart to extend into pre/post (TradingView "extended hours" toggle equivalent), both `LIVE_TIMERANGES` and the `marketStatus === 'open'` gate would need to become user-toggleable.
3. **Weekend behavior is identical to pre-market** since both fall through `marketStatus !== 'open'` → fallback path. No special "Friday close" handling beyond what 1W walk-back provides.
4. **PR #174 (this branch) does not touch this gate** — the horizontal scroll wrapper is presentation-only; all behavior described above is unchanged. The new `useVisiblePriceRange` reads whatever `displayBars` the gate produces.
