Historical Max Pain Data API - Pin Probability, Pain Curves & Dealer Alignment Over Time | FlashAlpha

Historical Max Pain Data API - Pin Probability, Pain Curves & Dealer Alignment Over Time

Pull max pain strike, pain curve, pin probability, and dealer alignment at any minute since 2018. This guide shows the response shape, runnable Python for measuring how often max pain actually predicts the close, and how to combine max pain with gamma flip for a tighter pin signal.

T
Tomasz Dobrowolski Quant Engineer
Apr 15, 2026
14 min read
MaxPain PinRisk HistoricalData OptionsAPI Backtesting Quant

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 2018-04-16. 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=
ParamDescription
symbolUnderlying (SPY today)
atyyyy-MM-ddTHH:mm:ss ET wall-clock - the as-of
expirationOptional 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 Caveats

SymbolsSPY (more on demand)
Dates2018-04-16 → 2026-04-02
ResolutionPer-minute (max pain itself is OI-driven and doesn't move intraday in any meaningful way; spot/distance/expected-move blocks do)
TierAlpha ($1,499/mo)
OI sourceEOD-stamped - applied across all intraday timestamps on the trading day

Why max pain doesn't shift much intraday: open interest is end-of-day (one value per trade day). Max pain is a function of OI, so the strike itself stays constant within a trading day. What shifts intraday is the distance from spot to max pain (because spot moves) and the expected-move bounds (because IV moves). For multi-day max-pain dynamics, sample once per day; for distance and pin-context dynamics, sample intraday.


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.

Where you'd still build it yourself: bespoke pin models that disagree with the FlashAlpha methodology, or weighted-pain calculations (volume-weighted, vega-weighted) that aren't standard max-pain.


Related Articles

Historical API · Alpha tier · from $1,199/mo
Replay any analytics endpoint at any minute since 2018
Same response shape as live, leak-free percentiles, 6.7B option rows for SPY, more symbols on demand.
View pricing →
Data freshness: intraday data through the previous trading day's close, refreshed by the daily pipeline run. Live coverage status at /v1/tickers.

Upgrade to Alpha API Spec

Live Market Pulse

Get tick-by-tick visibility into market shifts with full-chain analytics streaming in real time.

Intelligent Screening

Screen millions of option pairs per second using your custom EV rules, filters, and setups.

Execution-Ready

Instantly send structured orders to Interactive Brokers right from your scan results.

Join the Community

Discord

Engage in real time conversations with us!

Twitter / X

Follow us for real-time updates and insights!

GitHub

Explore our open-source SDK, examples, and analytics resources!