If you've spent any time in options content, you've seen the claim: "the market gravitates toward max pain on expiration." It's repeated by traders, blog posts, and YouTube channels. It's almost never tested at scale, because doing so requires historical max pain data - pain curve, max pain strike, pin probability - at minute resolution, joined to actual close prices across thousands of expiries. That dataset hasn't been buyable until now.
FlashAlpha's Historical API ships /v1/maxpain at every minute since 2017-01-03. Same shape as the live endpoint. This article walks the response, then runs the basic pin-rate test you've always wanted to see.
The Endpoint
GET https://historical.flashalpha.com/v1/maxpain/{symbol}?at=&expiration=
| Param | Description |
symbol | Underlying, e.g. SPY |
at | yyyy-MM-ddTHH:mm:ss ET wall-clock - the as-of |
expiration | Optional yyyy-MM-dd filter; defaults to nearest expiry |
What Comes Back
{
"symbol": "SPY",
"underlying_price": 679.46,
"as_of": "2026-03-05T15:30:00",
"max_pain_strike": 684,
"distance": { "absolute": 4.54, "percent": 0.67, "direction": "below" },
"signal": "neutral",
"expiration": "2026-03-05",
"put_call_oi_ratio": 2.033,
"pain_curve": [ { "strike": ..., "call_pain": ..., "put_pain": ..., "total_pain": ... } ],
"oi_by_strike": [ { "strike": ..., "call_oi": ..., "put_oi": ..., "total_oi": ... } ],
"max_pain_by_expiration": [ { "expiration": "...", "max_pain_strike": ..., "dte": ..., "total_oi": ... } ],
"dealer_alignment": {
"alignment": "moderate",
"description": "...",
"gamma_flip": 677.5,
"call_wall": 685,
"put_wall": 680
},
"regime": "positive_gamma",
"expected_move": { "straddle_price": 1.57, "atm_iv": 19.6, "max_pain_within_expected_range": false },
"pin_probability": 45
}
Five things to highlight in the response:
max_pain_strike: the strike at which total option holder loss is maximized (i.e. dealer P&L is best).
pain_curve: total pain at every strike - the full curve, not just the minimum point.
pin_probability: a 0-100 score blending OI concentration, gamma alignment, expected-move bounds, and time-to-expiry. Not the same as max pain - it's the FlashAlpha pin model.
dealer_alignment: cross-references max pain with gamma flip, call wall, put wall - gives you "alignment" labels (typically converging, moderate, diverging) for whether the dealer-positioning picture supports the pin or not.
max_pain_by_expiration: max pain across every loaded expiry, not just the requested one. Use this for term-structure plots of the pin level.
The Test Everyone Wants to See
Does the close on expiration day actually pin to max pain? Run it.
import httpx, pandas as pd
from tqdm import tqdm
API_KEY = "..."
BASE = "https://historical.flashalpha.com"
# SPY weekly Friday expiries, 2024-2025
fridays = pd.date_range("2024-01-05", "2025-12-26", freq="W-FRI")
rows = []
with httpx.Client(headers={"X-Api-Key": API_KEY}, timeout=30) as c:
for d in tqdm(fridays):
# Max pain at 15:30 on expiration day, for that day's expiry
mp = c.get(
f"{BASE}/v1/maxpain/SPY",
params={"at": d.strftime("%Y-%m-%dT15:30:00"),
"expiration": d.strftime("%Y-%m-%d")},
)
if mp.status_code != 200: continue
m = mp.json()
if not m.get("max_pain_strike"): continue
# Close spot
sq = c.get(f"{BASE}/v1/stockquote/SPY",
params={"at": d.strftime("%Y-%m-%dT16:00:00")})
if sq.status_code != 200: continue
rows.append({
"date": d,
"spot_1530": m["underlying_price"],
"max_pain": m["max_pain_strike"],
"pin_prob": m.get("pin_probability"),
"alignment": m["dealer_alignment"]["alignment"],
"spot_close": sq.json()["mid"],
})
df = pd.DataFrame(rows)
df["close_dist_to_pain"] = (df["spot_close"] - df["max_pain"]).abs()
df["close_dist_to_random"] = (df["spot_close"] - df["spot_1530"]).abs()
print(f"Median close-to-max-pain: ${df['close_dist_to_pain'].median():.2f}")
print(f"Median close-to-15:30 spot: ${df['close_dist_to_random'].median():.2f}")
print(f"Pin success rate (close within $1 of max pain): {(df['close_dist_to_pain'] < 1).mean():.1%}")
Two summary numbers tell you whether the cliché holds. Median close_dist_to_pain versus median close_dist_to_random - if max pain is informative, the first should be smaller. Empirically, on SPY weeklies it's somewhere between modestly smaller and roughly equivalent - the "always pins to max pain" claim is overstated, but max pain is not noise either.
Stratifying by pin_prob tightens the test:
df["pin_bucket"] = pd.cut(df["pin_prob"], bins=[0, 30, 60, 100], labels=["low", "med", "high"])
print(df.groupby("pin_bucket")["close_dist_to_pain"].agg(["count", "median"]))
The high-pin bucket - when OI concentration, gamma alignment, and expected-move bounds all line up - typically shows materially tighter close-distance than low-pin. Which is the whole point of having a pin probability score: it tells you when the cliché holds and when it doesn't.
Pattern: Dealer Alignment as a Filter
The dealer_alignment block scores whether max pain agrees with gamma flip and the call/put walls. When alignment is converging, all four signals point at the same level - that's a real pin setup. When alignment is diverging, max pain disagrees with the dealer picture and the pin claim weakens significantly.
df_conv = df[df["alignment"] == "converging"]
df_div = df[df["alignment"] == "diverging"]
print(f"Converging alignment ({len(df_conv)} expiries): "
f"median close-to-pain ${df_conv['close_dist_to_pain'].median():.2f}")
print(f"Diverging alignment ({len(df_div)} expiries): "
f"median close-to-pain ${df_div['close_dist_to_pain'].median():.2f}")
The converging subset typically shows materially tighter median close distance than the diverging subset on SPY weeklies - a measurable, exploitable signal that doesn't show up if you treat all expiries as equal.
Pattern: Max Pain Migration During the Week
Max pain isn't a static number - it shifts as OI builds and decays through the week. Track it intraday on a single expiry to see the migration:
from datetime import datetime, timedelta
# Trace the Friday Mar 5, 2026 expiry from Monday open through Friday close
expiry = "2026-03-06"
t = datetime(2026, 3, 2, 9, 30)
end = datetime(2026, 3, 6, 16, 0)
rows = []
with httpx.Client(headers={"X-Api-Key": API_KEY}, timeout=30) as c:
while t <= end:
if t.weekday() < 5: # weekdays only
r = c.get(f"{BASE}/v1/maxpain/SPY",
params={"at": t.strftime("%Y-%m-%dT%H:%M:%S"),
"expiration": expiry})
if r.status_code == 200:
m = r.json()
rows.append({"t": t,
"spot": m["underlying_price"],
"max_pain": m["max_pain_strike"]})
t += timedelta(hours=1)
trace = pd.DataFrame(rows).set_index("t")
trace[["spot", "max_pain"]].plot(title=f"SPY {expiry} expiry - max pain migration vs spot")
You'll see max pain step strike-by-strike as OI rebalances, and converge as the OI distribution stabilizes into the close. When max pain pulls toward spot in the final hours, pin probability typically rises; when it drifts away, the pin breaks.
Coverage and Access
| Symbols | 200+ symbols in the archive; 54 with 8+ years of minute-level history, 14 from 2017-01-03 |
| Dates | 2017-01-03 → present |
| Resolution | Per-minute |
| Tier | Alpha ($1,199/mo billed annually, $1,499 monthly) |
Open interest is end-of-day, so the max pain strike holds within a trading day while spot distance, expected-move bounds and pin probability move minute by minute - sample once per day for max-pain migration and intraday for the pin context.
What This Replaces
- Computing pain curves yourself from raw historical chains → ships pre-computed.
- Building your own pin-probability score → use the FlashAlpha score directly, or refit on top of the response.
- Cross-referencing max pain with gamma flip and walls in custom logic → the
dealer_alignment block does this for you.
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
Max pain is the most-quoted and least-tested options metric. FlashAlpha's /v1/maxpain endpoint, replayed historically at minute resolution since 2017, makes the test trivial: pull max pain at 15:30 ET on every expiration, compare to the 16:00 close, stratify by pin probability and dealer alignment. The cliché is overstated; the pin-probability-stratified version is real. The endpoint also ships the pain curve, dealer-alignment cross-reference with gamma flip and walls, and max pain across every loaded expiry - enough to build a complete pin-research stack from one HTTP call.