Historical Max Pain API: Pin Probability & Pain Curves Over Time | FlashAlpha
maxpain · 14 min read

Historical Max Pain API: Pin Probability & Pain Curves Over Time

Pull max pain strike, pain curve, pin probability, and dealer alignment at any minute since 2017. 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 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=
ParamDescription
symbolUnderlying, e.g. SPY
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 Access

Symbols200+ symbols in the archive; 54 with 8+ years of minute-level history, 14 from 2017-01-03
Dates2017-01-03 → present
ResolutionPer-minute
TierAlpha ($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 →
Full coverage map at /v1/tickers.

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.

Live Market Pulse

Get fast visibility into market shifts with full-chain analytics over low-latency REST and MCP polling.

Intelligent Screening

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

Export-Ready

Export structured signals to your own execution stack or broker integration - FlashAlpha delivers the analytics, you keep control of order routing.

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!