# gex — Net GEX All Expirations

CLI tool that produces a "Net GEX All Expirations" chart for any US-listed optionable ticker,
from free delayed public data (Cboe), on a twice-per-day schedule.

## Quick Start

```bash
pip install -r requirements.txt
python -m gex.snapshot --tickers SMH --slot pm
```

## GEX Formula & Units

Per-contract, per-strike, summed across ALL expirations:

```
gex_call(K) = Σ  abs(gamma) × OI × M × S² × 0.01    for calls at K
gex_put(K)  = Σ -abs(gamma) × OI × M × S² × 0.01    for puts  at K
net_gex(K)  = gex_call(K) + gex_put(K)
dex(K)      = Σ  delta × OI × M × S
```

Where M = 100 (contract multiplier), S = spot price, OI = open interest.

**Units:** net_gex is "dollars of dealer delta change per 1% move in the underlying."
The 0.01 factor and S² term produce the ±15M-scale axis.

**Sign convention:** dealer-perspective long-calls/short-puts. Calls contribute +gamma,
puts contribute −gamma. A strike is **green when call gamma exceeds put gamma there, red when
put gamma dominates**. Because call and put gamma are identical for the same strike and expiry
(put-call parity), the sign is driven by the **call/put open-interest imbalance at that strike —
not by whether the strike is above or below spot.** When dealers are short gamma (negative GEX
regime), they must buy into rallies and sell into declines, amplifying moves. When long gamma
(positive GEX regime), they hedge against the move, dampening volatility.

## OI, Not Volume

Exposure is based on **open interest**, not volume. OI is as of the prior session close, so
the morning and afternoon snapshots of the same day share OI and differ only via spot, IV,
and greeks recompute. This is expected and matches how vendors publish it.

## HVL & GEX Profile — Public Approximations

**HVL (High Vol Level)** is the gamma-profile zero crossing nearest spot (v1.5.0).
A level that separates a positive-gamma regime above from a negative-gamma regime
below IS by definition the sign change of the gamma profile. Any other construction
cannot partition the price axis that way.

**v1.5.0 (FIX 28–29):** the inflection rule was **retired**. Evidence from the
published 2026-07-24 pm run: increment 2.5, mask radius 1.5×2.5 = 3.75, masked
strikes [520, 550]. First grid point outside the mask = 550 + 3.75 → 553.8175.
Published hvl_inflection = 553.8175 EXACTLY. The inflection did not move away from
the OI cluster under masking; it walked to the mask boundary. Curvature peaks where
gamma concentrates, so this rule structurally re-finds the dominant put wall. The
indeterminacy band (v1.4.0) is also retired — HVL is always a single defined level.

Published fields:
- `hvl`: the zero crossing nearest spot (single number, snapped to strike increment)
- `hvl_distance_pct = (hvl − spot) / spot`
- `hvl_regime_note`:
  - |d| ≤ 0.03 → "near spot — regime flip in play"
  - 0.03 < |d| ≤ 0.08 → "moderately distant"
  - |d| > 0.08 → "far from spot — no nearby regime flip; sustained {sign} gamma"
- `hvl_status`: "ok" or "no_flip_in_range" (if no crossing exists even on the ±40% grid)
- `hvl_crossings`: all zero crossings found on the grid

If no crossing exists in the ±25% display grid, the GEX profile grid is widened to
±40% and the search retried. Still none → `hvl = null`, `hvl_status = "no_flip_in_range"`,
and the chart annotates "no gamma flip within ±40% of spot". That is a real market
state, not an error.

**GEX Transition (v1.5.0, FIX 30):** the per-strike bar sign flip (formerly `bar_flip`)
is promoted to its own named level. It marks where strike-level net gamma changes sign
locally. HVL marks where TOTAL portfolio gamma changes sign. They coincide only in
balanced chains; a large concentrated wall separates them. Hardened against noise:
the sign must hold for at least 3 consecutive populated strikes on each side of the
candidate. Published as `gex_transition`, `gex_transition_status`, `gex_transition_distance_pct`.
Plotted as a fifth dashed key level, colour #7FA6C9.

