Net GEX All Expirations (gex) — a Python tool that produces a
MenthorQ-style dealer gamma-exposure chart for US-listed optionable tickers, from free
Cboe delayed data. ← back to chart
Generated 2026-07-28 17:14 UTC. This page publishes the complete methodology and full source code for independent audit by humans and LLM search/review agents.
.txt under /gex_src/. A
machine-readable integrity manifest (SHA-256 per file) is provided in
JSON so you can verify the embedded code matches the published files
byte-for-byte. No API keys are used or required. Fetch the raw files directly for parsing.
The pipeline is a straight line with a cache tap:
Run via python -m gex.snapshot --tickers SMH,SPY --slot pm. The
--slot auto guard refuses to run unless the current time (America/New_York) is
within ±12 minutes of a slot (10:00 or 15:59) and the day is an NYSE trading day.
Source-timestamp guard (FIX 49): the slot guard above checks the wall clock; this
second guard checks the data. After converting the chain’s own source timestamp to ET,
the snapshot is refused unless that timestamp falls on an NYSE trading day and within
09:30–16:15 ET. A separate age check refuses any source older than 240 minutes (and warns above
45), except under --from-cache, where the age refusal is skipped (logged as INFO)
so a cached snapshot can always be replayed for reproducibility — the trading-day and
09:30–16:15 checks stay active in every mode because they validate the data itself, not its age.
This is why there is no SPY or NDX chart over a weekend or after hours: the most recent feed carries a
Friday-night or Saturday source timestamp and is correctly rejected rather than published as stale.
Primary source (fixed decision): Cboe free delayed quotes JSON, ~15 min delayed, no API key, full chain with per-contract greeks.
OSI-ish symbol parsing via regex
^([A-Z]+)(\d{2})(\d{2})(\d{2})([CP])(\d{8})$ →
strike = int/1000, expiry = date(2000+yy, mm, dd). Spot S = current_price if > 0
else close. Cboe reports gamma positive for both calls and puts, so we take
abs(gamma); put delta is kept negative as given. Raw JSON is cached gzipped to
data/raw/{SYMBOL}_{snapshot_id}.json.gz before any processing, so snapshots
are reproducible offline via --from-cache.
Fallback (implemented, rarely used): if Cboe 403s twice, a yfinance-style chain (no
greeks) can be used with delta+gamma recomputed from mid-price-implied vol; the chart footer is
then marked SOURCE: FALLBACK.
| # | Rule | Rationale |
|---|---|---|
| 1 | Drop if raw calendar expiry_date < snapshot_date (FIX 48) | Already-expired contracts, caught before any business-day maths. Counted in expired_contracts_dropped. Without this, a contract that expired yesterday would floor to DTE 0 and its Black-Scholes gamma would explode as spot nears its strike. |
| 2 | Drop DTE < 0; keep DTE == 0 | 0DTE is part of "All Expirations" (redundant safety net behind rule 1) |
| 3 | Drop DTE > 365 | Long-dated LEAPS distort the near-term picture |
| 4 | Drop open_interest ≤ 0 | OI is the exposure basis |
| 5 | Drop iv ≤ 0 or gamma == 0 | Stale / unpriced strikes |
| 6 | Keep strikes within spot × (1 ± strike_band) | Focus on the relevant range. strike_band is IV-derived (FIX 35): clip(0.80 × sigma_30d, 0.04, 0.20); the plot window is the tighter plot_band = clip(0.50 × sigma_30d, 0.03, 0.15). The fixed 0.12 / 0.08 values are fallback constants used only when band_basis == "fallback" (no ATM IV resolvable). Bars only — profiles use the full chain. |
Time-to-expiry for the greeks recompute is calendar time (FIX 65):
T = minutes_to_settlement / (365 × 24 × 60), where settlement
is 16:00 ET (09:30 ET for AM-settled index expiries — third-Friday expiries
settle at the Thursday close, so their effective expiry is one calendar day earlier).
Cboe’s reported gamma reflects actual hours remaining; the old business-day
convention (full_days/252) diverged by ~1.28× in gamma for multi-day
expiries. pandas_market_calendars('NYSE') is retained only for the
trading-day guard and DTE labels.
Contract multiplier M = 100. Exposure basis is open interest, not volume.
Per-strike, summed across all expirations:
Units: net_gex is "dollars of dealer delta change per 1% move in the underlying." The
0.01 factor and the S² term are mandatory — they set the
dollar scale of the axis (the bar limit itself is data-driven: 1.15 × p97 of
visible |net_gex|, FIX 19/50). Sign convention is dealer-perspective long-calls / short-puts:
calls contribute +gamma, puts −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.
OI caveat: 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. This is expected and matches how vendors publish it.
Both profiles are simulated over a fixed profile_grid_points = 400 price grid
(np.linspace, so cost is constant across symbols). The grid span is derived from the
chain’s own ATM IV (FIX 35): sigma_30d = atm_iv × √(30/365), then
profile_band = clip(1.50 × sigma_30d, 0.06, 0.35) for GEX and
clip(2.50 × sigma_30d, 0.10, 0.50) for the wider DEX grid — the
band_limits guardrails keep the span sane on very low- or high-vol chains. The profiles
use the full, untruncated chain: every filtering rule is applied except the
strike-window band — expired, negative-DTE, >365-day, zero-OI, and unpriced/zero-gamma
contracts are all dropped, but no strike-range truncation is applied, so the far OTM open interest
shapes the wings and creates the zero-crossing. They are window-independent and are
not rescaled onto the GEX bar axis.
Total net GEX of the whole chain re-evaluated as if spot were at each price level:
This recompute is required — the static reported gamma is not reused. HVL (below) is derived from this profile.
Total dealer delta exposure re-evaluated as if spot were at each price level:
This 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, which decreases in s; at high s calls dominate and it
increases. It is not monotonic. Its zero-crossing nearest spot is the
delta_neutral level; the V minimum is dex_min_price. Both are
window-independent and written to JSON. The earlier cumulative-sum DEX was removed — its
offset was an artifact of the lowest band strike.
The two profiles have different units — GEX Profile is "$ per 1% move", DEX Profile
is "$ delta notional" — and differ by 1–2 orders of magnitude, so a single shared axis
would crush the GEX curve to a flat line. Each therefore gets its own colour-matched x-axis:
GEX Profile on a top axis (yellow, cfg.gex_profile_color), DEX Profile on a
bottom-offset axis (orange, cfg.dex_color). All three x-axes are symmetric about zero,
so their zeros coincide (checked in code to < 0.5 px; a misalignment logs a WARNING rather than
asserting, since asserts are stripped under -O). Colour-matching axis to curve is the
cue for which axis reads which.
Axis mode (FIX 50, v1.6.1): the default profile_axis_mode = "data" sets each
axis limit to ±1.10 × max|profile| measured over the visible window
only (not the full grid), so the curves always fill the axis — simple and correct on every
chain regardless of OI scale. For cross-snapshot comparability, "rolling" mode sets the
limit to ±1.2 × the median of that ticker's last
rolling_window = 20 profile maxima (read from the per-ticker history CSV), which is
OI-aware by construction; it falls back to "data" when fewer than 5 snapshots exist.
The chosen limit, the data maximum, and their ratio are logged; a ratio above 5 logs a WARNING (the
signature of a scaling bug). The active mode is printed in the chart footer.
Deleted (FIX 50): the old "spot_relative" formula
(±k·spot²·1e-2·axis_ref_oi) and the fixed
axis_ref_oi constant were removed. That reference scaled as spot² with
a fixed OI, but real exposure scales as spot² × the chain's actual OI, so on
low-OI chains (e.g. NDX) the formula made the axis dwarf the data and crushed the curves.
Profile outlier guard (FIX 48, v1.6.2): both profile functions apply a per-contract
guard that removes a contract from the published curves only if it is both dominant
(peak |contribution| > 5× the sum of all other contracts at that grid point)
and a narrow spike (half-peak width < 5% of spot, measured in price terms so
the threshold means the same on every grid and every chain). That combination is the signature of
an expired contract whose floored time-to-expiry collapsed Black-Scholes gamma to a delta function;
legitimate dominant structure (the ATM 0DTE, or a far-OTM wing-shaper) is either not dominant or not
narrow, so it is kept. Because FIX 48 already drops expired contracts by raw calendar date before
they reach the profile, this guard should be unreachable in normal operation — any firing
is treated as a fault: logged at ERROR and surfaced as a red “⚠ FAULT” footnote on the
chart. Dropped contracts are published as profile_outliers_dropped (strike, expiry, value,
price_width_pct).
| Level | Definition |
|---|---|
| Call Resistance | Strike with the maximum gex_call(K), all expirations |
| Put Support | Strike with the minimum gex_put(K) (largest absolute put gamma) |
| HVL (High Vol Level) | v1.5.0: gamma-profile zero crossing nearest spot. 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. Published: hvl (single number, snapped to increment), hvl_distance_pct = (hvl−spot)/spot, hvl_regime_note (near spot / moderately distant / far from spot), hvl_status ("ok" or "no_flip_in_range"), hvl_crossings. If no crossing in ±25% grid, widened to ±40% and retried. Still none → hvl = null, chart annotates "no gamma flip within ±40% of spot". v1.5.0 (FIX 28): inflection rule RETIRED. Evidence: increment 2.5, mask radius 1.5×2.5 = 3.75, masked strikes [520, 550]. First grid point outside mask = 550 + 3.75 → 553.8175. Published hvl_inflection = 553.8175 EXACTLY. The inflection walked to the mask boundary; curvature peaks where gamma concentrates, so this rule structurally re-finds the dominant put wall. The v1.4.0 indeterminacy band is also retired. |
| GEX Transition | v1.5.0 (FIX 30): per-strike net-GEX sign flip (formerly bar_flip), promoted to its own named level. Marks where strike-level net gamma changes sign locally (distinct from HVL which marks where TOTAL portfolio gamma flips). Persistence-hardened: sign must hold for ≥3 consecutive populated strikes on each side. Published: gex_transition, gex_transition_status, gex_transition_distance_pct. Colour #7FA6C9. |
| Spot Price | S from §2 |
Also computed and written to JSON (not plotted): total_net_gex
(= total_net_gex_full), total_net_gex_band,
reconciliation {total_net_gex, profile_at_spot, rel_err, pass},
gamma_condition (v1.4.0: direct measurement — POSITIVE if
profile_at_spot > 0 else NEGATIVE; the old spot-vs-HVL inference was
invalid once HVL became an inflection point), gamma_condition_basis,
net_gex_at_spot, distance_to_flip_pct,
gex_put_call_ratio, oi_put_call_ratio,
1d_exp_move_pct = atm_iv × sqrt(1/252) 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 (bid/ask/OI/IV
of the two source contracts), realised_vol_20d (annualised, from 20
daily closes via Yahoo Finance chart API), iv_hv_ratio,
vol_regime ("IV > HV" / "IV < HV"),
delta_neutral (DEX-profile zero-crossing nearest spot),
delta_neutral_crossings (a V-shape admits two),
dex_min_price (DEX-profile V minimum; v1.4.0: null with
dex_min_status = "at_grid_boundary" if the argmin is within 2 grid
steps of either edge), 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 (illustrative bound, NOT the headline),
endpoint_variant, instrument_class, bands,
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
(v1.4.0: key levels that are >50% front-expiry OI, marked with a dagger on
the chart), and put_heavy_note / spread_note
(when flagged).
matplotlib, dark theme. Figure 10×9 in, dpi 110, facecolor
#0B0B0B, axes #000000. Horizontal bars (y = strike, x = GEX) on the
main axis; the two profile curves each on their own colour-matched x-axis (see §5).
Plot window (v1.1.0): the window is widened so it always contains the key levels —
lo = min(spot×(1−plot_band), put_support) − 2·increment,
hi = max(spot×(1+plot_band), call_resistance, hvl) + 2·increment, snapped to
the increment, with plot_band (IV-derived: clip(0.50×sigma_30d, 0.03, 0.15);
0.08 is the fallback constant used only when band_basis == "fallback") as the minimum span.
Robust x-limits + honest clipping (v1.3.0): the GEX bar axis is always linear
(symlog was removed — it distorts a linear dollar quantity).
xlim = clean(1.15 × p97) of |net_gex|, where
clean rounds up to a step from {1M, 5M, 10M, 25M, 50M, 100M, …}.
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. The footer reports the
clipped count and the max |net GEX| strike. An outlier_report (top 5 strikes by
|net_gex| with OI and a per-expiry breakdown) is written to JSON so a dominant bar can be judged
real vs. artifact.
Bars (v1.3.0, reworked FIX 60): fully opaque (alpha=1.0), deeper tones, no
edge. Strikes are aggregated into buckets targeting ~40 visible bars: bucket =
render_bucket(window_span, strike_increment), picking the ladder value
[0.5, 1, 2.5, 5, 10, 25, 50, 100, 250, 500] ≥ strike_increment whose bar count
lands closest to 40. Bar height = 0.8·bucket. Bucketing is
rendering only — call_resistance, put_support, hvl, gex_transition, delta_neutral and
the outlier report all stay at true strike resolution. Published: render_bucket
alongside strike_increment. SMH (span ~98, increment 1) → bucket 2.5;
NDX (span ~2310, increment 10) → bucket 50. Z-order: grid 0, bars 3, key-level hlines 4,
profile lines 5 — so bars never occlude the curves and the curves never hide behind the grid.
| Element | Color |
|---|---|
| Positive GEX bars | #2E9E4F |
| Negative GEX bars | #A32B20 |
| DEX Profile | #E08A3C |
| GEX Profile | #E8D44D |
| Call Resistance | #E03B3B (red, dashed) |
| Put Support | #3CB371 (dashed) |
| HVL | #8C8C3B (dashed; single line with distance annotation and regime note) |
| GEX Transition | #7FA6C9 (dashed; local net-GEX sign change) |
| Spot Price | #6E3B34 (dashed) |
Call Resistance color note: MenthorQ's written guide says green, but their chart itself uses red. We follow the chart (red).
The JSON carries two diagnostic blocks so a reader can audit a dominant bar or an unusual put/call ratio rather than trusting the headline number.
outlier_report.top_strikes[] — top 5 strikes by |net_gex|, each with
strike, net_gex, oi_call, oi_put, and
by_expiry[] = {expiry, oi_call, oi_put, gex}. Worked example (SMH
2026-07-24 pm): the dominant bar is K=550 (Put Support), net_gex ≈ −712M, of which
−656M comes from 67,681 puts in the 0DTE expiry — i.e. a real, concentrated
same-day put position, not a parsing artifact.
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. For SMH this
gives put/call OI = 4.84 (band frame), which trips the >3.0 put-heavy warning; the DTE-bucket
breakdown (0-7: 665k, 8-30: 498k, 31-90: 490k, 91-365: 400k) shows the concentration is in the
front week, consistent with active 0DTE/weekly hedging rather than stale LEAPS.
timestamp field is UTC and is converted to
America/New_York for display (the title shows the real "EDT"/"EST" suffix). Charts published
before v1.1.0 mislabelled this timestamp (UTC was treated as ET, ~4–5 h off); regenerate
from cache to correct them.oi_totals (call/put OI, contract
and expiry counts, and OI by DTE bucket). If put/call OI 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), so a reader checks the
per-DTE-bucket breakdown rather than trusting the headline ratio blindly.gex_by_expiry is published
for every expiry. v1.4.0: the concentration warning now keys on the dominant
expiry (max_expiry_share, max_expiry, max_expiry_dte),
not the front one. top3_expiry_share is also published; a chart note fires when
it exceeds 0.75. front_expiry_share is kept as a separate reference field. This
fixes the v1.3.0 bug where the (since-removed) ex-front variant reported
front_expiry_share = 0.028 and stayed silent while 2026-07-31 held 51% of total
|GEX|. v1.8.0 (FIX 97): the ex-front second chart and its
--exclude-front-expiry flag were removed; there is now a single
“All Expirations” chart per ticker/slot.reconciliation (signed rel_err between profile-at-spot and
total_net_gex_full) and rel_err_unsigned (Σ|per-expiry gap| /
Σ|reported GEX|, immune to signed cancellation). profile_reliable requires
BOTH rel_err < 0.05 AND rel_err_unsigned < 0.10;
reconciliation_pass_basis names which metric bound the decision
(signed/unsigned/signed+unsigned).reconciliation_denominator_basis: "variant". Each chart's curve-derived levels
(HVL, GEX Transition, delta-neutral) are fit to its own bars, so the validation that matters is
“does THIS profile reproduce THESE bars”. With the ex-front variant removed in
v1.8.0 there is a single book per ticker/slot (the full chain), so both denominators
(rel_err_denominator, rel_err_unsigned_denominator) describe the full
chain; they remain published for auditability.min_minutes_to_settlement = 30 of settlement is excluded from the reconciliation
numerator AND denominator, but is still plotted from reported gamma. Near settlement,
T → 0 and gamma ∝ 1/√T, so the recomputed profile is hypersensitive to the
~15-minute-delayed quote feed — a 5-minute-old quote against a 5-minute-to-settlement
expiry produces a spurious reconciliation gap that says nothing about the code. Published as
reconciliation_scope: "full" | "excl_near_settlement" and
reconciliation_excluded_expiries (each excluded expiry with its
minutes_to_settlement), so the exclusion is fully transparent. Live effect: NDX
signed 0.111 → 0.006, SMH signed 0.0226 → 0.0012.reconciliation_excluded_share = Σ|net GEX per excluded expiry| /
Σ|net GEX per strike| — the SAME net basis as gex_by_expiry.share, so the
two are directly comparable. (FIX 90 originally mixed a gross numerator with a gross denominator;
FIX 94 put both on the net basis.) A reader knows the pass covers (1 − share), not 100%.
The gex_by_expiry field is net_gex = |Σ GEX| (the absolute value
of the net per-expiry GEX), NOT Σ|GEX| (the sum of per-contract absolutes) — renamed
from sum_abs_gex in FIX 94 to say what it is.
reconciliation_pass_basis is disambiguated: "both_pass" (both
gates passed), "signed" / "unsigned" (a single gate
breached), "signed+unsigned" (both breached),
"excluded" (reconciliation undefined), and
"indeterminate" (precision-limited, FIX 88).gex_version and
schema_version in the canonical key set, populated from
gex/__init__.py. Identical snapshot_id values across versions can
produce different profile values (FIX 84 changed the settlement clock, moving NDX
rel_err 0.005877 → 0.006652 for the same capture), so byte-identity under
FIX 75 holds only WITHIN a version — the history needs the version to be readable.grid_rounding method. reconciliation_floor_unsigned
takes the recomputed Black-Scholes gamma as the full-precision TRUTH, rounds it to the observed
publication grid (Cboe publishes gamma to 4dp, interval ±0.5×10−4),
and measures the aggregate unsigned error of the rounded-vs-unrounded difference. Because BOTH
sides derive from the recomputed gamma, the genuine model error cancels and only quantisation
noise remains. This is deterministic (FIX 93): rounding to a fixed grid is not a random
process — each gamma rounds to exactly one published value — so the floor is a single
scalar, not a Monte Carlo distribution. (The v1.7.8 version simulated 2000 draws and reported a
median/p95 spread, implying a variability that does not exist.) The actual unsigned error can sit
BELOW this floor (NDX: 0.10345 vs floor 0.13606) because the reported gamma is itself rounded to
the same grid, so the two rounding errors partially cancel — the floor is an upper bound on
the rounding contribution, not a prediction of the actual error. When the floor brackets the base
gate, a per-symbol threshold is derived as floor × 1.5
(unsigned_floor_multiplier) but CAPPED at 2× the base gate
(unsigned_gate_cap_multiplier = 2.0, so ≤ 0.20). If the floor STILL brackets the
base gate and the unsigned error is not cleanly below it, the result is precision-limited:
profile_reliable: false (boolean — FIX 92, never the truthy string
"indeterminate") with
unsigned_gate_status: "precision_limited" and
reconciliation_pass_basis: "indeterminate". Model error cannot be separated
from publication rounding at this symbol’s gamma precision. This is NOT a pass. Since
v1.8.0 (FIX 95) the chart image carries no reconciliation banner; the precision-limited state
surfaces in the collapsible reconciliation section beneath the chart (amber, visually distinct
from a red definitive FAIL). Published:
reconciliation_floor_unsigned (method, rounding_interval, floor, brackets_gate),
unsigned_gate_effective, and unsigned_gate_status
("base" / "derived" / "capped" / "precision_limited").--from-cache reproduces exactly.
Settlement clock (v1.7.7, FIX 84): the settlement time is instrument-class-aware and
shared by BOTH the T clock and the raw minutes_to_settlement via a
single _settlement_time(am_settled, instrument_class) helper: 16:15 ET for
PM-settled index options (NDX), 09:30 ET (one day earlier) for AM-settled index
expiries, and 16:00 ET for equity/ETF (SPY, SMH). Before FIX 84 both clocks assumed
16:00 for everything, so the NDX 0DTE captured at 15:54 ET showed only 5.6 min to settlement
instead of the correct 20.6 min. The assumption is published per excluded expiry as
settlement_time_et inside reconciliation_excluded_expiries, so it is
auditable.atm_iv_expiry_oi and atm_iv_expiry_rank are published. 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,
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 atm_iv
is outside [0.05, 1.50] or no expiry/strike passes the filters, the value is set to null and
atm_iv_status = "rejected: <reason>". Never publish a number we cannot
defend.atm_iv (and its
atm_iv_expiry/atm_iv_source_strikes) comes from PASS 2, run on
df_band — the strike-band-filtered chain. bands.atm_iv_used comes
from PASS 1 (_pass1_atm_iv), run on df_coarse — a DTE-filtered
chain (DTE 5–60). Because the two passes run on different contract sets, they can pick
different expiries/strikes. The pass-1 source is now published as
bands.atm_iv_source_expiry and bands.atm_iv_source_strikes (always
present, null when pass-1 fell back to realised vol), so any divergence is auditable. Live
example: SMH top-level 0.5752 (2026-08-21/545.0) vs bands 0.5861 (2026-08-14/550.0); NDX both
0.286 because both passes picked 2026-08-07/28000.0.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). 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).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),
and |strike_a − strike_b| ≤ 8 × increment.
v1.6.0 greedy dedupe: pairs are sorted by combined |GEX| descending and a pair is accepted
only if NEITHER strike is already used within that expiry+right, so overlapping pairs
(e.g. 527.5/530, 527.5/532.5, 530/532.5) no longer all count and inflate the share.
Published as spread_candidates. 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. 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.Each published .txt mirror is byte-identical to its source file. SHA-256 hashes let
an auditor verify the embedded code matches the published files. Manifest also available as
gex_src/manifest.json.
| Source file | Published as | Bytes | SHA-256 (prefix) | Mirror OK |
|---|---|---|---|---|
gex/__init__.py | gex_src/gex___init__.py.txt | 539 | 77dec106cbb25afe… | ✓ |
gex/config.py | gex_src/config.py.txt | 7900 | 708c9ff16be65be4… | ✓ |
gex/fetch.py | gex_src/fetch.py.txt | 9290 | 28491686e62b0adf… | ✓ |
gex/greeks.py | gex_src/greeks.py.txt | 2306 | 0b00785c0b692186… | ✓ |
gex/compute.py | gex_src/compute.py.txt | 73424 | 24a6f784d86a9342… | ✓ |
gex/plot.py | gex_src/plot.py.txt | 25391 | 2e75dbc992aa5ab9… | ✓ |
gex/snapshot.py | gex_src/snapshot.py.txt | 63352 | 74557d602e9b0d21… | ✓ |
tests/test_compute.py | gex_src/test_compute.py.txt | 162521 | 62b23f03774a71f1… | ✓ |
requirements.txt | gex_src/requirements.txt | 131 | baa0665820e95a47… | ✓ |
README.md | gex_src/README.md | 19761 | 946e7b38e4291276… | ✓ |
{
"project": "Net GEX All Expirations (gex)",
"generated_utc": "2026-07-28 17:14 UTC",
"license_note": "Source published for public audit. No API keys required (Cboe delayed data, no key).",
"files": [
{
"file": "gex/__init__.py",
"published_as": "gex_src/gex___init__.py.txt",
"role": "Package marker + version",
"bytes": 539,
"sha256": "77dec106cbb25afe07ed35af6f99e49a86f9f155b267e266e4b0227e9f04ca89",
"mirror_matches_source": true
},
{
"file": "gex/config.py",
"published_as": "gex_src/config.py.txt",
"role": "Dataclass config: colors, defaults, URLs, schedule",
"bytes": 7900,
"sha256": "708c9ff16be65be4493bcc8e3b438bc8395e4f7f60b85310cfd8540c4af04365",
"mirror_matches_source": true
},
{
"file": "gex/fetch.py",
"published_as": "gex_src/fetch.py.txt",
"role": "Cboe delayed-quotes fetch, retry/backoff, gzip cache, OSI symbol parser",
"bytes": 9290,
"sha256": "28491686e62b0adf6dd8025b523be3e43a680087c6d2aefe3c84ea9d6ecf33ee",
"mirror_matches_source": true
},
{
"file": "gex/greeks.py",
"published_as": "gex_src/greeks.py.txt",
"role": "Black-Scholes gamma/delta (scalar + vectorised)",
"bytes": 2306,
"sha256": "0b00785c0b692186809b889534aa965c8a82944b05f6d4e177342c14195441e2",
"mirror_matches_source": true
},
{
"file": "gex/compute.py",
"published_as": "gex_src/compute.py.txt",
"role": "Filtering, GEX/DEX aggregation, profiles, HVL, key levels",
"bytes": 73424,
"sha256": "24a6f784d86a9342364dc6ec227f7743522ae235426576a92eb81645ace13289",
"mirror_matches_source": true
},
{
"file": "gex/plot.py",
"published_as": "gex_src/plot.py.txt",
"role": "matplotlib chart (horizontal bars, profiles, key levels)",
"bytes": 25391,
"sha256": "2e75dbc992aa5ab956855d4c009ba435c51eea95d4474fc538a9548fe0b04abe",
"mirror_matches_source": true
},
{
"file": "gex/snapshot.py",
"published_as": "gex_src/snapshot.py.txt",
"role": "CLI entrypoint + schedule guard",
"bytes": 63352,
"sha256": "74557d602e9b0d21c1d492dfc3f4d8423482572829938c6c3d3b4228260e7fd8",
"mirror_matches_source": true
},
{
"file": "tests/test_compute.py",
"published_as": "gex_src/test_compute.py.txt",
"role": "Pytest suite (75 tests)",
"bytes": 162521,
"sha256": "62b23f03774a71f12d2073db74fd3e0290f54b70c602ec98f3805e8715787505",
"mirror_matches_source": true
},
{
"file": "requirements.txt",
"published_as": "gex_src/requirements.txt",
"role": "Pinned dependency list",
"bytes": 131,
"sha256": "baa0665820e95a474b7b9a8ebd82fb3152a133bc6eeaabc50b0a1058e4c76d84",
"mirror_matches_source": true
},
{
"file": "README.md",
"published_as": "gex_src/README.md",
"role": "User-facing documentation",
"bytes": 19761,
"sha256": "946e7b38e4291276f586e504765ecfe368e6b0920b7f01f78a417cd602a3b845",
"mirror_matches_source": true
}
]
}Complete source, HTML-escaped below. Each block links to a raw .txt for direct
parsing. Verified identical to the published mirrors (see manifest).
"""gex — Net GEX All Expirations chart tool."""
__version__ = "1.8.0"
# FIX 89: canonical schema version, stamped into every artifact as schema_version.
# Bump whenever the canonical top-level key set changes. v1.8.0: FIX 95 moved
# reconciliation off the PNG (no key change); FIX 96 added tickers (no key change);
# FIX 97 removed the ex-front variant (variant field retained as constant
# "all_expirations", so the key set is unchanged). Bumped for the furniture/
# pipeline change and the new ticker set.
__schema_version__ = "1.8.0"
"""Dataclass config, colors, defaults."""
from dataclasses import dataclass, field
from typing import Dict, List
@dataclass
class GexConfig:
# --- data source ---
cboe_url_a: str = "https://cdn.cboe.com/api/global/delayed_quotes/options/{symbol}.json"
cboe_url_b: str = "https://cdn.cboe.com/api/global/delayed_quotes/options/_{symbol}.json"
user_agent: str = "Mozilla/5.0 (compatible; gex-snapshot)"
retries: int = 3
backoff_base: float = 2.0 # seconds; delays = base * 2^attempt
# --- tickers (FIX 34) ---
# Adding a ticker requires appending ONE string here — nothing else.
# FIX 96: added NVDA, GOOGL, AAPL (single-name equities; plain Cboe endpoint,
# instrument_class equity_etf, 16:00 ET settlement — all auto-derived).
tickers: List[str] = field(default_factory=lambda: ["NDX", "SPY", "SMH", "NVDA", "GOOGL", "AAPL"])
default_ticker: str = "NDX"
# Endpoint cache: data/endpoint_map.json records which URL variant worked per symbol.
endpoint_map_path: str = "data/endpoint_map.json"
# Contract-specification overrides ONLY (multiplier, settlement style).
# Never tuning parameters. Ship empty.
contract_spec_overrides: Dict[str, Dict] = field(default_factory=dict)
# Display labels — purely presentational, never affects computation.
display_labels: Dict[str, str] = field(default_factory=lambda: {
"NDX": "NDX (Nasdaq-100)",
"SPY": "SPY (S&P 500 ETF)",
"SMH": "SMH (Semiconductor ETF)",
"NVDA": "NVDA (Nvidia)",
"GOOGL": "GOOGL (Alphabet)",
"AAPL": "AAPL (Apple)",
})
# --- filtering ---
dte_max: int = 365
# FIX 80: expiries inside this many minutes of settlement are excluded from the
# reconciliation numerator and denominator (still plotted from reported gamma).
# Near settlement, T -> 0 and gamma ∝ 1/sqrt(T) makes the Black-Scholes
# recompute unstable against Cboe's ~15-min-delayed quote feed, so the
# reported-vs-recomputed gap for the front expiry is dominated by the feed lag,
# not by a real model error. Scoring reconciliation on those expiries produces
# false failures. 30 min is the starting threshold.
min_minutes_to_settlement: int = 30
# FIX 85: when the empirical gamma-rounding floor (Monte Carlo, p95) brackets
# the unsigned gate (0.10), the gate is dominated by data-precision noise and a
# per-symbol unsigned threshold is derived as floor_p95 × this multiplier rather
# than hand-tuned. Documented in the audit page; the multiplier is a headroom
# factor above the 95th percentile of pure rounding noise, NOT a green-light knob.
unsigned_floor_multiplier: float = 1.5
unsigned_gate: float = 0.10
# FIX 88: a derived unsigned gate is CAPPED at this multiple of the base gate.
# An uncapped gate (e.g. 0.35 against a realistic worst case of ~0.10) leaves
# the unsigned check unable to fire. When the corrected floor still brackets the
# base gate the result is "indeterminate" (precision-limited), never a pass.
unsigned_gate_cap_multiplier: float = 2.0
# FIX 35: bands are now derived from the chain's own ATM IV (two-pass).
# These are the DEFAULTS used when band_basis == "fallback" (IV unavailable).
strike_band: float = 0.12 # keep strikes within spot*(1±band) for BARS
plot_band: float = 0.08 # minimum visible window = spot*(1±band)
profile_band: float = 0.25 # GEX profile evaluated over spot*(1±band)
profile_band_dex: float = 0.40 # DEX profile wider (FIX 26) so its V-minimum is
# interior, not a grid-boundary artifact at ±25%
# FIX 35: band derivation constants. sigma_30d = atm_iv * sqrt(30/365).
band_strike_mult: float = 0.80 # strike_band = clip(0.80 * sigma_30d, ...)
band_plot_mult: float = 0.50 # plot_band = clip(0.50 * sigma_30d, ...)
band_profile_mult: float = 1.50 # profile_band = clip(1.50 * sigma_30d, ...)
band_dex_mult: float = 2.50 # dex_band = clip(2.50 * sigma_30d, ...)
atm_iv_fallback: float = 0.30 # used when IV picker fails
# Guardrails against a garbage IV — NOT tuning knobs.
band_limits: Dict[str, tuple] = field(default_factory=lambda: {
"strike_band": (0.04, 0.20),
"plot_band": (0.03, 0.15),
"profile_band": (0.06, 0.35),
"dex_band": (0.10, 0.50),
})
# --- profile grid (FIX 36) ---
profile_grid_points: int = 400 # fixed count; scale-free across symbols
# --- timestamp ---
source_timestamp_tz: str = "UTC" # Cboe's `timestamp` field is UTC; converted to ET for display
# --- profile axes (FIX 11, reworked FIX 50) ---
# "data": per-chart limits = ±1.10 * max|profile| over the VISIBLE window only.
# Simple, always correct scale — the curve fills the axis regardless of OI.
# "rolling": cross-snapshot-comparable = ±1.2 * median of this ticker's last
# `rolling_window` profile maxima (read from the FIX 40 history CSV), falling
# back to "data" when fewer than 5 snapshots exist. OI-aware by construction.
# The old spot_relative formula (k*spot^2*1e-2*axis_ref_oi) is DELETED — it
# scaled with a FIXED reference OI while real exposure scales with actual chain
# OI, so it wildly over-scaled low-OI chains like NDX (87k OI vs SMH's 2M+).
profile_axis_mode: str = "data"
rolling_window: int = 20
# --- greeks recompute ---
risk_free_rate: float = 0.04
dividend_yield: float = 0.0
contract_multiplier: int = 100
# --- HVL (FIX 29) ---
hvl_rule: str = "zero_cross"
profile_band_hvl: float = 0.40 # HVL search grid (wider than display ±25%)
# --- GEX Transition (FIX 30) ---
gex_transition_color: str = "#7FA6C9"
gex_transition_persistence: int = 3 # consecutive strikes each side
# --- Spread detection (FIX 31, revised FIX 37) ---
# OI floor is now relative: max(2000, 0.5% of chain OI).
spread_oi_min_abs: int = 2_000
spread_oi_min_frac: float = 0.005 # 0.5% of chain OI
spread_ratio_min: float = 0.80
spread_max_width: int = 8 # |strike_a - strike_b| <= this * increment
spread_flag_threshold: float = 0.20
# --- ATM IV cross-check (FIX 32) ---
realised_vol_days: int = 20
iv_hv_outlier_hi: float = 2.5
iv_hv_outlier_lo: float = 0.4
# --- dealer positioning (FIX 18) ---
dealer_proxy: str = "gross_oi"
# --- schedule (FIX 86b: explicit asymmetric windows) ---
# Slot label describes INTENT, not precision. The recorded capture time is
# authoritative; the window only gates whether a capture is accepted for a
# given slot filename. Late-but-same-day captures with correct timestamps are
# usable data — losing them entirely is the worse failure.
# AM window: 09:30–12:00 ET (morning session, broad to catch late opens)
# PM window: 14:00–16:15 ET (afternoon through settlement)
slot_am_start: str = "09:30"
slot_am_end: str = "12:00"
slot_pm_start: str = "14:00"
slot_pm_end: str = "16:15"
# --- chart ---
fig_width: float = 10.0
fig_height: float = 9.0
dpi: int = 110
bg_color: str = "#0B0B0B"
axes_bg: str = "#000000"
bar_pos_color: str = "#2E9E4F"
bar_neg_color: str = "#A32B20"
dex_color: str = "#E08A3C"
gex_profile_color: str = "#E8D44D"
call_res_color: str = "#E03B3B"
put_sup_color: str = "#3CB371"
hvl_color: str = "#8C8C3B"
spot_color: str = "#6E3B34"
grid_color: str = "#333333"
text_color: str = "#E8C89A"
title_color: str = "#F2C185"
footer_color: str = "#8A7A68"
brand_text: str = "WWW.ALLOFTHESEWORDS.COM"
watermark_text: str = ""
# --- output ---
outdir: str = "out"
cache_dir: str = "data/raw"
history_dir: str = "data/history" # FIX 40
"""Data acquisition + caching from Cboe delayed quotes."""
import gzip
import json
import logging
import os
import re
import time
from datetime import date, datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
import requests
from .config import GexConfig
logger = logging.getLogger(__name__)
OPTION_RE = re.compile(
r"^(?P<root>[A-Z]+)(?P<yy>\d{2})(?P<mm>\d{2})(?P<dd>\d{2})"
r"(?P<cp>[CP])(?P<strike>\d{8})$"
)
def parse_option_symbol(sym: str) -> Optional[Dict[str, Any]]:
"""Parse an OSI-ish option symbol into components."""
m = OPTION_RE.match(sym)
if not m:
return None
return {
"root": m.group("root"),
"expiry": date(2000 + int(m.group("yy")), int(m.group("mm")), int(m.group("dd"))),
"cp": m.group("cp"),
"strike": int(m.group("strike")) / 1000.0,
}
def _fetch_url(url: str, cfg: GexConfig) -> Optional[Dict]:
"""Fetch a single URL with retries and exponential backoff."""
for attempt in range(cfg.retries):
try:
resp = requests.get(
url,
headers={"User-Agent": cfg.user_agent},
timeout=30,
)
if resp.status_code == 200:
return resp.json()
logger.warning("HTTP %d for %s (attempt %d)", resp.status_code, url, attempt + 1)
if resp.status_code in (403, 404):
return None # signal to try URL B
except requests.RequestException as exc:
logger.warning("Request error for %s: %s (attempt %d)", url, exc, attempt + 1)
if attempt < cfg.retries - 1:
delay = cfg.backoff_base * (2 ** attempt)
logger.info("Backing off %.1fs before retry", delay)
time.sleep(delay)
return None
def fetch_chain(symbol: str, cfg: GexConfig, from_cache: bool = False,
cache_id: Optional[str] = None) -> Tuple[Dict, str, str]:
"""
Fetch the full options chain for *symbol*.
Returns (parsed_json, snapshot_id, endpoint_variant).
If from_cache, loads the most recent (or specified) cached snapshot.
FIX 34: caches the winning endpoint variant to data/endpoint_map.json so
subsequent runs skip the failed attempt. endpoint_variant is "plain" or "underscore".
"""
cache_dir = Path(cfg.cache_dir)
cache_dir.mkdir(parents=True, exist_ok=True)
if from_cache:
data, snap_id = _load_cache(symbol, cfg, cache_id)
# read cached endpoint variant
emap = _load_endpoint_map(cfg)
variant = emap.get(symbol, "plain")
# FIX 75: recover the frozen capture time so source-timestamp age is a
# property of the snapshot, not of when it happens to be re-rendered.
captured_utc = _read_capture_time(symbol, cfg, snap_id)
data["_captured_at_utc"] = captured_utc
return data, snap_id, variant
# FIX 34: check endpoint cache first
emap = _load_endpoint_map(cfg)
cached_variant = emap.get(symbol)
if cached_variant == "underscore":
url = cfg.cboe_url_b.format(symbol=symbol)
data = _fetch_url(url, cfg)
variant = "underscore"
elif cached_variant == "plain":
url = cfg.cboe_url_a.format(symbol=symbol)
data = _fetch_url(url, cfg)
variant = "plain"
else:
# Try URL A first, then URL B
url = cfg.cboe_url_a.format(symbol=symbol)
data = _fetch_url(url, cfg)
variant = "plain"
if data is None:
url = cfg.cboe_url_b.format(symbol=symbol)
data = _fetch_url(url, cfg)
variant = "underscore"
if data is None:
raise RuntimeError(f"Cboe returned no data for {symbol} after retries on both URLs")
# Cache the winning endpoint variant
emap[symbol] = variant
_save_endpoint_map(cfg, emap)
# Build snapshot id from API timestamp
ts_str = data.get("timestamp", "")
snapshot_id = ts_str.replace(" ", "_").replace(":", "") if ts_str else datetime.utcnow().strftime("%Y%m%d_%H%M%S")
# Cache raw JSON gzipped
cache_path = cache_dir / f"{symbol}_{snapshot_id}.json.gz"
with gzip.open(cache_path, "wt", encoding="utf-8") as f:
json.dump(data, f)
logger.info("Cached raw JSON -> %s (endpoint: %s)", cache_path, variant)
# FIX 75: freeze the capture time NOW (live fetch). Any later --from-cache
# re-render reads this back so the reported source-timestamp age is the age at
# capture, not at render. Without this, re-rendering an old snapshot reports it
# as hours stale even though the data is unchanged.
_write_capture_time(symbol, cfg, snapshot_id)
data["_captured_at_utc"] = datetime.now(timezone.utc).isoformat()
return data, snapshot_id, variant
def _load_endpoint_map(cfg: GexConfig) -> Dict:
"""FIX 34: load the endpoint variant cache."""
p = Path(cfg.endpoint_map_path)
if p.exists():
try:
with open(p) as f:
return json.load(f)
except Exception:
pass
return {}
def _save_endpoint_map(cfg: GexConfig, emap: Dict):
"""FIX 34: persist the endpoint variant cache."""
p = Path(cfg.endpoint_map_path)
p.parent.mkdir(parents=True, exist_ok=True)
try:
with open(p, "w") as f:
json.dump(emap, f, indent=2)
except Exception:
pass
def _load_cache(symbol: str, cfg: GexConfig,
cache_id: Optional[str] = None) -> Tuple[Dict, str]:
cache_dir = Path(cfg.cache_dir)
pattern = f"{symbol}_*.json.gz"
files = sorted(cache_dir.glob(pattern))
if not files:
raise FileNotFoundError(f"No cached snapshots for {symbol} in {cache_dir}")
if cache_id:
target = cache_dir / f"{symbol}_{cache_id}.json.gz"
if not target.exists():
raise FileNotFoundError(f"Cache file not found: {target}")
files = [target]
latest = files[-1]
snapshot_id = latest.stem.replace(f"{symbol}_", "").replace(".json", "")
with gzip.open(latest, "rt", encoding="utf-8") as f:
data = json.load(f)
logger.info("Loaded cache: %s", latest)
return data, snapshot_id
def _capture_time_path(symbol: str, cfg: GexConfig, snapshot_id: str) -> Path:
"""FIX 75: sidecar file holding the frozen capture time for a cached snapshot."""
return Path(cfg.cache_dir) / f"{symbol}_{snapshot_id}.captured.json"
def _write_capture_time(symbol: str, cfg: GexConfig, snapshot_id: str) -> None:
"""FIX 75: persist the capture time (UTC ISO-8601) next to the cached chain."""
p = _capture_time_path(symbol, cfg, snapshot_id)
try:
p.parent.mkdir(parents=True, exist_ok=True)
with open(p, "w") as f:
json.dump({"captured_at_utc": datetime.now(timezone.utc).isoformat()}, f)
except Exception as exc: # never fail the fetch over a metadata sidecar
logger.warning("FIX 75: could not persist capture time for %s: %s", symbol, exc)
def _read_capture_time(symbol: str, cfg: GexConfig,
snapshot_id: str) -> Optional[str]:
"""FIX 75: read back the frozen capture time. Falls back to the cached chain
file's mtime (the moment it was written) when no sidecar exists — e.g. chains
cached before v1.7.5. Returns an ISO-8601 UTC string, or None if unknowable."""
p = _capture_time_path(symbol, cfg, snapshot_id)
if p.exists():
try:
with open(p) as f:
return json.load(f).get("captured_at_utc")
except Exception:
pass
# fallback: the cache file's mtime is the best available capture proxy
cache_path = Path(cfg.cache_dir) / f"{symbol}_{snapshot_id}.json.gz"
if cache_path.exists():
try:
return datetime.fromtimestamp(cache_path.stat().st_mtime,
tz=timezone.utc).isoformat()
except Exception:
pass
return None
def parse_chain(data: Dict, symbol: str) -> Tuple[List[Dict], float, str]:
"""
Parse raw Cboe JSON into a list of contract dicts + spot price + timestamp.
Each contract dict: {strike, expiry, cp, iv, oi, volume, delta, gamma, vega, theta, theo, bid, ask}
"""
d = data.get("data", {})
spot = d.get("current_price", 0) or d.get("close", 0)
ts = data.get("timestamp", "")
contracts = []
for opt in d.get("options", []):
parsed = parse_option_symbol(opt.get("option", ""))
if parsed is None:
continue
contracts.append({
"strike": parsed["strike"],
"expiry": parsed["expiry"],
"cp": parsed["cp"],
"iv": float(opt.get("iv", 0) or 0),
"oi": int(opt.get("open_interest", 0) or 0),
"volume": int(opt.get("volume", 0) or 0),
"delta": float(opt.get("delta", 0) or 0),
"gamma": abs(float(opt.get("gamma", 0) or 0)), # Cboe reports positive for both
"vega": float(opt.get("vega", 0) or 0),
"theta": float(opt.get("theta", 0) or 0),
"theo": float(opt.get("theo", 0) or 0),
"bid": float(opt.get("bid", 0) or 0),
"ask": float(opt.get("ask", 0) or 0),
})
return contracts, float(spot), ts
"""Black-Scholes gamma/delta recompute."""
import numpy as np
from scipy.stats import norm
def bs_gamma(S: float, K: float, T: float, iv: float,
r: float = 0.04, q: float = 0.0) -> float:
"""Standard Black-Scholes gamma."""
if T <= 0 or iv <= 0 or S <= 0 or K <= 0:
return 0.0
sqrt_T = np.sqrt(T)
d1 = (np.log(S / K) + (r - q + 0.5 * iv ** 2) * T) / (iv * sqrt_T)
return np.exp(-q * T) * norm.pdf(d1) / (S * iv * sqrt_T)
def bs_delta(S: float, K: float, T: float, iv: float,
cp: str, r: float = 0.04, q: float = 0.0) -> float:
"""Black-Scholes delta. cp='C' or 'P'."""
if T <= 0 or iv <= 0 or S <= 0 or K <= 0:
return 0.0
sqrt_T = np.sqrt(T)
d1 = (np.log(S / K) + (r - q + 0.5 * iv ** 2) * T) / (iv * sqrt_T)
if cp == "C":
return np.exp(-q * T) * norm.cdf(d1)
else:
return np.exp(-q * T) * (norm.cdf(d1) - 1.0)
def bs_gamma_vec(S_arr, K_arr, T_arr, iv_arr,
r: float = 0.04, q: float = 0.0):
"""Vectorised BS gamma over arrays."""
S_arr = np.asarray(S_arr, dtype=float)
K_arr = np.asarray(K_arr, dtype=float)
T_arr = np.asarray(T_arr, dtype=float)
iv_arr = np.asarray(iv_arr, dtype=float)
valid = (T_arr > 0) & (iv_arr > 0) & (S_arr > 0) & (K_arr > 0)
out = np.zeros_like(S_arr)
if valid.any():
sqrt_T = np.sqrt(T_arr[valid])
d1 = (np.log(S_arr[valid] / K_arr[valid]) +
(r - q + 0.5 * iv_arr[valid] ** 2) * T_arr[valid]) / (iv_arr[valid] * sqrt_T)
out[valid] = np.exp(-q * T_arr[valid]) * norm.pdf(d1) / (S_arr[valid] * iv_arr[valid] * sqrt_T)
return out
def bs_delta_2d(s, K, T, iv, cp_sign, r: float = 0.04, q: float = 0.0):
"""Vectorised BS delta over a 2D broadcast.
s : (1, G) candidate spot levels
K,T,iv : (n, 1) per-contract arrays
cp_sign: (n, 1) +1 for calls, -1 for puts
Returns (n, G) deltas. Call delta positive, put delta negative (no extra sign).
"""
sqrt_T = np.sqrt(T)
d1 = (np.log(s / K) + (r - q + 0.5 * iv ** 2) * T) / (iv * sqrt_T)
# call delta = e^{-qT} N(d1); put delta = e^{-qT} (N(d1) - 1)
# combine via cp_sign: delta = e^{-qT} * ( N(d1) - (cp_sign<0) )
is_put = (cp_sign < 0)
return np.exp(-q * T) * (norm.cdf(d1) - is_put)
"""GEX/DEX aggregation + key levels."""
import logging
from datetime import date, datetime, time, timedelta
from typing import Dict, List, Optional, Tuple
from zoneinfo import ZoneInfo
import numpy as np
import pandas as pd
try:
import pandas_market_calendars as mcal
_NYSE = mcal.get_calendar("NYSE")
except Exception: # pragma: no cover
_NYSE = None
from .config import GexConfig
from .greeks import bs_gamma_vec
logger = logging.getLogger(__name__)
_ET = ZoneInfo("America/New_York")
# trading session length (09:30-16:00 ET)
_SESSION = timedelta(hours=6.5)
def business_days_to_expiry(expiry: date, today: date) -> int:
"""Count NYSE business days from today (inclusive) to expiry.
FIX 48: a PAST expiry returns -1 (not 0). Returning 0 let an already-expired
contract look like a 0DTE contract and survive the DTE<0 filter, after which
its floored T made Black-Scholes gamma explode as spot approached its strike.
"""
if expiry < today:
return -1
if _NYSE is not None:
try:
start = pd.Timestamp(today)
end = pd.Timestamp(expiry)
sched = _NYSE.schedule(start_date=start, end_date=end)
return max(len(sched) - 1, 0) # exclude today itself
except Exception:
pass
# fallback: naive weekday count
days = np.busday_count(today, expiry)
return max(int(days), 0)
def _settlement_time(am_settled: bool, instrument_class: str = "equity_etf") -> time:
"""FIX 84: the settlement clock depends on instrument class.
- AM-settled index expiries (third-Friday): 09:30 ET (one day earlier).
- PM-settled INDEX options (instrument_class == "index", not AM-settled):
16:15 ET. Index options settle at the close + 15 min, NOT 16:00.
- equity/ETF options: 16:00 ET.
Using 16:00 for a PM-settled index understates time-to-settlement by 15 min —
for a 0DTE NDX captured at 15:54 that is 6 min vs the true 21 min, which
pushes gamma ∝ 1/sqrt(T) ~1.9x too high and destabilises reconciliation.
"""
if am_settled:
return time(9, 30)
if instrument_class == "index":
return time(16, 15)
return time(16, 0)
def time_to_expiry_years(expiry: date, now_et: datetime, cfg: GexConfig,
am_settled: bool = False,
instrument_class: str = "equity_etf") -> float:
"""FIX 65: calendar-time T for ALL expiries.
T = (minutes from snapshot to settlement) / (365 * 24 * 60)
Settlement clock is instrument-class-aware (FIX 84): 16:15 ET for PM-settled
index options, 09:30 ET (one day earlier) for AM-settled index expiries,
16:00 ET for equity/ETF.
Replaces the business-day path entirely. Cboe's reported gamma reflects actual
calendar time remaining, not trading sessions. The old business-day path
(full_days/252) diverged from calendar time by sqrt(252/365 * 7/5) ≈ 1.28 in
gamma for multi-day expiries — the dominant residual error after FIX 62 fixed
the 0DTE branch. SMH 2026-07-31: T_business=0.0190 vs T_calendar=0.0115,
ratio 1.64, sqrt=1.28 — matching the observed reported/recomputed=1.235.
`now_et` must be the SNAPSHOT timestamp converted to ET (not wall-clock now)
so that --from-cache reproduces exactly.
FIX 45: for AM-settled index options (third-Friday expiries), settlement is at
the Thursday CLOSE, not Friday 16:00 — so the effective expiry is one day
earlier. Pass am_settled=True to apply that adjustment.
"""
eff_expiry = expiry - timedelta(days=1) if am_settled else expiry
settle = _settlement_time(am_settled, instrument_class)
settle_dt = datetime.combine(eff_expiry, settle, tzinfo=now_et.tzinfo)
minutes = max((settle_dt - now_et).total_seconds() / 60.0, 0.0)
T = minutes / (365.0 * 24.0 * 60.0)
return max(T, 1.0 / (252.0 * 13.0)) # floor ≈ 2.67 calendar hours (gamma cap)
def minutes_to_settlement(expiry: date, now_et: datetime,
am_settled: bool = False,
instrument_class: str = "equity_etf") -> float:
"""FIX 80: RAW (unfloored) minutes from the snapshot to settlement for one expiry.
Mirrors time_to_expiry_years exactly (same instrument-class-aware settlement
clock, FIX 84) but returns the raw minute count instead of the floored year
fraction. T is floored at ~160 min so it cannot be reversed to recover the true
minutes; reconciliation's near-settlement guard needs the real value to tell a
5-min expiry from a 160-min one.
"""
eff_expiry = expiry - timedelta(days=1) if am_settled else expiry
settle = _settlement_time(am_settled, instrument_class)
settle_dt = datetime.combine(eff_expiry, settle, tzinfo=now_et.tzinfo)
return max((settle_dt - now_et).total_seconds() / 60.0, 0.0)
def gamma_precision(df: pd.DataFrame) -> Tuple[int, bool, str]:
"""FIX 66 / FIX 70: detect Cboe's gamma publication precision for this chain.
Cboe publishes gamma to 4 decimal places. For underlyings priced in the tens
of thousands (NDX ~28 000), the true ATM gamma is ~0.00014 — one significant
figure at 4dp. Consecutive strikes all report exactly 0.0001, making the bars
coarse and the reconciliation gap a DATA limit, not a code bug.
Returns (sig_figs, low_precision, label):
sig_figs: significant figures in the median non-zero reported gamma.
low_precision: True when sig_figs == 1 (coarse bars; flip bars_reliable).
label: FIX 70 three-state — "high" (3+ digits) | "adequate" (2) | "coarse" (1).
The coarse-bars warning shows only for "coarse"; "adequate" (2 sig figs,
~1.3% granularity) is corroborated by SMH's 0.99% reconciliation and does
not warrant flipping bars_reliable.
"""
gammas = df["gamma"].dropna()
gammas = gammas[gammas > 0]
if gammas.empty:
return 4, False, "high"
med = float(gammas.median())
if med <= 0:
return 4, False, "high"
import math
# significant figures: count digits from first non-zero digit
# e.g. 0.0001 -> 1 sig fig; 0.00014 -> 2; 0.00123 -> 3
exponent = math.floor(math.log10(med))
# round to 4dp (Cboe's publication precision) and count non-zero digits
rounded = round(med, 4)
if rounded == 0:
return 1, True, "coarse"
s = f"{rounded:.10f}".rstrip("0")
# strip leading "0." and count remaining digits
digits = s.split(".")[-1].lstrip("0") if "." in s else s.lstrip("0")
sig_figs = max(len(digits), 1)
if sig_figs >= 3:
label = "high"
elif sig_figs == 2:
label = "adequate"
else:
label = "coarse"
return sig_figs, sig_figs <= 1, label
def filter_contracts_full(contracts: List[Dict], spot: float, cfg: GexConfig,
now_et, instrument_class: str = "equity_etf") -> pd.DataFrame:
"""Apply §2 rules 1-4 ONLY (no strike band). Returns the FULL chain frame.
This frame feeds BOTH profile curves — the far OTM open interest shapes the
profile wings and creates the zero-crossing, so it must not be truncated.
`now_et` may be a datetime (preferred — the snapshot timestamp in ET, used for
the continuous time-to-expiry) or a bare date (back-compat; treated as 15:35 ET).
FIX 45: for instrument_class "index", third-Friday expiries are AM-settled
(settlement at the Thursday close), so their T ends one day earlier.
FIX 48: compare RAW CALENDAR DATES before any business-day maths — a contract
whose expiry_date < snapshot_date is already expired and must be dropped. The
count is stored in df.attrs["expired_contracts_dropped"].
"""
if isinstance(now_et, datetime):
today = now_et.date()
else: # bare date — default to a mid-afternoon snapshot time
today = now_et
now_et = datetime.combine(today, time(15, 35), tzinfo=_ET)
rows = []
expired_dropped = 0
for c in contracts:
# FIX 48: raw calendar date guard BEFORE any business-day maths
if c["expiry"] < today:
expired_dropped += 1
continue
dte = (c["expiry"] - today).days
if dte < 0: # rule 1 (redundant with FIX 48 guard, kept for safety)
continue
if dte > cfg.dte_max: # rule 2
continue
if c["oi"] <= 0: # rule 3
continue
if c["iv"] <= 0 or c["gamma"] == 0: # rule 4
continue
am_settled = (instrument_class == "index" and is_third_friday(c["expiry"]))
T = time_to_expiry_years(c["expiry"], now_et, cfg, am_settled=am_settled,
instrument_class=instrument_class)
rows.append({**c, "dte": dte, "T": T})
df = pd.DataFrame(rows)
df.attrs["expired_contracts_dropped"] = expired_dropped
if expired_dropped > 0:
logger.warning("FIX 48: dropped %d expired contracts (expiry < %s) for chain.",
expired_dropped, today)
return df
def filter_contracts_band(df_full: pd.DataFrame, spot: float,
cfg: GexConfig) -> pd.DataFrame:
"""Apply §2 rule 5 (±12% strike band) to the full frame — for the BARS only."""
if df_full.empty:
return df_full
lo = spot * (1 - cfg.strike_band)
hi = spot * (1 + cfg.strike_band)
return df_full[(df_full["strike"] >= lo) & (df_full["strike"] <= hi)].copy()
def filter_contracts(contracts: List[Dict], spot: float, cfg: GexConfig,
today: date) -> pd.DataFrame:
"""Back-compat convenience: full filter then band (rules 1-5). Bars use this."""
return filter_contracts_band(filter_contracts_full(contracts, spot, cfg, today), spot, cfg)
def aggregate(df: pd.DataFrame, spot: float, cfg: GexConfig) -> pd.DataFrame:
"""
Per-strike GEX/DEX aggregation across all expirations.
Returns DataFrame indexed by strike with columns:
gex_call, gex_put, net_gex, dex, oi_call, oi_put
"""
M = cfg.contract_multiplier
S2 = spot * spot
df = df.copy()
sign = np.where(df["cp"] == "C", 1.0, -1.0)
df["gex"] = sign * df["gamma"].abs() * df["oi"] * M * S2 * 0.01
df["dex_c"] = df["delta"] * df["oi"] * M * spot
g = df.groupby("strike")
call_mask = df["cp"] == "C"
put_mask = df["cp"] == "P"
out = pd.DataFrame(index=g.groups.keys())
out["gex_call"] = df.loc[call_mask].groupby("strike")["gex"].sum()
out["gex_put"] = df.loc[put_mask].groupby("strike")["gex"].sum()
out["gex_call"] = out["gex_call"].fillna(0.0)
out["gex_put"] = out["gex_put"].fillna(0.0)
out["net_gex"] = out["gex_call"] + out["gex_put"]
out["dex"] = df.groupby("strike")["dex_c"].sum()
out["oi_call"] = df.loc[call_mask].groupby("strike")["oi"].sum().reindex(out.index).fillna(0)
out["oi_put"] = df.loc[put_mask].groupby("strike")["oi"].sum().reindex(out.index).fillna(0)
return out.sort_index()
def total_net_gex_from_contracts(df: pd.DataFrame, spot: float, cfg: GexConfig) -> float:
"""Total net GEX summed over a contract frame (FIX 15).
Uses the SAME formula as the profile (sign*|gamma|*OI*M*S²*0.01) so that the
bars and the profile are directly comparable. Pass the FULL chain frame to get
total_net_gex_full (the quantity the profile reproduces at s=spot).
"""
if df.empty:
return 0.0
M = cfg.contract_multiplier
S2 = spot * spot
sign = np.where(df["cp"].to_numpy() == "C", 1.0, -1.0)
gex = sign * df["gamma"].abs().to_numpy() * df["oi"].to_numpy() * M * S2 * 0.01
return float(gex.sum())
def reconcile(total_net_gex: float, grid: np.ndarray, gex_prof: np.ndarray,
spot: float, threshold: float = 0.10) -> Dict:
"""FIX 15: bars and profiles must agree at spot.
The simulated GEX profile evaluated at s = spot should approximately equal the
total net GEX summed over the SAME contract set the profile uses (the full
chain). A large relative error indicates the two paths (reported gamma vs
recomputed Black-Scholes gamma, or a time-to-expiry mismatch) have diverged.
"""
prof_at_spot = float(np.interp(spot, grid, gex_prof))
denom = max(abs(total_net_gex), 1.0)
rel_err = abs(prof_at_spot - total_net_gex) / denom
return {
"total_net_gex": float(total_net_gex),
"profile_at_spot": prof_at_spot,
"rel_err": float(rel_err),
"rel_err_denominator": float(denom), # FIX 73: what the signed error is scored against
"pass": bool(rel_err < threshold),
}
# FIX 60: bar rendering targets a bar COUNT, not a fixed spacing. NDX lists
# strikes every 10 points while OI clusters at round numbers, so increment-sized
# bars render as thin spikes with gaps. Bucketing for RENDERING ONLY aggregates
# strikes into ~target_bars bins; key levels stay at true strike resolution.
_BUCKET_LADDER = [0.5, 1, 2.5, 5, 10, 25, 50, 100, 250, 500]
def render_bucket(window_span: float, strike_increment: float,
target_bars: int = 40) -> float:
"""FIX 69: bucket = ladder value CLOSEST to raw = window_span / target_bars,
then clamped to at least strike_increment.
raw = visible_window_span / 40
bucket = closest ladder value [0.5,1,2.5,5,10,25,50,100,250,500] to raw
bucket = max(bucket, strike_increment)
SMH: span ~98 -> raw 2.45 -> closest 2.5 -> max(2.5, 2.5) = 2.5 (39 bars).
NDX: span ~2310 -> raw 57.75 -> closest 50 -> max(50, 10) = 50 (46 bars).
"""
if window_span <= 0:
return float(max(_BUCKET_LADDER[0], strike_increment))
raw = window_span / target_bars
bucket = min(_BUCKET_LADDER, key=lambda v: abs(v - raw))
return float(max(bucket, strike_increment))
def reconcile_by_expiry(df_full: pd.DataFrame, spot: float, cfg: GexConfig,
worst_n: int = 5) -> Dict:
"""FIX 45 / FIX 63 / FIX 64: per-expiry reconciliation breakdown.
For EVERY expiry, compare Σ(reported-gamma GEX) with Σ(recomputed-BS-gamma GEX)
at spot.
FIX 63: rank by DOLLAR gap (abs(reported - recomputed)) descending, not rel_err —
rel_err surfaces tiny expiries (a -2.8M expiry at 36% rel_err) while missing the
dominant one. Publish the top `worst_n`, each with its share of the total dollar
gap so it is obvious what to chase. rel_err is kept per entry.
FIX 64: also compute an UNSIGNED reconciliation — Σ|per-expiry gap| / Σ|reported
GEX| — published as rel_err_unsigned. The signed headline (profile@spot vs total)
can pass on cancelling errors; the unsigned figure cannot, so it exposes a
historical "pass" that was really offsetting mistakes.
"""
from scipy.stats import norm
if df_full.empty:
return {"reconciliation_worst_expiries": [], "zero_greek_contracts_dropped": 0,
"rel_err_unsigned": None, "rel_err_unsigned_denominator": 0.0}
M = cfg.contract_multiplier
S2 = spot * spot
r, q = cfg.risk_free_rate, cfg.dividend_yield
zero_greek = 0
# per-contract reported-gamma GEX
sign = np.where(df_full["cp"].to_numpy() == "C", 1.0, -1.0)
reported_gex = sign * df_full["gamma"].abs().to_numpy() * df_full["oi"].to_numpy() * M * S2 * 0.01
df_tmp = df_full.copy()
df_tmp["_reported_gex"] = reported_gex
rows = []
total_abs_gap = 0.0
total_abs_reported = 0.0
for exp, sub in df_tmp.groupby("expiry"):
rep = float(sub["_reported_gex"].sum())
# recomputed BS gamma at spot
K = sub["strike"].to_numpy(dtype=float)
T = sub["T"].to_numpy(dtype=float)
iv = sub["iv"].to_numpy(dtype=float)
oi = sub["oi"].to_numpy(dtype=float)
sgn = np.where(sub["cp"].to_numpy() == "C", 1.0, -1.0)
valid = (T > 0) & (iv > 0)
if not valid.any():
rec_gex = 0.0
else:
Kv, Tv, ivv, oiv, sgnv = K[valid], T[valid], iv[valid], oi[valid], sgn[valid]
sqrt_T = np.sqrt(Tv)
d1 = (np.log(spot / Kv) + (r - q + 0.5 * ivv ** 2) * Tv) / (ivv * sqrt_T)
gamma_bs = np.exp(-q * Tv) * norm.pdf(d1) / (spot * ivv * sqrt_T)
rec_gex = float((sgnv * gamma_bs * oiv * M * S2 * 0.01).sum())
dollar_gap = abs(rec_gex - rep)
denom = max(abs(rep), 1.0)
rel = dollar_gap / denom
total_abs_gap += dollar_gap
total_abs_reported += abs(rep)
rows.append({
"expiry": str(exp),
"reported_gex": rep,
"recomputed_gex": rec_gex,
"dollar_gap": dollar_gap,
"rel_err": float(rel),
})
# FIX 63: rank by dollar gap; share of the total dollar gap per entry.
rows.sort(key=lambda r: r["dollar_gap"], reverse=True)
for r in rows:
r["gap_share"] = float(r["dollar_gap"] / total_abs_gap) if total_abs_gap > 0 else 0.0
top = rows[:worst_n]
# FIX 64: unsigned reconciliation (immune to signed cancellation).
rel_err_unsigned = float(total_abs_gap / total_abs_reported) if total_abs_reported > 0 else None
return {
"reconciliation_worst_expiries": top,
"zero_greek_contracts_dropped": zero_greek,
"rel_err_unsigned": rel_err_unsigned,
# FIX 73: Σ|reported GEX| — what the unsigned error is scored against. A
# variant that drops the largest expiry is scored against a smaller book.
"rel_err_unsigned_denominator": float(total_abs_reported),
}
def reconciliation_floor_unsigned(df_full: pd.DataFrame, spot: float,
cfg: GexConfig) -> Dict:
"""FIX 87: the irreducible unsigned reconciliation error from PUBLICATION
ROUNDING, measured by the grid_rounding method.
Method (grid_rounding): take the recomputed Black-Scholes gamma as the
full-precision TRUTH, round it to the observed publication grid (Cboe publishes
gamma to 4 decimal places, so the grid interval is ±0.5 × 10^-4), and measure
the aggregate unsigned error of the ROUNDED-vs-UNROUNDED difference.
FIX 93: this is DETERMINISTIC. Rounding to a fixed grid is not a random process
— each gamma rounds to exactly one published value — so the floor is a single
scalar, not a Monte Carlo distribution. The previous version simulated 2000
uniform draws within the grid interval and reported a median/p95 spread, which
implied a variability that does not exist. The deterministic floor is computed
once: round every recomputed gamma to 4dp, aggregate by expiry, and measure the
unsigned error of rounded-vs-unrounded.
Why the actual unsigned error can sit BELOW this floor: the floor measures the
quantisation noise from rounding the RECOMPUTED gamma to the publication grid.
The actual unsigned error measures the net difference between the REPORTED
(Cboe-rounded) gamma and the recomputed gamma. The reported gamma is itself
rounded to the same 4dp grid, so the two rounding errors (reported vs recomputed)
can partially cancel. The floor is an upper bound on the rounding contribution to
the unsigned error, not a prediction of the actual error. When the actual error
(NDX: 0.10345) sits below the floor (0.13606), it means the model error and the
rounding error are partially offsetting — the reconciliation is better than the
rounding noise alone would suggest.
Why this replaces the FIX 85 estimator: the old method perturbed reported_gamma
(ALREADY rounded) by ±0.5 × 10^-4 and compared it against the recomputed gamma.
That double-counts rounding (the reported value is rounded, then perturbed again)
AND folds in the genuine model error, so it overstates the floor. The grid_rounding
method isolates pure rounding: the truth and the rounded value are BOTH derived
from the recomputed gamma, so the model error cancels and only the quantisation
remains.
If the floor brackets the unsigned gate, the gate is dominated by rounding noise
(see FIX 88 for the capped-gate / indeterminate handling).
"""
from scipy.stats import norm
if df_full.empty:
return {"method": "grid_rounding", "rounding_interval": None,
"floor": None, "brackets_gate": False}
M = cfg.contract_multiplier
S2 = spot * spot
r, q = cfg.risk_free_rate, cfg.dividend_yield
# Publication grid: Cboe publishes gamma to 4dp -> half a unit in the last place.
rounding_interval = 0.5e-4
# Recompute the full-precision BS gamma per contract — this is the TRUTH that
# rounding is applied to.
n = len(df_full)
gamma_bs = np.zeros(n)
K = df_full["strike"].to_numpy(dtype=float)
T = df_full["T"].to_numpy(dtype=float)
iv = df_full["iv"].to_numpy(dtype=float)
valid = (T > 0) & (iv > 0)
if valid.any():
Kv, Tv, ivv = K[valid], T[valid], iv[valid]
sqrt_T = np.sqrt(Tv)
d1 = (np.log(spot / Kv) + (r - q + 0.5 * ivv ** 2) * Tv) / (ivv * sqrt_T)
gamma_bs[valid] = np.exp(-q * Tv) * norm.pdf(d1) / (spot * ivv * sqrt_T)
sign = np.where(df_full["cp"].to_numpy() == "C", 1.0, -1.0)
oi = df_full["oi"].to_numpy(dtype=float)
# Map each contract to its expiry ordinal so we can aggregate per expiry.
exp_list = list(df_full["expiry"].unique())
exp_ordinal = {e: i for i, e in enumerate(exp_list)}
contract_exp = np.array([exp_ordinal[e] for e in df_full["expiry"]])
n_exp = len(exp_list)
# Per-expiry TRUTH GEX (unrounded recomputed gamma) and ROUNDED GEX (gamma
# rounded to the 4dp publication grid). The difference is the pure quantisation.
truth_gex_contract = sign * gamma_bs * oi * M * S2 * 0.01
gamma_rounded = np.round(gamma_bs, 4)
rounded_gex_contract = sign * gamma_rounded * oi * M * S2 * 0.01
truth_by_exp = np.zeros(n_exp)
rounded_by_exp = np.zeros(n_exp)
for i in range(n_exp):
mask = contract_exp == i
truth_by_exp[i] = truth_gex_contract[mask].sum()
rounded_by_exp[i] = rounded_gex_contract[mask].sum()
total_abs_truth = float(np.abs(truth_by_exp).sum())
total_abs_gap = float(np.abs(rounded_by_exp - truth_by_exp).sum())
floor = (total_abs_gap / total_abs_truth) if total_abs_truth > 0 else 0.0
return {
"method": "grid_rounding",
"rounding_interval": rounding_interval,
"floor": floor,
"brackets_gate": bool(floor >= cfg.unsigned_gate),
}
def _profile_outlier_guard(contrib: np.ndarray, meta: List[Dict],
grid: np.ndarray, spot: float,
ratio_threshold: float = 5.0,
width_threshold_pct: float = 5.0) -> Tuple[np.ndarray, List[Dict]]:
"""FIX 48 / v1.6.2: drop a contract that is BOTH dominant AND a narrow spike.
The expired-contract explosion this targets has a distinctive signature: a
floored T (1/(252*13)) makes Black-Scholes gamma collapse to a delta function,
so the contract's contribution is concentrated in a narrow price band around its
strike while dwarfing everything else there. A legitimate dominant contract —
the ATM 0DTE (~50% of others, ratio ~0.5) or a far-OTM wing-shaper (locally
huge but spread across its whole wing) — is never both dominant AND narrow.
v1.6.2: the width test is now in PRICE terms (% of spot) rather than a fraction
of grid points, so one threshold means the same thing on every grid (GEX, the
wider HVL grid, the DEX grid) and every chain regardless of profile_band or
grid resolution.
Thresholds (calibrated on three known cases):
- ratio_threshold = 5.0: peak |contribution| > 5x the sum of all others.
Expired explosion: 406x. Far-OTM wing-shaper: 568x. ATM 0DTE (realistic):
~0.5x (kept by this test alone).
- width_threshold_pct = 5.0: half-peak width < 5% of spot.
Expired explosion: 1.1% (narrow → dropped). Far-OTM wing-shaper: 12.0%
(wide → kept). ATM 0DTE: 0.96% (narrow, but ratio 0.5 < 5.0 → kept).
Margins: expired 1.1% vs 5% threshold (3.9pp headroom); far-OTM 12.0% vs 5%
(7.0pp headroom).
Since FIX 48 drops expired contracts by raw calendar date before they reach the
profile, this guard should be unreachable in normal operation. Any firing is a
fault, logged at ERROR.
`contrib` is (n_contracts, n_grid); `meta[i]` carries strike/expiry for contract i.
`grid` is the price grid; `spot` is the current spot price.
Returns (profile, outliers) where profile excludes the dropped contracts and
outliers is a list of {strike, expiry, value, price_width_pct}.
"""
n, G = contrib.shape
if n < 2:
return contrib.sum(axis=0), []
abs_c = np.abs(contrib)
total_abs = abs_c.sum(axis=0) # (G,)
peak_idx = abs_c.argmax(axis=1) # (n,) grid idx of each contract's peak
peak_val = abs_c[np.arange(n), peak_idx] # (n,)
others_at_peak = total_abs[peak_idx] - peak_val # (n,)
peak_ratio = peak_val / (others_at_peak + 1e-30) # (n,)
# spike width in PRICE terms: span of grid points where |contrib| >= half peak,
# expressed as % of spot. Grid-independent: same contract measures the same on
# any grid resolution or band width.
half = 0.5 * peak_val[:, None]
above = abs_c >= half # (n, G)
grid_arr = np.asarray(grid, dtype=float)
price_width_pct = np.zeros(n)
for i in range(n):
idx = np.where(above[i])[0]
if len(idx) > 0:
price_width_pct[i] = (grid_arr[idx[-1]] - grid_arr[idx[0]]) / spot * 100.0
# an expired/floored-T contract is dominant AND narrow; legitimate structure is
# either not dominant (ATM 0DTE, ratio ~0.5) or not narrow (far-OTM, width 12%)
is_outlier = (peak_ratio > ratio_threshold) & (price_width_pct < width_threshold_pct)
outliers = []
if is_outlier.any():
for i in np.where(is_outlier)[0]:
outliers.append({
"strike": float(meta[i]["strike"]),
"expiry": str(meta[i]["expiry"]),
"value": float(peak_val[i]),
"price_width_pct": round(float(price_width_pct[i]), 3),
})
logger.error("FIX 48 FAULT: profile outlier guard fired — %d contract(s) "
"dropped (dominant narrow spike; should be unreachable after "
"FIX 48 calendar-date filter): %s", len(outliers), outliers[:5])
profile = contrib[~is_outlier].sum(axis=0)
return profile, outliers
def gex_profile(contracts_df: pd.DataFrame, spot: float,
cfg: GexConfig, grid: np.ndarray) -> Tuple[np.ndarray, List[Dict]]:
"""§4b GEX Profile: re-evaluate total net GEX as if spot were at each grid level.
Uses reported iv held constant, recomputed BS gamma.
FIX 48 / v1.6.2: returns (profile, outliers). A per-contract outlier guard
drops any contract that is BOTH dominant (peak |contribution| > 5x the sum of
all others at that grid point) AND a narrow spike (half-peak width < 5% of spot
in price terms). This is the expired-contract gamma-explosion signature: a
floored T collapses BS gamma to a delta function. Legitimate dominant structure
(the ATM 0DTE, ratio ~0.5; far-OTM wing-shapers, width ~12%) is kept. Since
FIX 48 drops expired contracts by raw calendar date before they reach the
profile, this guard should be unreachable in normal operation; any firing is a
fault (logged at ERROR, surfaced on the chart). Outliers are reported as a list
of {strike, expiry, value, price_width_pct}.
(FIX 14: the unused `agg` argument was removed from the signature.)
"""
from scipy.stats import norm
M = cfg.contract_multiplier
r, q = cfg.risk_free_rate, cfg.dividend_yield
s = np.asarray(grid, dtype=float)
if contracts_df.empty or len(s) == 0:
return np.zeros_like(s, dtype=float), []
K = contracts_df["strike"].to_numpy(dtype=float)
T = contracts_df["T"].to_numpy(dtype=float)
iv = contracts_df["iv"].to_numpy(dtype=float)
oi = contracts_df["oi"].to_numpy(dtype=float)
sign = np.where(contracts_df["cp"].to_numpy() == "C", 1.0, -1.0)
expiry = contracts_df["expiry"].to_numpy()
# valid mask per contract (T>0, iv>0); s>0 guaranteed for price grid
valid = (T > 0) & (iv > 0)
K, T, iv, oi, sign, expiry = K[valid], T[valid], iv[valid], oi[valid], sign[valid], expiry[valid]
if len(K) == 0:
return np.zeros_like(s, dtype=float), []
sqrt_T = np.sqrt(T)[:, None] # (n,1)
s_b = s[None, :] # (1,G)
d1 = (np.log(s_b / K[:, None]) + (r - q + 0.5 * iv[:, None] ** 2) * T[:, None]) \
/ (iv[:, None] * sqrt_T)
gamma = np.exp(-q * T[:, None]) * norm.pdf(d1) / (s_b * iv[:, None] * sqrt_T)
contrib = sign[:, None] * gamma * oi[:, None] * M * (s_b ** 2) * 0.01 # (n, G)
meta = [{"strike": K[i], "expiry": expiry[i]} for i in range(len(K))]
return _profile_outlier_guard(contrib, meta, s, spot)
def dex_profile(contracts_full: pd.DataFrame, cfg: GexConfig,
grid: np.ndarray, spot: float) -> Tuple[np.ndarray, List[Dict]]:
"""Total dealer delta exposure re-evaluated as if spot were at each grid level.
Per contract: bs_delta(s, K, T, iv, cp, r, q) * OI * M * s.
Call delta positive, put delta already negative — NO extra sign applied.
Returns (profile, outliers) — same length as grid, in dollars.
Shape (FIX 12): generally rising in spot, but with a V-shaped minimum where
deep-ITM put delta dominates. NOT monotonic — has an interior minimum
(see dex_min_price). Window-independent.
FIX 48: applies the same per-contract dominance guard as gex_profile.
"""
from .greeks import bs_delta_2d
M = cfg.contract_multiplier
r, q = cfg.risk_free_rate, cfg.dividend_yield
s = np.asarray(grid, dtype=float)
if contracts_full.empty or len(s) == 0:
return np.zeros_like(s, dtype=float), []
K = contracts_full["strike"].to_numpy(dtype=float)
T = contracts_full["T"].to_numpy(dtype=float)
iv = contracts_full["iv"].to_numpy(dtype=float)
oi = contracts_full["oi"].to_numpy(dtype=float)
cp_sign = np.where(contracts_full["cp"].to_numpy() == "C", 1.0, -1.0)
expiry = contracts_full["expiry"].to_numpy()
valid = (T > 0) & (iv > 0)
K, T, iv, oi, cp_sign, expiry = K[valid], T[valid], iv[valid], oi[valid], cp_sign[valid], expiry[valid]
if len(K) == 0:
return np.zeros_like(s, dtype=float), []
delta = bs_delta_2d(s[None, :], K[:, None], T[:, None],
iv[:, None], cp_sign[:, None], r, q) # (n, G)
contrib = delta * oi[:, None] * M * s[None, :] # (n, G)
meta = [{"strike": K[i], "expiry": expiry[i]} for i in range(len(K))]
return _profile_outlier_guard(contrib, meta, s, spot)
def find_delta_neutral(grid: np.ndarray, dex_prof: np.ndarray,
spot: float) -> Tuple[Optional[float], list]:
"""DEX-profile zero-crossings (FIX 12).
A V-shaped DEX profile can cross zero twice. Returns (nearest, crossings):
- crossings: ALL grid prices where the profile crosses zero (linear interp).
- nearest: the crossing nearest spot (same rule as HVL), or None if no crossing.
"""
crossings = []
for i in range(1, len(dex_prof)):
y0, y1 = dex_prof[i - 1], dex_prof[i]
if (y0 < 0 <= y1) or (y0 > 0 >= y1):
x0, x1 = grid[i - 1], grid[i]
cross = x0 if y1 == y0 else x0 - y0 * (x1 - x0) / (y1 - y0)
crossings.append(float(cross))
if not crossings:
return None, []
nearest = min(crossings, key=lambda c: abs(c - spot))
return nearest, crossings
def dex_min_price(grid: np.ndarray, dex_prof: np.ndarray) -> Tuple[Optional[float], str]:
"""Price at the V-shaped minimum of the DEX profile (FIX 12).
FIX 26: routed through interior_extremum — if the minimum sits within 2 grid
steps of either edge it is a boundary artifact (the true V-minimum lies beyond
the grid), so return (None, "at_grid_boundary") rather than a misleading number.
The DEX grid is widened to ±40% (profile_band_dex) so the true minimum can be
located; if it is still at the edge there, it is genuinely off-grid.
"""
return interior_extremum(dex_prof, grid, which="min", edge_tol=2)
def detect_increment(strikes: np.ndarray) -> float:
"""Detect dominant strike increment as mode of diffs."""
s = np.sort(np.unique(strikes))
if len(s) < 2:
return 1.0
diffs = np.round(np.diff(s), 6)
diffs = diffs[diffs > 0]
if len(diffs) == 0:
return 1.0
vals, counts = np.unique(np.round(diffs, 4), return_counts=True)
return float(vals[np.argmax(counts)])
def detect_render_spacing(strikes: np.ndarray, net_gex: np.ndarray,
fallback_increment: float) -> float:
"""FIX 44: bar height should follow POPULATED strike spacing, not listed spacing.
detect_increment returns the mode of ALL listed diffs (e.g. 25 for NDX), but
strikes carrying non-trivial OI may sit ~100 apart, giving tiny bars with gaps
on a wide axis. This returns the median spacing among strikes that actually
carry a bar (|net_gex| >= 1% of the max), so bars render contiguous.
Falls back to fallback_increment if fewer than 3 populated strikes.
"""
if len(strikes) == 0:
return fallback_increment
max_abs = float(np.max(np.abs(net_gex))) if len(net_gex) else 0.0
if max_abs <= 0:
return fallback_increment
sig_mask = np.abs(net_gex) >= 0.01 * max_abs
sig = np.sort(strikes[sig_mask])
if len(sig) < 3:
return fallback_increment
diffs = np.diff(sig)
diffs = diffs[diffs > 0]
if len(diffs) == 0:
return fallback_increment
return float(np.median(diffs))
def _hvl_zero_cross(grid: np.ndarray, profile: np.ndarray,
spot: float) -> Tuple[Optional[float], list]:
"""Sign flip (neg->pos) of the simulated GEX profile.
Returns (nearest_to_spot, all_crossings). This is the ONLY HVL definition as of
v1.5.0 (FIX 29): 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.
"""
crossings = []
for i in range(1, len(profile)):
if profile[i - 1] < 0 and profile[i] >= 0:
x0, x1 = grid[i - 1], grid[i]
y0, y1 = profile[i - 1], profile[i]
cross = x0 if y1 == y0 else x0 - y0 * (x1 - x0) / (y1 - y0)
crossings.append(float(cross))
if not crossings:
return None, []
nearest = min(crossings, key=lambda c: abs(c - spot))
return nearest, crossings
def interior_extremum(arr: np.ndarray, grid: np.ndarray, which: str = "min",
edge_tol: int = 2) -> Tuple[Optional[float], str]:
"""FIX 26: return (price, status) for an extremum, guarding grid-boundary artifacts.
If the argmin/argmax falls within `edge_tol` grid steps of either edge, the
extremum is a boundary artifact (the true turning point lies beyond the grid),
so return (None, "at_grid_boundary"). Otherwise return (price, "interior").
"""
if len(arr) == 0:
return None, "empty"
idx = int(np.argmin(arr) if which == "min" else np.argmax(arr))
if idx < edge_tol or idx >= len(arr) - edge_tol:
return None, "at_grid_boundary"
return float(grid[idx]), "interior"
def _hvl_bar_flip(agg: pd.DataFrame, spot: float) -> Optional[float]:
"""Linear interpolation of the strike where the PER-STRIKE net_gex bar series
changes sign neg->pos, crossing nearest spot.
NOTE (FIX 30): this is NOT an HVL candidate. It measures where the per-strike
call/put OI composition flips locally; the HVL zero crossing measures where the
whole portfolio's gamma flips sign. Different quantities. Promoted to its own
named level `gex_transition` (see compute_gex_transition).
"""
if agg.empty:
return None
strikes = agg.index.to_numpy(dtype=float)
net = agg["net_gex"].to_numpy(dtype=float)
order = np.argsort(strikes)
strikes, net = strikes[order], net[order]
crossings = []
for i in range(1, len(net)):
if net[i - 1] < 0 and net[i] >= 0:
x0, x1 = strikes[i - 1], strikes[i]
y0, y1 = net[i - 1], net[i]
cross = x0 if y1 == y0 else x0 - y0 * (x1 - x0) / (y1 - y0)
crossings.append(float(cross))
if not crossings:
return None
return min(crossings, key=lambda c: abs(c - spot))
def compute_gex_transition(agg: pd.DataFrame, spot: float,
persistence: int = 3) -> Dict:
"""FIX 30: GEX Transition — where strike-level net gamma changes sign locally.
This is the per-strike bar sign flip (formerly "bar_flip"), promoted to its own
named level. It is NOT an HVL candidate: HVL marks where TOTAL portfolio gamma
changes sign; GEX Transition marks where strike-level net gamma changes sign
locally. They coincide only in balanced chains; a large concentrated wall
separates them.
Hardened against noise: the sign must hold for at least `persistence` consecutive
populated strikes on EACH side of the candidate. If no candidate satisfies that,
publish null with gex_transition_status = "no_persistent_flip".
"""
result = {
"gex_transition": None,
"gex_transition_status": "no_persistent_flip",
"gex_transition_distance_pct": None,
}
if agg.empty:
return result
strikes = agg.index.to_numpy(dtype=float)
net = agg["net_gex"].to_numpy(dtype=float)
order = np.argsort(strikes)
strikes, net = strikes[order], net[order]
# candidate crossings neg->pos
crossings = []
for i in range(1, len(net)):
if net[i - 1] < 0 and net[i] >= 0:
x0, x1 = strikes[i - 1], strikes[i]
y0, y1 = net[i - 1], net[i]
cross = x0 if y1 == y0 else x0 - y0 * (x1 - x0) / (y1 - y0)
crossings.append((float(cross), i))
if not crossings:
return result
# persistence check: `persistence` consecutive strikes negative BEFORE the flip
# and `persistence` consecutive strikes positive AFTER it.
persistent = []
for cross, i in crossings:
before = net[max(0, i - persistence):i]
after = net[i:i + persistence]
if len(before) >= persistence and len(after) >= persistence \
and (before < 0).all() and (after > 0).all():
persistent.append(cross)
if not persistent:
result["gex_transition_status"] = "no_persistent_flip"
return result
nearest = min(persistent, key=lambda c: abs(c - spot))
result.update({
"gex_transition": float(nearest),
"gex_transition_status": "ok",
"gex_transition_distance_pct": float((nearest - spot) / spot),
})
return result
def compute_hvl(grid: np.ndarray, profile: np.ndarray, spot: float,
cfg: GexConfig) -> Dict:
"""FIX 29: HVL = the gamma-profile zero crossing nearest spot. Always defined.
No "indeterminate". Distance is information, not a defect — publish
hvl_distance_pct and a hvl_regime_note. If no crossing exists in the grid,
return hvl=None with hvl_status="no_flip_in_range" (the caller widens the grid
to ±40% and retries; if still none, that is a real market state, not an error).
"""
nearest, crossings = _hvl_zero_cross(grid, profile, spot)
if nearest is None:
return {
"hvl": None,
"hvl_status": "no_flip_in_range",
"hvl_distance_pct": None,
"hvl_regime_note": None,
"hvl_crossings": [],
}
distance_pct = (nearest - spot) / spot
d = abs(distance_pct)
sign = "positive" if profile_at_spot_sign(profile, grid, spot) > 0 else "negative"
if d <= 0.03:
note = "near spot — regime flip in play"
elif d <= 0.08:
note = "moderately distant"
else:
note = f"far from spot — no nearby regime flip; sustained {sign} gamma"
return {
"hvl": float(nearest),
"hvl_status": "ok",
"hvl_distance_pct": float(distance_pct),
"hvl_regime_note": note,
"hvl_crossings": [float(c) for c in crossings],
}
def profile_at_spot_sign(profile: np.ndarray, grid: np.ndarray, spot: float) -> float:
"""Sign of the interpolated GEX profile at spot (helper for regime note)."""
return float(np.interp(spot, grid, profile))
def compute_hvl_candidates(grid: np.ndarray, profile: np.ndarray, agg: pd.DataFrame,
spot: float, cfg: GexConfig,
increment: float = 0.0) -> Dict:
"""DEPRECATED (v1.5.0): kept for back-compat only. Use compute_hvl + compute_gex_transition.
The old three-candidate system (inflection / zero_cross / bar_flip) with
indeterminacy gating is retired. HVL is now always the zero crossing (FIX 29);
bar_flip is promoted to gex_transition (FIX 30); inflection is deleted (FIX 28).
"""
hvl_info = compute_hvl(grid, profile, spot, cfg)
gt_info = compute_gex_transition(agg, spot, cfg.gex_transition_persistence)
return {
**hvl_info,
**gt_info,
"hvl_rule_used": "zero_cross",
"hvl_confidence": "high" if hvl_info["hvl"] is not None else "n/a",
"hvl_candidates": {"zero_cross": hvl_info["hvl"]},
"hvl_spread_pct": 0.0,
"hvl_range": None,
"hvl_inflection_masked_strikes": [],
"hvl_inflection_status": "retired_v1.5.0",
}
def find_hvl(grid: np.ndarray, profile: np.ndarray,
spot: float) -> Tuple[float, str, str, list]:
"""Back-compat wrapper (pre-FIX-16 signature). Returns (hvl, rule, confidence,
crossings) using the zero-crossing rule. New code should use compute_hvl.
FIX 28: inflection fallback deleted — it structurally re-finds the dominant
put wall (curvature peaks where gamma concentrates).
"""
crossings = []
for i in range(1, len(profile)):
if profile[i - 1] < 0 and profile[i] >= 0:
x0, x1 = grid[i - 1], grid[i]
y0, y1 = profile[i - 1], profile[i]
cross = x0 if y1 == y0 else x0 - y0 * (x1 - x0) / (y1 - y0)
crossings.append(float(cross))
if crossings:
best = min(crossings, key=lambda c: abs(c - spot))
return best, "zero_crossing", "high", crossings
# No crossing: return midpoint with low confidence (no inflection fallback)
mid = float(grid[len(grid) // 2])
return mid, "no_crossing_midpoint", "low", crossings
def compute_levels(agg: pd.DataFrame, grid: np.ndarray, profile: np.ndarray,
spot: float, increment: float, cfg: GexConfig = None,
profile_at_spot: float = None) -> Dict:
"""Compute all §5/§6 key levels + diagnostics.
FIX 29: HVL = gamma-profile zero crossing nearest spot. Always a single number.
Distance is information, not a defect — publish hvl_distance_pct + hvl_regime_note.
FIX 28: inflection rule retired (it structurally re-finds the dominant put wall).
FIX 30: bar_flip promoted to gex_transition (separate named level, persistence-hardened).
FIX 22: gamma_condition = sign of the simulated GEX profile at spot (unchanged).
`cfg` is optional for back-compat; when omitted a default config is used.
"""
if cfg is None:
cfg = GexConfig()
call_res = float(agg["gex_call"].idxmax())
put_sup = float(agg["gex_put"].idxmin())
# FIX 29: HVL = zero crossing nearest spot
hvl_info = compute_hvl(grid, profile, spot, cfg)
hvl_raw = hvl_info["hvl"]
# snap HVL to nearest strike increment
if hvl_raw is not None:
hvl = round(hvl_raw / increment) * increment if increment > 0 else hvl_raw
else:
hvl = None
# FIX 30: GEX Transition (formerly bar_flip, now its own named level)
gt_info = compute_gex_transition(agg, spot, cfg.gex_transition_persistence)
total_net = float(agg["net_gex"].sum())
# FIX 22: gamma_condition = sign of the simulated profile at spot (direct measure)
if profile_at_spot is None:
profile_at_spot = float(np.interp(spot, grid, profile))
gamma_condition = "POSITIVE" if profile_at_spot > 0 else "NEGATIVE"
# distance to the regime flip
distance_to_flip_pct = hvl_info["hvl_distance_pct"]
sum_gex_put = float(agg["gex_put"].sum())
sum_gex_call = float(agg["gex_call"].sum())
gex_pc_ratio = abs(sum_gex_put) / sum_gex_call if sum_gex_call != 0 else float("inf")
sum_oi_put = float(agg["oi_put"].sum())
sum_oi_call = float(agg["oi_call"].sum())
oi_pc_ratio = sum_oi_put / sum_oi_call if sum_oi_call != 0 else float("inf")
return {
"call_resistance": call_res,
"put_support": put_sup,
"hvl": hvl,
"hvl_raw": hvl_raw,
"hvl_status": hvl_info["hvl_status"],
"hvl_distance_pct": hvl_info["hvl_distance_pct"],
"hvl_regime_note": hvl_info["hvl_regime_note"],
"hvl_crossings": hvl_info["hvl_crossings"],
"hvl_rule": "zero_cross",
"hvl_rule_used": "zero_cross",
"hvl_confidence": "high" if hvl is not None else "n/a",
"hvl_candidates": {"zero_cross": hvl_raw},
"hvl_spread_pct": 0.0,
"hvl_range": None,
"hvl_inflection_masked_strikes": [],
# FIX 30: GEX Transition
"gex_transition": gt_info["gex_transition"],
"gex_transition_status": gt_info["gex_transition_status"],
"gex_transition_distance_pct": gt_info["gex_transition_distance_pct"],
# FIX 22: gamma condition
"spot": float(spot),
"total_net_gex": total_net,
"gamma_condition": gamma_condition,
"gamma_condition_basis": "sign of simulated GEX profile at spot",
"net_gex_at_spot": float(profile_at_spot),
"distance_to_flip_pct": distance_to_flip_pct,
"gex_put_call_ratio": gex_pc_ratio,
"oi_put_call_ratio": oi_pc_ratio,
}
def _atm_iv_for_expiry(ref: pd.DataFrame, spot: float,
contract_oi_floor: float = 250.0
) -> Tuple[Optional[float], list, str]:
"""ATM IV from a single expiry frame (FIX 24; gates made relative in FIX 43).
Requires BOTH bracketing strikes to pass (OI >= contract_oi_floor, two-sided
market bid > 0, 0.05 < iv < 2.0); averages call & put IV at each strike,
interpolates in strike to spot. Returns (atm_iv, source_strikes, status). If
only one strike passes, still computes but status =
"single_strike_no_interpolation" — never "ok".
"""
below = ref[ref["strike"] <= spot]
above = ref[ref["strike"] >= spot]
bracket_strikes = []
if not below.empty:
bracket_strikes.append(float(below["strike"].max()))
if not above.empty:
k = float(above["strike"].min())
if k not in bracket_strikes:
bracket_strikes.append(k)
if not bracket_strikes:
return None, [], "no_bracket"
pts = [] # (strike, iv)
for k in bracket_strikes:
legs = ref[ref["strike"] == k]
good = legs[(legs["oi"] >= contract_oi_floor) & (legs["bid"] > 0)
& (legs["iv"] > 0.05) & (legs["iv"] < 2.0)]
if good.empty:
continue
pts.append((k, float(good["iv"].mean())))
if not pts:
return None, [], "no_strike_passes"
if len(pts) == 1:
return pts[0][1], [p[0] for p in pts], "single_strike_no_interpolation"
pts.sort()
(k0, iv0), (k1, iv1) = pts[0], pts[1]
if k1 == k0:
atm_iv = 0.5 * (iv0 + iv1)
else:
w = (spot - k0) / (k1 - k0)
atm_iv = iv0 + w * (iv1 - iv0)
return atm_iv, [p[0] for p in pts], "ok"
def atm_expected_move(contracts_df: pd.DataFrame, spot: float) -> Dict:
"""1d expected move from ATM IV (FIX 17 picker, hardened in FIX 24).
Reference expiry = nearest expiry with DTE >= 5 AND total expiry OI >=
max(5000, 0.02 * chain_total_oi) — a liquidity gate so we never source IV from a
near-dead expiry (FIX 24a). On the two strikes bracketing spot, require BOTH to
pass (OI >= 250, two-sided market bid > 0, 0.05 < iv < 2.0); average call & put
IV at each strike, interpolate in strike to spot.
Cross-check (FIX 24d): compute atm_iv from the two nearest qualifying expiries;
if they differ by more than 0.15 absolute, the term structure is unstable and the
status says so. SANITY GATE: if atm_iv is outside [0.05, 1.50] or no strike
passes, publish nulls and atm_iv_status = "rejected: <reason>". Never publish a
number we cannot defend, and never report "ok" unless two strikes on a liquid
expiry were interpolated.
"""
result = {
"atm_iv": None, "exp_move_pct": None, "min_price": None, "max_price": None,
"atm_iv_expiry": None, "atm_iv_dte": None, "atm_iv_source_strikes": None,
"atm_iv_expiry_oi": None, "atm_iv_expiry_rank": None,
"atm_iv_alt": None, "atm_iv_term_spread": None, "atm_iv_status": None,
}
if contracts_df.empty or "dte" not in contracts_df.columns:
result["atm_iv_status"] = "rejected: empty or missing DTE"
return result
chain_total_oi = float(contracts_df["oi"].sum())
# FIX 43: chain-relative gates (were absolute 5000 / 250, SMH-scaled).
oi_floor = max(0.02 * chain_total_oi, 200.0)
contract_oi_floor = max(0.0002 * chain_total_oi, 10.0)
# FIX 24a: expiry-level liquidity gate (DTE >= 5 AND expiry OI >= floor)
elig = contracts_df[contracts_df["dte"] >= 5]
if elig.empty:
result["atm_iv_status"] = "rejected: no expiry with DTE >= 5"
return result
exp_oi = elig.groupby("expiry")["oi"].sum()
liquid = exp_oi[exp_oi >= oi_floor]
if liquid.empty:
result["atm_iv_status"] = (
f"rejected: no DTE>=5 expiry holds OI >= {oi_floor:.0f} "
f"(max(200, 2% of chain {chain_total_oi:.0f}))")
return result
# rank qualifying expiries by DTE (nearest first)
elig_dte = elig.groupby("expiry")["dte"].min().sort_values()
qual_exps = [e for e in elig_dte.index if e in liquid.index]
ref_exp = qual_exps[0]
ref = contracts_df[contracts_df["expiry"] == ref_exp].copy()
ref_dte = int(ref["dte"].iloc[0])
ref_oi = int(exp_oi[ref_exp])
ref_rank = int(list(elig_dte.index).index(ref_exp)) + 1
# FIX 43: try the nearest qualifying expiry, then widen to the three nearest
# before falling back — a low-OI chain may have a thin front expiry but a
# perfectly good second or third.
atm_iv = None
src_strikes = []
status = "no_strike_passes"
for candidate_exp in qual_exps[:3]:
cand_ref = contracts_df[contracts_df["expiry"] == candidate_exp].copy()
cand_iv, cand_strikes, cand_status = _atm_iv_for_expiry(
cand_ref, spot, contract_oi_floor)
if cand_iv is not None:
atm_iv = cand_iv
src_strikes = cand_strikes
status = cand_status
# re-point the published metadata at the expiry that actually resolved
ref_exp = candidate_exp
ref_dte = int(cand_ref["dte"].iloc[0])
ref_oi = int(exp_oi[candidate_exp])
ref_rank = int(list(elig_dte.index).index(candidate_exp)) + 1
break
if atm_iv is None:
result["atm_iv_status"] = f"rejected: {status} on expiry {ref_exp}"
result["atm_iv_expiry"] = str(ref_exp)
result["atm_iv_dte"] = ref_dte
result["atm_iv_expiry_oi"] = ref_oi
result["atm_iv_expiry_rank"] = ref_rank
return result
# sanity gate on the primary pick
if not (0.05 <= atm_iv <= 1.50):
result["atm_iv_status"] = f"rejected: atm_iv {atm_iv:.3f} outside [0.05, 1.50]"
result["atm_iv_source_strikes"] = src_strikes
result["atm_iv_expiry"] = str(ref_exp)
result["atm_iv_dte"] = ref_dte
result["atm_iv_expiry_oi"] = ref_oi
result["atm_iv_expiry_rank"] = ref_rank
return result
# FIX 24d: term-structure cross-check against the nearest OTHER qualifying
# expiry (FIX 43: the resolved expiry may not be qual_exps[0] any more).
atm_iv_alt = None
term_spread = None
alt_candidates = [e for e in qual_exps if e != ref_exp]
if alt_candidates:
alt_exp = alt_candidates[0]
alt_ref = contracts_df[contracts_df["expiry"] == alt_exp].copy()
alt_iv, _, alt_status = _atm_iv_for_expiry(alt_ref, spot, contract_oi_floor)
if alt_iv is not None and 0.05 <= alt_iv <= 1.50:
atm_iv_alt = float(alt_iv)
term_spread = float(abs(atm_iv - alt_iv))
if term_spread > 0.15 and status == "ok":
status = "term_structure_unstable"
exp_move_pct = atm_iv * np.sqrt(1 / 252.0)
result.update({
"atm_iv": float(atm_iv),
"exp_move_pct": float(exp_move_pct),
"min_price": float(spot * (1 - exp_move_pct)),
"max_price": float(spot * (1 + exp_move_pct)),
"atm_iv_expiry": str(ref_exp),
"atm_iv_dte": ref_dte,
"atm_iv_source_strikes": src_strikes,
"atm_iv_expiry_oi": ref_oi,
"atm_iv_expiry_rank": ref_rank,
"atm_iv_alt": atm_iv_alt,
"atm_iv_term_spread": term_spread,
"atm_iv_status": status,
})
return result
def build_outlier_report(agg: pd.DataFrame, df_band: pd.DataFrame,
cfg: GexConfig, spot: float, top_n: int = 5) -> Dict:
"""FIX 13: top strikes by |net_gex| with OI + per-expiry breakdown.
Lets a reader judge whether a dominant bar is real (e.g. concentrated LEAPS
put OI) or a parsing artifact, instead of just clipping it off the chart.
FIX 91: the per-expiry breakdown uses spot^2, the SAME price term as the bars
it explains (aggregate()). It previously used each strike's own K^2, which made
the breakdown disagree with its parent bar by (K/S)^2 and broke the invariant
Sigma by_expiry[].gex == net_gex. spot is passed in explicitly so the
decomposition cannot drift from the aggregate it documents.
"""
M = cfg.contract_multiplier
S2 = spot * spot
if agg.empty:
return {"top_strikes": []}
order = agg["net_gex"].abs().sort_values(ascending=False).index
top = []
for k in order[:top_n]:
row = agg.loc[k]
sub = df_band[df_band["strike"] == k]
by_exp = []
for expiry, g in sub.groupby("expiry"):
gc = g[g["cp"] == "C"]
gp = g[g["cp"] == "P"]
# per-expiry gex contribution (signed): calls +, puts -
gex_exp = (gc["gamma"].abs() * gc["oi"]).sum() - (gp["gamma"].abs() * gp["oi"]).sum()
by_exp.append({
"expiry": str(expiry),
"oi_call": int(gc["oi"].sum()),
"oi_put": int(gp["oi"].sum()),
"gex": float(gex_exp * M * S2 * 0.01),
})
top.append({
"strike": float(k),
"net_gex": float(row["net_gex"]),
"oi_call": int(row["oi_call"]),
"oi_put": int(row["oi_put"]),
"by_expiry": by_exp,
})
return {"top_strikes": top}
def level_front_expiry_pct(df_full: pd.DataFrame, strike: float, spot: float,
cfg: GexConfig) -> Dict:
"""FIX 27 / FIX 46: fraction of a key level's GEX attributable to the FRONT expiry.
Put Support / Call Resistance can be almost entirely 0DTE open interest that
ceases to exist at today's close — an ephemeral level.
FIX 46: the old denominator |net gex at strike| could be smaller than the
numerator when later expiries offset with opposite sign, yielding a "127%"
footnote that reads as a bug. Now publish TWO figures:
- front_expiry_abs_share = |front gex| / Σ|gex across all expiries| (<= 100%)
- front_expiry_net_ratio = |front gex| / |net gex at strike| (diagnostic, may exceed 1.0)
Returns a dict; empty dict if the strike has no GEX.
"""
M = cfg.contract_multiplier
S2 = spot * spot
if df_full.empty:
return {}
sub = df_full[df_full["strike"] == strike]
if sub.empty:
return {}
sign = np.where(sub["cp"].to_numpy() == "C", 1.0, -1.0)
gex = sign * sub["gamma"].abs().to_numpy() * sub["oi"].to_numpy() * M * S2 * 0.01
abs_total = float(np.abs(gex).sum())
net_total = abs(float(gex.sum()))
if abs_total == 0:
return {}
front_exp = df_full["expiry"].min()
front_mask = (sub["expiry"] == front_exp).to_numpy()
front_gex = abs(float(gex[front_mask].sum()))
result = {
"front_expiry": str(front_exp),
"front_expiry_abs_share": front_gex / abs_total,
}
if net_total > 0:
result["front_expiry_net_ratio"] = front_gex / net_total
return result
def build_oi_totals(df_full: pd.DataFrame) -> Dict:
"""FIX 14: OI sanity-check totals + DTE-bucket breakdown (full chain)."""
if df_full.empty:
return {"call_oi": 0, "put_oi": 0, "n_contracts": 0, "n_expiries": 0,
"oi_by_dte_bucket": {}}
calls = df_full[df_full["cp"] == "C"]
puts = df_full[df_full["cp"] == "P"]
buckets = {"0-7": 0, "8-30": 0, "31-90": 0, "91-365": 0}
for _, r in df_full.iterrows():
oi = int(r["oi"])
dte = int(r["dte"])
if dte <= 7:
buckets["0-7"] += oi
elif dte <= 30:
buckets["8-30"] += oi
elif dte <= 90:
buckets["31-90"] += oi
else:
buckets["91-365"] += oi
return {
"call_oi": int(calls["oi"].sum()),
"put_oi": int(puts["oi"].sum()),
"n_contracts": int(len(df_full)),
"n_expiries": int(df_full["expiry"].nunique()),
"oi_by_dte_bucket": buckets,
}
def build_gex_by_expiry(df_full: pd.DataFrame, spot: float, cfg: GexConfig) -> Dict:
"""FIX 18: per-expiry GEX concentration.
Returns front_expiry_share = |net_gex from the nearest expiry| / Σ|net_gex| over
all strikes, plus gex_by_expiry = [{expiry, dte, net_gex, share}] for every
expiry. FIX 94: the field was previously named sum_abs_gex but it is |Σ GEX|
(the absolute value of the net per-expiry GEX), NOT Σ|GEX| (the sum of absolute
per-contract GEX). Renamed to net_gex to match what it actually measures.
This exposes 0DTE domination: when one expiry drives most of the total
|GEX|, the chart is really a single-expiry picture and should be read as such.
FIX 25: the concentration warning must key on the DOMINANT expiry (argmax over
gex_by_expiry), not the front one — in the exfront variant the front expiry is a
tiny remnant while a later expiry holds the concentration. Also publishes
max_expiry_share / max_expiry / max_expiry_dte and top3_expiry_share (sum of the
three largest), so the warning fires on whichever expiry actually dominates.
"""
M = cfg.contract_multiplier
S2 = spot * spot
if df_full.empty:
return {"front_expiry_share": 0.0, "front_expiry": None,
"front_expiry_dte": None, "gex_by_expiry": [],
"max_expiry_share": 0.0, "max_expiry": None,
"max_expiry_dte": None, "top3_expiry_share": 0.0}
df = df_full.copy()
sign = np.where(df["cp"].to_numpy() == "C", 1.0, -1.0)
df["gex"] = sign * df["gamma"].abs() * df["oi"] * M * S2 * 0.01
by_exp = df.groupby("expiry")["gex"].sum()
per_strike_net = df.groupby("strike")["gex"].sum()
denom = float(per_strike_net.abs().sum())
front_exp = df["expiry"].min()
front_dte = int(df[df["expiry"] == front_exp]["dte"].min())
rows = []
for exp, g in by_exp.items():
dte = int(df[df["expiry"] == exp]["dte"].min())
abs_g = abs(float(g))
rows.append({"expiry": str(exp), "dte": dte, "net_gex": abs_g,
"share": abs_g / denom if denom > 0 else 0.0})
rows.sort(key=lambda r: r["dte"])
front_share = abs(float(by_exp.get(front_exp, 0.0))) / denom if denom > 0 else 0.0
# FIX 25: dominant expiry (argmax by share) and top-3 concentration
by_share = sorted(rows, key=lambda r: r["share"], reverse=True)
max_row = by_share[0] if by_share else None
top3_share = float(sum(r["share"] for r in by_share[:3]))
return {
"front_expiry_share": float(front_share),
"front_expiry": str(front_exp),
"front_expiry_dte": front_dte,
"gex_by_expiry": rows,
"max_expiry_share": float(max_row["share"]) if max_row else 0.0,
"max_expiry": max_row["expiry"] if max_row else None,
"max_expiry_dte": max_row["dte"] if max_row else None,
"top3_expiry_share": top3_share,
}
def detect_spread_candidates(df_full: pd.DataFrame, spot: float,
cfg: GexConfig, increment: float) -> Dict:
"""FIX 31 (revised FIX 33b, FIX 37): detect probable vertical-spread structures.
Within each expiry and right (P/C), find strike pairs where:
min(oi_a, oi_b) / max(oi_a, oi_b) >= cfg.spread_ratio_min,
both OI >= max(cfg.spread_oi_min_abs, cfg.spread_oi_min_frac * chain_oi),
|strike_a - strike_b| <= cfg.spread_max_width * increment.
FIX 33b: greedy-dedupe — sort pairs by combined_abs_gex descending, accept only
if NEITHER strike is already used (within that expiry+right). This prevents
527.5/530, 527.5/532.5, 530/532.5 all appearing and inflating the share.
FIX 33a: sensitivity uses a SIGN FLIP of the smaller leg (gross scores it
-g*OI; netted is +g*OI, so the adjustment is 2x), not removal.
Returns spread_candidates, spread_flagged_share, and (if flagged) a sensitivity
figure recomputing total_net_gex and HVL with the smaller leg sign-flipped.
"""
M = cfg.contract_multiplier
S2 = spot * spot
result = {
"spread_candidates": [],
"spread_flagged_share": 0.0,
"sensitivity_smaller_leg_sign_flipped": None,
}
if df_full.empty or increment <= 0:
return result
# per-expiry, per-right OI by strike
df = df_full.copy()
sign = np.where(df["cp"].to_numpy() == "C", 1.0, -1.0)
df["gex"] = sign * df["gamma"].abs() * df["oi"] * M * S2 * 0.01
total_abs_gex = float(df["gex"].abs().sum())
if total_abs_gex == 0:
return result
# FIX 37: relative OI floor
chain_oi = float(df["oi"].sum())
oi_floor = max(cfg.spread_oi_min_abs, cfg.spread_oi_min_frac * chain_oi)
candidates = []
max_width = cfg.spread_max_width * increment
for (expiry, right), grp in df.groupby(["expiry", "cp"]):
by_strike = grp.groupby("strike").agg(
oi=("oi", "sum"), gex=("gex", "sum")).reset_index()
by_strike = by_strike.sort_values("strike")
strikes = by_strike["strike"].to_numpy()
ois = by_strike["oi"].to_numpy()
gexs = by_strike["gex"].to_numpy()
for i in range(len(strikes)):
for j in range(i + 1, len(strikes)):
if strikes[j] - strikes[i] > max_width:
break
oi_a, oi_b = ois[i], ois[j]
if oi_a < oi_floor or oi_b < oi_floor:
continue
ratio = min(oi_a, oi_b) / max(oi_a, oi_b)
if ratio < cfg.spread_ratio_min:
continue
combined_abs = abs(gexs[i]) + abs(gexs[j])
candidates.append({
"expiry": str(expiry),
"right": right,
"strike_low": float(strikes[i]),
"strike_high": float(strikes[j]),
"oi_low": int(oi_a),
"oi_high": int(oi_b),
"combined_abs_gex": float(combined_abs),
"share_of_total_abs_gex": float(combined_abs / total_abs_gex),
})
# FIX 33b: greedy dedupe — sort by combined_abs_gex desc, accept only if
# neither strike is already used within that expiry+right.
candidates.sort(key=lambda c: c["combined_abs_gex"], reverse=True)
deduped = []
used = {} # (expiry, right) -> set of used strikes
for c in candidates:
key = (c["expiry"], c["right"])
used_set = used.setdefault(key, set())
if c["strike_low"] in used_set or c["strike_high"] in used_set:
continue
used_set.add(c["strike_low"])
used_set.add(c["strike_high"])
deduped.append(c)
result["spread_candidates"] = deduped
flagged_share = sum(c["share_of_total_abs_gex"] for c in deduped)
result["spread_flagged_share"] = float(flagged_share)
if flagged_share > cfg.spread_flag_threshold and deduped:
logger.warning(
"SPREAD DETECTION: %d probable vertical-spread structures = %.0f%% of |GEX|; "
"gross-OI proxy overstates net dealer gamma here.",
len(deduped), flagged_share * 100)
# FIX 33a: SENSITIVITY — sign-flip the smaller leg (adjustment is 2x removal).
# Gross scores a put leg as -g*OI; netted (dealer-long) is +g*OI.
# So the adjustment = +2 * |gex_smaller_leg| for puts, -2 * |gex_smaller_leg| for calls.
df_netted = df.copy()
for c in deduped:
if c["oi_low"] <= c["oi_high"]:
flip_strike, flip_oi = c["strike_low"], c["oi_low"]
else:
flip_strike, flip_oi = c["strike_high"], c["oi_high"]
mask = ((df_netted["expiry"].astype(str) == c["expiry"]) &
(df_netted["cp"] == c["right"]) &
(df_netted["strike"] == flip_strike))
idx = df_netted.index[mask]
if len(idx) > 0:
total_oi_at_strike = df_netted.loc[idx, "oi"].sum()
if total_oi_at_strike > 0:
frac = min(flip_oi / total_oi_at_strike, 1.0)
# Sign flip: negate the gex contribution of the flipped fraction.
# Original gex = sign * |gamma| * oi * M * S2 * 0.01
# Flipped gex = -sign * |gamma| * oi * M * S2 * 0.01
# Adjustment = -2 * original gex for the flipped fraction
orig_gex = df_netted.loc[idx, "gex"].sum()
df_netted.loc[idx, "gex"] -= 2.0 * orig_gex * frac
netted_total = float(df_netted["gex"].sum())
# recompute HVL on a quick grid
from .config import GexConfig as _Cfg
_cfg = _Cfg()
n_pts = cfg.profile_grid_points
grid_n = np.linspace(spot * (1 - _cfg.profile_band_hvl),
spot * (1 + _cfg.profile_band_hvl), n_pts)
gp_n, _ = gex_profile(df_netted, spot, cfg, grid_n)
hvl_n_info = compute_hvl(grid_n, gp_n, spot, cfg)
result["sensitivity_smaller_leg_sign_flipped"] = {
"total_net_gex": netted_total,
"hvl": hvl_n_info["hvl"],
"hvl_distance_pct": hvl_n_info["hvl_distance_pct"],
"note": "illustrative bound — smaller leg of each flagged spread sign-flipped; "
"NOT the headline number",
}
return result
def compute_realised_vol(closes: list, days: int = 20) -> Optional[float]:
"""FIX 32: annualised realised volatility from a list of daily closes.
Uses log returns over the last `days` observations. Returns None if fewer than
5 data points.
"""
if len(closes) < 5:
return None
arr = np.array(closes[-days - 1:], dtype=float) # need days+1 for days returns
if len(arr) < 2:
return None
log_ret = np.diff(np.log(arr))
if len(log_ret) == 0:
return None
return float(np.std(log_ret, ddof=1) * np.sqrt(252))
def atm_iv_cross_check(atm_iv: Optional[float], realised_vol: Optional[float],
cfg: GexConfig) -> Dict:
"""FIX 32: cross-check ATM IV against realised volatility.
Publishes iv_hv_ratio and vol_regime. If the ratio is an outlier (> 2.5 or < 0.4),
flags atm_iv_status accordingly.
"""
result = {
"realised_vol_20d": realised_vol,
"iv_hv_ratio": None,
"vol_regime": None,
}
if atm_iv is None or realised_vol is None or realised_vol <= 0:
return result
ratio = atm_iv / realised_vol
result["iv_hv_ratio"] = float(ratio)
result["vol_regime"] = "IV > HV" if ratio > 1.0 else "IV < HV"
if ratio > cfg.iv_hv_outlier_hi or ratio < cfg.iv_hv_outlier_lo:
result["iv_hv_outlier"] = True
else:
result["iv_hv_outlier"] = False
return result
def compute_bands(atm_iv: Optional[float], cfg: GexConfig,
realised_vol: Optional[float] = None,
atm_iv_status: Optional[str] = None) -> Dict:
"""FIX 35: derive volatility-scaled bands from the chain's own ATM IV.
Two-pass: PASS 1 gets ATM IV (already computed by the caller). DERIVE bands.
PASS 2 runs the pipeline with those bands.
sigma_30d = atm_iv * sqrt(30/365)
strike_band = clip(band_strike_mult * sigma_30d, limits)
plot_band = clip(band_plot_mult * sigma_30d, limits)
profile_band = clip(band_profile_mult * sigma_30d, limits)
dex_band = clip(band_dex_mult * sigma_30d, limits)
FIX 43: if atm_iv is None, fall back to realised vol (clip(rv*1.1, 0.10, 1.00))
instead of a hardcoded 0.30, and set band_basis="fallback_from_rv". Only if RV
is also unavailable do we use cfg.atm_iv_fallback with band_basis="fallback".
FIX 67: when atm_iv resolved from a single strike (no interpolation), set
band_basis="atm_iv_single_strike" so the audit trail is honest about the input.
"""
if atm_iv is not None and 0.01 < atm_iv < 3.0:
iv_used = atm_iv
basis = "atm_iv_single_strike" if atm_iv_status == "single_strike_no_interpolation" else "atm_iv"
elif realised_vol is not None and realised_vol > 0:
iv_used = float(np.clip(realised_vol * 1.1, 0.10, 1.00))
basis = "fallback_from_rv"
else:
iv_used = cfg.atm_iv_fallback
basis = "fallback"
sigma_30d = iv_used * np.sqrt(30.0 / 365.0)
lim = cfg.band_limits
def _clip(val, key):
lo, hi = lim[key]
return float(np.clip(val, lo, hi))
strike_band = _clip(cfg.band_strike_mult * sigma_30d, "strike_band")
plot_band = _clip(cfg.band_plot_mult * sigma_30d, "plot_band")
profile_band = _clip(cfg.band_profile_mult * sigma_30d, "profile_band")
dex_band = _clip(cfg.band_dex_mult * sigma_30d, "dex_band")
return {
"atm_iv_used": float(iv_used),
"sigma_30d": float(sigma_30d),
"strike_band": strike_band,
"plot_band": plot_band,
"profile_band": profile_band,
"dex_band": dex_band,
"band_basis": basis,
}
def snap_to_increment(value: Optional[float], increment: float) -> Optional[float]:
"""FIX 36: round a published level to the detected strike increment."""
if value is None or increment <= 0:
return value
return round(value / increment) * increment
def is_third_friday(d: date) -> bool:
"""FIX 37: check if a date is the third Friday of its month (monthly index expiry)."""
if d.weekday() != 4: # not a Friday
return False
# Third Friday: day-of-month is 15-21
return 15 <= d.day <= 21
"""matplotlib chart — Net GEX All Expirations."""
import logging
from datetime import datetime
from pathlib import Path
from typing import Dict, Optional
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import matplotlib.ticker as mticker
import numpy as np
import pandas as pd
from matplotlib.lines import Line2D
from matplotlib.patches import Patch
from zoneinfo import ZoneInfo
from .config import GexConfig
from .compute import render_bucket as compute_render_bucket
logger = logging.getLogger(__name__)
ET = ZoneInfo("America/New_York")
def _dynamic_formatter(x, pos):
"""FIX 37 / FIX 50: format ticks as K/M/B/T by magnitude (dynamic).
Drops the trailing ".0" on whole numbers (e.g. "3.0T" -> "3T")."""
ax = abs(x)
if ax == 0:
return "0"
if ax >= 1e12:
v = x / 1e12
return f"{v:.1f}T".replace(".0T", "T")
if ax >= 1e9:
v = x / 1e9
return f"{v:.1f}B".replace(".0B", "B")
if ax >= 1e6:
return f"{x / 1e6:.0f}M"
if ax >= 1e3:
return f"{x / 1e3:.0f}K"
return f"{x:.0f}"
def _clean_xlim(v: float) -> float:
"""FIX 37: dynamic clean-step ladder. Pick the largest power of ten below |v|,
then step through {1, 2.5, 5, 10} multiples of it. No fixed 1M..1000M table."""
v = abs(float(v))
if v <= 0:
return 1e6
import math
decade = 10 ** math.floor(math.log10(v))
for mult in (1, 2.5, 5, 10):
step = decade * mult
if v <= step:
return step
return decade * 10
def format_timestamp(timestamp_str: str, source_tz: str = "UTC") -> str:
"""Convert Cboe's UTC timestamp to ET for display, with real tz suffix (FIX 1).
Returns e.g. "2026-07-24 15:35 EDT". The suffix is derived from the converted
datetime (EDT/EST), never hardcoded.
"""
src = ZoneInfo(source_tz)
try:
ts = datetime.strptime(timestamp_str, "%Y-%m-%d %H:%M:%S").replace(tzinfo=src).astimezone(ET)
except Exception:
ts = datetime.now(ET)
return ts.strftime("%Y-%m-%d %H:%M") + f" {ts.tzname()}"
def _pick_ytick_step(span: float) -> float:
"""Pick a clean strike-tick step giving ~8-16 ticks across the window."""
candidates = [1, 2, 2.5, 5, 10, 20, 25, 50, 100]
for c in candidates:
if span / c <= 16:
return c
return 100
def render_chart(
symbol: str,
agg: pd.DataFrame,
grid: np.ndarray,
gex_prof: np.ndarray,
grid_dex: np.ndarray,
dex_prof: np.ndarray,
levels: Dict,
timestamp_str: str,
cfg: GexConfig,
outdir: str,
slot_label: str,
increment: float,
source_label: str = "Cboe delayed (~15m)",
display_label: Optional[str] = None,
render_bucket: Optional[float] = None,
rolling_gex_limit: Optional[float] = None,
rolling_dex_limit: Optional[float] = None,
) -> Path:
"""Render the full chart and save PNG. Returns the output path.
FIX 26: the DEX profile lives on its own wider grid (`grid_dex`, ±40%) while the
GEX profile uses `grid` (±25%). Both are restricted to the visible window below.
FIX 71 / FIX 72: the bar bucket is passed in (`render_bucket`, computed once in
snapshot.py from the plot-band window) and drives bar aggregation + height. The
plotted y-window is spot × (1 ± plot_band) — the region where bars actually
exist — NOT extended to distant key levels. A level that falls outside the bar
window is drawn as an edge marker/arrow with its price labelled instead of
stretching the axis.
"""
spot = levels["spot"]
call_res = levels["call_resistance"]
put_sup = levels["put_support"]
hvl = levels["hvl"] # may be None when no_flip_in_range (FIX 29)
hvl_rule = levels["hvl_rule"]
hvl_confidence = levels.get("hvl_confidence", "high")
hvl_status = levels.get("hvl_status", "ok")
hvl_regime_note = levels.get("hvl_regime_note")
gex_transition = levels.get("gex_transition")
levels_ephemeral = levels.get("levels_ephemeral", [])
# --- FIX 72: plot window = spot × (1 ± plot_band), the region where bars exist.
# Distant key levels (HVL, GEX-transition, even call_resistance/put_support) no
# longer stretch the axis; any level outside the bar window is collected here and
# drawn later as an edge marker/arrow with its price labelled.
lo = spot * (1 - cfg.plot_band)
hi = spot * (1 + cfg.plot_band)
if increment > 0:
lo = np.floor(lo / increment) * increment
hi = np.ceil(hi / increment) * increment
# Named key levels (name, strike, colour) for off-scale edge-marker handling.
_named_levels = [
("Call Resistance", call_res, cfg.call_res_color),
("Put Support", put_sup, cfg.put_sup_color),
]
if hvl is not None:
_named_levels.append(("HVL", hvl, cfg.hvl_color))
if gex_transition is not None:
_named_levels.append(("GEX Transition", gex_transition, cfg.gex_transition_color))
offscale = [(name, strike, color) for name, strike, color in _named_levels
if strike < lo or strike > hi]
if offscale:
logger.info("FIX 72: %d level(s) off-scale (window %.1f–%.1f): %s",
len(offscale), lo, hi,
", ".join(f"{n}@{s:g}" for n, s, _ in offscale))
vis = agg[(agg.index >= lo) & (agg.index <= hi)]
strikes = vis.index.to_numpy()
net = vis["net_gex"].to_numpy()
# --- FIX 19: honest LINEAR clipping (symlog removed — it distorts a linear
# dollar quantity). xlim = clean(1.15 * p97); widen for a key-level strike ONLY
# if 1.05*|net_gex| there is <= 3x that limit. Beyond 3x, clip and annotate. ---
nz = np.abs(net[net != 0])
if len(nz):
p97 = np.percentile(nz, 97)
xlim = _clean_xlim(1.15 * p97)
else:
p97 = 0.0
xlim = 5_000_000
def _net_at(strike):
return float(agg["net_gex"].get(strike, 0.0)) if strike in agg.index else 0.0
protect = max(abs(_net_at(put_sup)), abs(_net_at(call_res)))
if 1.05 * protect <= 3 * xlim:
xlim = max(xlim, 1.05 * protect) # widen to include the key-level bar
else:
logger.info("Key-level bar (%.3g) exceeds 3× p97 limit (%.3g); clipping + annotating.",
protect, xlim)
logger.info("Bar axis: linear (xlim=%.3g, p97=%.3g)", xlim, p97)
max_net = np.max(np.abs(net)) if len(net) else 0.0
argmax_strike = float(strikes[np.argmax(np.abs(net))]) if len(net) else float("nan")
# --- profiles on the fine price grid, restricted to the visible window ---
# GEX profile uses `grid`; DEX profile uses the wider `grid_dex` (FIX 26).
mask = (grid >= lo) & (grid <= hi)
g_vis = grid[mask]
gp_vis = gex_prof[mask]
mask_dex = (grid_dex >= lo) & (grid_dex <= hi)
g_vis_dex = grid_dex[mask_dex]
dp_vis = dex_prof[mask_dex]
# --- FIX 10 + FIX 11: each profile gets its OWN axis (different units) ---
gp_peak = float(np.max(np.abs(gp_vis))) if len(gp_vis) else 0.0
dp_peak = float(np.max(np.abs(dp_vis))) if len(dp_vis) else 0.0
if gp_peak > 0 and dp_peak > 0:
logger.info("profile peaks: gex=%.3g dex=%.3g ratio=%.1f", gp_peak, dp_peak, dp_peak / gp_peak)
# FIX 50: axis limits come from the DATA (visible-window max), not spot^2.
# "data": ±1.10 * visible max. "rolling": ±1.2 * median of last N maxima
# (passed in), but never clip the actual curve — take the larger of the two.
data_gex = 1.10 * gp_peak if gp_peak > 0 else 1.0
data_dex = 1.10 * dp_peak if dp_peak > 0 else 1.0
if cfg.profile_axis_mode == "rolling" and rolling_gex_limit and rolling_dex_limit:
ax2_xlim = max(rolling_gex_limit, data_gex)
ax3_xlim = max(rolling_dex_limit, data_dex)
else: # "data" (default) or rolling with insufficient history
ax2_xlim = data_gex
ax3_xlim = data_dex
ax2_xlim = max(ax2_xlim, 1.0)
ax3_xlim = max(ax3_xlim, 1.0)
# FIX 50: log chosen limit vs data max; ratio > 5 is the over-scaling signature.
for name, lim, peak in (("gex", ax2_xlim, gp_peak), ("dex", ax3_xlim, dp_peak)):
if peak > 0:
ratio = lim / (1.10 * peak)
logger.info("FIX 50 %s axis: limit=%.3g data_max=%.3g ratio=%.2f",
name, lim, peak, ratio)
if ratio > 5:
logger.warning("FIX 50 %s axis ratio %.1f > 5 — over-scaling signature.",
name, ratio)
# --- timestamp: Cboe's field is UTC; convert to ET for display (FIX 1) ---
UTC = ZoneInfo(cfg.source_timestamp_tz)
try:
ts = datetime.strptime(timestamp_str, "%Y-%m-%d %H:%M:%S").replace(tzinfo=UTC).astimezone(ET)
except Exception:
ts = datetime.now(ET)
ts_label = format_timestamp(timestamp_str, cfg.source_timestamp_tz)
# --- figure ---
fig, ax = plt.subplots(figsize=(cfg.fig_width, cfg.fig_height), dpi=cfg.dpi)
fig.patch.set_facecolor(cfg.bg_color)
ax.set_facecolor(cfg.axes_bg)
# FIX 19: linear bar axis always (symlog removed — it distorts a linear $ qty).
# FIX 21: opaque, deeper bars; zorder 3 (grid 0, bars 3, key-level hlines 4,
# profile lines 5 — bars never occlude the curves).
# FIX 60 / FIX 71 / FIX 72: bars are aggregated into buckets targeting ~40 visible
# bars. The bucket is computed ONCE in snapshot.py from the PLOT-BAND window and
# passed in as `render_bucket` — it is the single source of truth for bar
# aggregation AND the published render_spacing/render_bucket fields. Bucketing is
# RENDERING ONLY — call_resistance, put_support, hvl, gex_transition,
# delta_neutral and outlier_report all stay at true strike resolution.
if render_bucket is None:
render_bucket = compute_render_bucket(hi - lo, increment)
bucket = render_bucket
levels["render_bucket"] = bucket # audit trail: bar resolution vs strike_increment
if bucket > increment and len(strikes) > 0:
bucket_ids = np.round(strikes / bucket) * bucket
_bagg = pd.DataFrame({"c": bucket_ids, "n": net}).groupby("c")["n"].sum().sort_index()
bar_strikes = _bagg.index.to_numpy()
bar_net = _bagg.to_numpy()
else:
bar_strikes = strikes
bar_net = net
bar_h = 0.8 * bucket
net_clip = np.clip(bar_net, -xlim, xlim)
clipped_idx = np.where(np.abs(bar_net) > xlim)[0]
pos_mask = net_clip >= 0
neg_mask = ~pos_mask
if pos_mask.any():
ax.barh(bar_strikes[pos_mask], net_clip[pos_mask], height=bar_h,
color=cfg.bar_pos_color, alpha=1.0, edgecolor="none", zorder=3,
label="Positive GEX")
if neg_mask.any():
ax.barh(bar_strikes[neg_mask], net_clip[neg_mask], height=bar_h,
color=cfg.bar_neg_color, alpha=1.0, edgecolor="none", zorder=3,
label="Negative GEX")
# FIX 19: honest clip annotation — draw to the edge, add a » / « marker plus a
# text label with the bar's TRUE value just inside the axis, in the bar colour.
# (v1.7.0: the guillemet is drawn as text, not a plot marker — "«"/"»" are not
# valid matplotlib markers and crashed once FIX 60 bucketing made aggregated
# bars exceed xlim often enough to reach this path.)
for ci in clipped_idx:
color = cfg.bar_pos_color if bar_net[ci] >= 0 else cfg.bar_neg_color
edge = xlim if bar_net[ci] > 0 else -xlim
marker = "»" if bar_net[ci] > 0 else "«"
ax.text(edge, bar_strikes[ci], marker, color=color, fontsize=12,
fontweight="bold", va="center", ha="center", zorder=6, clip_on=False)
label_x = xlim * 0.985 if bar_net[ci] > 0 else -xlim * 0.985
ax.text(label_x, bar_strikes[ci], f"{bar_net[ci]/1e6:.0f}M", color=color,
fontsize=7, va="center", ha="right" if bar_net[ci] > 0 else "left",
zorder=6, clip_on=False)
# FIX 95: reconciliation status is NO LONGER drawn on the chart image. The PNG
# is self-contained chart furniture only (title, spot, levels, legend, source
# line). The red FAIL banner, amber precision-limited strip, and the
# reconciliation outcome all moved to a collapsible <details> section on the
# HTML page directly beneath the image. A short neutral footer line (below)
# points a reader who saves/shares the PNG at where the reliability detail lives.
# (Bars use Cboe's reported gamma and are unaffected by reconciliation either
# way; only the recomputed profile and its derived levels are in doubt on a
# fail — that nuance now lives on the page, not the image.)
# --- FIX 10: separate twin axis per profile (different units, colour-matched) ---
# ax2 (TOP): GEX Profile, yellow axis. ax3 (BOTTOM, offset): DEX Profile, orange.
ax2 = ax.twiny()
ax2.set_facecolor("none")
if len(g_vis) > 1:
ax2.plot(gp_vis, g_vis, color=cfg.gex_profile_color, lw=1.6, zorder=5,
label="GEX Profile")
ax2.set_xlim(-ax2_xlim, ax2_xlim)
ax2.xaxis.set_major_formatter(mticker.FuncFormatter(_dynamic_formatter))
ax2.set_xlabel("GEX Profile ($ per 1% move)", color=cfg.gex_profile_color, fontsize=10)
ax2.tick_params(colors=cfg.gex_profile_color, labelsize=8)
for spine in ax2.spines.values():
spine.set_color(cfg.gex_profile_color)
ax2.axvline(0.0, color="#555555", lw=0.8, zorder=1)
ax3 = ax.twiny()
ax3.set_facecolor("none")
if len(g_vis_dex) > 1:
ax3.plot(dp_vis, g_vis_dex, color=cfg.dex_color, lw=1.6, zorder=5,
label="DEX Profile")
ax3.set_xlim(-ax3_xlim, ax3_xlim)
# move ax3's spine to the BOTTOM, offset below ax's own x-axis
ax3.xaxis.set_ticks_position("bottom")
ax3.xaxis.set_label_position("bottom")
ax3.spines["bottom"].set_position(("outward", 42))
ax3.xaxis.set_major_formatter(mticker.FuncFormatter(_dynamic_formatter))
ax3.set_xlabel("DEX Profile ($ delta notional)", color=cfg.dex_color, fontsize=10)
ax3.tick_params(colors=cfg.dex_color, labelsize=8)
for spine in ax3.spines.values():
spine.set_color(cfg.dex_color)
# set the bar-axis limit, then check all three zeros coincide (FIX 10: warn,
# never assert — asserts are stripped under -O and kill the run on float drift).
ax.set_xlim(-xlim, xlim)
fig.canvas.draw()
ax0 = ax.transData.transform((0.0, 0.0))[0]
ax2_0 = ax2.transData.transform((0.0, 0.0))[0]
ax3_0 = ax3.transData.transform((0.0, 0.0))[0]
if abs(ax0 - ax2_0) > 0.5 or abs(ax0 - ax3_0) > 0.5:
logger.warning("twin-axis zero misaligned: ax=%.2f ax2=%.2f ax3=%.2f",
ax0, ax2_0, ax3_0)
# key levels — FIX 72: only draw an hline for levels INSIDE the bar window;
# off-scale levels are drawn as edge markers/arrows with their price labelled
# (see the offscale loop below) instead of stretching the axis to reach them.
def _in_window(strike):
return strike is not None and lo <= strike <= hi
if _in_window(call_res):
ax.axhline(call_res, color=cfg.call_res_color, ls="--", lw=1.4, zorder=4)
if _in_window(put_sup):
ax.axhline(put_sup, color=cfg.put_sup_color, ls="--", lw=1.4, zorder=4)
ax.axhline(spot, color=cfg.spot_color, ls="--", lw=1.4, zorder=4)
# FIX 29: HVL is always a single dashed line (zero crossing). No band, no
# "indeterminate". If no_flip_in_range, annotate that on the chart.
if hvl is not None and _in_window(hvl):
ax.axhline(hvl, color=cfg.hvl_color, ls="--", lw=1.4, zorder=4)
# regime note annotation next to the HVL line
if hvl_regime_note:
ax.text(xlim * 0.97, hvl, f" {hvl_regime_note}", color=cfg.hvl_color,
fontsize=7, va="bottom", ha="right", zorder=6, clip_on=False)
elif hvl is None and hvl_status == "no_flip_in_range":
ax.text(0.5, 0.50, "no gamma flip within ±40% of spot",
transform=ax.transAxes, fontsize=10, color=cfg.hvl_color,
ha="center", va="center", alpha=0.7, zorder=6)
# FIX 30: GEX Transition — separate named level, colour #7FA6C9
if gex_transition is not None and _in_window(gex_transition):
ax.axhline(gex_transition, color=cfg.gex_transition_color, ls="--", lw=1.2, zorder=4)
# FIX 72: off-scale key levels -> edge marker/arrow with price label. The level
# sits beyond the plotted bar window, so we point at the top/bottom edge and
# label its true strike so the reader knows where it is without widening the axis.
# Stack multiple labels on the same edge so they don't overlap.
_off_above = [(n, s, c) for n, s, c in offscale if s > hi]
_off_below = [(n, s, c) for n, s, c in offscale if s <= lo]
_line_h = (hi - lo) * 0.035 # vertical spacing between stacked labels
for i, (name, strike, color) in enumerate(_off_above):
y_pos = hi - i * _line_h
ax.text(xlim * 0.97, y_pos, f"▲ {name} {strike:g} (off-scale)",
color=color, fontsize=7.5, fontweight="bold",
va="top", ha="right", zorder=7, clip_on=False)
for i, (name, strike, color) in enumerate(_off_below):
y_pos = lo + i * _line_h
ax.text(xlim * 0.97, y_pos, f"▼ {name} {strike:g} (off-scale)",
color=color, fontsize=7.5, fontweight="bold",
va="bottom", ha="right", zorder=7, clip_on=False)
# grid
ax.grid(True, which="both", color=cfg.grid_color, ls=":", lw=0.6, zorder=0)
# axes cosmetics
for spine in ax.spines.values():
spine.set_color(cfg.text_color)
ax.tick_params(colors=cfg.text_color, labelsize=9)
ax.set_ylabel("Strike Price", color=cfg.text_color, fontsize=11)
ax.set_xlabel("GEX", color=cfg.text_color, fontsize=11)
ax.xaxis.set_major_formatter(mticker.FuncFormatter(_dynamic_formatter))
# y ticks (clean step across the widened window)
ytick_step = _pick_ytick_step(hi - lo)
yticks = np.arange(np.ceil(lo / ytick_step) * ytick_step, hi + 1, ytick_step)
ax.set_yticks(yticks)
ax.set_yticklabels([f"{v:g}" for v in yticks])
ax.set_ylim(lo - increment, hi + increment)
# titles — placed well above axes to avoid overlapping data
title_sym = display_label or symbol
fig.text(0.10, 0.97, f"Net GEX All Expirations for {title_sym}",
fontsize=17, fontweight="bold", color=cfg.title_color, ha="left")
fig.text(0.10, 0.94, f"Timestamp: {ts_label}",
fontsize=11, color=cfg.text_color, ha="left")
# legend (FIX 29: HVL is always a single line; FIX 30: GEX Transition added)
_off_names_leg = {n for n, _, _ in offscale}
if hvl is not None:
hvl_dist = levels.get("hvl_distance_pct")
dist_str = f" ({hvl_dist:+.1%})" if hvl_dist is not None else ""
hvl_off = " (off-scale)" if "HVL" in _off_names_leg else ""
hvl_lbl = f"HVL: {hvl:g}{dist_str}{hvl_off}"
hvl_handle = Line2D([0], [0], color=cfg.hvl_color, ls="--", lw=1.4, label=hvl_lbl)
elif hvl_status == "no_flip_in_range":
hvl_handle = Line2D([0], [0], color=cfg.hvl_color, ls="--", lw=1.4,
label="HVL: no flip in ±40%")
else:
hvl_handle = Line2D([0], [0], color=cfg.hvl_color, ls="--", lw=1.4, label="HVL: n/a")
cr_dag = " †" if "call_resistance" in levels_ephemeral else ""
ps_dag = " †" if "put_support" in levels_ephemeral else ""
# FIX 72: tag legend labels for levels drawn off-scale as edge markers.
_off_names = {n for n, _, _ in offscale}
cr_off = " (off-scale)" if "Call Resistance" in _off_names else ""
ps_off = " (off-scale)" if "Put Support" in _off_names else ""
handles = [
Line2D([0], [0], color=cfg.dex_color, lw=1.6, label="DEX Profile"),
Line2D([0], [0], color=cfg.gex_profile_color, lw=1.6, label="GEX Profile"),
Line2D([0], [0], color=cfg.call_res_color, ls="--", lw=1.4, label=f"Call Resistance: {call_res:g}{cr_dag}{cr_off}"),
Line2D([0], [0], color=cfg.put_sup_color, ls="--", lw=1.4, label=f"Put Support: {put_sup:g}{ps_dag}{ps_off}"),
hvl_handle,
]
# FIX 30: GEX Transition in legend
if gex_transition is not None:
gt_off = " (off-scale)" if "GEX Transition" in _off_names_leg else ""
handles.append(Line2D([0], [0], color=cfg.gex_transition_color, ls="--", lw=1.2,
label=f"GEX Transition: {gex_transition:g}{gt_off}"))
handles.extend([
Line2D([0], [0], color=cfg.spot_color, ls="--", lw=1.4, label=f"Spot Price: {spot:.1f}"),
Patch(facecolor=cfg.bar_pos_color, label="Positive GEX"),
Patch(facecolor=cfg.bar_neg_color, label="Negative GEX"),
])
ax.legend(handles=handles, loc="upper center", bbox_to_anchor=(0.5, 1.20),
ncol=4, frameon=False, fontsize=9, labelcolor=cfg.text_color,
columnspacing=1.2, handletextpad=0.4)
# Brand watermark row (FIX 20/27: centered at y=0.030, above the footnote strip).
# Known-safe centered element, intentionally NOT in the layout-guard list.
fig.text(0.5, 0.030, cfg.brand_text, ha="center", fontsize=11,
fontweight="bold", color="#F2E4CE")
# watermark
if cfg.watermark_text:
ax.text(0.72, 0.18, cfg.watermark_text, transform=ax.transAxes,
fontsize=28, alpha=0.35, color=cfg.text_color, ha="center", va="center")
# FIX 20: put/call warning removed from the chart image — it's already shown in
# the HTML dashboard below the chart. No fig.text annotation here.
# Guard list tracks fig.text y-positions. The brand (y=0.039) is intentionally
# excluded — it's a known-safe centered row, not an unexpected intrusion into the
# offset-DEX-axis furniture band the guard watches.
_fig_text_ys = [0.97, 0.94] # title, timestamp
# v1.6.5: the spread-structure and ephemeral-level footnotes were removed from
# the chart image — they duplicated data already shown on the HTML dashboard and
# their coexistence caused overlapping text at the bottom of the image. Only the
# safety-critical FAULT note (profile outlier guard) remains, plus the
# right-aligned source/axis footer.
profile_outliers = levels.get("profile_outliers_dropped", [])
if profile_outliers:
n_out = len(profile_outliers)
strikes = ", ".join(f"{o['strike']:g}" for o in profile_outliers[:3])
fig.text(0.10, 0.016,
f"⚠ FAULT: {n_out} contract(s) dropped by profile outlier guard "
f"(strike {strikes}{'…' if n_out > 3 else ''}) — investigate",
ha="left", va="center", fontsize=8,
color="#FF6B6B", fontweight="bold")
_fig_text_ys.append(0.016)
# FIX 95: the coarse-gamma footnote moved to the page's reconciliation
# <details> section (gamma_precision + digits are published in the JSON and
# rendered there). The PNG carries no reconciliation/precision commentary.
# FIX 95: neutral reliability pointer. The PNG is now caveat-free, so anyone
# who saves or shares it loses the reconciliation warning. This short neutral
# line tells them where the reliability detail lives — better than nothing.
fig.text(0.10, 0.010,
"reliability detail: allofthesewords.com/optionsdata",
ha="left", va="center", fontsize=7.5,
color=cfg.footer_color, fontstyle="italic")
_fig_text_ys.append(0.010)
# footer (FIX 11: profile axis mode; FIX 19: clipped-bar footnote) — right-aligned
# bottom row.
n_clip = len(clipped_idx)
clip_note = (f" | {n_clip} strike(s) clipped; max |net GEX| = "
f"{max_net/1e6:.0f}M at {argmax_strike:g}") if n_clip else ""
fig.text(0.98, 0.0045,
f"src: {source_label} | HVL rule: {hvl_rule} | profile axis: "
f"{cfg.profile_axis_mode}{clip_note}",
ha="right", fontsize=7, color=cfg.footer_color)
_fig_text_ys.append(0.0045)
# FIX 20 layout guard: no fig.text may sit in the bottom furniture band
# (0.02 <= y <= 0.14) where the offset DEX axis and its labels live. Warn, never assert.
for y in _fig_text_ys:
if 0.02 <= y <= 0.14:
logger.warning("LAYOUT: a fig.text element sits at y=%.3f inside the "
"bottom axis-furniture band [0.02, 0.14].", y)
plt.subplots_adjust(left=0.10, right=0.90, top=0.78, bottom=0.16)
# save
date_str = ts.strftime("%Y-%m-%d")
out_dir = Path(outdir) / symbol
out_dir.mkdir(parents=True, exist_ok=True)
png_path = out_dir / f"{date_str}_{slot_label}.png"
fig.savefig(png_path, facecolor=fig.get_facecolor())
plt.close(fig)
logger.info("Chart saved -> %s", png_path)
return png_path
"""CLI entrypoint + schedule guard."""
import argparse
import csv
import json
import logging
import sys
import time as _time
from datetime import date, datetime, time, timedelta, timezone
from pathlib import Path
from typing import Optional
from zoneinfo import ZoneInfo
import numpy as np
import pandas as pd
from .compute import (
aggregate,
atm_expected_move,
atm_iv_cross_check,
build_gex_by_expiry,
build_oi_totals,
build_outlier_report,
compute_bands,
compute_levels,
compute_realised_vol,
detect_increment,
detect_render_spacing,
detect_spread_candidates,
dex_min_price,
dex_profile,
filter_contracts_band,
filter_contracts_full,
find_delta_neutral,
gamma_precision,
gex_profile,
is_third_friday,
level_front_expiry_pct,
minutes_to_settlement,
reconcile,
reconcile_by_expiry,
reconciliation_floor_unsigned,
render_bucket,
snap_to_increment,
_settlement_time,
total_net_gex_from_contracts,
)
from .config import GexConfig
from .fetch import fetch_chain, parse_chain
from .plot import render_chart
# FIX 89: provenance stamp — every artifact carries the producing code version.
from . import __version__ as _GEX_VERSION, __schema_version__ as _GEX_SCHEMA_VERSION
logger = logging.getLogger("gex")
ET = ZoneInfo("America/New_York")
def _slot_label(now_et: datetime, cfg: GexConfig) -> str:
"""FIX 86b: explicit asymmetric windows (not center ± tolerance).
AM window: slot_am_start–slot_am_end ET (default 09:30–12:00).
PM window: slot_pm_start–slot_pm_end ET (default 14:00–16:15).
The slot label describes INTENT, not precision. The recorded capture time is
authoritative; this gate only decides whether a capture is accepted for a
given slot filename. Late-but-same-day captures with correct timestamps are
usable data — losing them entirely is the worse failure."""
def _hm(s: str):
h, m = map(int, s.split(":"))
return now_et.replace(hour=h, minute=m, second=0, microsecond=0)
am_start, am_end = _hm(cfg.slot_am_start), _hm(cfg.slot_am_end)
pm_start, pm_end = _hm(cfg.slot_pm_start), _hm(cfg.slot_pm_end)
if am_start <= now_et <= am_end:
return "am"
if pm_start <= now_et <= pm_end:
return "pm"
return ""
def _is_trading_day(d: date) -> bool:
try:
import pandas_market_calendars as mcal
nyse = mcal.get_calendar("NYSE")
sched = nyse.schedule(start_date=pd.Timestamp(d), end_date=pd.Timestamp(d))
return len(sched) > 0
except Exception:
return d.weekday() < 5 # fallback: Mon-Fri
# FIX 49: explicit NYSE trading-day check (same logic, clearer name for the guard).
_is_nyse_trading_day = _is_trading_day
def run_ticker(symbol: str, cfg: GexConfig, slot: str, from_cache: bool = False,
replay_unsafe: bool = False,
overwrite: bool = False) -> bool:
"""Process one ticker end-to-end. Returns True on success.
FIX 97: the ex-front (0DTE-removed) second chart was removed. There is now a
single pipeline and a single "All Expirations" variant per ticker/slot. The
drift risk that the old FIX 68 identical-keys test guarded against disappears
with the second path. The JSON `variant` field is retained with the constant
value "all_expirations" so the canonical key set does not churn.
replay_unsafe (FIX 52): bypass ONLY the FIX 49 trading-day/session-window check
so an out-of-session cached chain (e.g. the Saturday NDX chain that carries
expired contracts) can exercise FIX 48/50 on real data. Output is flagged
non-publishable and forced to a scratch outdir by main().
overwrite (FIX 79b): force-overwrite an existing output whose snapshot_id
differs. Default False = non-destructive (refuse rather than clobber history).
"""
try:
logger.info("=== %s ===", symbol)
data, snap_id, endpoint_variant = fetch_chain(symbol, cfg, from_cache=from_cache)
contracts, spot, ts_str = parse_chain(data, symbol)
# FIX 75: the frozen capture time (UTC ISO) persisted at fetch; used to report
# source-timestamp age as-of capture rather than as-of render.
captured_at_utc = data.get("_captured_at_utc")
logger.info("Spot=%.2f contracts=%d ts=%s endpoint=%s",
spot, len(contracts), ts_str, endpoint_variant)
# FIX 34: instrument class inferred from endpoint variant
instrument_class = "index" if endpoint_variant == "underscore" else "equity_etf"
today = datetime.now(ET).date()
# FIX 15: use the SNAPSHOT timestamp (converted to ET) for time-to-expiry,
# not wall-clock now, so --from-cache reproduces exactly.
try:
snap_et = datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S").replace(
tzinfo=ZoneInfo(cfg.source_timestamp_tz)).astimezone(ET)
except Exception:
snap_et = datetime.now(ET)
# FIX 49 (supersedes FIX 47): validate the SOURCE timestamp (converted to
# ET), not wall-clock. Refuse to publish unless it falls on an NYSE trading
# day AND within 09:30-16:15 ET. The slot guard checks wall-clock; this
# checks the DATA. Both are required.
# FIX 52: --replay-unsafe bypasses ONLY this trading-day/session-window check
# (so an out-of-session cached chain can exercise FIX 48/50), logging an ERROR
# on every run. The age check below stays active in all modes.
in_session = (_is_nyse_trading_day(snap_et.date())
and time(9, 30) <= snap_et.time() <= time(16, 15))
if not in_session:
if replay_unsafe:
logger.error("REPLAY MODE — source timestamp %s -> %s ET is outside "
"session, output is not publishable (%s/%s).",
ts_str, snap_et.strftime("%Y-%m-%d %H:%M"), symbol, slot)
elif not _is_nyse_trading_day(snap_et.date()):
logger.error("SOURCE timestamp %s -> %s ET is not an NYSE trading day. "
"Refusing snapshot for %s/%s.",
ts_str, snap_et.strftime("%Y-%m-%d %H:%M"), symbol, slot)
return False
else:
logger.error("SOURCE timestamp %s -> %s ET is outside 09:30-16:15 "
"trading window. Refusing snapshot for %s/%s.",
ts_str, snap_et.strftime("%Y-%m-%d %H:%M"), symbol, slot)
return False
# FIX 49: refuse stale data outright if the source is > 240 min old.
# v1.6.2: this age check compares the source timestamp to wall-clock now,
# so it must NOT block --from-cache replays (a cached snapshot is always
# "old" by wall-clock; blocking it breaks the reproducibility guarantee the
# audit page makes for --from-cache). Log INFO instead when replaying from
# cache. The two checks above (NYSE trading day + 09:30-16:15 ET window)
# validate the DATA itself, not its age, and stay active in all modes.
age_min = (datetime.now(ET) - snap_et).total_seconds() / 60.0
if age_min > 240:
if from_cache:
logger.info("SOURCE timestamp age %.1f min (>240) for %s/%s — "
"allowed because --from-cache (reproducibility replay).",
age_min, symbol, slot)
else:
logger.error("SOURCE timestamp age %.1f min (>240) for %s/%s — refusing.",
age_min, symbol, slot)
return False
# FIX 79a: enforce the slot source-window at WRITE time, not just at fetch.
# The output filename is {date}_{slot}.json where `date` comes from the Cboe
# source timestamp but `slot` is only a label (--slot / auto). Without this
# check a 15:55-ET capture run with --slot am writes {date}_am.json and
# clobbers the 10:00-ET original. Refuse any capture whose CAPTURE TIME (when
# the fetch happened, not the Cboe source timestamp which has a ~15-min feed
# delay) falls outside the requested slot's window (FIX 86b: explicit
# asymmetric windows, AM 09:30–12:00 / PM 14:00–16:15 ET). Applies in every
# mode (including --from-cache) because the filename must always match the
# capture's true window; bypassed only under --replay-unsafe (diagnostic
# scratch output that is never published).
if not replay_unsafe:
# Use the capture time (when the fetch happened), not the Cboe source
# timestamp. The source timestamp has a ~15-min feed delay, so a fetch
# at 10:00 ET gets data timestamped ~10:15+ ET — checking the source
# timestamp would reject all legitimate captures.
if captured_at_utc:
try:
capture_et = datetime.fromisoformat(captured_at_utc).astimezone(ET)
except Exception:
capture_et = snap_et # fallback to source timestamp
else:
capture_et = snap_et # no capture time available (pre-FIX 75 cache)
capture_slot = _slot_label(capture_et, cfg)
if capture_slot != slot:
if slot == "am":
window = "%s–%s" % (cfg.slot_am_start, cfg.slot_am_end)
else:
window = "%s–%s" % (cfg.slot_pm_start, cfg.slot_pm_end)
logger.error("SLOT WINDOW: capture taken at %s ET falls in slot %r, not "
"the requested %r (window %s ET). Refusing to write "
"%s/%s for %s.",
capture_et.strftime("%Y-%m-%d %H:%M"),
capture_slot or "(none)", slot,
window, symbol, slot, symbol)
return False
df_full = filter_contracts_full(contracts, spot, cfg, snap_et,
instrument_class=instrument_class)
if df_full.empty:
logger.warning("No contracts survived filtering for %s", symbol)
return False
# FIX 45: count contracts dropped by rule 4 (OI>0 but iv<=0 or gamma==0)
zero_greek = sum(1 for c in contracts
if c["oi"] > 0 and (c["iv"] <= 0 or c["gamma"] == 0))
# FIX 97: the per-expiry zero-greek tally was removed with the ex-front
# variant — only that second path needed a post-filter count. The single
# pipeline reports the full-chain count for both fields.
# primary run (all expirations)
primary_result = _process_and_render(
symbol, cfg, slot, snap_id, ts_str, spot, df_full,
suffix="", endpoint_variant=endpoint_variant,
instrument_class=instrument_class,
zero_greek_contracts=zero_greek,
zero_greek_full_chain=zero_greek,
replay_unsafe=replay_unsafe,
captured_at_utc=captured_at_utc,
overwrite=overwrite)
if primary_result is None:
logger.error("Write refused for %s/%s (snapshot_id conflict). "
"Pass --overwrite to force.", symbol, slot)
return False
# FIX 97: the ex-front (0DTE-removed) second chart was removed. There is a
# single pipeline and a single "All Expirations" variant per ticker/slot.
return True
except Exception as exc:
logger.exception("FAILED %s: %s", symbol, exc)
return False
def _load_recent_closes(symbol: str, cfg: GexConfig) -> list:
"""FIX 32: load recent daily closes for realised vol.
Tries Yahoo Finance chart API first (free, no key), then falls back to cached
snapshot spots. Returns a list of floats (oldest first), or empty list on failure.
"""
closes = []
# Try Yahoo Finance chart API (free, no key)
try:
import urllib.request
url = f"https://query1.finance.yahoo.com/v8/finance/chart/{symbol}?range=1mo&interval=1d"
req = urllib.request.Request(url, headers={"User-Agent": cfg.user_agent})
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read().decode())
raw = data["chart"]["result"][0]["indicators"]["quote"][0]["close"]
closes = [float(c) for c in raw if c is not None]
except Exception:
pass
# Fallback: cached snapshot spots (from prior JSON files)
if len(closes) < 5:
try:
out_dir = Path(cfg.outdir) / symbol
if out_dir.exists():
for jf in sorted(out_dir.glob("*.json")):
try:
with open(jf) as f:
d = json.load(f)
if "spot" in d:
closes.append(float(d["spot"]))
except Exception:
pass
except Exception:
pass
return closes
def _atm_iv_source_detail(df_band: pd.DataFrame, levels: dict, spot: float) -> list:
"""FIX 32: publish the two source contracts' bid/ask/OI/IV for full traceability."""
src_strikes = levels.get("atm_iv_source_strikes")
expiry = levels.get("atm_iv_expiry")
if not src_strikes or not expiry:
return []
detail = []
for k in src_strikes:
sub = df_band[(df_band["strike"] == k) & (df_band["expiry"].astype(str) == expiry)]
for _, row in sub.iterrows():
detail.append({
"strike": float(k),
"cp": row["cp"],
"oi": int(row["oi"]),
"bid": float(row.get("bid", 0)),
"ask": float(row.get("ask", 0)),
"iv": float(row["iv"]),
})
return detail
def _pass1_atm_iv(df_full: pd.DataFrame, spot: float, cfg: GexConfig) -> tuple:
"""FIX 35 PASS 1: coarse ATM IV estimate for band derivation.
Filter: OI > 0, IV in (0.01, 3.0), DTE in [5, 60]. FIX 43: delegate to the
SAME robust resolver (chain-relative gates + three-expiry widening) that the
full pipeline uses, so pass-1 resolves whenever the full run does — otherwise
a low-OI chain would resolve a real atm_iv downstream yet still derive its
bands from the fallback.
FIX 67: also returns the atm_iv_status so the caller can set
band_basis="atm_iv_single_strike" when the IV rests on one strike.
FIX 82: returns the full result dict (not just atm_iv + status) so the caller
can publish bands.atm_iv_source_expiry and bands.atm_iv_source_strikes, making
the pass-1 vs pass-2 divergence visible (pass 1 runs on df_coarse with a DTE
filter; pass 2 runs on df_band with a strike filter — they can pick different
expiries/strikes, so the two atm_iv values can differ).
"""
if df_full.empty:
return None, "rejected: empty", {}
# coarse filter
mask = (
(df_full["oi"] > 0) &
(df_full["iv"] > 0.01) & (df_full["iv"] < 3.0) &
(df_full["dte"] >= 5) & (df_full["dte"] <= 60)
)
df_coarse = df_full[mask]
if df_coarse.empty:
return None, "rejected: no eligible expiry", {}
result = atm_expected_move(df_coarse, spot)
return result.get("atm_iv"), result.get("atm_iv_status", "unknown"), result
def _append_history_csv(symbol: str, slot: str, variant: str, levels: dict,
cfg: GexConfig):
"""FIX 40: append one row per ticker/slot/variant to data/history/{SYMBOL}.csv."""
hist_dir = Path(cfg.history_dir)
hist_dir.mkdir(parents=True, exist_ok=True)
csv_path = hist_dir / f"{symbol}.csv"
header = [
"timestamp_et", "slot", "variant", "spot",
"call_resistance", "put_support", "hvl", "hvl_distance_pct",
"gex_transition", "delta_neutral", "total_net_gex", "net_gex_at_spot",
"gamma_condition", "atm_iv", "realised_vol_20d", "iv_hv_ratio",
"max_expiry_share", "spread_flagged_share",
"gex_profile_max", "dex_profile_max",
]
write_header = not csv_path.exists()
row = [
levels.get("timestamp_et", ""),
slot,
variant,
levels.get("spot"),
levels.get("call_resistance"),
levels.get("put_support"),
levels.get("hvl"),
levels.get("hvl_distance_pct"),
levels.get("gex_transition"),
levels.get("delta_neutral"),
levels.get("total_net_gex"),
levels.get("net_gex_at_spot"),
levels.get("gamma_condition"),
levels.get("atm_iv"),
levels.get("realised_vol_20d"),
levels.get("iv_hv_ratio"),
levels.get("max_expiry_share"),
levels.get("spread_flagged_share"),
levels.get("gex_profile_max"),
levels.get("dex_profile_max"),
]
with open(csv_path, "a", newline="") as f:
writer = csv.writer(f)
if write_header:
writer.writerow(header)
writer.writerow(row)
logger.info("History CSV -> %s", csv_path)
def _rolling_profile_limit(symbol: str, column: str, cfg: GexConfig) -> Optional[float]:
"""FIX 50: cross-snapshot-comparable axis limit = 1.2 * median of this ticker's
last `rolling_window` profile maxima (from the FIX 40 history CSV). Returns None
when fewer than 5 snapshots exist, so the caller falls back to "data" mode.
OI-aware by construction (each ticker's own history)."""
csv_path = Path(cfg.history_dir) / f"{symbol}.csv"
if not csv_path.exists():
return None
try:
df = pd.read_csv(csv_path)
except Exception:
return None
if column not in df.columns:
return None
vals = pd.to_numeric(df[column], errors="coerce").dropna()
if len(vals) < 5:
return None
recent = vals.tail(cfg.rolling_window)
return float(1.2 * recent.median())
def build_index_json(cfg: GexConfig):
"""FIX 38 / FIX 51: scan out/ for available JSON snapshots and write index.json.
FIX 51: the index is DERIVED from what is actually on disk, never authored from
cfg.tickers. `tickers` is the sorted list of directories that contain >=1 snapshot
JSON; `default` is cfg.default_ticker only if it is a member of that list, else the
first listed ticker. A configured ticker with zero published snapshots never appears
(and is named in a WARNING). This keeps `default` always a member of `tickers`.
Format: {"tickers": ["SMH"], "default": "SMH",
"snapshots": {"SMH": {"2026-07-24": ["pm"]}, ...}}
"""
out_dir = Path(cfg.outdir)
index = {"tickers": [], "default": None, "snapshots": {}}
if not out_dir.exists():
idx_path = out_dir / "index.json"
with open(idx_path, "w") as f:
json.dump(index, f, indent=2)
logger.warning("Index -> %s (outdir missing; empty index)", idx_path)
return
for ticker_dir in sorted(out_dir.iterdir()):
if not ticker_dir.is_dir():
continue
sym = ticker_dir.name
snaps = {}
for jf in sorted(ticker_dir.glob("*.json")):
# filename: 2026-07-24_pm.json (single variant since FIX 97)
stem = jf.stem # e.g. "2026-07-24_pm"
parts = stem.split("_", 1)
if len(parts) < 2:
continue
date_str, slot_variant = parts[0], parts[1]
snaps.setdefault(date_str, [])
if slot_variant not in snaps[date_str]:
snaps[date_str].append(slot_variant)
if snaps:
index["tickers"].append(sym)
index["snapshots"][sym] = snaps
# FIX 51: default must be a member of tickers; fall back to the first listed.
if index["tickers"]:
index["default"] = (cfg.default_ticker
if cfg.default_ticker in index["tickers"]
else index["tickers"][0])
if cfg.default_ticker not in index["tickers"]:
logger.warning("Configured default_ticker %r has no published snapshots; "
"index default falls back to %r.",
cfg.default_ticker, index["default"])
# FIX 51: name any configured ticker that produced no output.
for t in cfg.tickers:
if t not in index["tickers"]:
logger.warning("Configured ticker %r has no published snapshots; "
"omitted from index.", t)
# write to out/index.json (deployed as gex_out/index.json)
idx_path = out_dir / "index.json"
with open(idx_path, "w") as f:
json.dump(index, f, indent=2)
logger.info("Index -> %s (%d tickers, default=%s)",
idx_path, len(index["tickers"]), index["default"])
def _process_and_render(symbol, cfg, slot, snap_id, ts_str, spot, df_full,
suffix="", endpoint_variant="plain",
instrument_class="equity_etf",
zero_greek_contracts=0, zero_greek_full_chain=0,
replay_unsafe=False,
captured_at_utc=None, overwrite=False):
"""Shared pipeline: two-pass bands, profiles, levels, chart, JSON, parquet, CSV.
`suffix` is appended to the output filenames. FIX 97: the ex-front variant was
removed, so this is always "" in practice; the parameter is retained so the
JSON `variant` field resolves to the constant "all_expirations" and the
canonical key set does not churn.
`replay_unsafe` (FIX 52) is stamped into the levels JSON so downstream consumers
can tell a non-publishable replay apart from a live snapshot.
`captured_at_utc` (FIX 75) is the frozen fetch time (UTC ISO); when present, the
published source_timestamp_age_min is computed as-of capture, and the wall-clock
delta since capture is published separately as render_lag_min.
`zero_greek_contracts` (FIX 78) is the count of rule-4-dropped contracts for the
book being charted. `zero_greek_full_chain` is the raw full-chain count. With the
ex-front variant removed (FIX 97) the two are always equal.
`overwrite` (FIX 79b): when False (default), an existing output JSON whose
snapshot_id differs from `snap_id` is NOT clobbered — the write is refused. A
re-render of the SAME snapshot_id is always allowed (that is what FIX 75's
byte-identity guarantee depends on). Set True via --overwrite to force.
"""
# --- FIX 43: compute realised vol early so it can back the band fallback ---
closes = _load_recent_closes(symbol, cfg)
rv_early = compute_realised_vol(closes, cfg.realised_vol_days)
# --- FIX 35 PASS 1: coarse ATM IV -> derive bands ---
pass1_iv, pass1_iv_status, pass1_result = _pass1_atm_iv(df_full, spot, cfg)
bands = compute_bands(pass1_iv, cfg, realised_vol=rv_early,
atm_iv_status=pass1_iv_status)
# FIX 82: publish the pass-1 ATM IV source so the divergence between
# bands.atm_iv_used (pass 1, df_coarse) and the top-level atm_iv (pass 2,
# df_band) is visible. They can differ because the two passes run on
# different contract sets (DTE filter vs strike filter).
bands["atm_iv_source_expiry"] = pass1_result.get("atm_iv_expiry")
bands["atm_iv_source_strikes"] = pass1_result.get("atm_iv_source_strikes")
if bands["band_basis"] == "fallback_from_rv":
logger.warning("ATM IV unresolved — bands derived from realised vol "
"(rv=%.4f -> iv=%.4f).", rv_early, bands["atm_iv_used"])
logger.info("Bands (basis=%s): iv=%.4f sigma30d=%.4f strike=%.3f plot=%.3f "
"profile=%.3f dex=%.3f",
bands["band_basis"], bands["atm_iv_used"], bands["sigma_30d"],
bands["strike_band"], bands["plot_band"],
bands["profile_band"], bands["dex_band"])
# --- FIX 35 PASS 2: run pipeline with derived bands ---
# Override cfg bands for this run
cfg_run = GexConfig(
**{**vars(cfg),
"strike_band": bands["strike_band"],
"plot_band": bands["plot_band"],
"profile_band": bands["profile_band"],
"profile_band_dex": bands["dex_band"],
}
)
df_band = filter_contracts_band(df_full, spot, cfg_run)
logger.info("Filtered contracts%s: full=%d band=%d", suffix or "", len(df_full), len(df_band))
agg = aggregate(df_band, spot, cfg_run)
increment = detect_increment(agg.index.to_numpy())
# FIX 71 / FIX 72: ONE render bucket, computed once here from the PLOT-BAND
# window (spot × (1 ± plot_band)) — the region where bars actually exist — NOT
# the level-extended window (distant HVL/GEX-transition levels used to stretch
# it and force a coarser bucket). This bucket drives BOTH the bar aggregation in
# the renderer and the published render_spacing/render_bucket fields, so they can
# never disagree. detect_render_spacing (FIX 44) is retained only as a diagnostic.
_diag_spacing = detect_render_spacing(
agg.index.to_numpy(), agg["net_gex"].to_numpy(), increment)
_pb_lo = spot * (1 - cfg_run.plot_band)
_pb_hi = spot * (1 + cfg_run.plot_band)
render_bucket_val = render_bucket(_pb_hi - _pb_lo, increment)
logger.info("Strike increment: %s render bucket: %s (plot-band span %.1f; "
"diag spacing %s)", increment, render_bucket_val,
_pb_hi - _pb_lo, _diag_spacing)
# --- FIX 36: fixed-count profile grid (scale-free) ---
n_pts = cfg_run.profile_grid_points
grid = np.linspace(spot * (1 - cfg_run.profile_band),
spot * (1 + cfg_run.profile_band), n_pts)
grid_dex = np.linspace(spot * (1 - cfg_run.profile_band_dex),
spot * (1 + cfg_run.profile_band_dex), n_pts)
logger.info("Profile grid: gex=%d pts (±%.1f%%) dex=%d pts (±%.1f%%)",
len(grid), cfg_run.profile_band * 100,
len(grid_dex), cfg_run.profile_band_dex * 100)
_t0 = _time.perf_counter()
gp, gp_outliers = gex_profile(df_full, spot, cfg_run, grid)
dp, dp_outliers = dex_profile(df_full, cfg_run, grid_dex, spot)
_dt = _time.perf_counter() - _t0
logger.info("Profile compute: %.2fs (gex grid=%d, dex grid=%d)", _dt, len(grid), len(grid_dex))
if _dt > 30:
logger.warning("PROFILE TIME: %.1fs exceeds 30s threshold for %s", _dt, symbol)
# FIX 48: assert every profile array is finite before plotting; refuse to render if not.
if not (np.isfinite(gp).all() and np.isfinite(dp).all()):
logger.error("FIX 48: non-finite profile values for %s — refusing to render.", symbol)
return None
# FIX 22: profile at spot (interpolated on the GEX grid) drives gamma_condition
profile_at_spot = float(np.interp(spot, grid, gp))
# FIX 29: HVL = zero crossing nearest spot. If no crossing in the display
# grid, widen to ±profile_band_hvl and retry. Still none -> real market state.
from .compute import compute_hvl, _hvl_zero_cross
hvl_nearest, hvl_crossings = _hvl_zero_cross(grid, gp, spot)
if hvl_nearest is None:
grid_hvl = np.linspace(spot * (1 - cfg_run.profile_band_hvl),
spot * (1 + cfg_run.profile_band_hvl), n_pts)
gp_hvl, _ = gex_profile(df_full, spot, cfg_run, grid_hvl)
hvl_nearest, hvl_crossings = _hvl_zero_cross(grid_hvl, gp_hvl, spot)
if hvl_nearest is not None:
logger.info("HVL found on widened ±%.0f%% grid: %.2f",
cfg_run.profile_band_hvl * 100, hvl_nearest)
else:
logger.warning("HVL: no gamma flip within ±%.0f%% of spot — real market state.",
cfg_run.profile_band_hvl * 100)
levels = compute_levels(agg, grid, gp, spot, increment, cfg_run,
profile_at_spot=profile_at_spot)
# FIX 44 / FIX 71: publish the true strike grid increment (used for rounding) and
# the render bucket (used for bar aggregation + height). render_spacing is kept as
# an ALIAS of render_bucket for one version so the two can never disagree.
levels["strike_increment"] = float(increment)
levels["render_bucket"] = float(render_bucket_val)
levels["render_spacing"] = float(render_bucket_val) # FIX 71 alias of render_bucket
# FIX 52: stamp replay provenance so consumers can reject non-publishable output.
levels["replay_unsafe"] = bool(replay_unsafe)
# NOTE (v1.7.0): the redundant top-level `band_basis` convenience copy added in
# v1.6.5 is removed. The canonical field is levels["bands"]["band_basis"], which
# is what the page reads (d.bands.band_basis). No published consumer used the
# top-level copy, so removing it breaks nothing.
exp_move = atm_expected_move(df_band, spot)
levels.update(exp_move)
# FIX 15: reconcile bars vs profile at spot (full chain).
total_full = total_net_gex_from_contracts(df_full, spot, cfg_run)
total_band = float(agg["net_gex"].sum())
levels["total_net_gex_full"] = total_full
levels["total_net_gex_band"] = total_band
levels["total_net_gex"] = total_full
# FIX 80: guard reconciliation against near-settlement expiries. Near settlement
# (T -> 0), gamma ∝ 1/sqrt(T) makes the BS recompute unstable against Cboe's
# ~15-min-delayed feed, so the front expiry's reported-vs-recomputed gap is
# dominated by feed lag, not model error. Exclude expiries inside
# min_minutes_to_settlement from the reconciliation numerator AND denominator
# (they are still plotted from reported gamma — bars/levels/HVL use the full
# chain). Compute a second profile from the filtered chain for the signed check.
try:
_snap_et = datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S").replace(
tzinfo=ZoneInfo(cfg.source_timestamp_tz)).astimezone(ET)
except Exception:
_snap_et = datetime.now(ET)
_min_min = cfg_run.min_minutes_to_settlement
_excluded_expiries = []
_recon_expiries = set()
for _exp in df_full["expiry"].unique():
_am = (instrument_class == "index" and is_third_friday(_exp))
_mts = minutes_to_settlement(_exp, _snap_et, am_settled=_am,
instrument_class=instrument_class)
# FIX 84: publish the settlement time so the assumption is auditable.
_settle = _settlement_time(_am, instrument_class)
_settle_str = _settle.strftime("%H:%M")
if _mts < _min_min:
_excluded_expiries.append({
"expiry": str(_exp),
"minutes_to_settlement": round(_mts, 1),
"settlement_time_et": _settle_str,
})
else:
_recon_expiries.add(_exp)
if _excluded_expiries:
df_recon = df_full[df_full["expiry"].isin(_recon_expiries)].copy()
reconciliation_scope = "excl_near_settlement"
logger.info("FIX 80: excluding %d near-settlement expiries from reconciliation "
"(< %d min): %s", len(_excluded_expiries), _min_min,
", ".join(e["expiry"] for e in _excluded_expiries))
else:
df_recon = df_full
reconciliation_scope = "full"
# FIX 90 / FIX 94: disclose how much of the book the reconciliation actually
# covers. The near-settlement guard excludes the 0DTE expiry on every expiry day;
# here that is a material share of GEX validated by nothing.
# FIX 94: use the SAME basis as gex_by_expiry.share so the two are comparable.
# Numerator: Σ|net GEX per excluded expiry| (net per expiry, then abs, then sum).
# Denominator: Σ|net GEX per strike| (same as gex_by_expiry's denom).
# Previously mixed a gross numerator (Σ|GEX| per contract) with a gross
# denominator, which was inconsistent with gex_by_expiry.share's net basis.
_M = cfg_run.contract_multiplier
_S2 = spot * spot
_sgn_full = np.where(df_full["cp"].to_numpy() == "C", 1.0, -1.0)
_signed_gex_full = (_sgn_full * df_full["gamma"].abs().to_numpy()
* df_full["oi"].to_numpy() * _M * _S2 * 0.01)
_df_tmp = df_full.assign(_gex=_signed_gex_full)
_per_strike_net = _df_tmp.groupby("strike")["_gex"].sum()
_denom_net = float(_per_strike_net.abs().sum())
_excl_set = {e["expiry"] for e in _excluded_expiries}
if _denom_net > 0 and _excl_set:
_excl_by_exp = _df_tmp[_df_tmp["expiry"].astype(str).isin(_excl_set)].groupby("expiry")["_gex"].sum()
_net_excl = float(_excl_by_exp.abs().sum())
reconciliation_excluded_share = _net_excl / _denom_net
else:
reconciliation_excluded_share = 0.0
# Signed reconciliation: profile@spot vs total, both from the recon chain.
if df_recon.empty:
# Degenerate: every expiry is near-settlement. Reconciliation undefined.
rec = {"total_net_gex": 0.0, "profile_at_spot": 0.0, "rel_err": None,
"rel_err_denominator": 0.0, "pass": True}
rec_by_exp = {"reconciliation_worst_expiries": [], "zero_greek_contracts_dropped": 0,
"rel_err_unsigned": None, "rel_err_unsigned_denominator": 0.0}
else:
total_recon = total_net_gex_from_contracts(df_recon, spot, cfg_run)
if reconciliation_scope == "excl_near_settlement":
# Recompute the profile from the filtered chain for a consistent comparison.
gp_recon, _ = gex_profile(df_recon, spot, cfg_run, grid)
rec = reconcile(total_recon, grid, gp_recon, spot, threshold=0.05)
else:
rec = reconcile(total_recon, grid, gp, spot, threshold=0.05)
rec_by_exp = reconcile_by_expiry(df_recon, spot, cfg_run)
levels["reconciliation"] = rec
levels["reconciliation_scope"] = reconciliation_scope
levels["reconciliation_excluded_expiries"] = _excluded_expiries
# FIX 90: share of Σ|GEX| excluded by the near-settlement guard (0.0 when the
# scope is "full"). The pass covers (1 - share) of the book, not 100%.
levels["reconciliation_excluded_share"] = reconciliation_excluded_share
# FIX 45: per-expiry reconciliation breakdown
levels["reconciliation_worst_expiries"] = rec_by_exp["reconciliation_worst_expiries"]
# FIX 64: publish the unsigned reconciliation alongside the signed one.
levels["rel_err_unsigned"] = rec_by_exp["rel_err_unsigned"]
# FIX 73: publish the denominators each error is scored against. With the
# ex-front variant removed (FIX 97) there is a single book per ticker/slot, so
# these describe the full chain; they remain published for auditability.
levels["rel_err_denominator"] = rec["rel_err_denominator"]
levels["rel_err_unsigned_denominator"] = rec_by_exp["rel_err_unsigned_denominator"]
# FIX 76: reconciliation is scored against the book this chart depicts. Each
# chart's curve-derived levels are fit to that book, so the validation that
# matters is "does THIS profile reproduce THESE bars". With a single pipeline
# (FIX 97) the book is the full chain. Published so a reader can see which
# convention produced the flag.
levels["reconciliation_denominator_basis"] = "variant"
# FIX 73: pass requires BOTH the signed error < 0.05 AND the unsigned error
# < 0.10. The signed headline can pass on cancelling per-expiry errors (NDX main:
# signed 0.031 but unsigned 0.076); the unsigned figure cannot, so it is a second
# gate. reconciliation_pass_basis names which metric bound the decision.
# FIX 80: _signed/_unsigned are None when every expiry is near-settlement
# (reconciliation undefined); treat that as a pass with basis "excluded".
_signed = rec["rel_err"]
_unsigned = rec_by_exp["rel_err_unsigned"]
# FIX 87: estimate the irreducible unsigned error from PUBLICATION ROUNDING via
# the grid_rounding method (round the recomputed truth gamma to the 4dp grid and
# measure rounded-vs-unrounded). This isolates pure quantisation noise — the model
# error cancels — unlike the old FIX 85 estimator which double-counted rounding.
_floor = reconciliation_floor_unsigned(df_recon, spot, cfg_run)
levels["reconciliation_floor_unsigned"] = _floor
# FIX 88: derive an unsigned gate from the floor, but CAP it. An uncapped gate
# (e.g. 0.35 against a realistic worst case of ~0.10) leaves the unsigned check
# unable to fire. The derived gate is floor_p95 × multiplier, capped at
# base × unsigned_gate_cap_multiplier (2.0). If the corrected floor STILL brackets
# the base gate, model error cannot be separated from publication rounding at this
# symbol's gamma precision — the result is INDETERMINATE (precision_limited),
# never a pass.
_base_gate = cfg_run.unsigned_gate
_gate_cap = _base_gate * cfg_run.unsigned_gate_cap_multiplier
_brackets = bool(_floor["brackets_gate"])
if _brackets and _floor["floor"] is not None:
_derived = _floor["floor"] * cfg_run.unsigned_floor_multiplier
_unsigned_gate = min(_derived, _gate_cap)
_unsigned_gate_status = "capped" if _derived > _gate_cap else "derived"
logger.info("FIX 88: unsigned floor=%.4f brackets base gate %.2f -> "
"derived gate %.4f (floor × %.1f), capped at %.2f -> effective %.4f "
"(status=%s).", _floor["floor"], _base_gate, _derived,
cfg_run.unsigned_floor_multiplier, _gate_cap, _unsigned_gate,
_unsigned_gate_status)
else:
_unsigned_gate = _base_gate
_unsigned_gate_status = "base"
levels["unsigned_gate_effective"] = _unsigned_gate
levels["unsigned_gate_status"] = _unsigned_gate_status
_signed_ok = (_signed is None) or (_signed < 0.05)
_unsigned_ok = (_unsigned is None) or (_unsigned < _unsigned_gate)
# FIX 88: indeterminate state. When the floor brackets the base gate AND the
# unsigned error is not cleanly below the base gate, the unsigned check is
# precision-limited: we cannot tell model error from rounding noise. This is
# neither a pass nor a definitive fail.
_precision_limited = bool(
_brackets and _unsigned is not None and _unsigned >= _base_gate)
if _precision_limited:
# FIX 92: profile_reliable is boolean-or-null. "indeterminate" is truthy in
# Python, so a consumer's plain `if pass:` would read it as a pass. The
# precision-limited state is communicated via unsigned_gate_status instead.
profile_reliable = False
profile_ok = False # not a clean pass — curve-derived levels stay in doubt
_unsigned_gate_status = "precision_limited"
levels["unsigned_gate_status"] = _unsigned_gate_status # re-publish (was set pre-branch)
else:
profile_ok = bool(_signed_ok and _unsigned_ok)
profile_reliable = profile_ok
# FIX 83: the nested reconciliation.pass is one authoritative flag and must agree
# with profile_reliable (boolean: True / False).
rec["pass"] = profile_reliable
# FIX 90: disambiguate the basis. "both_pass" = both gates passed; "signed" /
# "unsigned" = which single gate was breached; "signed+unsigned" = both breached;
# "excluded" = reconciliation undefined (all expiries near-settlement);
# "indeterminate" = precision-limited (FIX 88).
if _precision_limited:
levels["reconciliation_pass_basis"] = "indeterminate"
elif _signed is None and _unsigned is None:
levels["reconciliation_pass_basis"] = "excluded"
elif profile_ok:
levels["reconciliation_pass_basis"] = "both_pass"
else:
_breached = []
if not _signed_ok:
_breached.append("signed")
if not _unsigned_ok:
_breached.append("unsigned")
levels["reconciliation_pass_basis"] = "+".join(_breached)
# FIX 61: scope the reliability flag. Bars use Cboe's REPORTED gamma and are
# unaffected by any profile discrepancy. Only the recomputed profile and the
# levels derived from it (HVL, GEX Transition, delta-neutral) are in doubt.
# FIX 66 / FIX 70: detect Cboe gamma publication precision. Three-state label:
# "high" (3+ sig figs) | "adequate" (2) | "coarse" (1). Only "coarse" flips
# bars_reliable and shows the chart warning; "adequate" (SMH/SPY, ~1.3%
# granularity) is corroborated by their <1% reconciliation and keeps bars reliable.
gamma_sig_figs, gamma_low_precision, gamma_label = gamma_precision(df_full)
levels["gamma_precision_digits"] = gamma_sig_figs
levels["gamma_precision"] = gamma_label # FIX 70: three-state
levels["reported_gamma_low_precision"] = gamma_low_precision # derived alias
levels["bars_reliable"] = not gamma_low_precision # coarse bars -> unreliable
# FIX 92: profile_reliable is boolean-or-null (True = pass, False = fail or
# precision-limited). The tri-state semantics live in unsigned_gate_status
# ("base" / "derived" / "capped" / "precision_limited") and reconciliation_pass_basis.
levels["profile_reliable"] = profile_reliable
if gamma_low_precision:
logger.warning("FIX 66: gamma precision = %d sig fig(s) (%s) — bars are coarse "
"(Cboe 4dp at this price level). bars_reliable=False; "
"recomputed profile is the higher-precision object.",
gamma_sig_figs, gamma_label)
if _precision_limited:
# FIX 88: indeterminate — curve-derived levels stay in doubt, but this is NOT
# a definitive failure. Mark the levels unreliable and log distinctly.
levels["hvl_reliable"] = False
levels["gex_transition_reliable"] = False
levels["delta_neutral_reliable"] = False
logger.warning("PROFILE RECONCILE INDETERMINATE (precision_limited): signed=%s "
"unsigned=%s vs base gate %.2f; floor=%.4f brackets the gate, "
"so model error cannot be separated from publication rounding at "
"this symbol's gamma precision. Curve-derived levels marked "
"unreliable; bars unaffected.",
f"{_signed:.3f}" if _signed is not None else "n/a",
f"{_unsigned:.3f}" if _unsigned is not None else "n/a",
_base_gate, _floor["floor"] if _floor["floor"] is not None else float("nan"))
elif not profile_ok:
# mark the curve-derived levels individually
levels["hvl_reliable"] = False
levels["gex_transition_reliable"] = False
levels["delta_neutral_reliable"] = False
worst = rec_by_exp["reconciliation_worst_expiries"]
worst_exp = worst[0]["expiry"] if worst else "n/a"
worst_gap = worst[0]["dollar_gap"] if worst else float("nan")
logger.error("PROFILE RECONCILE FAIL (basis=%s): signed=%s unsigned=%s "
"(profile@spot=%.4e vs total=%.4e); worst expiry %s dollar_gap=%.3e. "
"Bars unaffected; curve-derived levels marked unreliable.",
levels["reconciliation_pass_basis"],
f"{_signed:.3f}" if _signed is not None else "n/a",
f"{_unsigned:.3f}" if _unsigned is not None else "n/a",
rec["profile_at_spot"], rec["total_net_gex"],
worst_exp, worst_gap)
else:
# FIX 68 / FIX 86: always emit the reliability flags so the JSON key set
# is identical regardless of whether reconciliation passed (canonical schema).
levels["hvl_reliable"] = True
levels["gex_transition_reliable"] = True
levels["delta_neutral_reliable"] = True
logger.info("Reconcile OK (basis=%s): signed=%s unsigned=%s "
"(profile@spot=%.4e, total=%.4e)",
levels["reconciliation_pass_basis"],
f"{_signed:.4f}" if _signed is not None else "n/a",
f"{_unsigned:.4f}" if _unsigned is not None else "n/a",
rec["profile_at_spot"], rec["total_net_gex"])
levels["zero_greek_contracts_dropped"] = zero_greek_contracts
levels["zero_greek_contracts_dropped_full_chain"] = zero_greek_full_chain
# FIX 48: expired contracts dropped by the raw calendar-date guard, and
# profile outlier contracts dropped by the per-contract dominance guard.
levels["expired_contracts_dropped"] = int(df_full.attrs.get("expired_contracts_dropped", 0))
levels["profile_outliers_dropped"] = (gp_outliers + dp_outliers)
# FIX 12 / FIX 26: delta_neutral + dex_min on the WIDER dex grid
dn, dn_crossings = find_delta_neutral(grid_dex, dp, spot)
levels["delta_neutral"] = dn
levels["delta_neutral_crossings"] = dn_crossings
dmin, dmin_status = dex_min_price(grid_dex, dp)
levels["dex_min_price"] = dmin
levels["dex_min_status"] = dmin_status
if dmin is None:
logger.warning("dex_min_price is null (%s) — V-minimum is off the ±%.0f%% grid.",
dmin_status, cfg_run.profile_band_dex * 100)
# FIX 13
levels["outlier_report"] = build_outlier_report(agg, df_band, cfg_run, spot)
# FIX 14
levels["oi_totals"] = build_oi_totals(df_full)
# FIX 18 + FIX 25: per-expiry concentration
levels["dealer_proxy"] = cfg_run.dealer_proxy
gbe = build_gex_by_expiry(df_full, spot, cfg_run)
levels["front_expiry_share"] = gbe["front_expiry_share"]
levels["front_expiry"] = gbe["front_expiry"]
levels["front_expiry_dte"] = gbe["front_expiry_dte"]
levels["gex_by_expiry"] = gbe["gex_by_expiry"]
levels["max_expiry_share"] = gbe["max_expiry_share"]
levels["max_expiry"] = gbe["max_expiry"]
levels["max_expiry_dte"] = gbe["max_expiry_dte"]
levels["top3_expiry_share"] = gbe["top3_expiry_share"]
# FIX 68 / FIX 86: always emit note keys (null when not applicable) so the JSON
# key set is identical regardless of data (canonical schema).
levels["concentration_note"] = None
levels["top3_concentration_note"] = None
levels["spread_note"] = None
levels["put_heavy_note"] = None
if gbe["max_expiry_share"] > 0.40:
logger.warning("EXPIRY CONCENTRATION: %s (%dDTE) = %.0f%% of total |GEX|.",
gbe["max_expiry"], gbe["max_expiry_dte"],
gbe["max_expiry_share"] * 100)
levels["concentration_note"] = (
f"{gbe['max_expiry']} ({gbe['max_expiry_dte']}DTE) "
f"= {gbe['max_expiry_share']:.0%} of total |GEX|"
)
if gbe["top3_expiry_share"] > 0.75:
n_dom = sum(1 for r in sorted(gbe["gex_by_expiry"],
key=lambda r: r["share"], reverse=True)[:3]
if r["share"] > 0)
levels["top3_concentration_note"] = f"chart dominated by {n_dom} expiries"
# FIX 27 / FIX 46: ephemeral key levels
levels_ephemeral = []
# FIX 68: always emit the per-level ephemeral/front-expiry keys (null default).
for lvl_name in ("call_resistance", "put_support"):
levels[f"{lvl_name}_front_expiry_abs_share"] = None
levels[f"{lvl_name}_front_expiry_net_ratio"] = None
levels[f"{lvl_name}_ephemeral_note"] = None
for lvl_name, strike in (("call_resistance", levels["call_resistance"]),
("put_support", levels["put_support"])):
fe = level_front_expiry_pct(df_full, strike, spot, cfg_run)
if not fe:
continue
abs_share = fe["front_expiry_abs_share"]
net_ratio = fe.get("front_expiry_net_ratio")
front_exp = fe["front_expiry"]
levels[f"{lvl_name}_front_expiry_abs_share"] = float(abs_share)
if net_ratio is not None:
levels[f"{lvl_name}_front_expiry_net_ratio"] = float(net_ratio)
if abs_share > 0.50:
levels_ephemeral.append(lvl_name)
note = f"{lvl_name} {strike:g}: {abs_share:.0%} 0DTE — expires today"
if net_ratio is not None and net_ratio > 1.0:
note += " (offset by later expiries)"
levels[f"{lvl_name}_ephemeral_note"] = note
logger.warning("EPHEMERAL LEVEL: %s %s is %.0f%% front-expiry OI (%s).",
lvl_name, strike, abs_share * 100, front_exp)
levels["levels_ephemeral"] = levels_ephemeral
# FIX 31/33: spread detection
spread_info = detect_spread_candidates(df_full, spot, cfg_run, increment)
levels["spread_candidates"] = spread_info["spread_candidates"]
levels["spread_flagged_share"] = spread_info["spread_flagged_share"]
levels["sensitivity_smaller_leg_sign_flipped"] = spread_info["sensitivity_smaller_leg_sign_flipped"]
if spread_info["spread_flagged_share"] > cfg_run.spread_flag_threshold:
n = len(spread_info["spread_candidates"])
pct = spread_info["spread_flagged_share"]
levels["spread_note"] = (
f"{n} probable vertical-spread structures = {pct:.0%} of |GEX|; "
f"gross-OI proxy overstates net dealer gamma here"
)
# FIX 32: ATM IV cross-check against realised vol (FIX 43: reuse early RV)
rv = rv_early
iv_xc = atm_iv_cross_check(levels.get("atm_iv"), rv, cfg_run)
levels["realised_vol_20d"] = iv_xc["realised_vol_20d"]
levels["iv_hv_ratio"] = iv_xc["iv_hv_ratio"]
levels["vol_regime"] = iv_xc["vol_regime"]
# FIX 74: always emit atm_iv_status (null unless an outlier) for a canonical schema.
levels["atm_iv_status"] = None
if iv_xc.get("iv_hv_outlier"):
levels["atm_iv_status"] = "iv_hv_outlier — verify"
levels["iv_hv_outlier"] = True
logger.warning("ATM IV outlier: iv/hv = %.2f (atm_iv=%.4f, rv=%.4f).",
iv_xc["iv_hv_ratio"], levels.get("atm_iv", 0), rv or 0)
else:
levels["iv_hv_outlier"] = False
levels["atm_iv_source_detail"] = _atm_iv_source_detail(df_band, levels, spot)
# FIX 14 put-heavy
pcr = levels.get("oi_put_call_ratio", 0.0)
if pcr > 3.0:
logger.warning("put/call OI = %.2f — unusually put-heavy; see oi_totals.", pcr)
levels["put_heavy_note"] = f"put/call OI {pcr:.2f} — see per-expiry breakdown"
# --- FIX 34: publish endpoint_variant and instrument_class ---
levels["endpoint_variant"] = endpoint_variant
levels["instrument_class"] = instrument_class
# --- FIX 89: provenance stamp. Every artifact carries the producing code version
# and the canonical schema version, both from gex/__init__.py. Identical
# snapshot_id values across versions can produce different profile values (e.g.
# FIX 84 changed the settlement clock, moving rel_err 0.005877 -> 0.006652 for
# the same capture), so byte-identity under FIX 75 only holds WITHIN a version —
# the history needs the version to be readable.
levels["gex_version"] = _GEX_VERSION
levels["schema_version"] = _GEX_SCHEMA_VERSION
# --- FIX 37 / FIX 47 / FIX 74: index AM-settled front expiry. Always emit the
# key (null for non-index instruments) so the canonical schema is identical
# across all tickers regardless of instrument class.
if instrument_class == "index":
front_exp_str = gbe.get("front_expiry")
am_settled = False
if front_exp_str:
try:
front_exp_date = datetime.strptime(front_exp_str, "%Y-%m-%d").date()
am_settled = is_third_friday(front_exp_date)
except Exception:
pass
levels["front_expiry_am_settled"] = bool(am_settled)
if am_settled:
logger.info("Front expiry %s is a third Friday (AM-settled index option).",
front_exp_str)
else:
levels["front_expiry_am_settled"] = None # FIX 74: null, not omitted
# --- FIX 35: publish bands block ---
levels["bands"] = bands
# --- FIX 36: snap published levels to strike increment ---
# Preserve the UNROUNDED value in *_raw. hvl_raw is already set (unrounded) by
# compute_key_levels, so never overwrite an existing *_raw — otherwise the
# already-snapped value would clobber the true crossing (e.g. 630.0 over 629.8185).
# FIX 68 / FIX 86: always emit *_raw (even when the level is None) so the JSON
# key set is identical regardless of data (canonical schema).
for key in ("call_resistance", "put_support", "hvl", "gex_transition",
"delta_neutral", "dex_min_price"):
raw_key = f"{key}_raw"
val = levels.get(key)
if raw_key not in levels:
levels[raw_key] = val # None if the level was not found
if val is not None:
levels[key] = snap_to_increment(val, increment)
# --- FIX 36: timestamp for history CSV ---
try:
ts_et = datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S").replace(
tzinfo=ZoneInfo(cfg.source_timestamp_tz)).astimezone(ET)
levels["timestamp_et"] = ts_et.strftime("%Y-%m-%d %H:%M")
# FIX 75: source_timestamp_age_min is FROZEN at capture. It is the age of the
# Cboe source timestamp as of the moment the chain was fetched (captured_at_utc),
# NOT as of this render — so re-rendering an old snapshot reports the same
# freshness, not "hours stale". The wall-clock delta since capture is published
# separately as render_lag_min. When no capture time is available (legacy), fall
# back to the render-time age (the pre-v1.7.5 behaviour).
now_utc = datetime.now(timezone.utc)
if captured_at_utc:
captured = datetime.fromisoformat(captured_at_utc)
if captured.tzinfo is None:
captured = captured.replace(tzinfo=timezone.utc)
age_min = (captured - ts_et).total_seconds() / 60.0
render_lag = (now_utc - captured).total_seconds() / 60.0
levels["render_lag_min"] = round(render_lag, 1)
else:
age_min = (datetime.now(ET) - ts_et).total_seconds() / 60.0
levels["render_lag_min"] = None
levels["source_timestamp_age_min"] = round(age_min, 1)
if age_min > 45:
logger.warning("Source timestamp age %.1f min (>45) for %s.", age_min, symbol)
except Exception:
levels["timestamp_et"] = ts_str
levels["render_lag_min"] = None
logger.info("Levels%s: %s", suffix or "",
json.dumps({k: round(v, 4) if isinstance(v, float) else v
for k, v in levels.items()
if not isinstance(v, (dict, list))}, default=str))
# --- FIX 50: publish visible-window profile maxima and compute axis limits ---
# Visible window = the same lo/hi the plot uses (key levels + plot_band).
_lo = min(spot * (1 - cfg_run.plot_band), levels.get("put_support", spot),
levels.get("call_resistance", spot),
levels.get("hvl") or spot,
levels.get("gex_transition") or spot)
_hi = max(spot * (1 + cfg_run.plot_band), levels.get("put_support", spot),
levels.get("call_resistance", spot),
levels.get("hvl") or spot,
levels.get("gex_transition") or spot)
_gvis = gp[(grid >= _lo) & (grid <= _hi)]
_dvis = dp[(grid_dex >= _lo) & (grid_dex <= _hi)]
gex_pmax = float(np.max(np.abs(_gvis))) if len(_gvis) else 0.0
dex_pmax = float(np.max(np.abs(_dvis))) if len(_dvis) else 0.0
levels["gex_profile_max"] = gex_pmax
levels["dex_profile_max"] = dex_pmax
# Rolling-mode limits (cross-snapshot comparable); None -> fall back to "data".
rolling_gex = _rolling_profile_limit(symbol, "gex_profile_max", cfg_run) \
if cfg_run.profile_axis_mode == "rolling" else None
rolling_dex = _rolling_profile_limit(symbol, "dex_profile_max", cfg_run) \
if cfg_run.profile_axis_mode == "rolling" else None
# FIX 79b: non-destructive writes. Compute the output path up front (same
# naming rule as render_chart: {date}_{slot+suffix}.json under outdir/symbol)
# and refuse BEFORE doing any expensive work if an existing file carries a
# DIFFERENT snapshot_id. A re-render of the SAME snapshot_id is always allowed
# (FIX 75's byte-identity guarantee depends on it). --overwrite forces the write.
# The date is the ET date — render_chart parses ts_str as UTC (source_timestamp_tz)
# and converts to ET before formatting the filename, so mirror that exactly.
try:
_ts_utc = datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S").replace(
tzinfo=ZoneInfo(cfg.source_timestamp_tz))
date_str = _ts_utc.astimezone(ET).strftime("%Y-%m-%d")
except Exception:
date_str = datetime.now(ET).strftime("%Y-%m-%d")
out_json_path = Path(cfg.outdir) / symbol / f"{date_str}_{slot + suffix}.json"
if not overwrite and out_json_path.exists():
try:
with open(out_json_path) as _f:
existing_id = json.load(_f).get("snapshot_id")
except Exception:
existing_id = None
if existing_id != snap_id:
logger.error("REFUSING to overwrite %s: existing snapshot_id=%r differs "
"from incoming %r. Re-render the same snapshot or pass "
"--overwrite to force.",
out_json_path, existing_id, snap_id)
return None
# render chart (GEX profile on `grid`, DEX profile on the wider `grid_dex`)
display_label = cfg.display_labels.get(symbol, symbol)
png_path = render_chart(
symbol, agg, grid, gp, grid_dex, dp, levels, ts_str, cfg_run,
cfg.outdir, slot + suffix, increment,
display_label=display_label,
render_bucket=render_bucket_val,
rolling_gex_limit=rolling_gex,
rolling_dex_limit=rolling_dex,
)
# save JSON sibling
json_path = png_path.with_suffix(".json")
out_data = {
"symbol": symbol,
"snapshot_id": snap_id,
"timestamp": ts_str,
"spot": spot,
"slot": slot,
"variant": suffix.lstrip("_") or "all_expirations",
**levels,
}
with open(json_path, "w") as f:
json.dump(out_data, f, indent=2, default=str)
logger.info("JSON -> %s", json_path)
# save parquet
pq_path = png_path.with_suffix(".parquet")
agg.to_parquet(pq_path)
logger.info("Parquet -> %s", pq_path)
# --- FIX 40: append history CSV ---
variant_label = suffix.lstrip("_") or "all_expirations"
_append_history_csv(symbol, slot, variant_label, out_data, cfg)
return png_path
def main():
parser = argparse.ArgumentParser(description="Net GEX All Expirations snapshot")
parser.add_argument("--tickers", default=None,
help="Comma-separated tickers (default: all from config)")
parser.add_argument("--slot", default="auto", choices=["auto", "am", "pm"])
parser.add_argument("--from-cache", action="store_true")
parser.add_argument("--outdir", default="out")
parser.add_argument("--cache-dir", default="data/raw")
parser.add_argument("--replay-unsafe", action="store_true",
help="FIX 52: bypass ONLY the FIX 49 trading-day/session-window "
"check so an out-of-session cached chain can exercise FIX "
"48/50. Output is flagged non-publishable and forced to a "
"scratch outdir (out_replay/), never gex_out/.")
parser.add_argument("--overwrite", action="store_true",
help="FIX 79: force-overwrite an existing output whose "
"snapshot_id differs. Default is non-destructive: a "
"capture that would clobber a different snapshot is "
"refused. Re-renders of the same snapshot_id are always "
"allowed regardless of this flag.")
parser.add_argument("-v", "--verbose", action="store_true")
args = parser.parse_args()
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
# FIX 52: replay output must never land in the publishable outdir. Force a scratch
# path regardless of --outdir so a stray --replay-unsafe cannot poison gex_out/.
outdir = args.outdir
if args.replay_unsafe:
outdir = "out_replay"
logger.error("REPLAY MODE — output forced to scratch outdir %r; not publishable.",
outdir)
cfg = GexConfig(outdir=outdir, cache_dir=args.cache_dir)
# FIX 34: default to all tickers from config
if args.tickers:
tickers = [t.strip().upper() for t in args.tickers.split(",") if t.strip()]
else:
tickers = list(cfg.tickers)
now_et = datetime.now(ET)
if args.slot == "auto" and not args.from_cache:
slot = _slot_label(now_et, cfg)
if not slot:
logger.info("Outside slot window (%s ET). Exiting.", now_et.strftime("%H:%M"))
sys.exit(0)
if not _is_trading_day(now_et.date()):
logger.info("Not an NYSE trading day. Exiting.")
sys.exit(0)
else:
slot = args.slot if args.slot != "auto" else "pm"
results = {}
for t in tickers:
results[t] = run_ticker(t, cfg, slot, from_cache=args.from_cache,
replay_unsafe=args.replay_unsafe,
overwrite=args.overwrite)
failed = [t for t, ok in results.items() if not ok]
if failed and len(failed) == len(tickers):
logger.error("ALL tickers failed: %s", failed)
sys.exit(1)
elif failed:
logger.warning("Some tickers failed: %s", failed)
logger.info("Done. Success: %s", [t for t, ok in results.items() if ok])
# FIX 38: regenerate the page index after every run
build_index_json(cfg)
if __name__ == "__main__":
main()
"""Tests for gex.compute, gex.greeks, and gex.plot."""
import json
from datetime import date, datetime, time, timedelta
from zoneinfo import ZoneInfo
import numpy as np
import pandas as pd
import pytest
from gex.compute import (
aggregate,
atm_expected_move,
build_gex_by_expiry,
build_oi_totals,
build_outlier_report,
compute_hvl_candidates,
compute_levels,
detect_increment,
detect_render_spacing,
dex_min_price,
dex_profile,
filter_contracts,
filter_contracts_band,
filter_contracts_full,
find_delta_neutral,
find_hvl,
gex_profile,
reconcile,
reconcile_by_expiry,
render_bucket,
time_to_expiry_years,
total_net_gex_from_contracts,
)
from gex.config import GexConfig
from gex.greeks import bs_gamma
_ET = ZoneInfo("America/New_York")
# ---------------------------------------------------------------------------
# 1. Synthetic chain fixture: 2 strikes, 1 call + 1 put each
# ---------------------------------------------------------------------------
@pytest.fixture
def synthetic_chain():
"""2 strikes (100, 110), 1 call + 1 put each, known gamma/delta/OI."""
return [
{"strike": 100.0, "expiry": date(2026, 8, 15), "cp": "C",
"iv": 0.25, "oi": 1000, "volume": 500, "delta": 0.60, "gamma": 0.04,
"vega": 0.12, "theta": -0.05, "theo": 8.5, "bid": 8.4, "ask": 8.6},
{"strike": 100.0, "expiry": date(2026, 8, 15), "cp": "P",
"iv": 0.28, "oi": 800, "volume": 300, "delta": -0.40, "gamma": 0.04,
"vega": 0.11, "theta": -0.04, "theo": 3.2, "bid": 3.1, "ask": 3.3},
{"strike": 110.0, "expiry": date(2026, 8, 15), "cp": "C",
"iv": 0.22, "oi": 600, "volume": 200, "delta": 0.35, "gamma": 0.03,
"vega": 0.10, "theta": -0.04, "theo": 4.1, "bid": 4.0, "ask": 4.2},
{"strike": 110.0, "expiry": date(2026, 8, 15), "cp": "P",
"iv": 0.30, "oi": 900, "volume": 400, "delta": -0.65, "gamma": 0.03,
"vega": 0.09, "theta": -0.03, "theo": 6.8, "bid": 6.7, "ask": 6.9},
]
def test_aggregate_net_gex(synthetic_chain):
"""Assert net_gex, dex, and put-call ratios to 1e-6."""
cfg = GexConfig(strike_band=0.50, dte_max=365)
spot = 105.0
today = date(2026, 7, 24)
df = filter_contracts(synthetic_chain, spot, cfg, today)
assert len(df) == 4, f"Expected 4 contracts, got {len(df)}"
agg = aggregate(df, spot, cfg)
M = 100
S2 = spot * spot
gex_call_100 = 0.04 * 1000 * M * S2 * 0.01
gex_put_100 = -0.04 * 800 * M * S2 * 0.01
net_100 = gex_call_100 + gex_put_100
gex_call_110 = 0.03 * 600 * M * S2 * 0.01
gex_put_110 = -0.03 * 900 * M * S2 * 0.01
net_110 = gex_call_110 + gex_put_110
assert abs(agg.loc[100.0, "gex_call"] - gex_call_100) < 1e-6
assert abs(agg.loc[100.0, "gex_put"] - gex_put_100) < 1e-6
assert abs(agg.loc[100.0, "net_gex"] - net_100) < 1e-6
assert abs(agg.loc[110.0, "gex_call"] - gex_call_110) < 1e-6
assert abs(agg.loc[110.0, "gex_put"] - gex_put_110) < 1e-6
assert abs(agg.loc[110.0, "net_gex"] - net_110) < 1e-6
dex_100 = (0.60 * 1000 + (-0.40) * 800) * M * spot
dex_110 = (0.35 * 600 + (-0.65) * 900) * M * spot
assert abs(agg.loc[100.0, "dex"] - dex_100) < 1e-6
assert abs(agg.loc[110.0, "dex"] - dex_110) < 1e-6
total_gex_put = abs(gex_put_100 + gex_put_110)
total_gex_call = gex_call_100 + gex_call_110
expected_gex_pcr = total_gex_put / total_gex_call
total_oi_put = 800 + 900
total_oi_call = 1000 + 600
expected_oi_pcr = total_oi_put / total_oi_call
levels = compute_levels(agg, np.array([100.0, 110.0]),
np.array([-1e6, 1e6]), spot, 10.0)
assert abs(levels["gex_put_call_ratio"] - expected_gex_pcr) < 1e-6
assert abs(levels["oi_put_call_ratio"] - expected_oi_pcr) < 1e-6
# ---------------------------------------------------------------------------
# 2. bs_gamma sanity
# ---------------------------------------------------------------------------
def test_bs_gamma_atm_gt_otm():
S, T, iv, r, q = 100.0, 30 / 252, 0.25, 0.04, 0.0
assert bs_gamma(S, 100.0, T, iv, r, q) > bs_gamma(S, 120.0, T, iv, r, q)
def test_bs_gamma_decays_with_T():
S, K, iv, r, q = 100.0, 150.0, 0.25, 0.04, 0.0
gamma_long = bs_gamma(S, K, 1.0, iv, r, q)
gamma_short = bs_gamma(S, K, 0.001, iv, r, q)
assert gamma_short < gamma_long
assert gamma_short < 1e-10
# ---------------------------------------------------------------------------
# 3. HVL interpolation on hand-built profile (find_hvl now takes spot, returns 4)
# ---------------------------------------------------------------------------
def test_hvl_zero_crossing():
grid = np.array([90.0, 95.0, 100.0, 105.0, 110.0])
profile = np.array([-5e6, -2e6, -0.5e6, 1.5e6, 4e6])
# crossing between 100 (-0.5M) and 105 (+1.5M) -> 101.25
hvl, rule, conf, crossings = find_hvl(grid, profile, spot=100.0)
assert rule == "zero_crossing"
assert conf == "high"
assert abs(hvl - 101.25) < 1e-6, f"Expected 101.25, got {hvl}"
assert len(crossings) == 1
def test_hvl_no_crossing_midpoint():
"""FIX 28: inflection fallback deleted. All-positive profile with no crossing
returns midpoint with 'no_crossing_midpoint' rule and 'low' confidence."""
grid = np.array([90.0, 95.0, 100.0, 105.0, 110.0])
profile = np.array([1e6, 2e6, 3e6, 2e6, 1e6]) # all positive, no crossing
hvl, rule, conf, crossings = find_hvl(grid, profile, spot=100.0)
assert rule == "no_crossing_midpoint"
assert conf == "low"
# ---------------------------------------------------------------------------
# 4. Golden-image smoke test
# ---------------------------------------------------------------------------
def test_golden_image_smoke(tmp_path):
from gex.plot import render_chart
cfg = GexConfig(outdir=str(tmp_path), cache_dir=str(tmp_path / "cache"))
spot = 105.0
strikes = np.arange(95, 116, 5.0)
agg = pd.DataFrame({
"gex_call": np.random.uniform(1e6, 5e6, len(strikes)),
"gex_put": -np.random.uniform(1e6, 5e6, len(strikes)),
"dex": np.random.uniform(-2e7, 2e7, len(strikes)),
"oi_call": np.random.randint(100, 5000, len(strikes)),
"oi_put": np.random.randint(100, 5000, len(strikes)),
}, index=strikes)
agg["net_gex"] = agg["gex_call"] + agg["gex_put"]
grid = np.arange(90.0, 121.0, 1.0)
gp = np.linspace(-3e6, 3e6, len(grid))
dp = np.linspace(-1e9, 1e9, len(grid)) # simulated DEX (monotonic)
increment = 5.0
levels = compute_levels(agg, grid, gp, spot, increment)
png = render_chart("TEST", agg, grid, gp, grid, dp, levels,
"2026-07-24 15:44:00", cfg, str(tmp_path), "pm", increment)
assert png.exists(), f"PNG not found: {png}"
assert png.stat().st_size > 50_000, f"PNG too small: {png.stat().st_size} bytes"
# ---------------------------------------------------------------------------
# 5. detect_increment
# ---------------------------------------------------------------------------
def test_detect_increment():
assert detect_increment(np.array([100, 105, 110, 115, 120, 125, 130])) == 5.0
assert detect_increment(np.array([50, 51, 52, 53, 54, 55])) == 1.0
# ===========================================================================
# NEW TESTS (v1.1.0 audit fixes)
# ===========================================================================
# ---------------------------------------------------------------------------
# 6. FIX 1: timestamp UTC -> ET
# ---------------------------------------------------------------------------
def test_timestamp_utc_to_et():
"""'2026-07-24 19:35:00' UTC -> '2026-07-24 15:35 EDT'."""
from gex.plot import format_timestamp
label = format_timestamp("2026-07-24 19:35:00", source_tz="UTC")
assert label == "2026-07-24 15:35 EDT", f"got {label!r}"
# ---------------------------------------------------------------------------
# 7. FIX 2: profile uses the FULL chain (no strike-band truncation)
# ---------------------------------------------------------------------------
def test_profile_uses_full_chain():
"""Heavy call OI at spot*1.20 must shift the profile zero-crossing.
If the profile were fed the truncated (±12%) band, that far-OTM call OI would
be discarded and the crossing would not move — proving no truncation.
"""
cfg = GexConfig(strike_band=0.12, profile_band=0.25)
spot = 100.0
today = date(2026, 7, 24)
exp = date(2026, 8, 15)
base = [
{"strike": 95.0, "expiry": exp, "cp": "P", "iv": 0.30, "oi": 5000,
"volume": 0, "delta": -0.4, "gamma": 0.03, "vega": 0.1, "theta": -0.05,
"theo": 3.0, "bid": 2.9, "ask": 3.1},
{"strike": 105.0, "expiry": exp, "cp": "C", "iv": 0.25, "oi": 1000,
"volume": 0, "delta": 0.4, "gamma": 0.03, "vega": 0.1, "theta": -0.05,
"theo": 3.0, "bid": 2.9, "ask": 3.1},
]
far_call = {"strike": 120.0, "expiry": exp, "cp": "C", "iv": 0.22, "oi": 50000,
"volume": 0, "delta": 0.2, "gamma": 0.01, "vega": 0.1, "theta": -0.03,
"theo": 2.0, "bid": 1.9, "ask": 2.1}
grid = np.arange(spot * 0.75, spot * 1.25 + 0.5, 0.5)
df_without = filter_contracts_full(base, spot, cfg, today)
df_with = filter_contracts_full(base + [far_call], spot, cfg, today)
# the far call (strike 120 = spot*1.20) is OUTSIDE the ±12% band (112) but in the full frame
assert (df_with["strike"] == 120.0).any(), "far call must survive full filter"
assert not (filter_contracts(base + [far_call], spot, cfg, today)["strike"] == 120.0).any(), \
"far call must be dropped by the band filter"
agg = aggregate(df_with, spot, cfg)
gp_without, _ = gex_profile(df_without, spot, cfg, grid) # FIX 14: agg dropped
gp_with, _ = gex_profile(df_with, spot, cfg, grid)
# the profiles must differ where the far call OI has influence (high strikes)
assert not np.allclose(gp_without, gp_with), \
"profile must change when far-OTM call OI is added (full chain, not truncated)"
# specifically, adding call OI raises the profile in the upper wing
upper = grid > 112
assert gp_with[upper].mean() > gp_without[upper].mean()
# ---------------------------------------------------------------------------
# 8. FIX 12: DEX profile is V-shaped (NOT monotonic) — interior minimum
# ---------------------------------------------------------------------------
def test_dex_profile_v_shape():
"""Put OI at spot*1.15 + call OI at spot*0.85 => V-shaped DEX with an
interior minimum (supersedes the old monotonicity test, which was wrong).
At low s the high-strike puts are deep ITM (delta ~ -1) so dex ~ -100*s*OI_put
decreases in s; at high s the low-strike calls dominate and dex increases.
The minimum must be strictly interior (not index 0 or -1).
"""
cfg = GexConfig(profile_band=0.40)
spot = 100.0
today = date(2026, 7, 24)
exp = date(2026, 8, 15)
chain = [
# puts at spot*1.15 = 115
{"strike": 115.0, "expiry": exp, "cp": "P", "iv": 0.25, "oi": 20000,
"volume": 0, "delta": -0.5, "gamma": 0.02, "vega": 0.1, "theta": -0.05,
"theo": 5.0, "bid": 4.9, "ask": 5.1},
# calls at spot*0.85 = 85
{"strike": 85.0, "expiry": exp, "cp": "C", "iv": 0.25, "oi": 20000,
"volume": 0, "delta": 0.5, "gamma": 0.02, "vega": 0.1, "theta": -0.05,
"theo": 5.0, "bid": 4.9, "ask": 5.1},
]
df_full = filter_contracts_full(chain, spot, cfg, today)
grid = np.arange(spot * 0.60, spot * 1.40 + 0.5, 0.5)
dp, _ = dex_profile(df_full, cfg, grid, spot)
imin = int(np.argmin(dp))
assert 0 < imin < len(dp) - 1, \
f"DEX minimum must be strictly interior, got index {imin} of {len(dp)}"
# and it is genuinely V-shaped: both ends above the minimum
assert dp[0] > dp[imin] and dp[-1] > dp[imin], "DEX profile must be V-shaped"
# ---------------------------------------------------------------------------
# 9. FIX 10: three x-axes (bars + GEX profile + DEX profile), zeros aligned
# ---------------------------------------------------------------------------
def test_three_axis_zero_aligned(tmp_path):
"""FIX 10: separate twin axis per profile. All three x-axes share y (strikes)
and their zeros must coincide in display coords (warn-not-assert in render)."""
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from gex.plot import render_chart
cfg = GexConfig(outdir=str(tmp_path))
spot = 100.0
strikes = np.arange(90.0, 111.0, 2.0)
agg = pd.DataFrame({
"gex_call": np.linspace(1e6, 4e6, len(strikes)),
"gex_put": -np.linspace(2e6, 1e6, len(strikes)),
"dex": np.zeros(len(strikes)),
"oi_call": np.full(len(strikes), 1000),
"oi_put": np.full(len(strikes), 1200),
}, index=strikes)
agg["net_gex"] = agg["gex_call"] + agg["gex_put"]
grid = np.arange(85.0, 116.0, 0.5)
gp = np.linspace(-3e6, 3e6, len(grid))
dp = np.linspace(-1e9, 1e9, len(grid))
levels = compute_levels(agg, grid, gp, spot, 2.0)
captured = {}
orig = plt.Figure.savefig
def spy(self, *a, **k):
captured["fig"] = self
return orig(self, *a, **k)
plt.Figure.savefig = spy
try:
render_chart("TEST", agg, grid, gp, grid, dp, levels,
"2026-07-24 15:44:00", cfg, str(tmp_path), "pm", 2.0)
finally:
plt.Figure.savefig = orig
fig = captured["fig"]
ax = fig.axes[0]
gex_axes = [a for a in fig.axes if a is not ax and a.get_xlabel().startswith("GEX Profile")]
dex_axes = [a for a in fig.axes if a is not ax and a.get_xlabel().startswith("DEX Profile")]
assert len(gex_axes) == 1, "GEX profile axis must exist"
assert len(dex_axes) == 1, "DEX profile axis must exist"
# colour-matching: GEX axis yellow, DEX axis orange
assert gex_axes[0].xaxis.label.get_color() == cfg.gex_profile_color
assert dex_axes[0].xaxis.label.get_color() == cfg.dex_color
# all three zeros coincide
fig.canvas.draw()
d0 = ax.transData.transform((0.0, 0.0))[0]
dg = gex_axes[0].transData.transform((0.0, 0.0))[0]
dd = dex_axes[0].transData.transform((0.0, 0.0))[0]
assert abs(d0 - dg) < 0.5, f"GEX-axis zero misaligned: {abs(d0-dg):.3f}px"
assert abs(d0 - dd) < 0.5, f"DEX-axis zero misaligned: {abs(d0-dd):.3f}px"
# ---------------------------------------------------------------------------
# 10. FIX 7: robust x-limits resist a single outlier bar
# ---------------------------------------------------------------------------
def test_robust_xlim_outlier():
"""One 8e8 outlier among ~1e7 bars must NOT dominate the axis (FIX 7).
The robust limit is clean(1.15 * max(p97, median*8)). p97 excludes the single
outlier (it sits in the ~1e7 bulk); the median*8 floor caps the axis near the
bulk. The result is far below the naive max-based limit (1.15*8e8 = 920M),
which is the actual defect being fixed — the outlier no longer flattens the
mid-window bars to hairlines.
"""
from gex.plot import _clean_xlim
net = np.array([1e7] * 100 + [8e8])
nz = np.abs(net[net != 0])
p97 = np.percentile(nz, 97)
med8 = np.median(nz) * 8
xlim = _clean_xlim(1.15 * max(p97, med8))
naive = 1.15 * 8e8
# the outlier must not set the axis: robust limit is a small fraction of naive
assert xlim < 0.2 * naive, f"xlim={xlim:.2e} should be << naive {naive:.2e}"
# and it is governed by the bulk (median*8 = 8e7), not the outlier — i.e. within
# one clean-step rounding of 1.15*med8 (100M is the clean step above 92M)
assert xlim <= 1.15 * med8 * 1.10 + 1e7, f"xlim={xlim:.2e} should track the bulk"
# p97 itself must have rejected the outlier
assert p97 < 1e8, f"p97={p97:.2e} should sit in the bulk, not at the outlier"
# ---------------------------------------------------------------------------
# 11. FIX 6: HVL low-confidence flag when there is no crossing
# ---------------------------------------------------------------------------
def test_hvl_low_confidence_flag():
"""With an all-positive FLAT profile (no zero-crossing), HVL is null with
status 'no_flip_in_range' (FIX 29). No inflection fallback (FIX 28)."""
strikes = np.arange(90.0, 111.0, 5.0)
agg = pd.DataFrame({
"gex_call": np.full(len(strikes), 1e6),
"gex_put": np.full(len(strikes), -0.5e6),
"dex": np.zeros(len(strikes)),
"oi_call": np.full(len(strikes), 1000),
"oi_put": np.full(len(strikes), 800),
}, index=strikes)
agg["net_gex"] = agg["gex_call"] + agg["gex_put"]
grid = np.arange(85.0, 116.0, 1.0)
profile = np.full(len(grid), 2e6) # all positive, flat -> no crossing
cfg = GexConfig()
levels = compute_levels(agg, grid, profile, spot=100.0, increment=5.0, cfg=cfg)
# flat profile: no zero crossing -> hvl is None, status no_flip_in_range
assert levels["hvl"] is None
assert levels["hvl_status"] == "no_flip_in_range"
assert levels["hvl_rule"] == "zero_cross"
assert levels["hvl_confidence"] == "n/a"
# ===========================================================================
# NEW TESTS (v1.2.0 audit fixes)
# ===========================================================================
# ---------------------------------------------------------------------------
# 12. FIX 12: delta_neutral picks the crossing NEAREST spot; crossings listed
# ---------------------------------------------------------------------------
def test_delta_neutral_nearest_spot():
"""A V-shaped DEX profile crosses zero twice; pick the one nearest spot."""
grid = np.array([80.0, 90.0, 100.0, 110.0, 120.0])
# crosses zero near 85 (rising) and near 115 (rising again) — two crossings
dp = np.array([-2e9, -0.5e9, 1e9, -0.5e9, -2e9]) # not monotonic, two sign changes
nearest, crossings = find_delta_neutral(grid, dp, spot=100.0)
assert len(crossings) >= 1
# nearest must be the crossing closest to spot=100
assert nearest == min(crossings, key=lambda c: abs(c - 100.0))
def test_dex_min_price_interior():
"""dex_min_price returns (price, status) at the V minimum; interior -> 'interior'."""
grid = np.array([80.0, 90.0, 100.0, 110.0, 120.0])
dp = np.array([-1e9, -3e9, -5e9, -3e9, -1e9]) # min at index 2 (100.0)
price, status = dex_min_price(grid, dp)
assert price == 100.0
assert status == "interior"
# ---------------------------------------------------------------------------
# 13. FIX 13: outlier_report has top strikes with per-expiry breakdown
# ---------------------------------------------------------------------------
def test_outlier_report_structure(synthetic_chain):
cfg = GexConfig(strike_band=0.50)
spot = 105.0
today = date(2026, 7, 24)
df = filter_contracts(synthetic_chain, spot, cfg, today)
agg = aggregate(df, spot, cfg)
report = build_outlier_report(agg, df, cfg, spot, top_n=5)
assert "top_strikes" in report
assert len(report["top_strikes"]) > 0
top = report["top_strikes"][0]
for field in ("strike", "net_gex", "oi_call", "oi_put", "by_expiry"):
assert field in top, f"missing {field} in outlier_report entry"
# per-expiry breakdown entries carry expiry + oi + gex
exp0 = top["by_expiry"][0]
for field in ("expiry", "oi_call", "oi_put", "gex"):
assert field in exp0, f"missing {field} in per-expiry breakdown"
# ---------------------------------------------------------------------------
# 14. FIX 14: oi_totals totals + DTE buckets; gex_profile has no agg arg
# ---------------------------------------------------------------------------
def test_oi_totals(synthetic_chain):
cfg = GexConfig(strike_band=0.50)
spot = 105.0
today = date(2026, 7, 24)
df_full = filter_contracts_full(synthetic_chain, spot, cfg, today)
totals = build_oi_totals(df_full)
# calls: 1000 + 600 = 1600; puts: 800 + 900 = 1700
assert totals["call_oi"] == 1600
assert totals["put_oi"] == 1700
assert totals["n_contracts"] == 4
assert totals["n_expiries"] == 1
# all OI lands in exactly one DTE bucket
bucket_sum = sum(totals["oi_by_dte_bucket"].values())
assert bucket_sum == 1600 + 1700
def test_gex_profile_signature_no_agg():
"""FIX 14: gex_profile no longer takes agg (contracts_df, spot, cfg, grid)."""
import inspect
sig = inspect.signature(gex_profile)
params = list(sig.parameters.keys())
assert "agg" not in params, f"gex_profile still has 'agg': {params}"
assert params[0] == "contracts_df"
# ===========================================================================
# NEW TESTS (v1.3.0 audit fixes)
# ===========================================================================
def _load_smh_fixture():
"""Load the cached SMH fixture and return (contracts, spot, ts_str)."""
import glob, gzip, json, os
cache_dir = os.path.join(os.path.dirname(__file__), "..", "data", "raw")
paths = sorted(glob.glob(os.path.join(cache_dir, "SMH_*.json.gz")))
assert paths, f"No cached SMH fixture found in {cache_dir}"
with gzip.open(paths[-1], "rt", encoding="utf-8") as f:
data = json.load(f)
from gex.fetch import parse_chain
contracts, spot, ts_str = parse_chain(data, "SMH")
return contracts, spot, ts_str
# ---------------------------------------------------------------------------
# 1. FIX 15: reconcile within tolerance on the cached SMH fixture
# ---------------------------------------------------------------------------
def test_reconcile_within_tolerance():
"""rel_err < 0.05 on the cached SMH fixture (bars and profiles agree at spot)."""
contracts, spot, ts_str = _load_smh_fixture()
cfg = GexConfig()
snap_et = datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S").replace(
tzinfo=ZoneInfo(cfg.source_timestamp_tz)).astimezone(_ET)
df_full = filter_contracts_full(contracts, spot, cfg, snap_et)
df_band = filter_contracts_band(df_full, spot, cfg)
agg = aggregate(df_band, spot, cfg)
increment = detect_increment(agg.index.to_numpy())
step = max(increment / 5.0, 0.10)
grid = np.arange(spot * (1 - cfg.profile_band),
spot * (1 + cfg.profile_band) + step, step)
gp, _ = gex_profile(df_full, spot, cfg, grid)
total_full = total_net_gex_from_contracts(df_full, spot, cfg)
rec = reconcile(total_full, grid, gp, spot)
assert rec["pass"], f"reconciliation failed: rel_err={rec['rel_err']:.4f}"
assert rec["rel_err"] < 0.05, f"rel_err={rec['rel_err']:.4f} >= 0.05"
# ---------------------------------------------------------------------------
# 2. FIX 15: continuous time-to-expiry
# ---------------------------------------------------------------------------
def test_time_to_expiry_continuous():
"""FIX 65: calendar-time T for ALL expiries (not just 0DTE).
T = minutes_to_settlement / (365*24*60). Settlement = 16:00 ET (09:30 for AM-settled)."""
cfg = GexConfig()
expiry = date(2026, 7, 24) # same day as the snapshot
YEAR_MIN = 365.0 * 24.0 * 60.0
floor = 1.0 / (252.0 * 13.0) # ≈ 2.67 calendar-hours; caps gamma as T->0
# at 12:00 ET: 240 clock-minutes to 16:00 settlement (above the floor)
now_1200 = datetime(2026, 7, 24, 12, 0, tzinfo=_ET)
T_1200 = time_to_expiry_years(expiry, now_1200, cfg)
assert abs(T_1200 - 240.0 / YEAR_MIN) < 1e-12, f"T at 12:00 = {T_1200}"
# at 09:30 ET: 390 clock-minutes (6.5h) to 16:00 settlement
now_0930 = datetime(2026, 7, 24, 9, 30, tzinfo=_ET)
T_0930 = time_to_expiry_years(expiry, now_0930, cfg)
assert abs(T_0930 - 390.0 / YEAR_MIN) < 1e-12, f"T at 09:30 = {T_0930}"
# at 15:35 ET: only 25 clock-minutes left, below the floor -> T = floor
now_1535 = datetime(2026, 7, 24, 15, 35, tzinfo=_ET)
T_1535 = time_to_expiry_years(expiry, now_1535, cfg)
assert T_1535 == floor, f"T at 15:35 should be floor {floor}, got {T_1535}"
# after 16:00 ET: settlement passed -> minutes floored at 0 -> T = floor
now_1630 = datetime(2026, 7, 24, 16, 30, tzinfo=_ET)
T_1630 = time_to_expiry_years(expiry, now_1630, cfg)
assert T_1630 == floor, f"T after 16:00 should be floor {floor}, got {T_1630}"
# AM-settled same-day: settlement at 09:30. At 09:00 only 30 min remain,
# below the floor -> T = floor (the 09:30 settlement clock is selected).
T_am = time_to_expiry_years(expiry, datetime(2026, 7, 24, 9, 0, tzinfo=_ET),
cfg, am_settled=True)
assert T_am == floor, f"T am-settled at 09:00 should be floor {floor}, got {T_am}"
# FIX 65: MULTI-DAY path also uses calendar time. Snapshot Fri 07-24 15:35 ET,
# expiry Tue 07-28: 4 calendar days + 25 min = 5785 min to 16:00 ET settlement.
later = date(2026, 7, 28)
T_multi = time_to_expiry_years(later, now_1535, cfg)
expected_multi = 5785.0 / YEAR_MIN
assert abs(T_multi - expected_multi) < 1e-12, \
f"multi-day T should be {expected_multi}, got {T_multi}"
# sanity: multi-day T >> same-day T
assert T_multi > T_1200
# ---------------------------------------------------------------------------
# 3. FIX 16: three HVL candidates, default inflection, method-sensitivity flag
# ---------------------------------------------------------------------------
def test_hvl_zero_cross_only():
"""FIX 29: HVL is always the zero crossing nearest spot. compute_hvl_candidates
(deprecated wrapper) returns zero_cross as the only candidate, no inflection."""
cfg = GexConfig() # hvl_rule = "zero_cross"
spot = 100.0
grid = np.arange(80.0, 120.5, 0.5)
# profile crosses zero near 105
profile = (grid - 105.0) * 1e6
strikes = np.arange(85.0, 116.0, 5.0)
agg = pd.DataFrame({
"gex_call": np.linspace(1e6, 4e6, len(strikes)),
"gex_put": -np.linspace(3e6, 1e6, len(strikes)),
"dex": np.zeros(len(strikes)),
"oi_call": np.full(len(strikes), 1000),
"oi_put": np.full(len(strikes), 1200),
}, index=strikes)
agg["net_gex"] = agg["gex_call"] + agg["gex_put"]
result = compute_hvl_candidates(grid, profile, agg, spot, cfg)
# only zero_cross in candidates
assert "zero_cross" in result["hvl_candidates"]
assert result["hvl_candidates"]["zero_cross"] is not None
assert abs(result["hvl_candidates"]["zero_cross"] - 105.0) < 1.0
assert result["hvl_rule_used"] == "zero_cross"
assert result["hvl_confidence"] == "high"
# inflection retired
assert result["hvl_inflection_status"] == "retired_v1.5.0"
assert result["hvl_spread_pct"] == 0.0
# ---------------------------------------------------------------------------
# 4. FIX 17: ATM IV rejects garbage
# ---------------------------------------------------------------------------
def test_atm_iv_rejects_garbage():
"""Chain whose only near-spot contract has iv=0.83 and OI=3 -> rejected."""
cfg = GexConfig()
spot = 100.0
today = date(2026, 7, 24)
# only one expiry with DTE >= 5, one strike near spot, OI=3 (fails OI>=100)
chain = [
{"strike": 100.0, "expiry": date(2026, 8, 15), "cp": "C",
"iv": 0.83, "oi": 3, "volume": 0, "delta": 0.5, "gamma": 0.03,
"vega": 0.1, "theta": -0.05, "theo": 5.0, "bid": 4.9, "ask": 5.1},
]
df = filter_contracts_full(chain, spot, cfg, today)
result = atm_expected_move(df, spot)
assert result["atm_iv"] is None, f"atm_iv should be None, got {result['atm_iv']}"
assert result["atm_iv_status"].startswith("rejected"), \
f"status should start with 'rejected', got {result['atm_iv_status']}"
# ---------------------------------------------------------------------------
# 5. FIX 18: front_expiry_share
# ---------------------------------------------------------------------------
def test_front_expiry_share():
"""Fixture where the front expiry dominates Σ|net_gex| -> front_expiry_share > 0.40."""
cfg = GexConfig()
spot = 100.0
today = date(2026, 7, 24)
# front expiry (0DTE): put-heavy -> large negative net GEX
# later expiry: call-heavy -> smaller positive net GEX
chain = [
# front expiry (0DTE): put OI >> call OI -> net GEX ∝ (10000-60000)*0.05 = -2500
{"strike": 100.0, "expiry": date(2026, 7, 24), "cp": "C",
"iv": 0.30, "oi": 10000, "volume": 0, "delta": 0.5, "gamma": 0.05,
"vega": 0.1, "theta": -0.05, "theo": 5.0, "bid": 4.9, "ask": 5.1},
{"strike": 100.0, "expiry": date(2026, 7, 24), "cp": "P",
"iv": 0.30, "oi": 60000, "volume": 0, "delta": -0.5, "gamma": 0.05,
"vega": 0.1, "theta": -0.05, "theo": 5.0, "bid": 4.9, "ask": 5.1},
# later expiry: call OI >> put OI -> net GEX ∝ (40000-10000)*0.03 = +900
{"strike": 100.0, "expiry": date(2026, 8, 15), "cp": "C",
"iv": 0.25, "oi": 40000, "volume": 0, "delta": 0.5, "gamma": 0.03,
"vega": 0.1, "theta": -0.05, "theo": 5.0, "bid": 4.9, "ask": 5.1},
{"strike": 100.0, "expiry": date(2026, 8, 15), "cp": "P",
"iv": 0.25, "oi": 10000, "volume": 0, "delta": -0.5, "gamma": 0.03,
"vega": 0.1, "theta": -0.05, "theo": 5.0, "bid": 4.9, "ask": 5.1},
]
df_full = filter_contracts_full(chain, spot, cfg, today)
result = build_gex_by_expiry(df_full, spot, cfg)
# front share = 2500 / (2500 + 900) ≈ 0.735 > 0.40
assert result["front_expiry_share"] > 0.40, \
f"front_expiry_share {result['front_expiry_share']:.3f} should be > 0.40"
assert result["front_expiry_dte"] == 0
assert len(result["gex_by_expiry"]) == 2
# ---------------------------------------------------------------------------
# 6. FIX 19: no symlog
# ---------------------------------------------------------------------------
def test_no_symlog(tmp_path):
"""Render and assert ax.get_xscale() == 'linear' (symlog removed)."""
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from gex.plot import render_chart
cfg = GexConfig(outdir=str(tmp_path))
spot = 100.0
strikes = np.arange(90.0, 111.0, 2.0)
agg = pd.DataFrame({
"gex_call": np.linspace(1e6, 4e6, len(strikes)),
"gex_put": -np.linspace(2e6, 1e6, len(strikes)),
"dex": np.zeros(len(strikes)),
"oi_call": np.full(len(strikes), 1000),
"oi_put": np.full(len(strikes), 1200),
}, index=strikes)
agg["net_gex"] = agg["gex_call"] + agg["gex_put"]
grid = np.arange(85.0, 116.0, 0.5)
gp = np.linspace(-3e6, 3e6, len(grid))
dp = np.linspace(-1e9, 1e9, len(grid))
levels = compute_levels(agg, grid, gp, spot, 2.0, cfg)
captured = {}
orig = plt.Figure.savefig
def spy(self, *a, **k):
captured["fig"] = self
return orig(self, *a, **k)
plt.Figure.savefig = spy
try:
render_chart("TEST", agg, grid, gp, grid, dp, levels,
"2026-07-24 15:44:00", cfg, str(tmp_path), "pm", 2.0)
finally:
plt.Figure.savefig = orig
fig = captured["fig"]
ax = fig.axes[0]
assert ax.get_xscale() == "linear", f"xscale should be 'linear', got {ax.get_xscale()}"
# ---------------------------------------------------------------------------
# 7. FIX 20: layout guard clear
# ---------------------------------------------------------------------------
def test_layout_guard_clear(tmp_path, caplog):
"""Render the SMH fixture; assert the layout guard logs no warning."""
import logging
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from gex.plot import render_chart
contracts, spot, ts_str = _load_smh_fixture()
cfg = GexConfig(outdir=str(tmp_path))
snap_et = datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S").replace(
tzinfo=ZoneInfo(cfg.source_timestamp_tz)).astimezone(_ET)
df_full = filter_contracts_full(contracts, spot, cfg, snap_et)
df_band = filter_contracts_band(df_full, spot, cfg)
agg = aggregate(df_band, spot, cfg)
increment = detect_increment(agg.index.to_numpy())
step = max(increment / 5.0, 0.10)
grid = np.arange(spot * (1 - cfg.profile_band),
spot * (1 + cfg.profile_band) + step, step)
gp, _ = gex_profile(df_full, spot, cfg, grid)
dp, _ = dex_profile(df_full, cfg, grid, spot)
levels = compute_levels(agg, grid, gp, spot, increment, cfg)
# add put_heavy_note to trigger the note rendering path
levels["put_heavy_note"] = "put/call OI 4.84 — see per-expiry breakdown"
with caplog.at_level(logging.WARNING, logger="gex.plot"):
render_chart("SMH", agg, grid, gp, grid, dp, levels, ts_str, cfg,
str(tmp_path), "pm", increment)
layout_warnings = [r for r in caplog.records if "LAYOUT" in r.getMessage()]
assert len(layout_warnings) == 0, \
f"Layout guard logged {len(layout_warnings)} warning(s): {[r.getMessage() for r in layout_warnings]}"
# ===========================================================================
# NEW TESTS (v1.4.0 audit fixes)
# ===========================================================================
# ---------------------------------------------------------------------------
# 1. FIX 22: gamma_condition matches sign of profile at spot
# ---------------------------------------------------------------------------
def test_gamma_condition_matches_sign():
"""sign(profile_at_spot) must always agree with gamma_condition, on both
the all-expirations and exfront fixtures."""
contracts, spot, ts_str = _load_smh_fixture()
cfg = GexConfig()
snap_et = datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S").replace(
tzinfo=ZoneInfo(cfg.source_timestamp_tz)).astimezone(_ET)
for exclude_front in (False, True):
df_full = filter_contracts_full(contracts, spot, cfg, snap_et)
if exclude_front:
exps = sorted(df_full["expiry"].unique())
if len(exps) > 1:
df_full = df_full[df_full["expiry"] != exps[0]]
df_band = filter_contracts_band(df_full, spot, cfg)
agg = aggregate(df_band, spot, cfg)
increment = detect_increment(agg.index.to_numpy())
step = max(increment / 5.0, 0.10)
grid = np.arange(spot * (1 - cfg.profile_band),
spot * (1 + cfg.profile_band) + step, step)
gp, _ = gex_profile(df_full, spot, cfg, grid)
# interpolate profile at spot
profile_at_spot = float(np.interp(spot, grid, gp))
levels = compute_levels(agg, grid, gp, spot, increment, cfg,
profile_at_spot=profile_at_spot)
expected = "POSITIVE" if profile_at_spot > 0 else "NEGATIVE"
assert levels["gamma_condition"] == expected, \
f"gamma_condition={levels['gamma_condition']} but profile_at_spot={profile_at_spot:.2e} " \
f"(exclude_front={exclude_front})"
assert levels["gamma_condition_basis"] == "sign of simulated GEX profile at spot"
assert levels["net_gex_at_spot"] == profile_at_spot
# ---------------------------------------------------------------------------
# 2. FIX 23: HVL indeterminate when spread > 10%
# ---------------------------------------------------------------------------
def test_hvl_single_level_with_distance():
"""FIX 29: HVL is always a single defined level (zero crossing nearest spot).
No 'indeterminate'. Distance is information, not a defect. On the SMH fixture
the zero crossing is ~629.8, far from spot (557.09) -> regime note says
'far from spot'."""
contracts, spot, ts_str = _load_smh_fixture()
cfg = GexConfig()
snap_et = datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S").replace(
tzinfo=ZoneInfo(cfg.source_timestamp_tz)).astimezone(_ET)
df_full = filter_contracts_full(contracts, spot, cfg, snap_et)
df_band = filter_contracts_band(df_full, spot, cfg)
agg = aggregate(df_band, spot, cfg)
increment = detect_increment(agg.index.to_numpy())
step = max(increment / 5.0, 0.10)
grid = np.arange(spot * (1 - cfg.profile_band),
spot * (1 + cfg.profile_band) + step, step)
gp, _ = gex_profile(df_full, spot, cfg, grid)
levels = compute_levels(agg, grid, gp, spot, increment, cfg)
# HVL is a single number, not None, not indeterminate
assert levels["hvl"] is not None, "HVL must be a single defined level"
assert levels["hvl_status"] == "ok"
assert levels["hvl_rule"] == "zero_cross"
# distance annotation present
assert levels["hvl_distance_pct"] is not None
assert levels["hvl_regime_note"] is not None
# on this fixture the crossing is far from spot (>8%)
assert abs(levels["hvl_distance_pct"]) > 0.08
assert "far from spot" in levels["hvl_regime_note"]
# no indeterminate artifacts
assert levels["hvl_spread_pct"] == 0.0
assert levels["hvl_range"] is None
# ---------------------------------------------------------------------------
# 3. FIX 24: ATM IV liquidity gate
# ---------------------------------------------------------------------------
def test_atm_iv_liquidity_gate():
"""Expiry with OI < max(5000, 2% chain OI) must be rejected."""
cfg = GexConfig()
spot = 100.0
today = date(2026, 7, 24)
# one expiry with tiny OI (100 contracts total) -> fails liquidity gate
chain = [
{"strike": 100.0, "expiry": date(2026, 8, 15), "cp": "C",
"iv": 0.25, "oi": 50, "volume": 0, "delta": 0.5, "gamma": 0.03,
"vega": 0.1, "theta": -0.05, "theo": 5.0, "bid": 4.9, "ask": 5.1},
{"strike": 100.0, "expiry": date(2026, 8, 15), "cp": "P",
"iv": 0.25, "oi": 50, "volume": 0, "delta": -0.5, "gamma": 0.03,
"vega": 0.1, "theta": -0.05, "theo": 5.0, "bid": 4.9, "ask": 5.1},
]
df = filter_contracts_full(chain, spot, cfg, today)
result = atm_expected_move(df, spot)
assert result["atm_iv"] is None, f"atm_iv should be None, got {result['atm_iv']}"
assert "rejected" in result["atm_iv_status"], \
f"status should contain 'rejected', got {result['atm_iv_status']}"
# ---------------------------------------------------------------------------
# 4. FIX 25: max_expiry_share fires warning, not front_expiry_share
# ---------------------------------------------------------------------------
def test_max_expiry_share():
"""max_expiry_share keys on the argmax expiry, not the front one."""
cfg = GexConfig()
spot = 100.0
today = date(2026, 7, 24)
# front expiry (0DTE): small GEX; later expiry: dominant GEX
chain = [
# front expiry: tiny
{"strike": 100.0, "expiry": date(2026, 7, 24), "cp": "C",
"iv": 0.30, "oi": 100, "volume": 0, "delta": 0.5, "gamma": 0.05,
"vega": 0.1, "theta": -0.05, "theo": 5.0, "bid": 4.9, "ask": 5.1},
# later expiry: dominant
{"strike": 100.0, "expiry": date(2026, 8, 15), "cp": "C",
"iv": 0.25, "oi": 100000, "volume": 0, "delta": 0.5, "gamma": 0.03,
"vega": 0.1, "theta": -0.05, "theo": 5.0, "bid": 4.9, "ask": 5.1},
]
df_full = filter_contracts_full(chain, spot, cfg, today)
result = build_gex_by_expiry(df_full, spot, cfg)
# max_expiry should be the later one (08-15), not the front (07-24)
assert result["max_expiry"] == "2026-08-15"
assert result["max_expiry_share"] > 0.90, \
f"max_expiry_share {result['max_expiry_share']:.3f} should be > 0.90"
# front_expiry_share is separate and small
assert result["front_expiry_share"] < 0.10
# top3_expiry_share >= max_expiry_share
assert result["top3_expiry_share"] >= result["max_expiry_share"]
# ---------------------------------------------------------------------------
# 5. FIX 26: interior_extremum detects grid boundary
# ---------------------------------------------------------------------------
def test_interior_extremum_boundary():
"""argmin at the grid edge -> (None, 'at_grid_boundary')."""
grid = np.array([80.0, 90.0, 100.0, 110.0, 120.0])
# min at index 0 (edge)
dp_edge = np.array([-5e9, -3e9, -1e9, 1e9, 3e9])
price, status = dex_min_price(grid, dp_edge)
assert price is None
assert status == "at_grid_boundary"
# min at index 1 (within edge_tol=2 of edge) -> also boundary
dp_near = np.array([-3e9, -5e9, -1e9, 1e9, 3e9])
price2, status2 = dex_min_price(grid, dp_near)
assert price2 is None
assert status2 == "at_grid_boundary"
# min at index 2 (interior) -> returns price
dp_int = np.array([-1e9, -3e9, -5e9, -3e9, -1e9])
price3, status3 = dex_min_price(grid, dp_int)
assert price3 == 100.0
assert status3 == "interior"
# ---------------------------------------------------------------------------
# 6. FIX 27: ephemeral level detection
# ---------------------------------------------------------------------------
def test_ephemeral_levels():
"""level_front_expiry_pct returns >0.50 for a level dominated by 0DTE OI."""
from gex.compute import level_front_expiry_pct
cfg = GexConfig()
spot = 100.0
today = date(2026, 7, 24)
# put support at 95: 90% from 0DTE, 10% from later expiry
chain = [
# 0DTE: dominant put OI at 95
{"strike": 95.0, "expiry": date(2026, 7, 24), "cp": "P",
"iv": 0.30, "oi": 90000, "volume": 0, "delta": -0.5, "gamma": 0.05,
"vega": 0.1, "theta": -0.05, "theo": 5.0, "bid": 4.9, "ask": 5.1},
# later expiry: small put OI at 95
{"strike": 95.0, "expiry": date(2026, 8, 15), "cp": "P",
"iv": 0.25, "oi": 10000, "volume": 0, "delta": -0.5, "gamma": 0.03,
"vega": 0.1, "theta": -0.05, "theo": 5.0, "bid": 4.9, "ask": 5.1},
# call resistance at 105: mostly later expiry (not ephemeral)
{"strike": 105.0, "expiry": date(2026, 7, 24), "cp": "C",
"iv": 0.30, "oi": 1000, "volume": 0, "delta": 0.5, "gamma": 0.05,
"vega": 0.1, "theta": -0.05, "theo": 5.0, "bid": 4.9, "ask": 5.1},
{"strike": 105.0, "expiry": date(2026, 8, 15), "cp": "C",
"iv": 0.25, "oi": 9000, "volume": 0, "delta": 0.5, "gamma": 0.03,
"vega": 0.1, "theta": -0.05, "theo": 5.0, "bid": 4.9, "ask": 5.1},
]
df_full = filter_contracts_full(chain, spot, cfg, today)
# put support at 95: 90% front expiry -> ephemeral
fe_95 = level_front_expiry_pct(df_full, 95.0, spot, cfg)
assert fe_95["front_expiry_abs_share"] > 0.50, f"put_support at 95 should be >50% front expiry, got {fe_95['front_expiry_abs_share']:.2%}"
assert fe_95["front_expiry"] == "2026-07-24"
# call resistance at 105: 10% front expiry -> NOT ephemeral
fe_105 = level_front_expiry_pct(df_full, 105.0, spot, cfg)
assert fe_105["front_expiry_abs_share"] < 0.50, f"call_resistance at 105 should be <50% front expiry, got {fe_105['front_expiry_abs_share']:.2%}"
# ===========================================================================
# NEW TESTS (v1.5.0 audit fixes)
# ===========================================================================
# ---------------------------------------------------------------------------
# 1. FIX 28: no published level sits at a search/mask boundary
# ---------------------------------------------------------------------------
def test_no_level_at_search_boundary():
"""FIX 28 regression: no published level may equal a mask/search boundary to
within one grid step. The old inflection rule walked to the mask boundary
(550 + 3.75 = 553.8175 exactly). This test asserts that never happens again."""
contracts, spot, ts_str = _load_smh_fixture()
cfg = GexConfig()
snap_et = datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S").replace(
tzinfo=ZoneInfo(cfg.source_timestamp_tz)).astimezone(_ET)
df_full = filter_contracts_full(contracts, spot, cfg, snap_et)
df_band = filter_contracts_band(df_full, spot, cfg)
agg = aggregate(df_band, spot, cfg)
increment = detect_increment(agg.index.to_numpy())
step = max(increment / 5.0, 0.10)
grid = np.arange(spot * (1 - cfg.profile_band),
spot * (1 + cfg.profile_band) + step, step)
gp, _ = gex_profile(df_full, spot, cfg, grid)
levels = compute_levels(agg, grid, gp, spot, increment, cfg)
# the old mask boundary was at 553.8175 (= 550 + 1.5*2.5). Check no published
# level is within one grid step of that value.
old_mask_boundary = 553.8175
published_levels = [
levels["call_resistance"],
levels["put_support"],
levels["hvl"],
levels.get("gex_transition"),
levels.get("delta_neutral"),
]
for lvl in published_levels:
if lvl is None:
continue
dist = abs(lvl - old_mask_boundary)
assert dist > step, \
f"Level {lvl} is within one grid step ({step}) of the old mask boundary " \
f"{old_mask_boundary} (dist={dist:.4f}). This is the FIX 28 regression."
# ---------------------------------------------------------------------------
# 2. FIX 29: HVL distance annotation and regime note
# ---------------------------------------------------------------------------
def test_hvl_distance_and_regime_note():
"""FIX 29: hvl_distance_pct and hvl_regime_note are published. The note
buckets are: |d|<=3% -> 'near spot', 3-8% -> 'moderately distant',
>8% -> 'far from spot'."""
from gex.compute import compute_hvl
cfg = GexConfig()
spot = 100.0
grid = np.arange(80.0, 120.5, 0.5)
# crossing at 101 (1% from spot) -> "near spot"
profile_near = (grid - 101.0) * 1e6
info = compute_hvl(grid, profile_near, spot, cfg)
assert info["hvl"] is not None
assert abs(info["hvl_distance_pct"]) <= 0.03
assert "near spot" in info["hvl_regime_note"]
# crossing at 106 (6% from spot) -> "moderately distant"
profile_mod = (grid - 106.0) * 1e6
info2 = compute_hvl(grid, profile_mod, spot, cfg)
assert 0.03 < abs(info2["hvl_distance_pct"]) <= 0.08
assert "moderately distant" in info2["hvl_regime_note"]
# crossing at 115 (15% from spot) -> "far from spot"
profile_far = (grid - 115.0) * 1e6
info3 = compute_hvl(grid, profile_far, spot, cfg)
assert abs(info3["hvl_distance_pct"]) > 0.08
assert "far from spot" in info3["hvl_regime_note"]
# ---------------------------------------------------------------------------
# 3. FIX 30: gex_transition persistence check
# ---------------------------------------------------------------------------
def test_gex_transition_persistence():
"""FIX 30: gex_transition requires 3 consecutive strikes negative before and
positive after the flip. A noisy single-strike flip must be rejected."""
from gex.compute import compute_gex_transition
spot = 100.0
# persistent flip: 4 negative strikes then 4 positive strikes
strikes_p = np.array([90.0, 92.0, 94.0, 96.0, 98.0, 100.0, 102.0, 104.0])
net_p = np.array([-5e6, -4e6, -3e6, -2e6, -1e6, 1e6, 2e6, 3e6])
agg_p = pd.DataFrame({"net_gex": net_p}, index=strikes_p)
result_p = compute_gex_transition(agg_p, spot, persistence=3)
assert result_p["gex_transition"] is not None
assert result_p["gex_transition_status"] == "ok"
assert result_p["gex_transition_distance_pct"] is not None
# noisy flip: only 1 negative strike before the flip -> rejected
strikes_n = np.array([90.0, 92.0, 94.0, 96.0, 98.0, 100.0, 102.0, 104.0])
net_n = np.array([1e6, 2e6, 3e6, 2e6, -1e6, 1e6, 2e6, 3e6])
agg_n = pd.DataFrame({"net_gex": net_n}, index=strikes_n)
result_n = compute_gex_transition(agg_n, spot, persistence=3)
assert result_n["gex_transition"] is None
assert result_n["gex_transition_status"] == "no_persistent_flip"
# ---------------------------------------------------------------------------
# 4. FIX 31: spread detection finds the 520/500 and 522.5/517.5 pairs
# ---------------------------------------------------------------------------
def test_spread_detection():
"""FIX 31: detect_spread_candidates identifies the 2026-07-31 put spread
structures (520/500 and 522.5/517.5) in the SMH fixture."""
from gex.compute import detect_spread_candidates
contracts, spot, ts_str = _load_smh_fixture()
cfg = GexConfig()
snap_et = datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S").replace(
tzinfo=ZoneInfo(cfg.source_timestamp_tz)).astimezone(_ET)
df_full = filter_contracts_full(contracts, spot, cfg, snap_et)
df_band = filter_contracts_band(df_full, spot, cfg)
agg = aggregate(df_band, spot, cfg)
increment = detect_increment(agg.index.to_numpy())
result = detect_spread_candidates(df_full, spot, cfg, increment)
candidates = result["spread_candidates"]
assert len(candidates) > 0, "should find at least one spread candidate"
# the 520/500 pair must be present
pair_520_500 = [c for c in candidates
if c["strike_low"] == 500.0 and c["strike_high"] == 520.0
and c["right"] == "P" and "2026-07-31" in c["expiry"]]
assert len(pair_520_500) == 1, \
f"520/500 put pair not found. Candidates: {[(c['strike_low'], c['strike_high']) for c in candidates]}"
# the 522.5/517.5 pair must be present
pair_522_517 = [c for c in candidates
if c["strike_low"] == 517.5 and c["strike_high"] == 522.5
and c["right"] == "P" and "2026-07-31" in c["expiry"]]
assert len(pair_522_517) == 1, \
f"522.5/517.5 put pair not found. Candidates: {[(c['strike_low'], c['strike_high']) for c in candidates]}"
# flagged share > 0.20 -> sensitivity must be present
assert result["spread_flagged_share"] > 0.20, \
f"flagged share {result['spread_flagged_share']:.4f} should be > 0.20"
sens = result["sensitivity_smaller_leg_sign_flipped"]
assert sens is not None, "sensitivity_smaller_leg_sign_flipped must be present when flagged"
assert "total_net_gex" in sens
assert "hvl" in sens
assert "illustrative" in sens["note"].lower()
# ---------------------------------------------------------------------------
# 5. FIX 32: realised vol and IV cross-check
# ---------------------------------------------------------------------------
def test_realised_vol_and_iv_cross_check():
"""FIX 32: compute_realised_vol returns annualised vol from closes;
atm_iv_cross_check publishes iv_hv_ratio and vol_regime."""
from gex.compute import compute_realised_vol, atm_iv_cross_check
# synthetic closes: 21 daily prices with ~1% daily moves
np.random.seed(42)
closes = [100.0]
for _ in range(20):
closes.append(closes[-1] * (1 + np.random.normal(0, 0.01)))
rv = compute_realised_vol(closes, days=20)
assert rv is not None
assert 0.05 < rv < 0.50, f"realised vol {rv:.4f} outside plausible range"
# iv_hv_ratio with atm_iv = 0.30
cfg = GexConfig()
xc = atm_iv_cross_check(0.30, rv, cfg)
assert xc["iv_hv_ratio"] is not None
assert xc["vol_regime"] in ("IV > HV", "IV < HV")
assert xc["iv_hv_outlier"] is False # ratio ~1-2, not an outlier
# outlier case: atm_iv = 2.0 with rv = 0.15 -> ratio > 2.5
xc2 = atm_iv_cross_check(2.0, 0.15, cfg)
assert xc2["iv_hv_ratio"] > 2.5
assert xc2["iv_hv_outlier"] is True
# None inputs -> graceful
xc3 = atm_iv_cross_check(None, rv, cfg)
assert xc3["iv_hv_ratio"] is None
xc4 = atm_iv_cross_check(0.30, None, cfg)
assert xc4["iv_hv_ratio"] is None
# ===========================================================================
# NEW TESTS (v1.6.0)
# ===========================================================================
# ---------------------------------------------------------------------------
# 1. FIX 34: test_add_ticker_no_code_change
# ---------------------------------------------------------------------------
def test_add_ticker_no_code_change(tmp_path):
"""Appending a symbol to cfg.tickers must run end-to-end with no other
module modified. Uses a mocked chain (no network)."""
from gex.snapshot import _process_and_render
from gex.compute import filter_contracts_full
cfg = GexConfig(outdir=str(tmp_path), cache_dir=str(tmp_path / "cache"))
# append a new ticker to the list
cfg.tickers = ["NDX", "SPY", "SMH", "QQQ"]
assert "QQQ" in cfg.tickers
# build a minimal synthetic chain for QQQ
spot = 450.0
today = date(2026, 7, 24)
exp = date(2026, 8, 15)
chain = []
for strike in np.arange(400, 501, 5.0):
for cp in ("C", "P"):
chain.append({
"strike": float(strike), "expiry": exp, "cp": cp,
"iv": 0.25, "oi": 5000, "volume": 100,
"delta": 0.5 if cp == "C" else -0.5, "gamma": 0.02,
"vega": 0.1, "theta": -0.05, "theo": 5.0,
"bid": 4.9, "ask": 5.1,
})
df_full = filter_contracts_full(chain, spot, cfg, today)
assert not df_full.empty
# run the pipeline — must not raise
png = _process_and_render("QQQ", cfg, "pm", "test_snap", "2026-07-24 15:59:00",
spot, df_full, suffix="",
endpoint_variant="plain",
instrument_class="equity_etf")
assert png.exists()
json_path = png.with_suffix(".json")
assert json_path.exists()
with open(json_path) as f:
data = json.load(f)
assert data["symbol"] == "QQQ"
assert data["endpoint_variant"] == "plain"
assert data["instrument_class"] == "equity_etf"
assert "bands" in data
# ---------------------------------------------------------------------------
# 2. FIX 35: test_bands_scale_with_iv
# ---------------------------------------------------------------------------
def test_bands_scale_with_iv():
"""A 20% IV fixture and a 60% IV fixture at the same spot produce
profile_band differing by roughly 3x, both inside band_limits."""
from gex.compute import compute_bands
cfg = GexConfig()
bands_20 = compute_bands(0.20, cfg)
bands_60 = compute_bands(0.60, cfg)
# sigma_30d scales linearly with IV
assert bands_60["sigma_30d"] > bands_20["sigma_30d"]
ratio = bands_60["sigma_30d"] / bands_20["sigma_30d"]
assert abs(ratio - 3.0) < 0.1, f"sigma ratio {ratio:.2f} should be ~3.0"
# profile_band scales ~3x (both within clip limits)
pb_ratio = bands_60["profile_band"] / bands_20["profile_band"]
assert 2.5 < pb_ratio < 3.5, f"profile_band ratio {pb_ratio:.2f} should be ~3x"
# both within band_limits
lo, hi = cfg.band_limits["profile_band"]
assert lo <= bands_20["profile_band"] <= hi
assert lo <= bands_60["profile_band"] <= hi
# band_basis is "atm_iv" for both
assert bands_20["band_basis"] == "atm_iv"
assert bands_60["band_basis"] == "atm_iv"
# fallback case
bands_fb = compute_bands(None, cfg)
assert bands_fb["band_basis"] == "fallback"
assert bands_fb["atm_iv_used"] == cfg.atm_iv_fallback
# ---------------------------------------------------------------------------
# 3. FIX 36: test_grid_fixed_count
# ---------------------------------------------------------------------------
def test_grid_fixed_count():
"""Grid length == profile_grid_points for spot 500 and spot 25000."""
cfg = GexConfig()
n = cfg.profile_grid_points
for spot in (500.0, 25000.0):
grid = np.linspace(spot * (1 - cfg.profile_band),
spot * (1 + cfg.profile_band), n)
assert len(grid) == n, f"grid length {len(grid)} != {n} for spot={spot}"
# resolution check: spot*2*band/n (allow float tolerance)
expected_res = spot * 2 * cfg.profile_band / n
actual_res = grid[1] - grid[0]
assert abs(actual_res - expected_res) / expected_res < 0.01, \
f"resolution {actual_res:.6f} vs expected {expected_res:.6f}"
# DEX grid also fixed count
grid_dex = np.linspace(500 * (1 - cfg.profile_band_dex),
500 * (1 + cfg.profile_band_dex), n)
assert len(grid_dex) == n
# ===========================================================================
# NEW TESTS (v1.6.1 five-fix)
# ===========================================================================
# ---------------------------------------------------------------------------
# 1. FIX 43: test_oi_gates_scale_with_chain
# ---------------------------------------------------------------------------
def _bracketing_chain(spot, total_oi, base_expiry):
"""Two strikes bracketing spot on one DTE>=5 expiry, OI split so both the
expiry-level and per-contract chain-relative gates pass."""
per_strike = total_oi / 4.0 # 4 contracts (2 strikes x C/P)
rows = []
for strike in (spot - 5.0, spot + 5.0):
for cp in ("C", "P"):
rows.append({
"strike": strike, "expiry": base_expiry, "cp": cp,
"iv": 0.25, "oi": per_strike, "volume": 0,
"delta": 0.5 if cp == "C" else -0.5, "gamma": 0.04,
"vega": 0.1, "theta": -0.05, "theo": 5.0,
"bid": 4.9, "ask": 5.1, "dte": 10,
})
return pd.DataFrame(rows)
def test_oi_gates_scale_with_chain():
"""An 87k-OI chain and a 2M-OI chain BOTH resolve atm_iv; the small chain's
per-contract floor must be below 50 (proving the gates are relative, not the
old absolute 250)."""
spot = 100.0
base_expiry = date(2026, 8, 15)
small = _bracketing_chain(spot, 87_000, base_expiry)
large = _bracketing_chain(spot, 2_000_000, base_expiry)
res_small = atm_expected_move(small, spot)
res_large = atm_expected_move(large, spot)
assert res_small["atm_iv"] is not None, \
f"small chain should resolve atm_iv, got status={res_small['atm_iv_status']}"
assert res_large["atm_iv"] is not None, \
f"large chain should resolve atm_iv, got status={res_large['atm_iv_status']}"
# chain-relative per-contract floor for the small chain must be below 50
small_contract_floor = max(0.0002 * 87_000, 10.0) # = 17.4
assert small_contract_floor < 50, \
f"small-chain contract floor {small_contract_floor} should be < 50"
# ---------------------------------------------------------------------------
# 2. FIX 44: test_render_spacing
# ---------------------------------------------------------------------------
def test_render_spacing():
"""Strikes listed every 25 but populated every 100 yield render_spacing == 100
while strike_increment stays 25."""
# listed grid: every 25 from 100..500
listed = np.arange(100.0, 500.0 + 1, 25.0)
# populated (non-trivial |net_gex|) only every 100
net_gex = np.where(np.isclose(listed % 100.0, 0.0), 1000.0, 0.0)
inc = detect_increment(listed)
rs = detect_render_spacing(listed, net_gex, inc)
assert inc == 25.0, f"strike_increment should stay 25, got {inc}"
assert rs == 100.0, f"render_spacing should be 100, got {rs}"
# ---------------------------------------------------------------------------
# FIX 60 / FIX 69: test_render_bucket
# ---------------------------------------------------------------------------
def test_render_bucket():
"""FIX 69: bucket = ladder value CLOSEST to raw = window_span / 40,
then clamped to at least strike_increment.
SMH (span ~98, increment 2.5) -> raw 2.45 -> closest 2.5 -> max(2.5, 2.5) = 2.5.
NDX (span ~2310, increment 10) -> raw 57.75 -> closest 50 -> max(50, 10) = 50.
SPY (span ~16, increment 1) -> raw 0.4 -> closest 0.5 -> max(0.5, 1) = 1.0.
"""
assert render_bucket(98.0, 2.5) == 2.5 # SMH: raw 2.45 -> 2.5
assert render_bucket(2310.0, 10.0) == 50.0 # NDX: raw 57.75 -> 50
assert render_bucket(16.0, 1.0) == 1.0 # SPY: raw 0.4 -> 0.5 -> clamped to 1.0
# bucket never drops below strike_increment
assert render_bucket(5.0, 10.0) == 10.0 # raw 0.125 -> 0.5 -> clamped to 10
# bar count lands near target for the two real cases
assert 25 <= 2310.0 / render_bucket(2310.0, 10.0) <= 50
assert 30 <= 98.0 / render_bucket(98.0, 2.5) <= 50
# ---------------------------------------------------------------------------
# FIX 63 / FIX 64: reconcile_by_expiry ranks by dollar gap + unsigned rel_err
# ---------------------------------------------------------------------------
def test_reconcile_by_expiry_dollar_rank_and_unsigned():
"""FIX 63: worst expiries rank by abs dollar gap (not rel_err), each with a
gap_share of the total dollar gap. FIX 64: rel_err_unsigned is published."""
cfg = GexConfig()
spot = 100.0
# Two expiries. Expiry A: large reported GEX, recomputed matches closely
# (small rel_err but the absolute gap can still be big). Expiry B: tiny
# reported GEX with a huge rel_err but a small absolute gap. Dollar ranking
# must surface A first even though B has the worse rel_err.
rows = []
# expiry A: one call, reported gamma 0.05, OI 10000 -> large GEX
rows.append({"strike": 100.0, "expiry": date(2026, 8, 15), "cp": "C",
"iv": 0.20, "oi": 10000, "gamma": 0.05, "T": 20 / 252.0})
# expiry B: one call, reported gamma 0.05, OI 10 -> tiny GEX
rows.append({"strike": 100.0, "expiry": date(2026, 8, 22), "cp": "C",
"iv": 0.20, "oi": 10, "gamma": 0.05, "T": 25 / 252.0})
df = pd.DataFrame(rows)
out = reconcile_by_expiry(df, spot, cfg)
worst = out["reconciliation_worst_expiries"]
assert len(worst) == 2
# ranked by dollar gap descending -> the big-OI expiry A first
assert worst[0]["dollar_gap"] >= worst[1]["dollar_gap"]
assert worst[0]["expiry"] == "2026-08-15"
# gap_share sums to 1 across all entries
assert abs(sum(r["gap_share"] for r in worst) - 1.0) < 1e-9
# each entry carries rel_err AND dollar_gap
for r in worst:
assert "rel_err" in r and "dollar_gap" in r and "gap_share" in r
# FIX 64: unsigned rel_err published and non-negative
assert out["rel_err_unsigned"] is not None
assert out["rel_err_unsigned"] >= 0.0
# ---------------------------------------------------------------------------
# FIX 66: gamma_precision
# ---------------------------------------------------------------------------
def test_gamma_precision():
"""FIX 66 / FIX 70: detect Cboe's 4dp gamma publication precision.
NDX-style (gamma ~0.0001): 1 sig fig -> coarse, low_precision=True.
SMH-style (gamma ~0.0012): 2 sig figs -> adequate, low_precision=False.
SPY-style (gamma ~0.0123): 3 sig figs -> high, low_precision=False."""
from gex.compute import gamma_precision
# NDX: all strikes report exactly 0.0001 (1 sig fig at 4dp) -> coarse
df_ndx = pd.DataFrame({"gamma": [0.0001, 0.0001, 0.0001, 0.0002, 0.0001]})
sig_figs, low, label = gamma_precision(df_ndx)
assert sig_figs == 1, f"NDX gamma should be 1 sig fig, got {sig_figs}"
assert low is True, "NDX should be flagged low precision"
assert label == "coarse"
# SMH: gamma ~0.0012 (2 sig figs) -> adequate, bars stay reliable
df_smh = pd.DataFrame({"gamma": [0.0012, 0.0011, 0.0013, 0.0012]})
sig_figs2, low2, label2 = gamma_precision(df_smh)
assert sig_figs2 == 2, f"SMH gamma should be 2 sig figs, got {sig_figs2}"
assert low2 is False, "SMH (adequate) should NOT flip bars_reliable"
assert label2 == "adequate"
# SPY: gamma ~0.0123 (3 sig figs at 4dp) -> high
df_spy = pd.DataFrame({"gamma": [0.0123, 0.0118, 0.0131, 0.0120]})
sig_figs3, low3, label3 = gamma_precision(df_spy)
assert sig_figs3 >= 3, f"SPY gamma should be >= 3 sig figs, got {sig_figs3}"
assert low3 is False
assert label3 == "high"
# empty / all-zero: default to high, not low precision
df_empty = pd.DataFrame({"gamma": []})
sig_figs4, low4, label4 = gamma_precision(df_empty)
assert sig_figs4 == 4 and low4 is False and label4 == "high"
# ---------------------------------------------------------------------------
# FIX 67: compute_bands band_basis = atm_iv_single_strike
# ---------------------------------------------------------------------------
def test_compute_bands_single_strike_basis():
"""FIX 67: when atm_iv_status='single_strike_no_interpolation', band_basis
must be 'atm_iv_single_strike', not 'atm_iv'."""
from gex.compute import compute_bands
cfg = GexConfig()
# normal two-strike interpolation
bands_ok = compute_bands(0.25, cfg, atm_iv_status="ok")
assert bands_ok["band_basis"] == "atm_iv"
# single-strike (no interpolation)
bands_ss = compute_bands(0.25, cfg, atm_iv_status="single_strike_no_interpolation")
assert bands_ss["band_basis"] == "atm_iv_single_strike", \
f"expected atm_iv_single_strike, got {bands_ss['band_basis']}"
# same numeric bands (the value is the same; only the label differs)
assert abs(bands_ok["sigma_30d"] - bands_ss["sigma_30d"]) < 1e-12
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# FIX 97: the ex-front variant was removed. The old FIX 68 test
# (test_exfront_identical_keys) that guarded the exfront path's key set against
# drift from the main pipeline is RETIRED — with a single pipeline there is no
# second path to drift. The canonical-schema guarantee is now covered by
# test_canonical_schema_enumerates_index_json over every published entry.
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# FIX 78: zero-greek dropped-contract count (single pipeline, FIX 97)
# ---------------------------------------------------------------------------
def test_zero_greek_count_dropped(monkeypatch):
"""FIX 78 / FIX 97: zero_greek_contracts_dropped and
zero_greek_contracts_dropped_full_chain are published on the single
all-expirations pipeline. With the ex-front variant removed (FIX 97) the two
counts are equal — both describe the full chain. The fields remain in the
canonical schema so downstream consumers keep working."""
import matplotlib
matplotlib.use("Agg")
from gex.snapshot import _process_and_render
import gex.snapshot as snap_mod
import json, os, tempfile
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
monkeypatch.setattr(snap_mod, "_load_recent_closes",
lambda symbol, cfg: [99.0, 100.0, 101.0, 100.5, 100.2, 100.8])
cfg = GexConfig()
spot = 100.0
ET = ZoneInfo("America/New_York")
today = date(2026, 7, 24)
later = today + timedelta(days=7)
# Two expiries; 3 contracts with gamma=0 (rule 4) across the full chain.
contracts = []
for i, exp in enumerate((today, later)):
for j, k in enumerate((95.0, 100.0, 105.0)):
for cp in ("C", "P"):
zero_gamma = (i == 0 and j < 2) or (i == 1 and j == 0 and cp == "C")
contracts.append({
"strike": k, "expiry": exp, "cp": cp,
"iv": 0.25, "oi": 5000, "volume": 100,
"delta": 0.5 if cp == "C" else -0.5,
"gamma": 0.0 if zero_gamma else 0.02,
"vega": 0.1, "theta": -0.05,
"theo": 5.0, "bid": 4.9, "ask": 5.1,
})
snap_et = datetime(2026, 7, 24, 15, 35, tzinfo=ET)
df_full = filter_contracts_full(contracts, spot, cfg, snap_et)
assert not df_full.empty
zero_greek = sum(1 for c in contracts
if c["oi"] > 0 and (c["iv"] <= 0 or c["gamma"] == 0))
ts_str = "2026-07-24 19:35:29"
snap_id = "test_fix78"
with tempfile.TemporaryDirectory() as tmpdir:
cfg_tmp = GexConfig(outdir=tmpdir)
_process_and_render("TEST", cfg_tmp, "am", snap_id, ts_str, spot, df_full,
suffix="", instrument_class="equity_etf",
zero_greek_contracts=zero_greek,
zero_greek_full_chain=zero_greek)
main_json = os.path.join(tmpdir, "TEST", "2026-07-24_am.json")
with open(main_json) as f:
main = json.load(f)
# Both fields present (canonical schema)
assert "zero_greek_contracts_dropped" in main
assert "zero_greek_contracts_dropped_full_chain" in main
# Single pipeline: variant count == full-chain count
assert main["zero_greek_contracts_dropped"] == zero_greek
assert main["zero_greek_contracts_dropped_full_chain"] == zero_greek
# ---------------------------------------------------------------------------
# FIX 74: canonical schema — identical key set across instrument classes
# ---------------------------------------------------------------------------
def test_canonical_schema_across_instrument_classes():
"""FIX 74: every output file must match ONE canonical key set regardless of
instrument class. Instrument-class-specific fields (front_expiry_am_settled)
are emitted as null for non-index tickers rather than omitted, so an index run
(NDX) and an equity/ETF run (SMH/SPY) produce the exact same JSON keys."""
from gex.snapshot import _process_and_render
import json, tempfile, os
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
cfg = GexConfig()
spot = 100.0
ET = ZoneInfo("America/New_York")
today = date(2026, 7, 24)
later = today + timedelta(days=7)
contracts = []
for exp in (today, later):
for k in (95.0, 100.0, 105.0):
for cp in ("C", "P"):
contracts.append({
"strike": k, "expiry": exp, "cp": cp,
"iv": 0.25, "oi": 8000 if cp == "P" else 3000, "volume": 100,
"delta": 0.5 if cp == "C" else -0.5,
"gamma": 0.02, "vega": 0.1, "theta": -0.05,
"theo": 5.0, "bid": 4.9, "ask": 5.1,
})
snap_et = datetime(2026, 7, 24, 15, 35, tzinfo=ET)
df_full = filter_contracts_full(contracts, spot, cfg, snap_et)
ts_str = "2026-07-24 19:35:29"
with tempfile.TemporaryDirectory() as tmpdir:
cfg_tmp = GexConfig(outdir=tmpdir)
# index instrument (NDX-style)
_process_and_render("IDX", cfg_tmp, "am", "test_fix74", ts_str, spot,
df_full, suffix="", instrument_class="index")
# equity/ETF instrument (SMH/SPY-style)
_process_and_render("ETF", cfg_tmp, "am", "test_fix74", ts_str, spot,
df_full, suffix="", instrument_class="equity_etf")
with open(os.path.join(tmpdir, "IDX", "2026-07-24_am.json")) as f:
idx = json.load(f)
with open(os.path.join(tmpdir, "ETF", "2026-07-24_am.json")) as f:
etf = json.load(f)
idx_keys = set(idx.keys())
etf_keys = set(etf.keys())
missing = idx_keys - etf_keys
extra = etf_keys - idx_keys
assert not missing, f"ETF MISSING keys vs index: {sorted(missing)}"
assert not extra, f"ETF has EXTRA keys vs index: {sorted(extra)}"
# the instrument-class field is present in BOTH, null for the ETF
assert "front_expiry_am_settled" in idx
assert "front_expiry_am_settled" in etf
assert etf["front_expiry_am_settled"] is None
assert isinstance(idx["front_expiry_am_settled"], bool)
# atm_iv_status is present in both (null unless an outlier)
assert "atm_iv_status" in idx and "atm_iv_status" in etf
# ---------------------------------------------------------------------------
# FIX 86: canonical schema over EVERY entry enumerated from index.json
# ---------------------------------------------------------------------------
def test_canonical_schema_enumerates_index_json():
"""FIX 86: the canonical-schema guarantee must hold over EVERY snapshot that
index.json advertises, not just a fixed six-file sample. index.json lists all
published snapshots (every ticker/date/slot); the test enumerates each entry,
loads its JSON, and asserts they all share ONE identical key set. Before FIX 86
the mislabeled 2026-07-27 AM files (v1.7.5, no reconciliation_scope /
bands.atm_iv_source_expiry) were listed in index.json but not covered by the
six-file sample, so the drift went undetected.
Runs against the live public gex_out (GEX_OUT env var, defaulting to the VPS
public path). Skips when index.json is absent (e.g. a fresh checkout)."""
import json, os
base = os.environ.get("GEX_OUT",
"/home/allofthesewords/public_html/gex_out")
idx_path = os.path.join(base, "index.json")
if not os.path.exists(idx_path):
import pytest
pytest.skip("no index.json at %s (set GEX_OUT to the public gex_out)" % base)
with open(idx_path) as f:
idx = json.load(f)
# enumerate every (ticker, date, slot) entry
entries = []
for ticker, dates in idx.get("snapshots", {}).items():
for date_str, slots in dates.items():
for slot in slots:
entries.append((ticker, date_str, slot))
assert entries, "index.json lists no snapshots"
keysets = {}
for ticker, date_str, slot in entries:
p = os.path.join(base, ticker, "%s_%s.json" % (date_str, slot))
assert os.path.exists(p), "index.json lists %s but file is missing: %s" % (
"%s/%s_%s" % (ticker, date_str, slot), p)
with open(p) as f:
d = json.load(f)
keysets["%s/%s_%s" % (ticker, date_str, slot)] = set(d.keys())
ref_name, ref = next(iter(keysets.items()))
for name, ks in keysets.items():
missing = ref - ks
extra = ks - ref
assert not missing and not extra, (
"canonical schema drift: %s vs %s — missing %s, extra %s" % (
name, ref_name, sorted(missing), sorted(extra)))
# ---------------------------------------------------------------------------
# FIX 75: re-rendering a snapshot is byte-identical apart from render_lag_min
# ---------------------------------------------------------------------------
def test_rerender_byte_identical_except_render_lag(monkeypatch):
"""FIX 75: source_timestamp_age_min is frozen at capture, so re-rendering the
SAME snapshot (same captured_at_utc) produces a byte-identical JSON apart from
the explicitly whitelisted render_lag_min (the wall-clock delta since capture,
which legitimately advances between renders). Before FIX 75 the age was computed
at render time, so two renders of identical data reported different freshness."""
from gex.snapshot import _process_and_render
import gex.snapshot as snap_mod
import json, tempfile, os
from datetime import date, datetime, timedelta, timezone
from zoneinfo import ZoneInfo
# determinism: pin realised-vol input so neither the Yahoo network call nor the
# outdir feedback loop (which reads prior JSON) can vary between the two renders.
monkeypatch.setattr(snap_mod, "_load_recent_closes",
lambda symbol, cfg: [99.0, 100.0, 101.0, 100.5, 100.2, 100.8])
cfg = GexConfig()
spot = 100.0
ET = ZoneInfo("America/New_York")
today = date(2026, 7, 24)
later = today + timedelta(days=7)
contracts = []
for exp in (today, later):
for k in (95.0, 100.0, 105.0):
for cp in ("C", "P"):
contracts.append({
"strike": k, "expiry": exp, "cp": cp,
"iv": 0.25, "oi": 8000 if cp == "P" else 3000, "volume": 100,
"delta": 0.5 if cp == "C" else -0.5,
"gamma": 0.02, "vega": 0.1, "theta": -0.05,
"theo": 5.0, "bid": 4.9, "ask": 5.1,
})
snap_et = datetime(2026, 7, 24, 15, 35, tzinfo=ET)
df_full = filter_contracts_full(contracts, spot, cfg, snap_et)
ts_str = "2026-07-24 19:35:29"
# a frozen capture time, as persisted by fetch at capture
captured = "2026-07-24T19:36:00+00:00"
WHITELIST = {"render_lag_min"} # the ONLY field allowed to differ between renders
with tempfile.TemporaryDirectory() as tmpdir:
cfg_tmp = GexConfig(outdir=tmpdir)
_process_and_render("TEST", cfg_tmp, "am", "test_fix75", ts_str, spot,
df_full, suffix="", instrument_class="equity_etf",
captured_at_utc=captured)
path = os.path.join(tmpdir, "TEST", "2026-07-24_am.json")
with open(path) as f:
first = f.read()
first_obj = json.loads(first)
# re-render the identical snapshot (same capture time) into the same file
_process_and_render("TEST", cfg_tmp, "am", "test_fix75", ts_str, spot,
df_full, suffix="", instrument_class="equity_etf",
captured_at_utc=captured)
with open(path) as f:
second = f.read()
second_obj = json.loads(second)
# the frozen age must be identical across renders
assert first_obj["source_timestamp_age_min"] == second_obj["source_timestamp_age_min"]
# render_lag_min is published and is a number (capture time is in the past)
assert isinstance(first_obj["render_lag_min"], (int, float))
# every field except the whitelist must match exactly
all_keys = set(first_obj) | set(second_obj)
for k in all_keys - WHITELIST:
assert first_obj.get(k) == second_obj.get(k), (
f"field {k!r} differs between renders: "
f"{first_obj.get(k)!r} != {second_obj.get(k)!r}")
# ---------------------------------------------------------------------------
# FIX 71: render_spacing must equal render_bucket (single source of truth)
# ---------------------------------------------------------------------------
def test_render_spacing_equals_render_bucket():
"""FIX 71 / FIX 97: render_spacing is an ALIAS of render_bucket — the bucket
is computed once in snapshot.py from the plot-band window and drives both the
published fields and the renderer's bar aggregation. They must never disagree.
(FIX 97 removed the ex-front variant; there is now a single pipeline.)"""
from gex.snapshot import _process_and_render
import json, tempfile, os
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
cfg = GexConfig()
spot = 100.0
ET = ZoneInfo("America/New_York")
today = date(2026, 7, 24)
later = today + timedelta(days=7)
contracts = []
for exp in (today, later):
for k in (95.0, 100.0, 105.0):
for cp in ("C", "P"):
contracts.append({
"strike": k, "expiry": exp, "cp": cp,
"iv": 0.25, "oi": 5000, "volume": 100,
"delta": 0.5 if cp == "C" else -0.5,
"gamma": 0.02, "vega": 0.1, "theta": -0.05,
"theo": 5.0, "bid": 4.9, "ask": 5.1,
})
snap_et = datetime(2026, 7, 24, 15, 35, tzinfo=ET)
df_full = filter_contracts_full(contracts, spot, cfg, snap_et)
ts_str = "2026-07-24 19:35:29"
snap_id = "test_fix71"
with tempfile.TemporaryDirectory() as tmpdir:
cfg_tmp = GexConfig(outdir=tmpdir)
_process_and_render("TEST", cfg_tmp, "am", snap_id, ts_str, spot, df_full,
suffix="", instrument_class="equity_etf")
path = os.path.join(tmpdir, "TEST", "2026-07-24_am.json")
with open(path) as f:
d = json.load(f)
assert "render_bucket" in d, "render_bucket missing"
assert "render_spacing" in d, "render_spacing missing"
assert d["render_spacing"] == d["render_bucket"], (
f"render_spacing ({d['render_spacing']}) != "
f"render_bucket ({d['render_bucket']})")
# bucket must be at least the strike increment
assert d["render_bucket"] >= d["strike_increment"]
# ---------------------------------------------------------------------------
# FIX 73: dual-threshold pass (signed < 0.05 AND unsigned < 0.10) + denominators
# ---------------------------------------------------------------------------
def test_reconciliation_dual_gate_and_denominators():
"""FIX 73: profile_reliable requires BOTH rel_err < 0.05 AND rel_err_unsigned
< 0.10. reconciliation_pass_basis names the binding metric; rel_err_denominator
and rel_err_unsigned_denominator are published so a variant scored against a
smaller book is visible."""
from gex.snapshot import _process_and_render
import json, tempfile, os
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
cfg = GexConfig()
spot = 100.0
ET = ZoneInfo("America/New_York")
today = date(2026, 7, 24)
later = today + timedelta(days=7)
contracts = []
for exp in (today, later):
for k in (95.0, 100.0, 105.0):
for cp in ("C", "P"):
# FIX 73 test: asymmetric OI (puts heavier) so per-expiry reported
# GEX does NOT cancel to zero — a symmetric chain would make
# Σ|reported| ≈ 0 and rel_err_unsigned_denominator = 0.
contracts.append({
"strike": k, "expiry": exp, "cp": cp,
"iv": 0.25, "oi": 8000 if cp == "P" else 3000, "volume": 100,
"delta": 0.5 if cp == "C" else -0.5,
"gamma": 0.02, "vega": 0.1, "theta": -0.05,
"theo": 5.0, "bid": 4.9, "ask": 5.1,
})
snap_et = datetime(2026, 7, 24, 15, 35, tzinfo=ET)
df_full = filter_contracts_full(contracts, spot, cfg, snap_et)
ts_str = "2026-07-24 19:35:29"
with tempfile.TemporaryDirectory() as tmpdir:
cfg_tmp = GexConfig(outdir=tmpdir)
_process_and_render("TEST", cfg_tmp, "am", "test_fix73", ts_str, spot,
df_full, suffix="", instrument_class="equity_etf")
path = os.path.join(tmpdir, "TEST", "2026-07-24_am.json")
with open(path) as f:
d = json.load(f)
# new fields present
assert "reconciliation_pass_basis" in d
assert "rel_err_denominator" in d
assert "rel_err_unsigned_denominator" in d
assert d["rel_err_denominator"] > 0
assert d["rel_err_unsigned_denominator"] > 0
# the gate logic must be self-consistent with the published errors
signed = d["reconciliation"]["rel_err"]
unsigned = d["rel_err_unsigned"]
expected_ok = (signed < 0.05) and (unsigned is None or unsigned < 0.10)
assert d["profile_reliable"] == expected_ok, (
f"profile_reliable={d['profile_reliable']} but signed={signed} "
f"unsigned={unsigned} -> expected {expected_ok}")
# pass_basis is one of the documented values
assert d["reconciliation_pass_basis"] in (
"signed", "unsigned", "signed+unsigned")
# FIX 76: denominator convention is published and is "variant"
assert d["reconciliation_denominator_basis"] == "variant"
# ---------------------------------------------------------------------------
# FIX 77: exercise the UNSIGNED branch of the FIX 73 gate
# ---------------------------------------------------------------------------
def test_unsigned_branch_binds_gate(monkeypatch, tmp_path):
"""FIX 77: the dual gate's unsigned condition must actually bind on real-shaped
data, not only in the abstract. Engineered fixture: two multi-day expiries whose
reported gammas deviate from Black-Scholes in OPPOSITE directions but with equal
OI weight, so the per-expiry gaps CANCEL in the signed sum (signed ~0.003 < 0.05)
yet ADD in the unsigned sum (unsigned ~0.63 >= 0.10). The gate must therefore
fail on the unsigned branch: reconciliation_pass_basis == "unsigned",
profile_reliable == False, and the reconciliation-failure banner renders. This
confirms the basis string is genuinely derived, not defaulting to "signed"."""
import matplotlib
matplotlib.use("Agg")
from matplotlib.figure import Figure
from gex.snapshot import _process_and_render
import gex.snapshot as snap_mod
import json, os
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
# determinism: pin realised-vol input (no network, no outdir feedback)
monkeypatch.setattr(snap_mod, "_load_recent_closes",
lambda symbol, cfg: [99.0, 100.0, 101.0, 100.5, 100.2, 100.8])
# FIX 85 isolation: this test verifies the dual-gate MECHANICS under a FIXED
# 0.10 gate. Mock the Monte Carlo floor to a non-bracketing result so the
# adaptive per-symbol threshold does not absorb the engineered unsigned error.
monkeypatch.setattr(snap_mod, "reconciliation_floor_unsigned",
lambda df, spot, cfg, **kw: {
"method": "grid_rounding", "rounding_interval": 0.5e-4,
"floor": 0.002, "brackets_gate": False})
cfg = GexConfig(outdir=str(tmp_path), cache_dir=str(tmp_path / "cache"))
spot = 100.0
ET = ZoneInfo("America/New_York")
today = date(2026, 7, 24)
# rep0 + rep1 = 0.106 keeps the signed sum near zero (matches the BS profile
# total at spot); the split (0.03 vs 0.076) puts the two per-expiry gaps on
# opposite sides so they cancel signed but add unsigned.
contracts = []
for dte, rep_gamma in ((10, 0.03), (40, 0.076)):
exp = today + timedelta(days=dte)
for k in (95.0, 100.0, 105.0):
for cp in ("C", "P"):
contracts.append({
"strike": k, "expiry": exp, "cp": cp,
"iv": 0.25, "oi": 9000 if cp == "P" else 1000, "volume": 100,
"delta": 0.5 if cp == "C" else -0.5,
"gamma": rep_gamma, "vega": 0.1, "theta": -0.05,
"theo": 5.0, "bid": 4.9, "ask": 5.1,
})
snap_et = datetime(2026, 7, 24, 15, 35, tzinfo=ET)
df_full = filter_contracts_full(contracts, spot, cfg, snap_et)
ts_str = "2026-07-24 19:35:29"
# spy on fig.text to confirm the reconciliation-failure banner renders
texts = []
orig_text = Figure.text
def spy_text(self, x, y, s, *a, **k):
texts.append(str(s))
return orig_text(self, x, y, s, *a, **k)
monkeypatch.setattr(Figure, "text", spy_text)
_process_and_render("TEST", cfg, "am", "test_fix77", ts_str, spot, df_full,
suffix="", instrument_class="equity_etf",
captured_at_utc="2026-07-24T19:36:00+00:00")
path = os.path.join(str(tmp_path), "TEST", "2026-07-24_am.json")
with open(path) as f:
d = json.load(f)
signed = d["reconciliation"]["rel_err"]
unsigned = d["rel_err_unsigned"]
# the engineered window: signed passes, unsigned fails
assert signed < 0.05, f"fixture broken: signed={signed:.4f} should be < 0.05"
assert unsigned >= 0.10, f"fixture broken: unsigned={unsigned:.4f} should be >= 0.10"
# the gate binds on the UNSIGNED branch
assert d["reconciliation_pass_basis"] == "unsigned", (
f"expected basis 'unsigned', got {d['reconciliation_pass_basis']!r} "
f"(signed={signed:.4f}, unsigned={unsigned:.4f})")
assert d["profile_reliable"] is False
# FIX 83: the nested reconciliation.pass must agree with profile_reliable.
# Before FIX 83 it was signed-only (True here, since signed < 0.05), contradicting
# profile_reliable == False. One authoritative pass flag.
assert d["reconciliation"]["pass"] is d["profile_reliable"], (
"reconciliation.pass must equal profile_reliable (FIX 83)")
assert d["reconciliation"]["pass"] is False
# FIX 95: the reconciliation banner NO LONGER renders on the chart image. The
# PNG is caveat-free; the failure status lives in the page's collapsible
# <details> section (driven by the JSON fields asserted above). Confirm the
# image carries no reconciliation commentary.
banners = [t for t in texts if "RECONCILIATION" in t.upper()]
assert not banners, (
"FIX 95: reconciliation status must NOT render on the PNG; "
f"found fig.text: {banners}")
# ---------------------------------------------------------------------------
# FIX 48: test_expired_contracts_dropped
# ---------------------------------------------------------------------------
def test_expired_contracts_dropped():
"""A chain containing an expiry one day BEFORE the snapshot date must be
excluded; expired_contracts_dropped == 1; and the profile maximum must stay
within 3x of total_net_gex (i.e. no expired-contract gamma explosion)."""
cfg = GexConfig()
spot = 100.0
snap_et = datetime(2026, 7, 24, 15, 35, tzinfo=_ET)
today = snap_et.date()
# one EXPIRED contract (expiry yesterday) with huge OI right at the money —
# pre-FIX-48 this would survive (DTE floored to 0) and explode the profile.
expired = {"strike": 100.0, "expiry": today - timedelta(days=1), "cp": "C",
"iv": 0.30, "oi": 500000, "volume": 0, "delta": 0.5, "gamma": 0.10,
"vega": 0.1, "theta": -0.05, "theo": 5.0, "bid": 4.9, "ask": 5.1}
# valid contracts on a live expiry bracketing spot
live = []
for strike in (95.0, 105.0):
for cp in ("C", "P"):
live.append({"strike": strike, "expiry": date(2026, 8, 15), "cp": cp,
"iv": 0.25, "oi": 5000, "volume": 0,
"delta": 0.5 if cp == "C" else -0.5, "gamma": 0.04,
"vega": 0.1, "theta": -0.05, "theo": 5.0,
"bid": 4.9, "ask": 5.1})
df_full = filter_contracts_full([expired] + live, spot, cfg, snap_et)
# the expired contract must be gone
assert df_full.attrs.get("expired_contracts_dropped") == 1, \
f"expected 1 expired dropped, got {df_full.attrs.get('expired_contracts_dropped')}"
assert (df_full["expiry"] >= today).all(), "no expired contract should survive the filter"
# profile maximum must stay within 3x of total_net_gex (no explosion)
grid = np.linspace(spot * 0.8, spot * 1.2, 400)
gp, _ = gex_profile(df_full, spot, cfg, grid)
total = total_net_gex_from_contracts(df_full, spot, cfg)
assert np.isfinite(gp).all(), "profile must be finite"
gp_max = float(np.max(np.abs(gp)))
assert gp_max <= 3.0 * abs(total), \
f"profile max {gp_max:.3g} exceeds 3x total_net_gex {abs(total):.3g} — gamma explosion"
# ---------------------------------------------------------------------------
# v1.6.2 item 1: --from-cache must not be blocked by the age_min refusal,
# while the NYSE-trading-day and 09:30-16:15 ET window checks stay active.
# ---------------------------------------------------------------------------
def test_from_cache_skips_age_refusal_only(tmp_path, monkeypatch):
"""Three cases, wall-clock pinned to a Saturday so the age check is live:
(a) cached Friday-15:35-ET snapshot replayed with from_cache=True renders
(age refusal skipped); the SAME snapshot with from_cache=False refuses
(age > 240 min).
(b) cached Saturday-stamped snapshot still refuses (not a trading day) even
with from_cache=True.
(c) cached 23:44-ET snapshot still refuses (outside window) even with
from_cache=True.
"""
import gex.snapshot as snap_mod
spot = 100.0
friday = date(2026, 7, 24) # an NYSE trading day
saturday = date(2026, 7, 25) # not a trading day
def make_chain(exp):
chain = []
for strike in (95.0, 100.0, 105.0):
for cp in ("C", "P"):
chain.append({"strike": strike, "expiry": exp, "cp": cp,
"iv": 0.25, "oi": 5000, "volume": 0,
"delta": 0.5 if cp == "C" else -0.5, "gamma": 0.04,
"vega": 0.1, "theta": -0.05, "theo": 5.0,
"bid": 4.9, "ask": 5.1})
return chain
# pin wall-clock to a fixed Saturday 16:00 ET so the age check is exercised
fake_now = datetime(2026, 7, 25, 16, 0, 0, tzinfo=_ET)
class FakeDatetime(datetime):
@classmethod
def now(cls, tz=None):
return fake_now if tz is None else fake_now.astimezone(tz)
monkeypatch.setattr(snap_mod, "datetime", FakeDatetime)
rendered = []
monkeypatch.setattr(snap_mod, "_process_and_render",
lambda *a, **k: rendered.append(a[0] if a else k.get("symbol")) or (a[0] if a else "ok"))
cfg = GexConfig(outdir=str(tmp_path), cache_dir=str(tmp_path / "cache"))
def run(ts_str, from_cache):
chain = make_chain(friday)
monkeypatch.setattr(snap_mod, "fetch_chain",
lambda symbol, cfg, from_cache=False: ({"data": {}}, "snap_test", "plain"))
monkeypatch.setattr(snap_mod, "parse_chain",
lambda data, symbol: (list(chain), spot, ts_str))
return snap_mod.run_ticker("SMH", cfg, "pm", from_cache=from_cache)
# (a) Friday 15:50 ET (19:50 UTC) — valid trading day + pm window, but age > 240 min
assert run("2026-07-24 19:50:00", from_cache=True) is True, \
"cached Friday-15:50 snapshot must render under --from-cache (age refusal skipped)"
assert len(rendered) == 1, "exactly one render expected for the cached Friday replay"
assert run("2026-07-24 19:50:00", from_cache=False) is False, \
"the same stale snapshot must refuse WITHOUT --from-cache (age > 240 min)"
assert len(rendered) == 1, "no additional render for the non-cache stale refusal"
# (b) Saturday 10:05 ET (14:05 UTC) — not a trading day; refuses even from cache
assert run("2026-07-25 14:05:00", from_cache=True) is False, \
"Saturday-stamped snapshot must refuse even under --from-cache"
# (c) Friday 23:44 ET (Sat 03:44 UTC) — outside 09:30-16:15; refuses even from cache
assert run("2026-07-25 03:44:00", from_cache=True) is False, \
"23:44-ET snapshot must refuse even under --from-cache"
# ---------------------------------------------------------------------------
# v1.6.4: a firing profile outlier guard MUST surface the red FAULT footnote
# ---------------------------------------------------------------------------
def test_outlier_guard_fault_footnote_rendered(tmp_path, monkeypatch):
"""The outlier guard is kept as drop-and-alert (v1.6.4): its loudness is
load-bearing, so if it fires (drops a contract) the chart MUST carry the red
'FAULT' footnote. This test fails if a firing guard does not produce it.
"""
import matplotlib
matplotlib.use("Agg")
from matplotlib.figure import Figure
from gex.plot import render_chart
cfg = GexConfig(outdir=str(tmp_path), cache_dir=str(tmp_path / "cache"))
spot = 105.0
strikes = np.arange(95, 116, 5.0)
agg = pd.DataFrame({
"gex_call": np.random.uniform(1e6, 5e6, len(strikes)),
"gex_put": -np.random.uniform(1e6, 5e6, len(strikes)),
"dex": np.random.uniform(-2e7, 2e7, len(strikes)),
"oi_call": np.random.randint(100, 5000, len(strikes)),
"oi_put": np.random.randint(100, 5000, len(strikes)),
}, index=strikes)
agg["net_gex"] = agg["gex_call"] + agg["gex_put"]
grid = np.arange(90.0, 121.0, 1.0)
gp = np.linspace(-3e6, 3e6, len(grid))
dp = np.linspace(-1e9, 1e9, len(grid))
increment = 5.0
levels = compute_levels(agg, grid, gp, spot, increment)
# simulate a guard firing: one contract dropped
levels["profile_outliers_dropped"] = [
{"strike": 100.0, "expiry": "2026-07-24", "value": 1.2e9, "price_width_pct": 1.1}
]
# capture every fig.text() string so we can assert the footnote was emitted
texts = []
orig_text = Figure.text
def spy_text(self, x, y, s, *a, **k):
texts.append(str(s))
return orig_text(self, x, y, s, *a, **k)
monkeypatch.setattr(Figure, "text", spy_text)
png = render_chart("TEST", agg, grid, gp, grid, dp, levels,
"2026-07-24 15:44:00", cfg, str(tmp_path), "pm", increment)
assert png.exists(), f"PNG not found: {png}"
fault_notes = [t for t in texts if "FAULT" in t and "profile outlier guard" in t]
assert fault_notes, (
"a firing outlier guard MUST render the red FAULT footnote; "
f"fig.text calls were: {texts}"
)
assert "100" in fault_notes[0], "footnote must name the dropped strike"
# ---------------------------------------------------------------------------
# v1.6.5 FIX 52: --replay-unsafe bypasses ONLY the session check, forces a
# scratch outdir, and stamps replay_unsafe into the levels JSON.
# ---------------------------------------------------------------------------
def test_replay_unsafe_forces_scratch_outdir_and_stamp(tmp_path, monkeypatch):
"""Two parts:
(a) replay_unsafe=True on an out-of-session (Saturday) chain renders, passes
replay_unsafe=True down to _process_and_render, and main() forces the
outdir to the scratch path out_replay/.
(b) the SAME out-of-session chain with replay_unsafe=False refuses (returns
False) — the session guard stays active without the flag.
"""
import gex.snapshot as snap_mod
spot = 100.0
saturday = date(2026, 7, 25) # not an NYSE trading day
def make_chain(exp):
chain = []
for strike in (95.0, 100.0, 105.0):
for cp in ("C", "P"):
chain.append({"strike": strike, "expiry": exp, "cp": cp,
"iv": 0.25, "oi": 5000, "volume": 0,
"delta": 0.5 if cp == "C" else -0.5, "gamma": 0.04,
"vega": 0.1, "theta": -0.05, "theo": 5.0,
"bid": 4.9, "ask": 5.1})
return chain
# pin wall-clock to a fixed Saturday so the age check is not the deciding factor
fake_now = datetime(2026, 7, 25, 16, 0, 0, tzinfo=_ET)
class FakeDatetime(datetime):
@classmethod
def now(cls, tz=None):
return fake_now if tz is None else fake_now.astimezone(tz)
monkeypatch.setattr(snap_mod, "datetime", FakeDatetime)
captured = []
monkeypatch.setattr(
snap_mod, "_process_and_render",
lambda *a, **k: captured.append(k.get("replay_unsafe")) or "ok")
chain = make_chain(saturday)
monkeypatch.setattr(snap_mod, "fetch_chain",
lambda symbol, cfg, from_cache=False: ({"data": {}}, "snap_test", "plain"))
# Saturday 10:05 ET (14:05 UTC) — out of session (not a trading day)
monkeypatch.setattr(snap_mod, "parse_chain",
lambda data, symbol: (list(chain), spot, "2026-07-25 14:05:00"))
cfg = GexConfig(outdir=str(tmp_path), cache_dir=str(tmp_path / "cache"))
# (a) replay_unsafe=True renders and stamps replay_unsafe=True downstream
assert snap_mod.run_ticker("NDX", cfg, "pm", from_cache=True,
replay_unsafe=True) is True, \
"out-of-session chain must render under --replay-unsafe"
assert captured == [True], \
f"replay_unsafe=True must reach _process_and_render; got {captured}"
# (b) replay_unsafe=False refuses the same out-of-session chain
captured.clear()
assert snap_mod.run_ticker("NDX", cfg, "pm", from_cache=True,
replay_unsafe=False) is False, \
"out-of-session chain must REFUSE without --replay-unsafe"
assert captured == [], "no render expected for the refused run"
# (c) main() forces the outdir to the scratch path under --replay-unsafe
monkeypatch.setattr("sys.argv",
["snapshot", "--tickers", "NDX", "--slot", "pm",
"--from-cache", "--replay-unsafe"])
seen_cfg = {}
monkeypatch.setattr(snap_mod, "run_ticker",
lambda symbol, cfg, slot, **k: seen_cfg.update(outdir=cfg.outdir) or True)
monkeypatch.setattr(snap_mod, "build_index_json", lambda cfg: None)
snap_mod.main()
assert seen_cfg["outdir"] == "out_replay", \
f"--replay-unsafe must force outdir to out_replay/, got {seen_cfg['outdir']!r}"
# ---------------------------------------------------------------------------
# FIX 79a: slot source-window check at write time
# ---------------------------------------------------------------------------
def test_slot_window_rejects_out_of_window_capture(tmp_path, monkeypatch):
"""FIX 79a: a capture whose ET time falls outside the requested slot's window
must be refused at write time, not just at fetch. A 15:55-ET capture with
slot='am' (window 09:48-10:12) must return False; a 10:00-ET capture with
slot='pm' (window 15:47-16:11) must also return False."""
import gex.snapshot as snap_mod
from datetime import date, datetime
from zoneinfo import ZoneInfo
spot = 100.0
friday = date(2026, 7, 24)
ET = ZoneInfo("America/New_York")
def make_chain():
chain = []
for strike in (95.0, 100.0, 105.0):
for cp in ("C", "P"):
chain.append({"strike": strike, "expiry": friday, "cp": cp,
"iv": 0.25, "oi": 5000, "volume": 0,
"delta": 0.5 if cp == "C" else -0.5, "gamma": 0.04,
"vega": 0.1, "theta": -0.05, "theo": 5.0,
"bid": 4.9, "ask": 5.1})
return chain
# Pin wall-clock to Friday 16:00 ET so the age check passes for in-session captures
fake_now = datetime(2026, 7, 24, 16, 0, 0, tzinfo=ET)
class FakeDatetime(datetime):
@classmethod
def now(cls, tz=None):
return fake_now if tz is None else fake_now.astimezone(tz)
monkeypatch.setattr(snap_mod, "datetime", FakeDatetime)
rendered = []
monkeypatch.setattr(snap_mod, "_process_and_render",
lambda *a, **k: rendered.append(a[0] if a else k.get("symbol")) or (a[0] if a else "ok"))
cfg = GexConfig(outdir=str(tmp_path), cache_dir=str(tmp_path / "cache"))
def run(ts_str, slot):
chain = make_chain()
monkeypatch.setattr(snap_mod, "fetch_chain",
lambda symbol, cfg, from_cache=False: ({"data": {}}, "snap_test", "plain"))
monkeypatch.setattr(snap_mod, "parse_chain",
lambda data, symbol: (list(chain), spot, ts_str))
return snap_mod.run_ticker("SMH", cfg, slot, from_cache=True)
# 15:55 ET (19:55 UTC) with slot="am" — outside am window (09:48-10:12)
assert run("2026-07-24 19:55:00", "am") is False, \
"15:55-ET capture must be refused for slot='am'"
assert len(rendered) == 0, "no render expected for out-of-window capture"
# 10:00 ET (14:00 UTC) with slot="pm" — outside pm window (15:47-16:11)
assert run("2026-07-24 14:00:00", "pm") is False, \
"10:00-ET capture must be refused for slot='pm'"
assert len(rendered) == 0, "no render expected for out-of-window capture"
# 10:00 ET with slot="am" — inside am window, should succeed
assert run("2026-07-24 14:00:00", "am") is True, \
"10:00-ET capture must be accepted for slot='am'"
assert len(rendered) == 1, "exactly one render expected for in-window capture"
# 15:55 ET with slot="pm" — inside pm window, should succeed
rendered.clear()
assert run("2026-07-24 19:55:00", "pm") is True, \
"15:55-ET capture must be accepted for slot='pm'"
assert len(rendered) == 1, "exactly one render expected for in-window capture"
def test_slot_window_uses_capture_time_not_source_ts(tmp_path, monkeypatch):
"""FIX 79a (capture-time path): the slot window must be checked against the
CAPTURE time (when the fetch happened), NOT the Cboe source timestamp. The
source feed is delayed ~15 min, so a fetch at 10:00 ET carries data timestamped
~10:15 ET. Checking the source timestamp would reject every legitimate AM
capture. This test pins the production scenario: capture at 10:00 ET (in the
AM window 09:30–12:00, FIX 86b) with source ts 10:15 ET must be ACCEPTED for
slot='am', because the capture time governs."""
import gex.snapshot as snap_mod
from datetime import date, datetime
from zoneinfo import ZoneInfo
spot = 100.0
friday = date(2026, 7, 24)
ET = ZoneInfo("America/New_York")
def make_chain():
chain = []
for strike in (95.0, 100.0, 105.0):
for cp in ("C", "P"):
chain.append({"strike": strike, "expiry": friday, "cp": cp,
"iv": 0.25, "oi": 5000, "volume": 0,
"delta": 0.5 if cp == "C" else -0.5, "gamma": 0.04,
"vega": 0.1, "theta": -0.05, "theo": 5.0,
"bid": 4.9, "ask": 5.1})
return chain
# Pin wall-clock so the >240-min age refusal does not interfere.
fake_now = datetime(2026, 7, 24, 16, 0, 0, tzinfo=ET)
class FakeDatetime(datetime):
@classmethod
def now(cls, tz=None):
return fake_now if tz is None else fake_now.astimezone(tz)
monkeypatch.setattr(snap_mod, "datetime", FakeDatetime)
rendered = []
monkeypatch.setattr(snap_mod, "_process_and_render",
lambda *a, **k: rendered.append(a[0] if a else k.get("symbol")) or (a[0] if a else "ok"))
cfg = GexConfig(outdir=str(tmp_path), cache_dir=str(tmp_path / "cache"))
def run(source_ts_str, captured_at_utc, slot):
chain = make_chain()
# fetch_chain returns _captured_at_utc so run_ticker uses the capture-time path
monkeypatch.setattr(
snap_mod, "fetch_chain",
lambda symbol, cfg, from_cache=False: (
{"data": {}, "_captured_at_utc": captured_at_utc}, "snap_test", "plain"))
monkeypatch.setattr(snap_mod, "parse_chain",
lambda data, symbol: (list(chain), spot, source_ts_str))
return snap_mod.run_ticker("SMH", cfg, slot, from_cache=True)
# Capture at 10:00 ET (14:00 UTC, in am window) but Cboe source ts 10:15 ET
# (14:15 UTC, OUTSIDE am window). Must be ACCEPTED — capture time governs.
assert run("2026-07-24 14:15:00", "2026-07-24T14:00:00+00:00", "am") is True, \
"10:00-ET capture with delayed 10:15-ET source ts must be accepted for slot='am'"
assert len(rendered) == 1, "exactly one render expected"
# Capture at 15:55 ET (19:55 UTC, in pm window) but source ts 10:00 ET
# (14:00 UTC, an am time). Must be REFUSED for slot='am' — the capture happened
# in the pm window, so it must not be written as an am file.
rendered.clear()
assert run("2026-07-24 14:00:00", "2026-07-24T19:55:00+00:00", "am") is False, \
"15:55-ET capture must be refused for slot='am' even if source ts is an am time"
assert len(rendered) == 0, "no render expected for out-of-window capture"
# ---------------------------------------------------------------------------
# FIX 79b: non-destructive writes
# ---------------------------------------------------------------------------
def test_non_destructive_write_refuses_different_snapshot_id(monkeypatch, tmp_path):
"""FIX 79b: if an output JSON already exists with a DIFFERENT snapshot_id,
the write must be refused (return None) and the existing file must NOT be
modified. A re-render of the SAME snapshot_id must always succeed."""
import matplotlib
matplotlib.use("Agg")
from gex.snapshot import _process_and_render
import gex.snapshot as snap_mod
import json, os
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
monkeypatch.setattr(snap_mod, "_load_recent_closes",
lambda symbol, cfg: [99.0, 100.0, 101.0, 100.5, 100.2, 100.8])
cfg = GexConfig(outdir=str(tmp_path), cache_dir=str(tmp_path / "cache"))
spot = 100.0
ET = ZoneInfo("America/New_York")
today = date(2026, 7, 24)
contracts = []
for k in (95.0, 100.0, 105.0):
for cp in ("C", "P"):
contracts.append({
"strike": k, "expiry": today, "cp": cp,
"iv": 0.25, "oi": 5000, "volume": 100,
"delta": 0.5 if cp == "C" else -0.5,
"gamma": 0.02, "vega": 0.1, "theta": -0.05,
"theo": 5.0, "bid": 4.9, "ask": 5.1,
})
snap_et = datetime(2026, 7, 24, 15, 55, tzinfo=ET)
df_full = filter_contracts_full(contracts, spot, cfg, snap_et)
ts_str = "2026-07-24 19:55:00"
# First write with snapshot_id "snap_A"
result_a = _process_and_render("TEST", cfg, "pm", "snap_A", ts_str, spot, df_full,
suffix="", instrument_class="equity_etf",
captured_at_utc="2026-07-24T19:56:00+00:00")
assert result_a is not None, "first write must succeed"
json_path = os.path.join(str(tmp_path), "TEST", "2026-07-24_pm.json")
assert os.path.exists(json_path), f"JSON not found: {json_path}"
with open(json_path) as f:
data_a = json.load(f)
assert data_a["snapshot_id"] == "snap_A"
# Second write with a DIFFERENT snapshot_id "snap_B" — must be refused
result_b = _process_and_render("TEST", cfg, "pm", "snap_B", ts_str, spot, df_full,
suffix="", instrument_class="equity_etf",
captured_at_utc="2026-07-24T19:56:00+00:00")
assert result_b is None, "write with different snapshot_id must be refused"
with open(json_path) as f:
data_after = json.load(f)
assert data_after["snapshot_id"] == "snap_A", \
f"existing file must NOT be clobbered; got {data_after['snapshot_id']!r}"
# Third write with the SAME snapshot_id "snap_A" — must succeed (re-render)
result_c = _process_and_render("TEST", cfg, "pm", "snap_A", ts_str, spot, df_full,
suffix="", instrument_class="equity_etf",
captured_at_utc="2026-07-24T19:56:00+00:00")
assert result_c is not None, "re-render of same snapshot_id must succeed"
with open(json_path) as f:
data_c = json.load(f)
assert data_c["snapshot_id"] == "snap_A"
def test_overwrite_flag_forces_clobber(monkeypatch, tmp_path):
"""FIX 79b: with overwrite=True, a different snapshot_id MUST clobber the
existing file."""
import matplotlib
matplotlib.use("Agg")
from gex.snapshot import _process_and_render
import gex.snapshot as snap_mod
import json, os
from datetime import date, datetime
from zoneinfo import ZoneInfo
monkeypatch.setattr(snap_mod, "_load_recent_closes",
lambda symbol, cfg: [99.0, 100.0, 101.0, 100.5, 100.2, 100.8])
cfg = GexConfig(outdir=str(tmp_path), cache_dir=str(tmp_path / "cache"))
spot = 100.0
ET = ZoneInfo("America/New_York")
today = date(2026, 7, 24)
contracts = []
for k in (95.0, 100.0, 105.0):
for cp in ("C", "P"):
contracts.append({
"strike": k, "expiry": today, "cp": cp,
"iv": 0.25, "oi": 5000, "volume": 100,
"delta": 0.5 if cp == "C" else -0.5,
"gamma": 0.02, "vega": 0.1, "theta": -0.05,
"theo": 5.0, "bid": 4.9, "ask": 5.1,
})
snap_et = datetime(2026, 7, 24, 15, 55, tzinfo=ET)
df_full = filter_contracts_full(contracts, spot, cfg, snap_et)
ts_str = "2026-07-24 19:55:00"
# First write with snapshot_id "snap_A"
_process_and_render("TEST", cfg, "pm", "snap_A", ts_str, spot, df_full,
suffix="", instrument_class="equity_etf",
captured_at_utc="2026-07-24T19:56:00+00:00")
json_path = os.path.join(str(tmp_path), "TEST", "2026-07-24_pm.json")
with open(json_path) as f:
assert json.load(f)["snapshot_id"] == "snap_A"
# Overwrite with snapshot_id "snap_B" and overwrite=True — must succeed
result = _process_and_render("TEST", cfg, "pm", "snap_B", ts_str, spot, df_full,
suffix="", instrument_class="equity_etf",
captured_at_utc="2026-07-24T19:56:00+00:00",
overwrite=True)
assert result is not None, "overwrite=True must force the write"
with open(json_path) as f:
data = json.load(f)
assert data["snapshot_id"] == "snap_B", \
f"overwrite=True must clobber; got {data['snapshot_id']!r}"
# ---------------------------------------------------------------------------
# FIX 80: reconciliation guard against near-settlement expiries
# ---------------------------------------------------------------------------
def test_reconciliation_excludes_near_settlement(monkeypatch, tmp_path):
"""FIX 80: an expiry inside min_minutes_to_settlement of settlement must be
excluded from the reconciliation numerator AND denominator, but still plotted
(bars use the full chain). reconciliation_scope must be 'excl_near_settlement'
and reconciliation_excluded_expiries must list the expiry with its
minutes_to_settlement. When no expiry is near settlement, scope is 'full'."""
import matplotlib
matplotlib.use("Agg")
from gex.snapshot import _process_and_render
import gex.snapshot as snap_mod
import json, os
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
monkeypatch.setattr(snap_mod, "_load_recent_closes",
lambda symbol, cfg: [99.0, 100.0, 101.0, 100.5, 100.2, 100.8])
cfg = GexConfig(outdir=str(tmp_path), cache_dir=str(tmp_path / "cache"))
spot = 100.0
ET = ZoneInfo("America/New_York")
# Snapshot at 15:55 ET. Front expiry = today (0DTE) settles at 16:00 ET -> 5 min
# to settlement (< 30 threshold -> excluded). Second expiry = +7 days -> included.
today = date(2026, 7, 24)
later = today + timedelta(days=7)
contracts = []
for exp in (today, later):
for k in (95.0, 100.0, 105.0):
for cp in ("C", "P"):
contracts.append({
"strike": k, "expiry": exp, "cp": cp,
"iv": 0.25, "oi": 9000 if cp == "P" else 1000, "volume": 100,
"delta": 0.5 if cp == "C" else -0.5,
"gamma": 0.02, "vega": 0.1, "theta": -0.05,
"theo": 5.0, "bid": 4.9, "ask": 5.1,
})
# ts_str is the Cboe source timestamp (UTC); 19:55 UTC = 15:55 ET.
snap_et = datetime(2026, 7, 24, 15, 55, tzinfo=ET)
df_full = filter_contracts_full(contracts, spot, cfg, snap_et)
assert not df_full.empty
ts_str = "2026-07-24 19:55:00"
_process_and_render("TEST", cfg, "pm", "test_fix80", ts_str, spot, df_full,
suffix="", instrument_class="equity_etf",
captured_at_utc="2026-07-24T19:56:00+00:00")
json_path = os.path.join(str(tmp_path), "TEST", "2026-07-24_pm.json")
with open(json_path) as f:
d = json.load(f)
# The 0DTE front expiry is near settlement and must be excluded.
assert d["reconciliation_scope"] == "excl_near_settlement"
excluded = d["reconciliation_excluded_expiries"]
assert len(excluded) == 1, f"expected 1 excluded expiry, got {excluded}"
assert excluded[0]["expiry"] == str(today)
assert excluded[0]["minutes_to_settlement"] < 30
# The near-settlement expiry is NOT in the worst-expiry reconciliation list
# (it was dropped from the reconciliation entirely).
worst_expiries = {e["expiry"] for e in d["reconciliation_worst_expiries"]}
assert str(today) not in worst_expiries, \
"near-settlement expiry must not appear in reconciliation_worst_expiries"
# Bars still reflect the full chain: total_net_gex_full uses ALL expiries.
assert d["total_net_gex_full"] != 0
# --- Control: a snapshot with NO near-settlement expiry -> scope 'full' ---
# Move the snapshot to mid-morning so the front expiry has > 30 min to settlement.
snap_et2 = datetime(2026, 7, 24, 11, 0, tzinfo=ET) # 11:00 ET -> 300 min to 16:00
df_full2 = filter_contracts_full(contracts, spot, cfg, snap_et2)
ts_str2 = "2026-07-24 15:00:00" # 15:00 UTC = 11:00 ET
_process_and_render("TEST", cfg, "am", "test_fix80_full", ts_str2, spot, df_full2,
suffix="", instrument_class="equity_etf",
captured_at_utc="2026-07-24T15:01:00+00:00")
json_path2 = os.path.join(str(tmp_path), "TEST", "2026-07-24_am.json")
with open(json_path2) as f:
d2 = json.load(f)
assert d2["reconciliation_scope"] == "full"
assert d2["reconciliation_excluded_expiries"] == []
# ---------------------------------------------------------------------------
# FIX 82: publish the pass-1 ATM IV source so the atm_iv divergence is visible
# ---------------------------------------------------------------------------
def test_bands_publish_atm_iv_source(monkeypatch, tmp_path):
"""FIX 82: bands.atm_iv_used comes from PASS 1 (_pass1_atm_iv on df_coarse,
DTE-filtered) while the top-level atm_iv comes from PASS 2 (atm_expected_move
on df_band, strike-filtered). The two passes run on different contract sets and
can legitimately pick different expiries/strikes (live SMH: 0.5752 vs 0.5861).
The fix publishes bands.atm_iv_source_expiry and bands.atm_iv_source_strikes so
the divergence is auditable. Both keys must ALWAYS be present (canonical
schema), and must be populated whenever pass-1 resolved a real atm_iv."""
import matplotlib
matplotlib.use("Agg")
from gex.snapshot import _process_and_render
import gex.snapshot as snap_mod
import json, os
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
monkeypatch.setattr(snap_mod, "_load_recent_closes",
lambda symbol, cfg: [99.0, 100.0, 101.0, 100.5, 100.2, 100.8])
cfg = GexConfig(outdir=str(tmp_path), cache_dir=str(tmp_path / "cache"))
spot = 100.0
ET = ZoneInfo("America/New_York")
today = date(2026, 7, 24)
later = today + timedelta(days=7) # DTE 7 -> eligible for pass-1 (DTE >= 5)
contracts = []
for exp in (today, later):
for k in (95.0, 100.0, 105.0):
for cp in ("C", "P"):
contracts.append({
"strike": k, "expiry": exp, "cp": cp,
"iv": 0.25, "oi": 9000 if cp == "P" else 1000, "volume": 100,
"delta": 0.5 if cp == "C" else -0.5,
"gamma": 0.02, "vega": 0.1, "theta": -0.05,
"theo": 5.0, "bid": 4.9, "ask": 5.1,
})
snap_et = datetime(2026, 7, 24, 11, 0, tzinfo=ET)
df_full = filter_contracts_full(contracts, spot, cfg, snap_et)
ts_str = "2026-07-24 15:00:00" # 15:00 UTC = 11:00 ET
_process_and_render("TEST", cfg, "am", "test_fix82", ts_str, spot, df_full,
suffix="", instrument_class="equity_etf",
captured_at_utc="2026-07-24T15:01:00+00:00")
json_path = os.path.join(str(tmp_path), "TEST", "2026-07-24_am.json")
with open(json_path) as f:
d = json.load(f)
bands = d["bands"]
# Canonical schema: both keys ALWAYS present (null when pass-1 didn't resolve).
assert "atm_iv_source_expiry" in bands, \
f"bands keys: {sorted(bands.keys())}"
assert "atm_iv_source_strikes" in bands, \
f"bands keys: {sorted(bands.keys())}"
# When pass-1 resolved a real atm_iv (basis starts with 'atm_iv'), the source
# must be published; when it fell back, the source is null.
if bands["band_basis"].startswith("atm_iv"):
assert bands["atm_iv_source_expiry"] is not None
assert bands["atm_iv_source_strikes"] is not None
assert isinstance(bands["atm_iv_source_strikes"], list)
# The source strikes must bracket or equal spot (100.0).
assert any(abs(k - spot) <= 5.0 for k in bands["atm_iv_source_strikes"]), \
f"source strikes {bands['atm_iv_source_strikes']} should be near spot {spot}"
else:
assert bands["atm_iv_source_expiry"] is None
assert bands["atm_iv_source_strikes"] is None
# When both passes pick the SAME expiry, the top-level atm_iv and
# bands.atm_iv_used agree (live NDX case: both 0.286). When they differ, the
# published source makes the divergence visible. Either way the two source
# fields are internally consistent with the resolver output.
if bands["atm_iv_source_expiry"] is not None and \
d.get("atm_iv_expiry") == bands["atm_iv_source_expiry"] and \
d.get("atm_iv_source_strikes") == bands["atm_iv_source_strikes"]:
assert abs(d["atm_iv"] - bands["atm_iv_used"]) < 1e-9, \
"same source expiry+strikes must yield identical atm_iv"
# ---------------------------------------------------------------------------
# FIX 84: instrument-class-aware settlement time
# ---------------------------------------------------------------------------
def test_settlement_time_instrument_class():
"""FIX 84: the settlement clock depends on instrument class. PM-settled index
options settle at 16:15 ET (not 16:00), AM-settled index expiries at 09:30 ET
one day earlier, equity/ETF at 16:00 ET. Using 16:00 for a PM-settled index
understates time-to-settlement by 15 min — for a 0DTE NDX captured at 15:54
that is 6 min vs the true 21 min, pushing gamma ~1.9x too high."""
from datetime import date, datetime, time as dtime
from zoneinfo import ZoneInfo
from gex.compute import _settlement_time, minutes_to_settlement, time_to_expiry_years
ET = ZoneInfo("America/New_York")
# --- settlement clock ---
assert _settlement_time(am_settled=False, instrument_class="index") == dtime(16, 15)
assert _settlement_time(am_settled=True, instrument_class="index") == dtime(9, 30)
assert _settlement_time(am_settled=False, instrument_class="equity_etf") == dtime(16, 0)
assert _settlement_time(am_settled=True, instrument_class="equity_etf") == dtime(9, 30)
# --- minutes_to_settlement: 0DTE NDX captured at 15:54:22 ET ---
expiry = date(2026, 7, 27)
snap = datetime(2026, 7, 27, 15, 54, 22, tzinfo=ET)
mts_index = minutes_to_settlement(expiry, snap, am_settled=False,
instrument_class="index")
mts_etf = minutes_to_settlement(expiry, snap, am_settled=False,
instrument_class="equity_etf")
# index: 16:15 - 15:54:22 = 20.63 min (NOT 6 min)
assert abs(mts_index - 20.63) < 0.1, f"index 0DTE should be ~20.6 min, got {mts_index}"
# equity/ETF: 16:00 - 15:54:22 = 5.63 min
assert abs(mts_etf - 5.63) < 0.1, f"etf 0DTE should be ~5.6 min, got {mts_etf}"
# --- T shares the same assumption (the user's explicit concern) ---
# At 0DTE both minutes are below the T floor (~160 min), so T is floored
# identically — the gamma cap dominates. Verify the shared clock instead with
# an above-floor case: a +1-day expiry captured at 11:00 ET. Index settles
# 16:15 (315 min), equity/ETF settles 16:00 (300 min) — both above the floor,
# so the index T must be strictly larger.
cfg = GexConfig()
expiry_tmr = date(2026, 7, 28)
snap_mid = datetime(2026, 7, 27, 11, 0, tzinfo=ET)
T_index = time_to_expiry_years(expiry_tmr, snap_mid, cfg, am_settled=False,
instrument_class="index")
T_etf = time_to_expiry_years(expiry_tmr, snap_mid, cfg, am_settled=False,
instrument_class="equity_etf")
assert T_index > T_etf, "index T must exceed equity/ETF T (16:15 vs 16:00 clock)"
# the difference is exactly 15 minutes of settlement time
delta_min = (T_index - T_etf) * (365.0 * 24.0 * 60.0)
assert abs(delta_min - 15.0) < 0.01, \
f"T difference should be exactly 15 min, got {delta_min}"
def test_settlement_time_published_per_expiry(monkeypatch, tmp_path):
"""FIX 84: reconciliation_excluded_expiries entries carry settlement_time_et so
the settlement assumption is auditable. An index 0DTE captured at 15:54 ET has
20.6 min to settlement (16:15 clock) — under the 30-min threshold, so it is
excluded and its settlement_time_et is published as '16:15'."""
import matplotlib
matplotlib.use("Agg")
from gex.snapshot import _process_and_render
import gex.snapshot as snap_mod
import json, os
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
monkeypatch.setattr(snap_mod, "_load_recent_closes",
lambda symbol, cfg: [99.0, 100.0, 101.0, 100.5, 100.2, 100.8])
cfg = GexConfig(outdir=str(tmp_path), cache_dir=str(tmp_path / "cache"))
spot = 100.0
ET = ZoneInfo("America/New_York")
today = date(2026, 7, 27)
later = today + timedelta(days=7)
contracts = []
for exp in (today, later):
for k in (95.0, 100.0, 105.0):
for cp in ("C", "P"):
contracts.append({
"strike": k, "expiry": exp, "cp": cp,
"iv": 0.25, "oi": 9000 if cp == "P" else 1000, "volume": 100,
"delta": 0.5 if cp == "C" else -0.5,
"gamma": 0.02, "vega": 0.1, "theta": -0.05,
"theo": 5.0, "bid": 4.9, "ask": 5.1,
})
# 15:54 ET = 19:54 UTC. Index 0DTE -> 20.6 min to settlement (< 30 -> excluded).
snap_et = datetime(2026, 7, 27, 15, 54, tzinfo=ET)
df_full = filter_contracts_full(contracts, spot, cfg, snap_et,
instrument_class="index")
ts_str = "2026-07-27 19:54:00"
_process_and_render("NDX", cfg, "pm", "test_fix84", ts_str, spot, df_full,
suffix="", instrument_class="index",
captured_at_utc="2026-07-27T19:55:00+00:00")
json_path = os.path.join(str(tmp_path), "NDX", "2026-07-27_pm.json")
with open(json_path) as f:
d = json.load(f)
excluded = d["reconciliation_excluded_expiries"]
today_excl = [e for e in excluded if e["expiry"] == str(today)]
assert today_excl, f"0DTE index expiry should be excluded, got {excluded}"
entry = today_excl[0]
assert entry["settlement_time_et"] == "16:15", \
f"index PM settlement must be 16:15, got {entry['settlement_time_et']}"
# 21 min to settlement (16:15 - 15:54), NOT 6 min
assert abs(entry["minutes_to_settlement"] - 21.0) < 0.2, \
f"index 0DTE should be ~21 min, got {entry['minutes_to_settlement']}"
# ---------------------------------------------------------------------------
# FIX 85: empirical unsigned reconciliation floor (Monte Carlo)
# ---------------------------------------------------------------------------
def test_reconciliation_floor_unsigned_shape():
"""FIX 93: reconciliation_floor_unsigned is DETERMINISTIC — rounding to a
fixed grid is not random, so the floor is a single scalar, not a Monte Carlo
distribution. Returns method, rounding_interval, floor, brackets_gate."""
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
from gex.compute import reconciliation_floor_unsigned
cfg = GexConfig()
spot = 100.0
ET = ZoneInfo("America/New_York")
today = date(2026, 7, 24)
contracts = []
for dte in (7, 30):
exp = today + timedelta(days=dte)
for k in (95.0, 100.0, 105.0):
for cp in ("C", "P"):
contracts.append({
"strike": k, "expiry": exp, "cp": cp,
"iv": 0.25, "oi": 9000 if cp == "P" else 1000, "volume": 100,
"delta": 0.5 if cp == "C" else -0.5,
"gamma": 0.02, "vega": 0.1, "theta": -0.05,
"theo": 5.0, "bid": 4.9, "ask": 5.1,
})
snap_et = datetime(2026, 7, 24, 11, 0, tzinfo=ET)
df_full = filter_contracts_full(contracts, spot, cfg, snap_et)
floor = reconciliation_floor_unsigned(df_full, spot, cfg)
# FIX 93: deterministic scalar, no n_draws/median/p95 spread.
assert floor["method"] == "grid_rounding"
assert floor["rounding_interval"] == 0.5e-4
assert floor["floor"] is not None
assert floor["floor"] >= 0.0
assert isinstance(floor["brackets_gate"], bool)
# Determinism: same input -> same output, no RNG.
floor2 = reconciliation_floor_unsigned(df_full, spot, cfg)
assert floor2["floor"] == floor["floor"], "floor must be deterministic"
def test_unsigned_gate_adapts_to_floor(monkeypatch, tmp_path):
"""FIX 85: when the floor's p95 brackets the unsigned gate (0.10), the
effective unsigned threshold becomes floor_p95 × multiplier (1.5), and
unsigned_gate_effective is published. A symbol whose unsigned error sits
between 0.10 and the derived threshold then passes on the unsigned branch —
NOT because of hand-tuning, but because the gate is dominated by rounding
noise. When the floor does NOT bracket, the fixed 0.10 gate applies."""
import matplotlib
matplotlib.use("Agg")
from gex.snapshot import _process_and_render
import gex.snapshot as snap_mod
import json, os
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
monkeypatch.setattr(snap_mod, "_load_recent_closes",
lambda symbol, cfg: [99.0, 100.0, 101.0, 100.5, 100.2, 100.8])
cfg = GexConfig(outdir=str(tmp_path), cache_dir=str(tmp_path / "cache"))
spot = 100.0
ET = ZoneInfo("America/New_York")
today = date(2026, 7, 24)
later = today + timedelta(days=7)
contracts = []
for exp in (today, later):
for k in (95.0, 100.0, 105.0):
for cp in ("C", "P"):
contracts.append({
"strike": k, "expiry": exp, "cp": cp,
"iv": 0.25, "oi": 9000 if cp == "P" else 1000, "volume": 100,
"delta": 0.5 if cp == "C" else -0.5,
"gamma": 0.02, "vega": 0.1, "theta": -0.05,
"theo": 5.0, "bid": 4.9, "ask": 5.1,
})
snap_et = datetime(2026, 7, 24, 11, 0, tzinfo=ET)
df_full = filter_contracts_full(contracts, spot, cfg, snap_et)
ts_str = "2026-07-24 15:00:00"
# --- Case A: floor brackets the gate -> adaptive threshold = floor × 1.5 ---
monkeypatch.setattr(snap_mod, "reconciliation_floor_unsigned",
lambda df, spot, cfg, **kw: {
"method": "grid_rounding", "rounding_interval": 0.5e-4,
"floor": 0.12, "brackets_gate": True})
_process_and_render("BRK", cfg, "am", "test_fix85a", ts_str, spot, df_full,
suffix="", instrument_class="equity_etf",
captured_at_utc="2026-07-24T15:01:00+00:00")
with open(os.path.join(str(tmp_path), "BRK", "2026-07-24_am.json")) as f:
da = json.load(f)
assert da["reconciliation_floor_unsigned"]["brackets_gate"] is True
assert abs(da["unsigned_gate_effective"] - 0.12 * 1.5) < 1e-9, \
f"adaptive gate should be floor 0.12 × 1.5 = 0.18, got {da['unsigned_gate_effective']}"
# --- Case B: floor does NOT bracket -> fixed 0.10 gate applies ---
monkeypatch.setattr(snap_mod, "reconciliation_floor_unsigned",
lambda df, spot, cfg, **kw: {
"method": "grid_rounding", "rounding_interval": 0.5e-4,
"floor": 0.02, "brackets_gate": False})
_process_and_render("NBR", cfg, "am", "test_fix85b", ts_str, spot, df_full,
suffix="", instrument_class="equity_etf",
captured_at_utc="2026-07-24T15:01:00+00:00")
with open(os.path.join(str(tmp_path), "NBR", "2026-07-24_am.json")) as f:
db = json.load(f)
assert db["reconciliation_floor_unsigned"]["brackets_gate"] is False
assert abs(db["unsigned_gate_effective"] - 0.10) < 1e-9, \
f"non-bracketing gate should stay 0.10, got {db['unsigned_gate_effective']}"
# ---------------------------------------------------------------------------
# FIX 87: the grid_rounding floor estimator does not double-count rounding
# ---------------------------------------------------------------------------
def test_floor_grid_rounding_does_not_double_count():
"""FIX 87: the corrected estimator rounds the recomputed (truth) gamma to the
4dp publication grid and measures rounded-vs-unrounded. Because BOTH sides derive
from the recomputed gamma, the genuine model error cancels and only quantisation
noise remains — so the floor must be far smaller than the old FIX 85 estimator
(which perturbed already-rounded reported gamma and folded in model error).
For an NDX-like chain (spot ~28000, 1-sig-fig gamma) the old estimator gave
median ~0.166 / p95 ~0.236; the grid_rounding method must fall well below that."""
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
from gex.compute import reconciliation_floor_unsigned
cfg = GexConfig()
spot = 28000.0
ET = ZoneInfo("America/New_York")
today = date(2026, 7, 24)
contracts = []
for dte in (7, 30):
exp = today + timedelta(days=dte)
for k in (27500.0, 28000.0, 28500.0):
for cp in ("C", "P"):
contracts.append({
"strike": k, "expiry": exp, "cp": cp, "iv": 0.25,
"oi": 9000 if cp == "P" else 1000, "volume": 100,
"delta": 0.5 if cp == "C" else -0.5, "gamma": 0.0001,
"vega": 0.1, "theta": -0.05, "theo": 5.0, "bid": 4.9, "ask": 5.1,
})
snap_et = datetime(2026, 7, 24, 11, 0, tzinfo=ET)
df_full = filter_contracts_full(contracts, spot, cfg, snap_et)
floor = reconciliation_floor_unsigned(df_full, spot, cfg)
assert floor["method"] == "grid_rounding"
# The whole point of FIX 87: the corrected floor is well below the old 0.236.
# FIX 93: deterministic scalar, not a distribution.
assert floor["floor"] < 0.15, \
f"grid_rounding floor ({floor['floor']:.4f}) should be well below the old " \
f"double-counted 0.236 — model error must cancel, leaving only rounding"
# ---------------------------------------------------------------------------
# FIX 88: gate cap + indeterminate (precision_limited) state
# ---------------------------------------------------------------------------
def test_gate_capped_and_indeterminate(monkeypatch, tmp_path):
"""FIX 88: a derived gate is capped at 2× the base gate (0.20). When the floor
brackets the base gate AND the unsigned error is not cleanly below the base gate,
the result is precision-limited: profile_reliable == False (boolean, FIX 92),
unsigned_gate_status == "precision_limited"/"capped", and the nested
reconciliation.pass agrees (False). Passing and precision-limited must be
distinct — precision-limited is NOT a pass."""
import matplotlib
matplotlib.use("Agg")
from gex.snapshot import _process_and_render
import gex.snapshot as snap_mod
import json, os
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
monkeypatch.setattr(snap_mod, "_load_recent_closes",
lambda symbol, cfg: [99.0, 100.0, 101.0, 100.5, 100.2, 100.8])
cfg = GexConfig(outdir=str(tmp_path), cache_dir=str(tmp_path / "cache"))
spot = 100.0
ET = ZoneInfo("America/New_York")
today = date(2026, 7, 24)
later = today + timedelta(days=7)
contracts = []
for exp in (today, later):
for k in (95.0, 100.0, 105.0):
for cp in ("C", "P"):
contracts.append({
"strike": k, "expiry": exp, "cp": cp, "iv": 0.25,
"oi": 9000 if cp == "P" else 1000, "volume": 100,
"delta": 0.5 if cp == "C" else -0.5, "gamma": 0.02,
"vega": 0.1, "theta": -0.05, "theo": 5.0, "bid": 4.9, "ask": 5.1,
})
snap_et = datetime(2026, 7, 24, 11, 0, tzinfo=ET)
df_full = filter_contracts_full(contracts, spot, cfg, snap_et)
ts_str = "2026-07-24 15:00:00"
# Floor brackets the base gate (p95 = 0.12 > 0.10). The derived gate would be
# 0.12 × 1.5 = 0.18 (< cap 0.20). Engineer the unsigned error to 0.11 — above the
# base gate (0.10) so it is precision-limited, but below the derived gate (0.18).
# We force this by mocking the floor AND checking the indeterminate path triggers
# on the unsigned error being >= base gate while the floor brackets.
monkeypatch.setattr(snap_mod, "reconciliation_floor_unsigned",
lambda df, spot, cfg, **kw: {
"method": "grid_rounding",
"rounding_interval": 0.5e-4,
"floor": 0.12, "brackets_gate": True})
# Build a chain whose unsigned error lands in (0.10, 0.18): reuse the FIX 77
# opposite-deviation fixture but scaled down. Two expiries, reported gammas that
# cancel signed but add unsigned to ~0.12.
contracts2 = []
for dte, rep_gamma in ((10, 0.024), (40, 0.028)):
exp = today + timedelta(days=dte)
for k in (95.0, 100.0, 105.0):
for cp in ("C", "P"):
contracts2.append({
"strike": k, "expiry": exp, "cp": cp, "iv": 0.25,
"oi": 9000 if cp == "P" else 1000, "volume": 100,
"delta": 0.5 if cp == "C" else -0.5, "gamma": rep_gamma,
"vega": 0.1, "theta": -0.05, "theo": 5.0, "bid": 4.9, "ask": 5.1,
})
df2 = filter_contracts_full(contracts2, spot, cfg, snap_et)
_process_and_render("IND", cfg, "am", "test_fix88", ts_str, spot, df2,
suffix="", instrument_class="equity_etf",
captured_at_utc="2026-07-24T15:01:00+00:00")
with open(os.path.join(str(tmp_path), "IND", "2026-07-24_am.json")) as f:
d = json.load(f)
unsigned = d["rel_err_unsigned"]
# The gate is capped: derived 0.18 < cap 0.20, so effective is 0.18 (status derived).
assert d["unsigned_gate_status"] in ("derived", "capped", "precision_limited")
assert d["unsigned_gate_effective"] <= 0.20 + 1e-9, \
f"gate must be capped at 2× base (0.20), got {d['unsigned_gate_effective']}"
# FIX 92: if the unsigned error is >= base gate (0.10) and the floor brackets,
# the result is precision-limited: profile_reliable is False (boolean, NOT the
# truthy string "indeterminate"), unsigned_gate_status == "precision_limited",
# and the nested reconciliation.pass agrees (False).
if unsigned is not None and unsigned >= 0.10:
assert d["profile_reliable"] is False, \
f"unsigned={unsigned:.4f} >= base gate with bracketing floor must be " \
f"False (boolean), got {d['profile_reliable']!r}"
assert d["unsigned_gate_status"] == "precision_limited"
assert d["reconciliation_pass_basis"] == "indeterminate"
assert d["reconciliation"]["pass"] is False, \
"nested pass must agree with boolean profile_reliable (FIX 83/92)"
def test_gate_cap_enforced_at_2x(monkeypatch, tmp_path):
"""FIX 88: when the derived gate (p95 × 1.5) EXCEEDS the 2× cap, the effective
gate is clamped to exactly 2× base (0.20) and status is 'capped'. This is the
guard against an un-fireable gate (the 0.3535 case from v1.7.7)."""
import matplotlib
matplotlib.use("Agg")
from gex.snapshot import _process_and_render
import gex.snapshot as snap_mod
import json, os
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
monkeypatch.setattr(snap_mod, "_load_recent_closes",
lambda symbol, cfg: [99.0, 100.0, 101.0, 100.5, 100.2, 100.8])
# Floor = 0.30 -> derived = 0.45 -> capped at 0.20.
monkeypatch.setattr(snap_mod, "reconciliation_floor_unsigned",
lambda df, spot, cfg, **kw: {
"method": "grid_rounding",
"rounding_interval": 0.5e-4,
"floor": 0.30, "brackets_gate": True})
cfg = GexConfig(outdir=str(tmp_path), cache_dir=str(tmp_path / "cache"))
spot = 100.0
ET = ZoneInfo("America/New_York")
today = date(2026, 7, 24)
later = today + timedelta(days=7)
contracts = []
for exp in (today, later):
for k in (95.0, 100.0, 105.0):
for cp in ("C", "P"):
contracts.append({
"strike": k, "expiry": exp, "cp": cp, "iv": 0.25,
"oi": 8000 if cp == "P" else 3000, "volume": 100,
"delta": 0.5 if cp == "C" else -0.5, "gamma": 0.02,
"vega": 0.1, "theta": -0.05, "theo": 5.0, "bid": 4.9, "ask": 5.1,
})
snap_et = datetime(2026, 7, 24, 11, 0, tzinfo=ET)
df_full = filter_contracts_full(contracts, spot, cfg, snap_et)
_process_and_render("CAP", cfg, "am", "test_fix88cap", "2026-07-24 15:00:00",
spot, df_full, suffix="", instrument_class="equity_etf",
captured_at_utc="2026-07-24T15:01:00+00:00")
with open(os.path.join(str(tmp_path), "CAP", "2026-07-24_am.json")) as f:
d = json.load(f)
# FIX 92: when the floor brackets AND the unsigned error >= base gate, the
# precision_limited branch fires and overrides the status. Both "capped" and
# "precision_limited" are valid here; the key assertion is that the gate IS 0.20.
assert d["unsigned_gate_status"] in ("capped", "precision_limited"), \
f"expected capped or precision_limited, got {d['unsigned_gate_status']!r}"
assert abs(d["unsigned_gate_effective"] - 0.20) < 1e-9, \
f"derived 0.45 must be capped at 0.20, got {d['unsigned_gate_effective']}"
# ---------------------------------------------------------------------------
# FIX 89: provenance stamp in the canonical key set
# ---------------------------------------------------------------------------
def test_provenance_stamp_in_every_artifact(tmp_path):
"""FIX 89: every artifact carries gex_version and schema_version, populated from
gex/__init__.py. Both are part of the canonical key set (present regardless of
instrument class or reconciliation outcome)."""
import matplotlib
matplotlib.use("Agg")
from gex.snapshot import _process_and_render
import gex.snapshot as snap_mod
import gex
import json, os
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
monkeypatch_free = snap_mod._load_recent_closes
snap_mod._load_recent_closes = lambda s, c: [99.0, 100.0, 101.0, 100.5]
try:
cfg = GexConfig(outdir=str(tmp_path), cache_dir=str(tmp_path / "cache"))
spot = 100.0
ET = ZoneInfo("America/New_York")
today = date(2026, 7, 24)
later = today + timedelta(days=7)
contracts = []
for exp in (today, later):
for k in (95.0, 100.0, 105.0):
for cp in ("C", "P"):
contracts.append({
"strike": k, "expiry": exp, "cp": cp, "iv": 0.25,
"oi": 8000 if cp == "P" else 3000, "volume": 100,
"delta": 0.5 if cp == "C" else -0.5, "gamma": 0.02,
"vega": 0.1, "theta": -0.05, "theo": 5.0, "bid": 4.9, "ask": 5.1,
})
snap_et = datetime(2026, 7, 24, 11, 0, tzinfo=ET)
df_full = filter_contracts_full(contracts, spot, cfg, snap_et)
_process_and_render("PROV", cfg, "am", "test_fix89", "2026-07-24 15:00:00",
spot, df_full, suffix="", instrument_class="equity_etf")
with open(os.path.join(str(tmp_path), "PROV", "2026-07-24_am.json")) as f:
d = json.load(f)
assert d["gex_version"] == gex.__version__
assert d["schema_version"] == gex.__schema_version__
assert d["gex_version"] == "1.8.0"
finally:
snap_mod._load_recent_closes = monkeypatch_free
# ---------------------------------------------------------------------------
# FIX 90: excluded share + disambiguated pass basis
# ---------------------------------------------------------------------------
def test_excluded_share_and_basis_disambiguation(monkeypatch, tmp_path):
"""FIX 90: reconciliation_excluded_share discloses the share of Σ|GEX| excluded
by the near-settlement guard (the pass covers 1 - share, not 100%), and
reconciliation_pass_basis distinguishes 'both_pass' from a single-gate breach."""
import matplotlib
matplotlib.use("Agg")
from gex.snapshot import _process_and_render
import gex.snapshot as snap_mod
import json, os
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
monkeypatch.setattr(snap_mod, "_load_recent_closes",
lambda symbol, cfg: [99.0, 100.0, 101.0, 100.5, 100.2, 100.8])
# Non-bracketing floor so the base 0.10 gate applies and a clean pass is possible.
monkeypatch.setattr(snap_mod, "reconciliation_floor_unsigned",
lambda df, spot, cfg, **kw: {
"method": "grid_rounding", "rounding_interval": 0.5e-4,
"floor": 0.002, "brackets_gate": False})
cfg = GexConfig(outdir=str(tmp_path), cache_dir=str(tmp_path / "cache"))
spot = 100.0
ET = ZoneInfo("America/New_York")
today = date(2026, 7, 24)
later = today + timedelta(days=7)
contracts = []
for exp in (today, later):
for k in (95.0, 100.0, 105.0):
for cp in ("C", "P"):
contracts.append({
"strike": k, "expiry": exp, "cp": cp, "iv": 0.25,
"oi": 8000 if cp == "P" else 3000, "volume": 100,
"delta": 0.5 if cp == "C" else -0.5, "gamma": 0.02,
"vega": 0.1, "theta": -0.05, "theo": 5.0, "bid": 4.9, "ask": 5.1,
})
# Capture at 15:54 ET so the 0DTE (today) expiry is near-settlement (< 30 min for
# an equity/ETF settling at 16:00) and gets excluded — exercising the share field.
snap_et = datetime(2026, 7, 24, 15, 54, tzinfo=ET)
df_full = filter_contracts_full(contracts, spot, cfg, snap_et)
_process_and_render("SHR", cfg, "pm", "test_fix90", "2026-07-24 19:54:00",
spot, df_full, suffix="", instrument_class="equity_etf",
captured_at_utc="2026-07-24T19:54:30+00:00")
with open(os.path.join(str(tmp_path), "SHR", "2026-07-24_pm.json")) as f:
d = json.load(f)
# excluded_share is published and in [0, 1]; with a 0DTE excluded it should be > 0.
assert "reconciliation_excluded_share" in d
share = d["reconciliation_excluded_share"]
assert 0.0 <= share <= 1.0
if d["reconciliation_scope"] == "excl_near_settlement":
assert share > 0.0, "an excluded 0DTE must yield a positive excluded share"
else:
assert share == 0.0
# basis is one of the disambiguated values
assert d["reconciliation_pass_basis"] in (
"both_pass", "signed", "unsigned", "signed+unsigned", "excluded", "indeterminate")
# a clean pass must read "both_pass", not the old ambiguous "signed"/"unsigned"
if d["profile_reliable"] is True:
assert d["reconciliation_pass_basis"] == "both_pass"
# ---------------------------------------------------------------------------
# FIX 91: per-contract GEX uses spot^2, not strike^2 (regression test)
# ---------------------------------------------------------------------------
def test_per_contract_gex_uses_spot_squared():
"""FIX 91: per-contract GEX must equal gamma x OI x spot^2 x M x 0.01, with
spot pinned. The bug was that build_outlier_report used each strike's own K^2,
which made the breakdown disagree with its parent bar by (K/S)^2."""
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
from gex.compute import aggregate, build_outlier_report
cfg = GexConfig()
spot = 100.0
ET = ZoneInfo("America/New_York")
today = date(2026, 7, 24)
exp = today + timedelta(days=7)
# One call, one put, known gamma and OI.
contracts = [
{"strike": 95.0, "expiry": exp, "cp": "C", "iv": 0.25, "oi": 1000,
"volume": 100, "delta": 0.6, "gamma": 0.03, "vega": 0.1, "theta": -0.05,
"theo": 5.0, "bid": 4.9, "ask": 5.1},
{"strike": 95.0, "expiry": exp, "cp": "P", "iv": 0.25, "oi": 800,
"volume": 100, "delta": -0.4, "gamma": 0.03, "vega": 0.1, "theta": -0.05,
"theo": 5.0, "bid": 4.9, "ask": 5.1},
]
snap_et = datetime(2026, 7, 24, 11, 0, tzinfo=ET)
df = filter_contracts(contracts, spot, cfg, snap_et)
agg = aggregate(df, spot, cfg)
# Per-contract GEX = sign * |gamma| * OI * M * spot^2 * 0.01
M = cfg.contract_multiplier
S2 = spot * spot
call_gex = 1.0 * 0.03 * 1000 * M * S2 * 0.01
put_gex = -1.0 * 0.03 * 800 * M * S2 * 0.01
expected_net = call_gex + put_gex
assert abs(agg.loc[95.0, "net_gex"] - expected_net) < 1e-6, \
f"bar net_gex must use spot^2: expected {expected_net}, got {agg.loc[95.0, 'net_gex']}"
# The outlier breakdown must agree with the bar (FIX 91b invariant).
report = build_outlier_report(agg, df, cfg, spot, top_n=5)
top = report["top_strikes"][0]
by_sum = sum(e["gex"] for e in top["by_expiry"])
assert abs(by_sum - top["net_gex"]) < max(1.0, abs(top["net_gex"]) * 1e-9), \
f"FIX 91b: sum(by_expiry[].gex)={by_sum} must equal net_gex={top['net_gex']}"
# ---------------------------------------------------------------------------
# FIX 91b: decomposition reconciles to its parent (invariant test)
# ---------------------------------------------------------------------------
def test_outlier_breakdown_reconciles_to_bar(synthetic_chain):
"""FIX 91b: for every entry in outlier_report.top_strikes, the sum of the
per-expiry breakdown must equal the bar's net_gex within float tolerance.
This is the invariant that would have caught the K^2 bug."""
from datetime import date
cfg = GexConfig(strike_band=0.50)
spot = 105.0
today = date(2026, 7, 24)
df = filter_contracts(synthetic_chain, spot, cfg, today)
agg = aggregate(df, spot, cfg)
report = build_outlier_report(agg, df, cfg, spot, top_n=5)
for entry in report["top_strikes"]:
by_sum = sum(e["gex"] for e in entry["by_expiry"])
tol = max(1.0, abs(entry["net_gex"]) * 1e-9)
assert abs(by_sum - entry["net_gex"]) < tol, \
f"strike {entry['strike']}: sum(by_expiry)={by_sum} != net_gex={entry['net_gex']}"
# ---------------------------------------------------------------------------
# FIX 92: profile_reliable is boolean-or-null; tri-state lives in unsigned_gate_status
# ---------------------------------------------------------------------------
def test_profile_reliable_is_boolean(monkeypatch, tmp_path):
"""FIX 92: profile_reliable must be True or False (boolean), never the truthy
string 'indeterminate'. A consumer's plain `if pass:` must not read
precision-limited as a pass. The tri-state semantics live in unsigned_gate_status."""
import matplotlib
matplotlib.use("Agg")
from gex.snapshot import _process_and_render
import gex.snapshot as snap_mod
import json, os
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
monkeypatch.setattr(snap_mod, "_load_recent_closes",
lambda symbol, cfg: [99.0, 100.0, 101.0, 100.5, 100.2, 100.8])
# Force a bracketing floor so the precision_limited path fires.
monkeypatch.setattr(snap_mod, "reconciliation_floor_unsigned",
lambda df, spot, cfg, **kw: {
"method": "grid_rounding", "rounding_interval": 0.5e-4,
"floor": 0.15, "brackets_gate": True})
cfg = GexConfig(outdir=str(tmp_path), cache_dir=str(tmp_path / "cache"))
spot = 100.0
ET = ZoneInfo("America/New_York")
today = date(2026, 7, 24)
later = today + timedelta(days=7)
contracts = []
for exp in (today, later):
for k in (95.0, 100.0, 105.0):
for cp in ("C", "P"):
contracts.append({
"strike": k, "expiry": exp, "cp": cp, "iv": 0.25,
"oi": 9000 if cp == "P" else 1000, "volume": 100,
"delta": 0.5 if cp == "C" else -0.5, "gamma": 0.02,
"vega": 0.1, "theta": -0.05, "theo": 5.0, "bid": 4.9, "ask": 5.1,
})
snap_et = datetime(2026, 7, 24, 11, 0, tzinfo=ET)
df_full = filter_contracts_full(contracts, spot, cfg, snap_et)
_process_and_render("BOOL", cfg, "am", "test_fix92", "2026-07-24 15:00:00",
spot, df_full, suffix="", instrument_class="equity_etf",
captured_at_utc="2026-07-24T15:01:00+00:00")
with open(os.path.join(str(tmp_path), "BOOL", "2026-07-24_am.json")) as f:
d = json.load(f)
# profile_reliable must be a Python bool, never a string.
assert isinstance(d["profile_reliable"], bool), \
f"profile_reliable must be bool, got {type(d['profile_reliable']).__name__}: {d['profile_reliable']!r}"
# The nested reconciliation.pass must also be bool.
assert isinstance(d["reconciliation"]["pass"], bool), \
f"reconciliation.pass must be bool, got {type(d['reconciliation']['pass']).__name__}"
# If precision-limited fired, unsigned_gate_status carries the tri-state.
if d["unsigned_gate_status"] == "precision_limited":
assert d["profile_reliable"] is False
assert d["reconciliation_pass_basis"] == "indeterminate"
# ---------------------------------------------------------------------------
# FIX 93: floor is deterministic (no n_draws, no median/p95 spread)
# ---------------------------------------------------------------------------
def test_floor_is_deterministic_scalar():
"""FIX 93: reconciliation_floor_unsigned returns a single deterministic scalar,
not a Monte Carlo distribution. The keys n_draws, median, p95 must NOT be present."""
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
from gex.compute import reconciliation_floor_unsigned
cfg = GexConfig()
spot = 100.0
ET = ZoneInfo("America/New_York")
today = date(2026, 7, 24)
contracts = []
for dte in (7, 30):
exp = today + timedelta(days=dte)
for k in (95.0, 100.0, 105.0):
for cp in ("C", "P"):
contracts.append({
"strike": k, "expiry": exp, "cp": cp, "iv": 0.25,
"oi": 9000 if cp == "P" else 1000, "volume": 100,
"delta": 0.5 if cp == "C" else -0.5, "gamma": 0.02,
"vega": 0.1, "theta": -0.05, "theo": 5.0, "bid": 4.9, "ask": 5.1,
})
snap_et = datetime(2026, 7, 24, 11, 0, tzinfo=ET)
df_full = filter_contracts_full(contracts, spot, cfg, snap_et)
floor = reconciliation_floor_unsigned(df_full, spot, cfg)
# Must have the deterministic keys.
assert "floor" in floor
assert "method" in floor and floor["method"] == "grid_rounding"
assert "brackets_gate" in floor
# Must NOT have the old Monte Carlo keys.
assert "n_draws" not in floor, "n_draws must be removed (FIX 93)"
assert "median" not in floor, "median must be removed (FIX 93)"
assert "p95" not in floor, "p95 must be removed (FIX 93)"
# Determinism: two calls give identical results.
floor2 = reconciliation_floor_unsigned(df_full, spot, cfg)
assert floor2["floor"] == floor["floor"]
# ---------------------------------------------------------------------------
# FIX 94: reconciliation_excluded_share uses net basis consistent with gex_by_expiry
# ---------------------------------------------------------------------------
def test_excluded_share_net_basis(monkeypatch, tmp_path):
"""FIX 94: reconciliation_excluded_share must use the same net basis as
gex_by_expiry.share (numerator: Sigma|net GEX per excluded expiry|;
denominator: Sigma|net GEX per strike|). Previously mixed gross numerator
with gross denominator, inconsistent with gex_by_expiry."""
import matplotlib
matplotlib.use("Agg")
from gex.snapshot import _process_and_render
import gex.snapshot as snap_mod
import json, os
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
monkeypatch.setattr(snap_mod, "_load_recent_closes",
lambda symbol, cfg: [99.0, 100.0, 101.0, 100.5, 100.2, 100.8])
cfg = GexConfig(outdir=str(tmp_path), cache_dir=str(tmp_path / "cache"))
spot = 100.0
ET = ZoneInfo("America/New_York")
today = date(2026, 7, 24)
later = today + timedelta(days=7)
contracts = []
for exp in (today, later):
for k in (95.0, 100.0, 105.0):
for cp in ("C", "P"):
contracts.append({
"strike": k, "expiry": exp, "cp": cp, "iv": 0.25,
"oi": 9000 if cp == "P" else 1000, "volume": 100,
"delta": 0.5 if cp == "C" else -0.5, "gamma": 0.02,
"vega": 0.1, "theta": -0.05, "theo": 5.0, "bid": 4.9, "ask": 5.1,
})
snap_et = datetime(2026, 7, 24, 11, 0, tzinfo=ET)
df_full = filter_contracts_full(contracts, spot, cfg, snap_et)
_process_and_render("NET", cfg, "am", "test_fix94", "2026-07-24 15:00:00",
spot, df_full, suffix="", instrument_class="equity_etf",
captured_at_utc="2026-07-24T15:01:00+00:00")
with open(os.path.join(str(tmp_path), "NET", "2026-07-24_am.json")) as f:
d = json.load(f)
# reconciliation_excluded_share must be a float in [0, 1].
share = d["reconciliation_excluded_share"]
assert isinstance(share, float)
assert 0.0 <= share <= 1.0, f"excluded_share must be in [0,1], got {share}"
# gex_by_expiry must use "net_gex" (FIX 94 rename from sum_abs_gex).
for row in d.get("gex_by_expiry", []):
assert "net_gex" in row, f"gex_by_expiry row missing net_gex: {row.keys()}"
assert "sum_abs_gex" not in row, "sum_abs_gex must be renamed to net_gex (FIX 94)"
requests>=2.28
pandas>=2.0
numpy>=1.24
scipy>=1.10
matplotlib>=3.7
pandas_market_calendars>=4.0
python-dateutil>=2.8
pyarrow>=12.0
# 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
```
The suite covers: synthetic-chain net_gex/dex/put-call ratios to 1e-6; Black-Scholes gamma sanity (ATM > OTM; gamma → 0 as T → 0 for far OTM); HVL interpolation on a hand-built profile with a zero-crossing between grid points; a golden-image smoke test (PNG > 50 KB); strike-increment detection; UTC→ET timestamp conversion; proof the profile uses the full (untruncated) chain; the DEX-profile V-shape (interior minimum, supersedes the old monotonicity test); three-axis zero alignment with colour-matched profile axes; robust x-limit outlier resistance; delta_neutral nearest-spot selection; dex_min_price; outlier_report structure; oi_totals; gex_profile signature (no agg arg); bars↔profile reconciliation (rel_err < 0.05 on the SMH fixture); continuous time-to-expiry (same-day expiry at 15:35/09:30/16:00 ET); HVL zero crossing (single defined level, distance + regime note); ATM IV rejection (garbage chain → null with stated reason); front_expiry_share (0DTE domination detection); no symlog (linear bar axis always); layout guard (no text in the bottom axis-furniture band); v1.4.0: gamma_condition_matches_sign (sign of profile at spot always agrees with gamma_condition, on both the full chain and a front-expiry-dropped subset); atm_iv_liquidity_gate (tiny-OI expiry rejected); max_expiry_share (warning keys on dominant expiry, not front); interior_extremum_boundary (edge argmin → null + "at_grid_boundary"); ephemeral_levels (level_front_expiry_pct > 0.50 flags 0DTE-dominated levels); v1.5.0: no_level_at_search_boundary (no published level within one grid step of the old mask boundary 553.8175); hvl_distance_and_regime_note (distance annotation and regime note published); gex_transition_persistence (persistence-hardened sign flip); spread_detection (520/500 + 522.5/517.5 pairs found in 2026-07-31); realised_vol_and_iv_cross_check (iv_hv_ratio, vol_regime, source detail).
cdn.cboe.com/api/global/delayed_quotes/options/SMH.json):plot.py. The image is self-contained chart furniture only (title, spot, levels, legend, source/timestamp line). Because a saved or shared PNG would otherwise lose the warning entirely, a short neutral footer line was added: “reliability detail: allofthesewords.com/optionsdata”. On the page, directly beneath the image, a single always-present status line now doubles as the header of a collapsible <details> section (plain HTML, no JS dependency), collapsed by default in both pass and fail states. On a fail or indeterminate it reads exactly: “⚠ Profile reconciliation failed — curve-derived levels (HVL, GEX Transition, delta-neutral) unreliable; bars, Call Resistance and Put Support unaffected. See audit.” On a pass it shows the muted neutral equivalent (“✓ Profile reconciliation passed — rel_err x.x%”). Inside the expander: signed and unsigned rel_err with the gate each was measured against, reconciliation_pass_basis, reconciliation_scope and any reconciliation_excluded_expiries with minutes-to-settlement, reconciliation_excluded_share, the deterministic floor and whether it brackets the gate, gamma_precision with digits, and the top five per-expiry dollar gaps with their shares.instrument_class is equity_etf and settlement is 16:00 ET — both auto-derived from the endpoint variant, not hardcoded. Endpoint naming confirmed live: each uses the plain Cboe path /options/AAPL.json (HTTP 200) rather than the underscore-prefixed /options/_NDX.json that indices use (the fetch layer auto-detects and caches the working variant per symbol). strike_increment, render_bucket, and gamma_precision are all derived from each chain rather than hardcoded. Verified on the first render (not assumed): NVDA and GOOGL publish gamma with 2 significant figures (gamma_precision: "adequate"), better than NDX’s 1; AAPL, despite its higher price, also lands at 1 significant figure (coarse) because its ATM gamma rounds to a single non-zero digit at 4dp — so bars_reliable: false there exactly as it is for NDX. The FIX 91b decomposition invariant and the canonical-schema enumeration were run against all three before publishing.run_ticker code path, the --exclude-front-expiry CLI flag, the artifacts, the index entries, and every test that referenced it. The FIX 68 single-pipeline identical-keys test is retired — the drift risk it guarded against disappears with the second path, which is the main reason this was worth doing. The JSON variant field is retained with the constant value all_expirations so the canonical key set does not churn. Net effect: six tickers, one variant, two slots a day.reported_gamma (ALREADY rounded) by ±0.5×10−4 and compared it against the recomputed gamma — which double-counts rounding AND folds in the genuine model error, so it overstated the floor. The evidence: NDX’s actual unsigned error (0.1035) fell BELOW the simulated median (0.1655), impossible for a pure-noise floor. The new grid_rounding method takes the recomputed Black-Scholes gamma as the full-precision TRUTH, rounds it to the observed publication grid (4dp), and measures the aggregate unsigned error of the rounded-vs-unrounded difference — both sides derive from the recomputed gamma, so the model error cancels and only quantisation noise remains. Published as reconciliation_floor_unsigned.method: "grid_rounding" alongside median/p95. Old vs new floor, side by side (NDX, 2026-07-27 PM): old (FIX 85) median 0.1655 / p95 0.2356 / derived gate 0.3535 — new (FIX 87) median 0.1361 / p95 0.1915 / gate capped at 0.20. The corrected floor falls below the old estimate (the double-counting is removed), but it still brackets the 0.10 base gate — so NDX is now correctly reported as INDETERMINATE rather than silently passed under an un-fireable 0.3535 gate. FIX 88: capped the derived gate and added an indeterminate state. A gate of 0.3535 against a realistic worst case of ~0.10 left the unsigned check unable to fire. Any derived gate is now CAPPED at 2× the base gate (unsigned_gate_cap_multiplier = 2.0, so ≤ 0.20). When the corrected floor still brackets the base gate AND the unsigned error is not cleanly below it, the result is INDETERMINATE rather than a pass: profile_reliable: "indeterminate", unsigned_gate_status: "precision_limited", reconciliation_pass_basis: "indeterminate", and a chart footnote (amber, visually distinct from the red FAIL banner) stating that model error cannot be separated from publication rounding at this symbol’s gamma precision. The page shows a matching amber strip. Passing and indeterminate are distinct states; indeterminate is NOT a pass. Published: unsigned_gate_status ("base" / "derived" / "capped"). FIX 89: provenance stamp. No JSON previously carried the producing code version. Every artifact now carries gex_version and schema_version in the canonical key set, populated from gex/__init__.py. Note: identical snapshot_id values across v1.7.6 and v1.7.7 produced different profile values (NDX rel_err 0.005877 → 0.006652) because FIX 84 changed the settlement clock — legitimate, but it means byte-identity under FIX 75 only holds WITHIN a version, and the history needs the version to be readable. FIX 90: disclose how much of the book the pass covers, and disambiguate the basis. The PM slot excludes the 0DTE expiry on every expiry day; here that is 11.4% of gross GEX validated by nothing. New reconciliation_excluded_share = Σ|GEX| of excluded expiries / Σ|GEX| of the full chain, so a reader knows the pass covers (1 − share) = 88.6%, not 100%. reconciliation_pass_basis now distinguishes "both_pass" (both gates passed) from "signed" / "unsigned" (a single gate breached), "signed+unsigned" (both breached), "excluded" (reconciliation undefined), and "indeterminate" (precision-limited). Tests: 71.reconciliation.pass previously evaluated the SIGNED gate only, so it could read true while reconciliation_pass_basis was "unsigned" and profile_reliable was false — a direct contradiction in the same file. It now evaluates the SAME dual gate as profile_reliable (signed < 0.05 AND unsigned < effective gate), so the two can never disagree. Test asserts reconciliation.pass == profile_reliable in the engineered unsigned-bind fixture. FIX 84: instrument-class-aware settlement time. minutes_to_settlement returned 5.6 for the NDX 0DTE from a 15:54:22 ET capture, implying a 16:00 settlement; PM-settled index options settle at 16:15 ET (20.6 min). The T clock shared the same wrong assumption. Both now route through a single _settlement_time(am_settled, instrument_class) helper: 16:15 ET for PM-settled index, 09:30 ET (one day earlier) for AM-settled index, 16:00 ET for equity/ETF. NDX 0DTE captured at 15:54 ET now reports 21.0 min to settlement (16:15 clock), not 6 min. The assumption is published per excluded expiry as settlement_time_et inside reconciliation_excluded_expiries. FIX 85: the unsigned reconciliation floor is now established empirically, not assumed. reconciliation_floor_unsigned runs a Monte Carlo (≥2000 draws) perturbing each reported gamma uniformly within its rounding interval (±0.5×10−4 for 1-sig-fig gamma) and recomputes the aggregate unsigned error per draw, publishing the median and 95th percentile. When the floor’s p95 brackets the 0.10 gate, the gate is dominated by data-precision noise and a per-symbol unsigned threshold is derived as floor_p95 × 1.5 (configurable unsigned_floor_multiplier) — documented headroom above the 95th percentile of pure rounding noise, NOT a hand-tuned green-light knob. Published as reconciliation_floor_unsigned (n_draws, rounding_interval, median, p95, brackets_gate) and unsigned_gate_effective (the threshold actually applied). Superseded by FIX 87/88 in v1.7.8 (the estimator double-counted rounding; the gate is now capped and an indeterminate state added). FIX 86: the canonical-schema test now enumerates EVERY entry in index.json and asserts the identical key set on each, instead of a fixed six-file sample — so a stale or mislabeled file listed in the index can no longer escape the guarantee. Today’s six mislabeled 2026-07-27 AM files (NDX/SMH/SPY × am/am_exfront) were exact duplicates of their PM counterparts by snapshot_id but labelled slot: "am" with no reconciliation_scope (v1.7.5 schema); they were backed up to /tmp and purged, and the stale v1.7.0-era SMH 2026-07-24 files (97 keys, unrecoverable — no raw cache) were likewise backed up and removed so the history starts clean. index.json rebuilt from the published directory. Slot tolerance widened (FIX 86b): the tool now accepts explicit asymmetric windows — AM 09:30–12:00 ET, PM 14:00–16:15 ET — replacing the old center±12-min model. The slot label describes INTENT, not precision; the recorded capture time stays authoritative, and a late-but-same-day capture with a correct timestamp is usable data rather than a rejected one. The cron windows in gex-run.sh stay narrower and centred on the target capture times (AM ~10:00, PM ~15:59) with a retry margin; idempotency still publishes only the first success. Tests: 66._am.json files for NDX and SMH were replaced by 15:54/15:55 ET captures still labelled slot: "am", destroying the 10:00 ET originals. (a) The slot source-window check now runs at WRITE time, not just at fetch — an out-of-window capture is rejected with a clear error rather than written as a mislabeled slot file. The check uses the capture time (captured_at_utc), not the Cboe source timestamp, because the delayed feed can run ~15 min late; it falls back to the Cboe source timestamp only for pre-FIX-75 caches that have no capture-time sidecar. (b) Writes are non-destructive: if <symbol>/<date>_<slot>.json already exists with a DIFFERENT snapshot_id, the write is refused unless --overwrite is passed. Re-renders of the SAME snapshot_id remain allowed (that is what FIX 75’s byte-identity guarantee depends on). New CLI flag --overwrite. run_ticker returns False if the primary write was refused. Cron windows aligned to the slot tolerance (AM 09:48–10:12 ET, PM 15:47–16:11 ET). Four tests: out-of-window capture rejected; existing different-snapshot_id file not clobbered; --overwrite forces clobber; capture-time (not source-timestamp) drives the window check. FIX 80: reconciliation is now guarded against near-settlement expiries. At 21 min (NDX) and 5 min (SMH) to settlement, T is 4.0e-5 and 9.5e-6; gamma ∝ 1/√T makes the recompute unstable against a ~15-minute-delayed quote feed. New min_minutes_to_settlement = 30: expiries inside that window are still plotted from reported gamma but excluded from the reconciliation numerator AND denominator. Published as reconciliation_scope: "full" | &quo...[truncated]
v1.7.5 (2026-07-27): Final cleanup pass — four fixes, no new features. FIX 75: source_timestamp_age_min is now frozen at capture time. Previously it was computed at render time from the Cboe source timestamp, so re-rendering the same cached snapshot reported a different freshness (the v1.7.4 redeploy moved NDX 192.2→247.2 and SMH 187.5→242.5, both exactly +55.0 min, while snapshot_id and timestamp stayed identical). The capture time is now persisted alongside the cached chain (<symbol>_<snapid>.captured.json); source_timestamp_age_min = captured_at_utc − source_timestamp_utc is computed once and never changes on re-render. The wall-clock delta since capture is published separately as render_lag_min. Regression test re-renders the same snapshot twice and asserts byte-identical JSON apart from render_lag_min. FIX 76: the reconciliation denominator convention for variants is decided and documented as "variant" — each chart is scored against the book it actually depicts, because its curve-derived levels are fit to its own bars. Consequence: an exfront variant can fail reconciliation while main passes (NDX exfront drops ~$999M of 0DTE, shrinking the denominator from $2.74B to $1.74B), which is a true statement about the variant’s smaller book, not an artifact. Published as reconciliation_denominator_basis: "variant" in every JSON; methodology section updated. FIX 77: the unsigned branch of the FIX 73 dual gate is now exercised by a synthetic test. Engineered fixture: two multi-day expiries whose reported gammas deviate from Black-Scholes in opposite directions with equal OI weight, so per-expiry gaps cancel in the signed sum (signed 0.003 < 0.05) yet add in the unsigned sum (unsigned 0.63 ≥ 0.10). Asserts reconciliation_pass_basis == "unsigned", profile_reliable == false, and the reconciliation-failure banner renders — confirming the basis string is genuinely derived, not defaulting to "signed". FIX 78: zero_greek_contracts_dropped now reflects the variant’s own book. Previously both main and exfront reported the same full-chain count (NDX: 2855 for both, despite different n_contracts), mislabelling exfront files. The full-chain count is preserved as zero_greek_contracts_dropped_full_chain (identical across variants); the variant-specific count subtracts the front expiry’s zero-greek contracts for exfront. Both fields emitted unconditionally (canonical schema preserved). Tests: 56.
v1.7.4 (2026-07-27): Four fixes. FIX 71: render_bucket is now wired into the actual renderer. Previously the published render_spacing (from the FIX 44 populated-spacing heuristic) and render_bucket (the window-span rule) were two independent formulas — the bars were driven by the bucket but the published spacing came from the other, so they disagreed in the main files (NDX 25 vs 50, SMH 3.75 vs 5.0). The bucket is now computed ONCE in snapshot.py and passed into the renderer; render_spacing is published as an alias of render_bucket and the two can never diverge. Regression test asserts render_spacing == render_bucket for every ticker and both variants. FIX 72: the bucket-sizing window is now spot × (1 ± plot_band) — the region where bars actually exist — not the level-extended window. Distant levels (SMH’s HVL at 640, +18.5%) used to stretch the window to 161.9 and force bucket 5.0, merging strikes near spot; now SMH span ≈ 104 → raw 2.6 → bucket 2.5 (restores the correct look), NDX span ≈ 2455 → bucket 50 (unchanged). A level that falls outside the bar window is drawn as an edge marker/arrow (▲/▼) with its price labelled and tagged “(off-scale)” in the legend, rather than extending the plotted range; multiple off-scale labels on the same edge are stacked. FIX 73: reliability is no longer gated on the signed error alone. profile_reliable now requires BOTH rel_err < 0.05 AND rel_err_unsigned < 0.10; the signed headline can pass on cancelling per-expiry errors (NDX main: signed 0.031 but unsigned 0.076) while the unsigned figure cannot. reconciliation_pass_basis names the binding metric (signed/unsigned/signed+unsigned), and rel_err_denominator + rel_err_unsigned_denominator are published so a variant scored against a smaller book (NDX exfront drops ~$999M of 0DTE) is visibly so. FIX 74: one canonical schema. front_expiry_am_settled (index-only) and atm_iv_status (outlier-only) are now emitted as null rather than omitted, so every output file — index or equity/ETF, main or exfront — matches the exact same JSON key set. Regression test renders an index and an equity/ETF chain and asserts identical keys. Tests: 53.
v1.7.3 (2026-07-27): Three fixes. FIX 68: the exfront variant was running stale code — its published JSON was missing every field added in v1.7.1/v1.7.2 (gamma_precision_digits, bars_reliable, profile_reliable, rel_err_unsigned, dollar_gap/gap_share) and still carried the removed top-level band_basis. Root cause: the exfront files on disk predated those releases; the code path itself was already unified (both variants call the same _process_and_render). Regenerated all three tickers’ exfront outputs. Added a regression test asserting the exfront JSON key set is IDENTICAL to the main run’s, so the two can never drift again. FIX 69: render_bucket reimplemented as originally specified — raw = visible_window_span / 40, bucket = the ladder value [0.5,1,2.5,5,10,25,50,100,250,500] CLOSEST to raw, then max(bucket, strike_increment). SMH: span ~98 → raw 2.45 → bucket 2.5 (was 5.0). NDX: span ~2310 → raw 57.75 → bucket 50. Published as render_bucket; levels stay at true strike resolution. FIX 70: three-state gamma precision. The boolean reported_gamma_low_precision is replaced by gamma_precision: "high" (3+ digits) | "adequate" (2) | "coarse" (1). The coarse-bars chart warning and the bars_reliable=false flip now fire only for "coarse" (NDX, 1 sig fig); SMH and SPY (2 sig figs, ~1.3% granularity, corroborated by <1% reconciliation) are "adequate" and keep bars_reliable=true. reported_gamma_low_precision and bars_reliable retained as derived aliases. Tests: 50.
v1.7.2 (2026-07-27): Three fixes. FIX 65: calendar-time T for ALL expiries, not just 0DTE. The business-day path (full_days/252) is deleted entirely; T = minutes_to_settlement / (365×24×60) for every expiry. Cboe’s reported gamma reflects actual calendar time remaining; the old business-day convention diverged by sqrt(252/365 × 7/5) ≈ 1.28 in gamma for multi-day expiries — the dominant residual error after FIX 62. SMH 2026-07-31: T_business=0.0190 vs T_calendar=0.0115, ratio 1.64, sqrt=1.28, matching observed reported/recomputed=1.235. pandas_market_calendars retained only for the trading-day guard and DTE labels. FIX 66: Cboe gamma precision detection. gamma_precision(df) counts significant figures in the median non-zero reported gamma; when ≤ 2 (e.g. NDX at ~28 000: all strikes report exactly 0.0001), reported_gamma_low_precision: true is published, bars_reliable flips to false, and a footnote appears on the chart: “Cboe publishes gamma to 4dp; at this price level that is ~1 significant figure — bar magnitudes are coarse.” The reconciliation gap for such symbols is a DATA limit, not a code bug. FIX 67: when ATM IV resolves from a single strike (no interpolation), band_basis is now "atm_iv_single_strike" instead of "atm_iv", so the audit trail is honest about the input quality. Tests: 49.
v1.7.1 (2026-07-27): Reconciliation diagnostics overhaul + the 0DTE T fix. FIX 61: the reconciliation banner no longer condemns the whole chart — bars use Cboe’s REPORTED gamma and are unaffected, so the banner now reads “PROFILE RECONCILIATION FAILED — curve-derived levels (HVL, GEX Transition, delta-neutral) unreliable; bars, Call Resistance and Put Support unaffected” and sits in the top margin clear of the bars. The single publishable flag is replaced by bars_reliable: true (always) and profile_reliable: ; the affected levels are individually marked hvl_reliable / gex_transition_reliable / delta_neutral_reliable: false. FIX 62: same-day expiries (full_days == 0) now use CALENDAR clock time to settlement (minutes to 16:00 ET, or 09:30 for AM-settled, over 365×24×60) instead of session-fraction time — Cboe’s reported gamma reflects actual hours remaining, and the old session_frac/252 convention diverged ~5x in T at 0DTE (~2.25x in gamma), which drove the whole headline gap. Multi-day path unchanged. FIX 63: reconciliation_worst_expiries now ranks by DOLLAR gap (abs(reported − recomputed)) descending, not rel_err, publishes the top 5, and each entry carries dollar_gap + gap_share (share of the total dollar gap) so it is obvious what to chase. FIX 64: an UNSIGNED reconciliation (rel_err_unsigned = Σ|per-expiry gap| / Σ|reported GEX|) is published alongside the signed one — the signed headline can pass on cancelling errors, the unsigned figure cannot. Tests: 47.
v1.7.0 (2026-07-27): Three fixes + audit cleanup. FIX 58: a failing reconciliation no longer publishes silently — every snapshot now carries publishable (mirrors reconciliation.pass); failing runs log ERROR naming the worst expiry from reconciliation_worst_expiries, stamp a boxed banner across the plot (“RECONCILIATION FAILED — rel_err NN% — values unreliable”, negative-bar colour), and the page shows a warning strip above the chart. The run still lands in gex_out/ (audit trail preserved). FIX 60: bars now target a bar COUNT, not a spacing — strikes aggregate into buckets via render_bucket(window_span, strike_increment) (ladder [0.5…500] ≥ increment, value whose bar count lands closest to 40), bar height = 0.8·bucket, published as render_bucket. Bucketing is rendering only; call_resistance, put_support, hvl, gex_transition, delta_neutral and the outlier report stay at true strike resolution. NDX 2310/10→50 (46 bars, not 100); SMH 98/1→2.5 (unchanged appearance). MINOR: removed the redundant top-level band_basis (canonical field is bands.band_basis, which the page reads); fixed four stale audit items (§3 T formula now documents the continuous (full_days + session_fraction)/252; §4 “±15M-scale axis” replaced with the data-driven bar limit; §7 hardcoded “plot_band (0.08)” now shows the IV-derived value with 0.08 as fallback-only; bars section documents FIX 60). Determination: atm_iv_status="single_strike_no_interpolation" already records input quality alongside band_basis (the IV source), so no separate basis label is needed. FIX 59 (0DTE reconciliation gap) is diagnosed separately; no T change shipped in this release.
v1.6.6 (2026-07-25): Two corrections + one confirmation, no calc changes. FIX 56: the v1.6.5 note wrongly said band_basis was “computed + logged but never published”; in fact it was always published nested in the bands block (levels["bands"] = bands) — the v1.6.5 top-level copy was a harmless duplicate, not a restoration. Verified all seven bands keys (atm_iv_used, sigma_30d, strike_band, plot_band, profile_band, dex_band, band_basis) emit identically on a fresh run vs the deployed SMH 07-24 file; none missing. FIX 57: corrected the changelog attribution — the crushed NDX curves were caused entirely by the spot-derived axis formula (±3000B against a 1.85B reading), fixed by FIX 50; FIX 48’s expired-contract drop is a valid guard but against a failure mode not yet observed in live data (Cboe drops expired series from the feed, so the Saturday cache carried zero past-dated contracts). CONFIRM: the NDX 2026-07-25 Saturday snapshot’s 404 is the intended outcome of the earlier stale-snapshot cleanup, not a side effect of the index rebuild (build_index_json only writes index.json, deletes nothing); SMH 07-24 pm and pm_exfront (JSON + PNG) all still resolve HTTP 200.
v1.6.5 (2026-07-25): Plumbing only, no methodology change. FIX 51: index.json is derived by scanning gex_out/ for directories with ≥1 snapshot JSON; default is now always a member of tickers (falls back to the first listed ticker), and any configured ticker with no output is named in a WARNING. FIX 52: new --replay-unsafe flag bypasses ONLY the FIX 49 trading-day/session-window check so an out-of-session cached chain can exercise FIX 48/50; it logs ERROR “REPLAY MODE” on every run, stamps replay_unsafe: true into the JSON, and forces output to a scratch out_replay/ dir (never gex_out/). FIX 53: a top-level band_basis convenience copy was added to the levels JSON (the field was already published nested under bands; see v1.6.6 FIX 56); NDX replay reports strike_increment=10, render_spacing=50, band_basis=atm_iv, atm_iv=0.256, expired_contracts_dropped=0 (the Saturday NDX cache’s earliest expiry is 2026-07-27, after the snapshot, so there are genuinely no past-dated contracts to drop). FIX 54: SPY produces no chart because its cached source timestamp converts to Friday 23:44 ET, outside the 09:30–16:15 window — FIX 49 correctly refuses; no action. FIX 55: removed the SMH-specific “13% up” example from the plain-English HVL explainer so it cannot contradict the displayed ticker’s own hvl_distance_pct.
v1.6.4 (2026-07-25): Documentation and test accuracy only. Added the missing v1.6.2/v1.6.3 changelog entries; corrected section 3 rule 6 to document the IV-derived bands (strike_band = clip(0.80×sigma_30d, 0.04, 0.20), plot_band = clip(0.50×sigma_30d, 0.03, 0.15)) with the fixed 0.12/0.08 constants applying only when band_basis == "fallback"; corrected the manifest test count; and added a regression test asserting a firing outlier guard renders the red FAULT footnote.
v1.6.3 (2026-07-25): Documentation accuracy only, no behaviour change. Section 5’s “rules 1–4 only” profile claim was stale after FIX 48 inserted a new rule 1 (it implied unpriced/zero-gamma strikes feed the curves); rewritten to name the rules rather than number them so the next renumbering cannot break it. Section 5’s profile grid corrected from the abandoned “step = max(increment/5, 0.10), profile_band default 0.25” to what actually runs: a fixed profile_grid_points = 400 grid whose span is IV-derived (clip(1.50×sigma_30d, 0.06, 0.35) GEX, clip(2.50×sigma_30d, 0.10, 0.50) DEX). Section 1 now documents the FIX 49 source-timestamp guard (NYSE trading day AND 09:30–16:15 ET on the data’s own timestamp, the 240-min age refusal, and the --from-cache age exemption) so a reader asking “why is there no SPY chart” finds the answer in the overview.
v1.6.2 (2026-07-25): Corrections to FIX 48–50. (a) The profile outlier guard’s width test moved from a fraction of grid points to a price width (% of spot), so one threshold means the same on every grid (GEX / wider HVL / DEX) and every chain; a firing is now logged at ERROR and surfaced as a red “⚠ FAULT” footnote on the chart, not only in the JSON. Deviation from the original FIX 48 spec: the spec’s pure 5%-of-others magnitude test dropped legitimate dominant structure (the ATM 0DTE peaks at ~50% of others; a far-OTM wing-shaper legitimately dominates its wing) and broke reconciliation, so the guard instead requires a contract to be BOTH dominant (>5× the sum of all others) AND a narrow spike (<5% of spot) — the floored-T explosion signature. (b) --from-cache is no longer blocked by the 240-min age refusal (logged as INFO instead), restoring the reproducibility guarantee; the NYSE-trading-day and 09:30–16:15 ET checks stay active in all modes because they validate the data, not its age. (c) gex_out/index.json is regenerated from build_index_json (it had been hand-edited to list tickers with no snapshots); NDX remains the configured default. (d) Documentation corrections: axis modes (data/rolling), the FIX 48 filtering rule, the outlier-guard disclosure, and the reconciliation threshold (0.10 → 0.05 to match the code).
v1.6.1 (2026-07-25): Three-fix patch. FIX 48 (expired-contract guard): expired contracts could survive the filter because business_days_to_expiry returned 0 for a past expiry; it now returns −1 and filter_contracts_full additionally drops any contract whose raw calendar expiry is before the snapshot date (counted in expired_contracts_dropped). Note (corrected v1.6.6): this is a guard against a failure mode not yet observed in live data — Cboe drops expired series from the feed, so no cached chain has carried past-dated contracts; the crushed NDX curves were caused by the axis formula and fixed by FIX 50 below, not by FIX 48. Profile functions now return (profile, outliers): a contract is dropped only if it is BOTH dominant (>5× the sum of all others at its peak grid point) AND a narrow spike (<10% of grid points above half its peak) — the floored-T gamma-explosion signature — published as profile_outliers_dropped; legitimate dominant structure (ATM 0DTE, far-OTM wings) is wide and kept. All profile arrays are asserted finite before rendering. FIX 49 (supersedes FIX 47): a snapshot is refused unless its SOURCE timestamp (converted to ET) falls on an NYSE trading day AND within 09:30–16:15 ET; source_timestamp_age_min warns above 45 and refuses above 240; stale weekend/after-hours NDX and SPY snapshots deleted. FIX 50 (this fixed the crushed curves): profile axis limits are data-driven (profile_axis_mode="data" = ±1.10× visible-window max) with a rolling mode (±1.2× median of the last 20 per-ticker profile maxima, falling back to data under 5 snapshots); the spot_relative formula and axis_ref_oi are deleted (that spot²-scaled formula made the axis dwarf low-OI chains like NDX, flattening the curves); the axis formatter now renders trillions ("3T") and drops trailing ".0".
v1.6.1 (2026-07-25): Five-fix patch — root cause was absolute SMH-scaled thresholds; all now chain-relative. FIX 43: ATM-IV OI gates are now max(2%, 200) expiry / max(0.02%, 10) per-contract (was fixed 5000/250), search widens to 3 nearest qualifying expiries, and the fallback IV is clip(RV20×1.1, 0.10, 1.00) published as band_basis="fallback_from_rv" — NDX now resolves a real atm_iv instead of the hardcoded 0.30. FIX 44: bar height follows populated-strike spacing (render_spacing) not listed spacing, so bars render contiguous; both strike_increment and render_spacing published. FIX 45: per-expiry reconciliation breakdown (reconciliation_worst_expiries) and zero_greek_contracts_dropped published, AM-settled index T ends at Thursday close, threshold tightened 0.10→0.05 — dominant NDX contributor is BS-gamma vs reported-gamma divergence (worst expiry 2026-07-29, rel_err 0.59). FIX 46: front-expiry footnote now bounded to 100% via *_front_expiry_abs_share (denominator Σ|gex| across expiries), signed diagnostic kept as *_front_expiry_net_ratio. FIX 47: snapshots are refused when the SOURCE timestamp falls outside 09:00–17:00 ET on a trading day; source_timestamp_age_min and explicit front_expiry_am_settled published.
v1.6.1 (2026-07-25): hvl_raw (and any pre-existing *_raw) now preserves the unrounded zero-crossing instead of being overwritten by the already-snapped value (e.g. 629.8185, not 630.0). NDX + SPY snapshots now generated and served.
v1.6.0 (2026-07-25): Ticker-agnostic architecture — adding a symbol is now appending one string to cfg.tickers. NDX + SPY added.
FIX 33 — Spread netting corrected. (a) sensitivity_spread_netted renamed sensitivity_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 removal. (b) spread_candidates greedy-deduped (sort by combined |GEX| desc, accept only if neither strike already used within expiry+right) so overlapping pairs no longer inflate spread_flagged_share.
FIX 34 — Ticker registry. One tickers list + default_ticker; no symbol-name branching anywhere else. Endpoint winner cached to data/endpoint_map.json; endpoint_variant ("plain"/"underscore") and instrument_class ("index" if underscore endpoint required, else "equity_etf") published. contract_spec_overrides (multiplier/settlement only, ships empty) and cosmetic display_labels added.
FIX 35 — Volatility-scaled bands. All framing derived from the chain’s own ATM IV: sigma_30d = IV×√(30/365), then strike/plot/profile/dex bands are multiples of sigma_30d clipped to band_limits guardrails. Two-pass (coarse ATM-IV pick, then pipeline). bands block published in every JSON. SMH output materially unchanged (IV 0.61 → bands land near the old hand-tuned values).
FIX 36 — Fixed-count profile grid. profile_grid_points = 400 via np.linspace (separate GEX/DEX grids) so cost is constant across symbols. Every published level snapped to the strike increment with the unrounded value in *_raw.
FIX 37 — Scale assumptions purged. X-axis tick ladder generated dynamically (power-of-ten base × {1,2.5,5,10}, K/M/B format) instead of a fixed 1M–1000M ladder. Spread OI floor now max(2000, 0.5% of chain OI) instead of a fixed 10k. Index AM-settlement handled generically: front_expiry_am_settled: true when instrument_class=="index" and front expiry is a third Friday.
FIX 38 — Page selectors. Ticker + slot + expiry selectors driven by generated gex_out/index.json; choice persisted in localStorage; missing combination shows "no snapshot" not a broken image.
FIX 39 — Page copy. Methodology collapsed into a <details> (expanded on click); plain-English "what this chart shows" section added above the fold.
FIX 40 — History CSV. One row per ticker/slot/variant appended to data/history/{SYMBOL}.csv each run (header created if absent; existing rows never rewritten).
Tests: 39 (36 from v1.5.0 + 3 new: add-ticker-no-code-change, bands-scale-with-iv, grid-fixed-count).
v1.5.0 (2026-07-25): Five fixes focused on making HVL a single defined level again.
FIX 28 — Inflection rule 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 v1.4.0 indeterminacy band is also retired.
FIX 29 — HVL = gamma-profile zero crossing nearest spot. Always a single defined level.
Published: hvl, hvl_distance_pct, hvl_regime_note
(near spot / moderately distant / far from spot), hvl_status, hvl_crossings.
If no crossing in ±25% grid, widened to ±40% and retried. Still none →
hvl = null, hvl_status = "no_flip_in_range", chart annotates
"no gamma flip within ±40% of spot".
FIX 30 — GEX Transition. The per-strike bar sign flip (formerly bar_flip)
is promoted to its own named level. Marks where strike-level net gamma changes sign locally
(distinct from HVL which marks where TOTAL portfolio gamma flips). Persistence-hardened: sign
must hold for ≥3 consecutive populated strikes on each side. Published:
gex_transition, gex_transition_status,
gex_transition_distance_pct. Colour #7FA6C9.
FIX 31 — Vertical-spread detection. 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. detect_spread_candidates(df_full) finds pairs within each
expiry/right where ratio ≥ 0.80, both OI ≥ 10k, width ≤ 8×increment. Published:
spread_candidates, spread_flagged_share,
sensitivity_spread_netted (illustrative bound, NOT the headline). Disclosure, not
correction — the headline chart stays gross-OI.
FIX 32 — ATM IV cross-check. atm_iv compared against
realised_vol_20d (annualised, from 20 daily closes via Yahoo Finance chart API).
Published: iv_hv_ratio, vol_regime, atm_iv_source_detail.
Gate: if ratio > 2.5 or < 0.4, atm_iv_status = "iv_hv_outlier — verify" and
expected-move/min/max suppressed from chart (kept in JSON with flag).
Tests: 36 (31 from v1.4.0 + 5 new).
v1.4.0 (2026-07-25): Six fixes: gamma_condition from profile sign; HVL indeterminate
band (spread > 10%); ATM IV liquidity gate; max_expiry concentration warning;
interior_extremum + DEX grid ±40%; ephemeral level daggers. 31 tests.
v1.3.0 (2026-07-24): Seven fixes: dealer-proxy honesty; expiry concentration;
bars↔profile reconciliation; continuous time-to-expiry; ATM IV picker; robust x-limits;
profile axes. 25 tests.
v1.2.0 (2026-07-23): Five fixes: separate profile axes; outlier report; OI sanity
check; strike-increment detection; UTC→ET timestamp. 18 tests.
v1.1.0 (2026-07-22): Four fixes: plot window widening; honest clipping; no symlog;
layout guard. 13 tests.
v1.0.0 (2026-07-21): Initial release. Net GEX All Expirations chart for SMH/SPY using
free delayed Cboe data. 9 tests.
Source published for public audit. No API keys required. Generated 2026-07-28 17:14 UTC.