If you've ever read a blog post claiming "short straddles when GEX is positive returns 30% a year" and wondered why the claim evaporates when you try to reproduce it, the answer is usually the same: the backtest cheated somewhere. Either it used data that didn't exist at the decision time, it labelled regimes using end-of-day aggregates that look clean but smear across the true intraday flip, or it skipped the execution costs that swamp the signal.
FlashAlpha's Historical Analytics API provides minute-level historical GEX data through the same analytics engine as live. Use the at parameter for decision-time regime labels and the historical VRP endpoint for leak-free percentiles. This article walks through the research pipeline.
Historical GEX Data: Minute-Level Coverage Since 2017
The Historical API mirrors 52 analytics routes with the same paths, fields and units as their live counterparts. The archive covers 200+ symbols, including at least 54 with eight or more years of history and 14 beginning on January 3, 2017. Minute-level quotes and Greeks support historical gamma exposure, historical GEX levels and regime studies; settled open interest and surface parameters have daily vintages. The historical methodology explains those inputs and leak-free percentile construction.
Replay runs the current engine over point-in-time archived inputs, rather than returning a log of previously served JSON. Try the route and symbol in the historical playground; the public coverage response at /v1/tickers gives each symbol's dates and gaps. SPX starts later than SPY, as the example below shows.
SPX Historical GEX: Check Coverage Before Choosing Dates
The public historical coverage response reports SPX coverage.first = 2022-01-05, with healthy-day counts and gaps per symbol. Those are coverage bounds, so refresh the response and test the dates your study needs before committing to a window.
| Symbol | First minute | Last session | Healthy sessions |
| SPY | 2017-01-03 | 2026-09-15 | 2,370 |
| QQQ | 2017-01-03 | 2026-09-15 | 2,352 |
| IWM | 2017-01-03 | 2026-09-15 | 2,435 |
| SPXW | 2018-05-16 | 2026-09-15 | 2,092 |
| SPX | 2022-01-05 | 2026-09-15 | 1,175 |
Snapshot of /v1/tickers on September 17, 2026. A healthy session has every input layer present (quotes, Greeks, settled OI, surface parameters). The last session trails today by the archive's settlement lag.
A first SPX smoke test is GET https://historical.flashalpha.com/v1/exposure/gex/SPX?at=2025-04-07T15:30:00, using an Alpha key in the X-Api-Key header. The timestamp is Eastern wall-clock under the historical API contract. This is a request example, not a captured SPX result. Inspect the returned timestamp, units, expiry scope, provenance and errors before extending the date range. SPY coverage cannot be substituted for SPX coverage; futures history is a separate question.
For today's levels, open SPX gamma exposure. For research, keep the historical response and retrieval date: replay recomputes the analytics from archived point-in-time inputs with the current engine, so it reflects today's methodology rather than a log of past responses.
A Research Example with Inspectable Evidence
The 50-strategy research and downloadable ledgers show how to make a historical options study inspectable. Its monthly ETF sample spans SPY, QQQ and IWM from January 2020 to December 2025, with six structures compared on 133 matched symbol-month windows. The after-cost option scenario buys at ask, sells at bid and charges $0.65 per contract per side. Results use a 100-share entry-value denominator. This is a structure comparison, not a test of the intraday GEX hypothesis below; earnings and the wheel have separate samples.
Use that study's ledger format to record the GEX observation time, entry, exit and costs in your own test. For the intraday example below, use an exchange calendar, exclude early closes from a 15:30-to-16:00 study, log missing sessions and preserve unknown regimes.
What We're Testing
A simple, well-known hypothesis: SPY mean-reverts intraday when dealers are long gamma and trends when dealers are short gamma. Specifically, we'll test whether short-term (30-minute) returns after 15:30 ET differ by dealer regime. If the hypothesis is real, 15:30-to-close returns should be smaller and more mean-reverting during positive-gamma regimes and larger (in either direction) during negative-gamma regimes.
This is a textbook GEX claim. It's also exactly the kind of thing that looks real in a sloppy backtest and dies in a careful one. Let's do it carefully.
The pipeline in five steps
- Regime labels -
/v1/exposure/summary/SPY?at=<date>T15:30:00, one call per session.
- Forward returns -
/v1/stockquote/SPY?at=<date>T16:00:00, divided by the 15:30 spot from step 1.
- Condition on regime - group returns by
positive_gamma / negative_gamma.
- Stratify on magnitude - decile
net_gex, fit bins on a training window.
- Scale to minutes - the same loop at 390 timestamps per session.
Step 1: Pull the Regime Labels
We need, for every trading day in the test window, the dealer regime at 15:30 ET. That's /v1/exposure/summary/{symbol} with at set to 15:30 on each day.
import httpx, pandas as pd
from tqdm import tqdm
API_KEY = "..."
BASE = "https://historical.flashalpha.com"
dates = pd.bdate_range("2022-01-03", "2025-12-31")
rows = []
with httpx.Client(headers={"X-Api-Key": API_KEY}, timeout=30) as c:
for d in tqdm(dates):
ts = d.strftime("%Y-%m-%dT15:30:00")
r = c.get(f"{BASE}/v1/exposure/summary/SPY", params={"at": ts})
if r.status_code != 200:
continue
j = r.json()
rows.append({
"date": d,
"spot_1530": j["underlying_price"],
"net_gex": j["exposures"]["net_gex"],
"net_dex": j["exposures"]["net_dex"],
"regime": j["regime"],
"gamma_flip": j["gamma_flip"],
"zero_dte_pct": j.get("zero_dte", {}).get("pct_of_total_gex", 0),
})
gex = pd.DataFrame(rows).set_index("date")
The date range contains roughly 1,000 sessions. The weekday grid above also includes exchange holidays; replace it with a trading calendar and log failed or missing requests instead of silently discarding them in a production study. Cache valid responses with their query parameters and provenance. Runtime depends on response time, rate limits and retries.
Leak check: the settled net_gex calculation combines the official OI available at the session open with the point-in-time chain and Greeks for 15:30 ET. The morning OI load is fixed during settled replay; intraday OI estimates belong to the separate flow simulator. Official OI uses the documented effective T+2 settlement basis, so it should not be described as necessarily yesterday's positions. See the input and computation methodology.
Step 2: Pull the Forward Returns
For each full session, we need the 15:30-to-16:00 ET return. The example reuses the summary's 15:30 underlying price and makes one stock-quote call at 16:00: two requests per date including the summary. Validate that both prices use a compatible convention.
close_rows = []
with httpx.Client(headers={"X-Api-Key": API_KEY}, timeout=30) as c:
for d in tqdm(dates):
r = c.get(f"{BASE}/v1/stockquote/SPY", params={"at": d.strftime("%Y-%m-%dT16:00:00")})
if r.status_code == 200:
close_rows.append({"date": d, "spot_close": r.json()["mid"]})
closes = pd.DataFrame(close_rows).set_index("date")
df = gex.join(closes, how="inner").dropna()
df["ret_30min"] = df["spot_close"] / df["spot_1530"] - 1
Leak check: the forward return uses 16:00 ET data to measure performance of a signal generated at 15:30 ET. There's a 30-minute gap, which is the signal window. No feature crosses the signal-to-outcome boundary.
Step 3: Condition on Regime
by_regime = df.groupby("regime")["ret_30min"].agg(
n="count",
mean_bps=lambda x: x.mean() * 10000,
std_bps=lambda x: x.std() * 10000,
abs_mean_bps=lambda x: x.abs().mean() * 10000,
)
print(by_regime)
The interesting column is abs_mean_bps - the average absolute move. If the mean-reversion hypothesis is right, it should be smaller in positive_gamma than in negative_gamma. If the directional hypothesis is right (short puts in positive gamma), mean_bps should differ too.
Compare abs_mean_bps and sample counts across regimes, then repeat on held-out dates. Smaller absolute moves indicate volatility compression; test mean reversion separately against the preceding move and include execution costs before turning the result into a trading rule.
Step 4: Stratify on GEX Magnitude
The regime label is a coarse cut. The underlying net_gex is continuous. For a descriptive in-sample comparison, bucket by deciles. Full-sample bins use future observations and must not become trading features; fit bins on a prior training window for an out-of-sample test:
df["gex_decile"] = pd.qcut(df["net_gex"], 10, labels=False)
by_decile = df.groupby("gex_decile").agg(
gex_mean=("net_gex", "mean"),
n=("ret_30min", "count"),
abs_move_bps=("ret_30min", lambda x: x.abs().mean() * 10000),
)
print(by_decile)
What you want to see is a monotone curve - low GEX deciles should show larger absolute moves, high GEX deciles smaller. If the curve is flat or non-monotone, the regime signal isn't really tracking what you think it's tracking and the rest of the strategy won't work.
Step 5: Scale Up - Every Minute, Every Day
Once the daily-grid version works, the minute-level version is mechanical. Instead of one sample per day at 15:30, pull every minute from 9:30 to 16:00 and condition on intraday regime changes. That's 390 calls per day × 1000 days = 390,000 calls, subject to the plan's throughput limits.
The minute-level grid lets you answer strictly-better questions:
- What happens in the 15 minutes after a regime flip? Filter
df to rows where regime[t] != regime[t-1], compute forward returns over varying windows.
- Do moves compress as spot approaches the gamma flip? Compute
spot_to_flip_pct and stratify.
- Is 0DTE GEX a stronger signal intraday than all-expiry GEX? The
zero_dte block in the summary gives you both; run the same test on each.
These are the questions the dealer-positioning literature claims to answer but rarely tests at the resolution of the claim. The Historical API makes them runnable.
The Three Traps This Setup Avoids
| Trap | What goes wrong | What this setup does |
| Rolling-percentile leakage | A percentile at time t is computed with data from after t. | Uses the historical VRP endpoint, whose percentiles are date-bounded. |
| End-of-day smearing | Close-of-day GEX labels a 10:30 entry that could not have seen it. | Labels every bar with the regime as it stood at that minute. |
| Survivorship | The universe is today's survivors, not what was tradable then. | Fixed SPY study; single names need dated universe membership. |
1. Rolling-Percentile Leakage
If your backtest uses VRP percentile or GEX percentile as a feature, make sure the percentile at time t is computed only from data strictly before t. FlashAlpha's VRP endpoint does this by default; custom percentiles you compute yourself need the same treatment. Full write-up on this →
2. End-of-Day Smearing
Net GEX can flip intraday. A backtest that uses close-of-day GEX to label a 10:30 ET entry is measuring a feature that didn't exist yet. The Historical API's minute resolution removes the excuse - you can label each bar with the regime as it stood at that bar's open.
3. Survivorship in Symbol Selection
A fixed SPY study avoids selecting a basket of today's surviving stocks, but its results remain specific to that instrument and sample. For single names, reconstruct the investable universe as it existed at each decision date, including delistings. The current /v1/tickers coverage list identifies available data; it is not a historical universe-membership database.
Going Beyond Regime Labels
Everything above uses the scalar regime and net GEX as the signal. The full exposure/summary response is richer - it includes net DEX (directional dealer positioning), net VEX (vanna - how positioning shifts with vol), net CHEX (charm - time-decay flow), and the interpretation text. For ML workflows, you'd pull the whole response and let the model sort out which fields matter:
def features_at(client, symbol, at_ts):
s = client.get(f"{BASE}/v1/exposure/summary/{symbol}", params={"at": at_ts}).json()
v = client.get(f"{BASE}/v1/vrp/{symbol}", params={"at": at_ts}).json()
return {
"spot": s["underlying_price"],
"net_gex": s["exposures"]["net_gex"],
"net_dex": s["exposures"]["net_dex"],
"net_vex": s["exposures"]["net_vex"],
"net_chex": s["exposures"]["net_chex"],
"gamma_flip": s["gamma_flip"],
"spot_to_flip_pct": (s["underlying_price"] - s["gamma_flip"]) / s["underlying_price"],
"zero_dte_pct": s.get("zero_dte", {}).get("pct_of_total_gex", 0),
"vrp_20d": v["vrp"]["vrp_20d"],
"vrp_z": v["vrp"]["z_score"],
"vrp_pct": v["vrp"]["percentile"],
"atm_iv": v["vrp"]["atm_iv"],
"hv_20": v["vrp"]["rv_20d"],
}
| Feature | Endpoint | What it captures |
net_gex, gamma_flip, spot_to_flip_pct | exposure/summary | Regime sign, the level where it flips, and how far spot sits from it. |
net_dex | exposure/summary | Directional dealer positioning. |
net_vex, net_chex | exposure/summary | How positioning shifts with volatility (vanna) and time decay (charm). |
zero_dte_pct | exposure/summary | Share of gamma sitting in same-day expiries. |
vrp_20d, vrp_z, vrp_pct | vrp | Implied-minus-realized premium and its date-bounded z-score and percentile. |
atm_iv, hv_20 | vrp | The two legs of that premium. |
That's about 15 features per timestamp with two API calls. For an LLM/transformer model, you'd pull the full nested responses and flatten lazily. For XGBoost or similar tabular models, the above is already in the right shape.
What You Actually Pay For
The cost of this backtest is a function of how many timestamps you pull. Alpha has no daily request cap, so the budget is wall time and discipline about caching, not quota.
| Resolution (4 years, one symbol) | Calls per endpoint | Summary + closing quote |
| One 15:30 snapshot per session | ~1,000 | ~2,000 |
| Every minute, 9:30 to 16:00 | ~390,000 | ~780,000 |
Cache each (symbol, at, endpoint) response to parquet once; a portfolio-sized study then re-reads locally instead of re-requesting.
The alternative - building this dataset yourself - requires maintaining ThetaData subscription, options parquets, BSM pipeline, SVI fitter, QuestDB or similar columnar store, minute-level partitioning, holidays/half-days handling, coverage reports. FlashAlpha's Historical API is the bought-not-built version of that stack.
Historical GEX Data Questions
How do I backtest a gamma exposure regime strategy?
Query minute-level historical exposure at each decision timestamp, join later prices to measure outcomes, and report results by regime. Use an exchange calendar, log exclusions and missing inputs, and keep full-sample analysis separate from an out-of-sample trading test.
How far back does SPX historical GEX coverage go?
SPX historical GEX starts on January 5, 2022; SPY, QQQ and IWM run from January 3, 2017. The coverage response on the historical host lists each symbol's dates, healthy days and gaps, so refresh it and test the route and minute you need before choosing a study window.
How many requests does a historical GEX study need?
At roughly 1,000 sessions over four years, one daily summary and one closing quote require about 2,000 requests per symbol, before retries. Sampling 390 minutes across those sessions needs 390,000 requests per endpoint. Runtime depends on latency and throughput limits.
Is historical GEX a saved live response?
Replay recomputes the analytics from archived point-in-time inputs with the current engine, so it reflects today's methodology rather than a log of past responses. Preserve query parameters, retrieval time, input vintages and available provenance fields.
What are the common traps in GEX backtests?
Rolling-percentile leakage uses future observations in a feature; end-of-day smearing uses closing GEX to label an earlier entry; survivorship bias selects only stocks still trading today. Use the historical VRP endpoint for leak-free percentiles, minute-level GEX for intraday labels, and historical universe membership for single-stock studies.
Can I backtest GEX strategies at minute resolution?
Yes. Historical exposure/gex, exposure/summary and exposure/levels accept the at timestamp for minute-level queries within each symbol's coverage. Sample once daily for a daily study or use finer timestamps for regime transitions. Across roughly 1,000 full sessions, 390 observations per session require 390,000 requests per endpoint; observe throughput limits and cache the responses.
Where can I get historical gamma exposure data with leak-free percentiles?
Use the Historical API exposure/gex route for historical gamma exposure, exposure/levels for historical GEX levels, and the VRP route for leak-free VRP percentiles and z-scores. These are separate outputs; the GEX response itself does not supply VRP percentiles. Alpha includes supported historical routes across 200+ symbols, with at least 54 starting eight or more years ago and 14 beginning on January 3, 2017.
Related Articles
Historical API · Alpha tier · from $1,199/mo billed annually
Replay 52 analytics routes at any minute since 2017
Same response shape as live, leak-free percentiles, 80+ billion minute-level option rows and a 4.6-billion-print trade tape, back to 2017.
View pricing →
Upgrade to Alpha
API Spec
A correct GEX backtest needs minute-level regime labels, leak-free percentiles and features computable at decision time. FlashAlpha's Historical API supplies historical gamma exposure and levels through the live analytics engine, with point-in-time inputs and date-bounded VRP statistics. Cache the responses, extend the code above, and test the dealer-positioning hypothesis against held-out sessions with explicit costs and exclusions.