The **GEX Profile** re-evaluates total net GEX as if spot were at each price level on a fine grid
(step = max(increment/5, 0.10)), recomputing Black-Scholes gamma at each level with reported IV held
constant. The **DEX Profile** is likewise simulated: total dealer delta exposure re-evaluated as if
spot were at each grid level. It is **generally rising in spot, with a V-shaped minimum** where
deep-ITM put delta dominates (at low s all puts are deep ITM, delta ≈ −1, so total dex ≈ −100·s·OI_put
decreases in s; at high s calls dominate and it increases). Its zero-crossing nearest spot is the
`delta_neutral` level; the V minimum is `dex_min_price`. Both are window-independent. This is a
simulation, not MenthorQ's proprietary method.

**Profile axes (v1.2.0):** the two profile curves have **different units** — GEX Profile is "$ per 1%
move", DEX Profile is "$ delta notional" — and differ by 1–2 orders of magnitude, so each gets its
**own colour-matched x-axis** (GEX = yellow, top; DEX = orange, bottom-offset). All three x-axes are
symmetric about zero, so their zeros coincide. In the default `profile_axis_mode = "spot_relative"`
the axis limits are a fixed formula of spot (`±k·spot²·1e-2·ref_oi`, k tuned once per profile), so a
profile's distance from zero is **comparable across snapshots for a given ticker**. In `"autoscale"`
mode the limits are per-chart (`±1.10·max|profile|`) and are NOT comparable across snapshots. The
active mode is printed in the chart footer.

## Timestamp (UTC → ET)

Cboe's `timestamp` field is **UTC**. It is converted to America/New_York for display (the chart
title shows the real suffix, "EDT" or "EST", never hardcoded). **Charts published before v1.1.0
mislabelled this timestamp** (treated UTC as if it were ET, ~4–5 h off); regenerate from cache to
correct them.

## Data Source & Legal

- **Primary:** Cboe free delayed quotes JSON (~15 min delayed, no API key, full chain + per-contract greeks).
- **Fallback:** If Cboe 403s twice, a yfinance-style chain can be used with self-computed greeks
  (chart footer marked "SOURCE: FALLBACK").

**15-minute delay:** The pm snapshot labels the API's own UTC timestamp (converted to ET,
typically ~15:44 ET), not 15:59. This is the actual data timestamp; we do not fake the clock.

**Legal note:** Cboe delayed data is for personal/non-redistribution use.
Check Cboe terms of service before publishing charts publicly.

## Schedule / Crontab

Slot windows (America/New_York): am = 10:00, pm = 15:59.
With `--slot auto`, the tool refuses to run unless now() is within ±12 minutes of a slot
AND today is an NYSE trading day.

Ready-to-paste crontab:

```cron
TZ=America/New_York
0 10 * * 1-5   cd /path/to/gex-project && python -m gex.snapshot --tickers SMH,SPY --slot am
59 15 * * 1-5  cd /path/to/gex-project && python -m gex.snapshot --tickers SMH,SPY --slot pm
```

## CLI

```
python -m gex.snapshot --tickers SMH,SPY --slot auto|am|pm --from-cache --outdir out
```

- `--tickers`  Comma-separated ticker symbols
- `--slot`     auto (schedule guard), am, or pm
- `--from-cache`  Load from cached gzipped JSON (offline / reproducible)
- `--exclude-front-expiry`  Also emit a second chart with the 0DTE front expiry removed
- `--outdir`   Output directory (default: out)
- `--cache-dir`  Cache directory (default: data/raw)
- `-v`         Verbose logging

## Output

For each ticker and snapshot:
- `out/{SYMBOL}/{YYYY-MM-DD}_{am|pm}.png` — the chart
- `out/{SYMBOL}/{YYYY-MM-DD}_{am|pm}.json` — all computed levels
- `out/{SYMBOL}/{YYYY-MM-DD}_{am|pm}.parquet` — per-strike table

## Outlier Handling (v1.3.0)

A single strike can dominate the GEX axis (e.g. SMH 550 with a large 0DTE put
position). The bar axis is **always linear** (symlog was removed in v1.3.0 — it
distorts a linear dollar quantity).

- `xlim = clean(1.15 × p97)` of |net_gex|.
- The limit is widened to include a key-level strike (`put_support` /
  `call_resistance`) **only if** `1.05 × |net_gex|` there is ≤ 3× the p97-derived
  limit. Beyond 3×, the bar is clipped and annotated instead.
- Every clipped bar is drawn to the axis edge with a `»`/`«` marker and a text
  label showing its **true value** (e.g. `-712M`) just inside the axis, in the bar
  colour — so the real magnitude is always visible even when the bar is clipped.
- The footer reports the clipped count and the max |net GEX| strike.
- An **`outlier_report`** is written to JSON: the top 5 strikes by |net_gex|,
  each with `oi_call`, `oi_put`, and a per-expiry breakdown
  (`[{expiry, oi_call, oi_put, gex}]`), so a reader can judge whether a dominant
  bar is real (concentrated LEAPS/0DTE OI) or a parsing artifact.

## Dealer-Proxy Honesty (v1.3.0)

Exposure uses gross open interest as a dealer-inventory proxy: it assumes dealers
are long every call and short every put. Vendors such as MenthorQ classify
customer-vs-dealer positioning, which requires signed trade data unavailable in
free feeds. Absolute dollar magnitudes are therefore NOT comparable between
implementations — the unit convention (whether S² is included, and how positions
are netted) differs and is not publicly documented. We state our formula in full
and make no claim to match any vendor's scale. Compare shape, level locations and
sign — not dollar values. Our figures are an upper bound on dealer gamma, not an
estimate of it.

The config field `dealer_proxy = "gross_oi"` is published in every JSON output.

## Expiry Concentration (v1.3.0, revised v1.4.0)

`gex_by_expiry = [{expiry, dte, sum_abs_gex, share}]` is published for every expiry.

**v1.4.0:** the concentration warning now keys on the **dominant** expiry, not the
front one. Published fields:

- `max_expiry_share`, `max_expiry`, `max_expiry_dte` — argmax over gex_by_expiry.
  The WARNING and chart subtitle fire on `max_expiry_share > 0.40`, naming that
  expiry: `"{max_expiry} ({dte}DTE) = {share:.0%} of total |GEX|"`.
- `top3_expiry_share` — sum of the three largest shares. A chart note fires when
  `top3_expiry_share > 0.75`: "chart dominated by {n} expiries".
- `front_expiry_share`, `front_expiry`, `front_expiry_dte` — kept as separate fields
  for reference (the front expiry is not necessarily the dominant one, especially in
  the exfront variant).

This fixes the v1.3.0 bug where the exfront variant reported `front_expiry_share = 0.028`
and stayed silent while 2026-07-31 held 51% of total |GEX|.

CLI flag `--exclude-front-expiry`: when the front expiry has DTE == 0, a second
chart `{date}_{slot}_exfront.png` is also produced with that expiry removed. The
default "All Expirations" chart is always produced — this never replaces it.

## Bars ↔ Profile Reconciliation (v1.3.0)

The bars use Cboe's reported gamma; the profiles use recomputed Black-Scholes
gamma. These are two independent paths to the same quantity, so the simulated GEX
profile evaluated at s = spot must approximately equal the total net GEX summed
over the **same contract set** the profile uses (the full chain, not the ±12%
banded bar set). The JSON publishes `reconciliation = {total_net_gex,
profile_at_spot, rel_err, pass}` where `pass = rel_err < 0.10`. A WARNING is
logged when `pass` is false. `total_net_gex_full` and `total_net_gex_band` are
both published so the difference is visible.

Time-to-expiry is now **continuous** (whole NYSE sessions after today plus the
fraction of today's 6.5-hour session remaining, floored at ~half an hour), using
the snapshot timestamp converted to ET — so `--from-cache` reproduces exactly.
This fixed the bars-vs-profile disagreement that occurred on expiration days when
0DTE was floored at a full trading day.

## ATM IV / Expected Move (v1.3.0, revised v1.4.0)

The picker uses the **nearest expiry with DTE ≥ 5 AND total expiry OI ≥ max(5000,
0.02 × chain_total_oi)** (v1.4.0 liquidity gate — never a near-dead expiry holding
a trivial fraction of the chain). Among survivors, the nearest by DTE is chosen.
`atm_iv_expiry_oi` and `atm_iv_expiry_rank` are published so the pick is auditable.

On the two strikes bracketing spot, each leg must have OI ≥ 250 and bid > 0
(two-sided market). **Both bracketing strikes must pass** for status "ok". If only
one passes, the IV is still computed but `atm_iv_status = "single_strike_no_interpolation"`
— never "ok".

**Term-structure cross-check (v1.4.0):** `atm_iv` is computed from the two nearest
qualifying expiries. `atm_iv_alt` and `atm_iv_term_spread` are published. If the two
differ by more than 0.15 absolute, `atm_iv_status = "term_structure_unstable"`.

**Sanity gate:** if the resulting `atm_iv` is outside [0.05, 1.50] or no expiry/strike
passes the filters, `atm_iv`, `exp_move_pct`, `min_price`, `max_price` are set to
null and `atm_iv_status = "rejected: <reason>"`. Never publish a number we cannot
defend. `atm_iv_expiry`, `atm_iv_dte`, and `atm_iv_source_strikes` are published
so the pick is auditable.

**Independent cross-check (v1.5.0, FIX 32):** `atm_iv` is compared against
`realised_vol_20d`, the annualised realised volatility from 20 trading days of
underlying closes (Yahoo Finance daily chart API, no paid source). Published:
- `iv_hv_ratio = atm_iv / realised_vol_20d`
- `vol_regime` ("IV > HV" / "IV < HV")
- `atm_iv_source_detail`: bid/ask/OI/IV of the two source contracts, for full traceability

Gate: if `iv_hv_ratio > 2.5` or `< 0.4`, `atm_iv_status = "iv_hv_outlier — verify"`
and `exp_move_pct`/`min_price`/`max_price` are suppressed from the chart (kept in
JSON with the flag). This flags an IV number that is implausible relative to recent
realised moves without discarding it.

## Vertical-Spread Detection (v1.5.0, revised v1.6.0 FIX 33/37)

Gross OI counts both legs of a vertical spread as dealer-short puts (or dealer-long
calls), so their gamma ADDS — when in a real book the legs substantially offset. This
is the mechanism behind an inflated `gex_put_call_ratio` and a distant HVL.

`detect_spread_candidates(df_full)` finds, within each expiry and right (P/C), strike
pairs where:
- `min(oi_a, oi_b) / max(oi_a, oi_b) >= 0.80`
- both OI >= `max(2000, 0.5% of chain OI)` (v1.6.0: relative floor, was a fixed 10,000)
- `|strike_a − strike_b| <= 8 × increment`

**v1.6.0 greedy dedupe:** candidate pairs are sorted by `combined_abs_gex` descending
and a pair is accepted only if NEITHER strike is already used within that expiry+right.
This stops overlapping pairs (e.g. 527.5/530, 527.5/532.5, 530/532.5) all counting and
inflating the share.

Published as `spread_candidates`: `[{expiry, right, strike_low, strike_high, oi_low,
oi_high, combined_abs_gex, share_of_total_abs_gex}]`.

`spread_flagged_share = Σ combined_abs_gex / Σ|net_gex|` (recomputed from the deduped
set). If > 0.20, a WARNING is logged and a chart footnote added: "{n} probable
vertical-spread structures = {pct:.0%} of |GEX|; gross-OI proxy overstates net dealer
gamma here."

**Sensitivity (JSON only, not plotted):** `sensitivity_smaller_leg_sign_flipped`
(v1.6.0 rename of `sensitivity_spread_netted`) recomputes `total_net_gex` and the HVL
zero crossing with each flagged pair's SMALLER leg SIGN-FLIPPED. Netting a spread leg
flips its sign (gross scores −g×OI, netted +g×OI), so the adjustment is 2× the old
leg-removal proxy. Labelled clearly as an illustrative bound, NOT the headline number.
This is disclosure, not correction — the headline chart stays gross-OI.

## OI Sanity Check (v1.2.0)

The JSON includes **`oi_totals`** = `{call_oi, put_oi, n_contracts, n_expiries,
oi_by_dte_bucket: {"0-7", "8-30", "31-90", "91-365"}}` over the full chain. If the
put/call OI ratio exceeds 3.0 (unusual for broad ETFs like SMH), a WARNING is
logged and the note is shown in the HTML dashboard below the chart (not on the
chart image itself), prompting the reader to check the per-DTE-bucket breakdown
rather than trust the headline number blindly.

## Key Levels

| Level | Definition |
|-------|-----------|
| Call Resistance | Strike with maximum gex_call(K) |
| Put Support | Strike with minimum gex_put(K) (largest absolute put gamma) |
| HVL | Gamma-profile zero crossing nearest spot (v1.5.0). Single defined level with distance annotation and regime note. See HVL section. |
| GEX Transition | Per-strike net-GEX sign flip, persistence-hardened (v1.5.0). Local composition change, distinct from HVL. Colour #7FA6C9. |
| Spot Price | Current price from Cboe |

## Gamma Condition (v1.4.0)

`gamma_condition` is a **direct measurement**, not an inference from spot-vs-HVL:

```
gamma_condition = "POSITIVE" if profile_at_spot > 0 else "NEGATIVE"
```

Published alongside:
- `gamma_condition_basis = "sign of simulated GEX profile at spot"`
- `net_gex_at_spot` — the interpolated profile value at spot
- `distance_to_flip_pct = (hvl − spot) / spot` (the HVL zero crossing)

This replaces the v1.3.0 rule `spot > hvl → POSITIVE`, which was only valid when HVL
was a sign change. With the inflection default, the profile can be negative on both
sides of HVL, making the old derivation invalid.

## Ephemeral Levels (v1.4.0)

For each key level (call_resistance, put_support), the JSON publishes
`level_front_expiry_pct = |gex at that strike from the front expiry| / |gex at strike|`.
If > 0.50, the level is marked with a dagger (†) in the chart legend and a footnote:
`"† {level} {strike}: {pct:.0%} 0DTE — expires today"`. The JSON includes
`levels_ephemeral: [...]` listing all flagged level names.

On expiration days, the exfront chart shows the forward-looking structure with the
0DTE expiry removed — the two charts should be read side by side.

## DEX Minimum (v1.4.0)

`dex_min_price` is routed through `interior_extremum(arr, grid, edge_tol=2)`: if the
argmin is within 2 grid steps of either edge, `dex_min_price = null` and
`dex_min_status = "at_grid_boundary"`. The DEX profile grid is widened to ±40%
(`profile_band_dex = 0.40`) so the true V-minimum can be located; the GEX profile
stays at ±25%. If the minimum is still at the edge at ±40%, it reports null.

Also computed (JSON only): total_net_gex (= total_net_gex_full), total_net_gex_band,
reconciliation {total_net_gex, profile_at_spot, rel_err, pass}, gamma_condition,
gamma_condition_basis, net_gex_at_spot, distance_to_flip_pct, gex_put_call_ratio,
oi_put_call_ratio, 1d_exp_move_pct with min/max prices, atm_iv_status, atm_iv_expiry,
atm_iv_dte, atm_iv_expiry_oi, atm_iv_expiry_rank, atm_iv_source_strikes, atm_iv_alt,
atm_iv_term_spread, atm_iv_source_detail, realised_vol_20d, iv_hv_ratio, vol_regime,
delta_neutral (DEX-profile zero-crossing nearest spot), delta_neutral_crossings,
dex_min_price, dex_min_status, hvl, hvl_raw, hvl_status, hvl_distance_pct,
hvl_regime_note, hvl_crossings, hvl_rule, gex_transition, gex_transition_status,
gex_transition_distance_pct, spread_candidates, spread_flagged_share,
sensitivity_smaller_leg_sign_flipped, dealer_proxy, max_expiry_share, max_expiry, max_expiry_dte,
top3_expiry_share, front_expiry_share, front_expiry, front_expiry_dte, gex_by_expiry,
outlier_report, oi_totals, levels_ephemeral, and put_heavy_note / spread_note (when flagged).

**v1.6.0 additions:** endpoint_variant ("plain"/"underscore"), instrument_class
("index"/"equity_etf"), bands {atm_iv_used, sigma_30d, strike_band, plot_band,
profile_band, dex_band, band_basis}, gex_transition_raw, delta_neutral_raw,
dex_min_price_raw (unrounded levels; the headline fields are snapped to the strike
increment), and front_expiry_am_settled (true for AM-settled index front expiry).

## Tests

39 tests (v1.6.0): 36 from v1.5.0 + 3 new (add_ticker_no_code_change,
bands_scale_with_iv, grid_fixed_count).

```bash
pytest tests/ -v
```
