Build a Max Pain Dashboard with an API: Track Options Pin Risk in Real Time | FlashAlpha

Build a Max Pain Dashboard with an API: Track Options Pin Risk in Real Time

Step-by-step guide to building a real-time max pain dashboard using the FlashAlpha API. Python code for pain curve charting, multi-expiry calendar, dealer alignment overlay, and pin probability monitoring.

T
Tomasz Dobrowolski Quant Engineer
Apr 9, 2026
18 min read
MaxPain API Python Dashboard OptionsAnalytics

What We're Building

Most max pain calculators give you a single number - a strike price - and leave you to guess whether it matters today. That is barely more useful than a coin flip. A proper max pain dashboard tells you the strike, how strong the pin is, whether dealers are aligned behind it, and how it shifts across expirations.

We are building one that answers the three questions every options trader asks before expiration:

  1. Where is max pain? - the strike, the distance from spot, and the direction.
  2. How strong is the pin? - pin probability score, OI concentration, dealer alignment.
  3. How does it evolve across expirations? - multi-expiry calendar showing max pain drift.

We'll use the FlashAlpha max pain API for the data and Python for the dashboard. The same logic works in any language - SDKs are available for JavaScript, C#, Go, and Java.

Prerequisites

  • A FlashAlpha API key - sign up here (Basic plan or higher for max pain)
  • Python 3.8+ with requests and matplotlib (or plotly for interactive charts)
  • Optional: the FlashAlpha Python SDK (pip install flashalpha)
pip install requests matplotlib flashalpha

Step 1: Fetch Max Pain Data

The /v1/maxpain/{symbol} endpoint returns everything in one call. Without an expiration filter, you get full-chain analysis plus per-expiry breakdown.

import requests

API_KEY = "YOUR_API_KEY"
BASE = "https://lab.flashalpha.com"
HEADERS = {"X-Api-Key": API_KEY}

def get_maxpain(symbol, expiration=None):
    """Fetch max pain data for a symbol."""
    url = f"{BASE}/v1/maxpain/{symbol}"
    params = {}
    if expiration:
        params["expiration"] = expiration
    resp = requests.get(url, headers=HEADERS, params=params)
    resp.raise_for_status()
    return resp.json()

# Full chain  -  includes multi-expiry breakdown
data = get_maxpain("SPY")
print(f"Symbol: {data['symbol']}")
print(f"Spot: ${data['underlying_price']:.2f}")
print(f"Max Pain: ${data['max_pain_strike']}")
print(f"Distance: {data['distance']['percent']:.2f}% {data['distance']['direction']}")
print(f"Pin Probability: {data['pin_probability']}/100")
print(f"Signal: {data['signal']}")
print(f"Dealer Alignment: {data['dealer_alignment']['alignment']}")

Using the Python SDK

from flashalpha import FlashAlpha

fa = FlashAlpha("YOUR_API_KEY")

# Full chain
data = fa.max_pain("SPY")

# Single expiry
data = fa.max_pain("SPY", expiration="2026-04-17")

Step 2: Chart the Pain Curve

The pain curve shows total option holder loss at each possible settlement price. The minimum is the max pain strike. Charting it reveals how steep or flat the pin is.

import matplotlib.pyplot as plt
import matplotlib.ticker as mticker

def plot_pain_curve(data):
    """Plot the pain curve with max pain strike marked."""
    curve = data["pain_curve"]
    strikes = [p["strike"] for p in curve]
    call_pain = [p["call_pain"] for p in curve]
    put_pain = [p["put_pain"] for p in curve]
    total_pain = [p["total_pain"] for p in curve]

    fig, ax = plt.subplots(figsize=(12, 6))

    # Stacked area: call pain (blue) + put pain (red)
    ax.fill_between(strikes, 0, call_pain, alpha=0.3, color="#2563eb", label="Call Pain")
    ax.fill_between(strikes, call_pain,
                    [c + p for c, p in zip(call_pain, put_pain)],
                    alpha=0.3, color="#dc2626", label="Put Pain")
    ax.plot(strikes, total_pain, color="#18181b", linewidth=2, label="Total Pain")

    # Mark max pain
    mp = data["max_pain_strike"]
    mp_idx = strikes.index(mp) if mp in strikes else None
    if mp_idx is not None:
        ax.axvline(mp, color="#059669", linewidth=2, linestyle="--", label=f"Max Pain: ${mp}")
        ax.annotate(f"Max Pain\n${mp}", xy=(mp, total_pain[mp_idx]),
                    xytext=(15, 20), textcoords="offset points",
                    fontsize=10, fontweight="bold", color="#059669",
                    arrowprops=dict(arrowstyle="->", color="#059669"))

    # Mark spot price
    spot = data["underlying_price"]
    ax.axvline(spot, color="#d97706", linewidth=1.5, linestyle=":", label=f"Spot: ${spot:.2f}")

    # Mark dealer levels if available
    da = data.get("dealer_alignment", {})
    if da.get("call_wall"):
        ax.axvline(da["call_wall"], color="#2563eb", linewidth=1, linestyle="-.",
                   alpha=0.6, label=f"Call Wall: ${da['call_wall']}")
    if da.get("put_wall"):
        ax.axvline(da["put_wall"], color="#dc2626", linewidth=1, linestyle="-.",
                   alpha=0.6, label=f"Put Wall: ${da['put_wall']}")

    ax.set_xlabel("Settlement Price ($)", fontsize=11)
    ax.set_ylabel("Total Pain ($)", fontsize=11)
    ax.set_title(f"{data['symbol']} Max Pain Curve  -  Pin Probability: {data['pin_probability']}/100",
                 fontsize=13, fontweight="bold")
    ax.yaxis.set_major_formatter(mticker.FuncFormatter(lambda x, _: f"${x/1e6:.0f}M"))
    ax.legend(loc="upper right", fontsize=9)
    ax.grid(True, alpha=0.15)
    plt.tight_layout()
    plt.show()

data = get_maxpain("SPY")
plot_pain_curve(data)

The pain curve is in USD notional (intrinsic value × OI × 100). A steep V around max pain means strong gravitational pull. A flat curve means the pin is weak.

How to Read the Chart

The chart you just generated is the single most important view in your dashboard. Here is what to look for:

  • Sharp V-shape - the total pain line drops steeply into max pain and rises steeply on both sides. This means even a $2-3 move away from max pain costs option holders tens of millions more. Dealers have a strong incentive to keep price pinned. Trade the pin with confidence.
  • Flat bottom - the total pain line is roughly equal across 5-10 strikes. There is no single magnet strike. Max pain is less meaningful - skip the pin trade.
  • Spot price far from max pain - if the orange dotted line (spot) is far from the green dashed line (max pain), the stock would need to move significantly to reach the pin. Check the expected move - if max pain is outside the 1-sigma range, the pin is unlikely this cycle.
  • Call wall / put wall alignment - if the blue and red dash-dot lines (walls) bracket max pain tightly, dealers are hedging in a way that supports the pin. This is the "converging" alignment signal.

Step 3: OI Breakdown by Strike

The oi_by_strike array shows call and put OI at each strike. This reveals where the weight is concentrated.

def plot_oi_by_strike(data):
    """Horizontal bar chart of call vs put OI by strike."""
    oi = data["oi_by_strike"]
    strikes = [s["strike"] for s in oi]
    call_oi = [s["call_oi"] for s in oi]
    put_oi = [-s["put_oi"] for s in oi]  # Negative for left side

    fig, ax = plt.subplots(figsize=(10, max(6, len(strikes) * 0.3)))
    ax.barh(strikes, call_oi, height=0.8, color="#2563eb", alpha=0.7, label="Call OI")
    ax.barh(strikes, put_oi, height=0.8, color="#dc2626", alpha=0.7, label="Put OI")
    ax.axvline(0, color="#71717a", linewidth=0.5)

    mp = data["max_pain_strike"]
    ax.axhline(mp, color="#059669", linewidth=2, linestyle="--", label=f"Max Pain: ${mp}")

    ax.set_ylabel("Strike ($)")
    ax.set_xlabel("Open Interest (contracts)")
    ax.set_title(f"{data['symbol']} OI by Strike  -  P/C Ratio: {data['put_call_oi_ratio']:.3f}")
    ax.legend(loc="lower right", fontsize=9)
    plt.tight_layout()
    plt.show()

plot_oi_by_strike(data)

Step 4: Multi-Expiry Max Pain Calendar

Track how max pain evolves across expirations. This is returned automatically when you omit the ?expiration= filter.

def print_maxpain_calendar(data):
    """Print the multi-expiry max pain table."""
    calendar = data.get("max_pain_by_expiration", [])
    if not calendar:
        print("Single-expiry mode  -  no calendar data.")
        return

    spot = data["underlying_price"]
    print(f"\n{'Expiration':<14} {'DTE':>4} {'Max Pain':>10} {'Distance':>10} {'Total OI':>12}")
    print("-" * 56)
    for entry in calendar:
        mp = entry["max_pain_strike"]
        dist_pct = abs(spot - mp) / spot * 100
        direction = "above" if spot > mp else "below" if spot < mp else "at"
        print(f"{entry['expiration']:<14} {entry['dte']:>4} "
              f"${mp:>8} {dist_pct:>7.2f}% {direction:<6}"
              f"{entry['total_oi']:>10,}")

print_maxpain_calendar(data)

Sample output:

Expiration     DTE   Max Pain   Distance     Total OI
--------------------------------------------------------
2026-04-11       2      $547     0.18% above    520,000
2026-04-17       8      $545     0.55% above  1,840,000
2026-05-16      37      $540     1.52% above    980,000

What This Tells You

The calendar is where you spot the trades. If this Friday's max pain is $547 but the monthly (8 DTE) is $545, expect a step-down after the weekly rolls off. If the monthly has 3x the OI of the weekly, the monthly pin is the dominant force - use it for iron condor positioning. When all expirations agree on the same strike, the pin is exceptionally strong because multiple expiry cycles reinforce the same magnet.

Step 5: Dealer Alignment Dashboard

This is the view that separates a useful dashboard from a toy. Max pain alone is a number. Combined with the gamma flip, call wall, and put wall, it becomes a trade decision. When these levels converge, pin probability is highest.

def print_dealer_alignment(data):
    """Print the dealer alignment overlay."""
    da = data.get("dealer_alignment", {})
    spot = data["underlying_price"]

    print(f"\n=== {data['symbol']} Dealer Alignment ===")
    print(f"Spot:           ${spot:.2f}")
    print(f"Max Pain:       ${data['max_pain_strike']}")
    print(f"Gamma Flip:     ${da.get('gamma_flip', 'N/A')}")
    print(f"Call Wall:      ${da.get('call_wall', 'N/A')}")
    print(f"Put Wall:       ${da.get('put_wall', 'N/A')}")
    print(f"Alignment:      {da.get('alignment', 'unknown')}")
    print(f"Regime:         {data.get('regime', 'unknown')}")
    print(f"Pin Probability: {data['pin_probability']}/100")
    print(f"Signal:         {data['signal']}")

    # Expected move context
    em = data.get("expected_move", {})
    if em:
        straddle = em.get("straddle_price", 0)
        within = em.get("max_pain_within_expected_range", False)
        print(f"ATM Straddle:   ${straddle:.2f}")
        print(f"MP in EM Range: {'Yes' if within else 'No'}")

    print(f"\nDescription:    {da.get('description', '')}")

print_dealer_alignment(data)

Step 6: Multi-Symbol Scanner

Scan your watchlist for the strongest pin setups:

import time

WATCHLIST = ["SPY", "QQQ", "AAPL", "TSLA", "NVDA", "META", "AMZN", "MSFT"]

def scan_maxpain(symbols):
    """Scan multiple symbols for pin probability."""
    results = []
    for symbol in symbols:
        try:
            data = get_maxpain(symbol)
            results.append({
                "symbol": symbol,
                "spot": data["underlying_price"],
                "max_pain": data["max_pain_strike"],
                "distance_pct": data["distance"]["percent"],
                "direction": data["distance"]["direction"],
                "pin_prob": data["pin_probability"],
                "alignment": data["dealer_alignment"]["alignment"],
                "regime": data.get("regime", "unknown"),
                "signal": data["signal"],
            })
            time.sleep(0.5)  # Rate limit courtesy
        except Exception as e:
            print(f"  {symbol}: {e}")

    # Sort by pin probability (highest first)
    results.sort(key=lambda r: r["pin_prob"], reverse=True)

    print(f"\n{'Symbol':<8} {'Spot':>8} {'MaxPain':>8} {'Dist%':>6} {'Pin':>4} {'Alignment':<12} {'Regime':<16} {'Signal':<8}")
    print("-" * 80)
    for r in results:
        print(f"{r['symbol']:<8} ${r['spot']:>7.2f} ${r['max_pain']:>6} "
              f"{r['distance_pct']:>5.1f}% {r['pin_prob']:>3} "
              f"{r['alignment']:<12} {r['regime']:<16} {r['signal']:<8}")

scan_maxpain(WATCHLIST)
Pro Tip

Focus on symbols where pin_probability > 60 AND alignment = "converging" AND regime = "positive_gamma". These are the highest-conviction pin setups. Use the 0DTE endpoint on expiration day for real-time updates.

Step 7: Putting It All Together

A complete dashboard script that runs on a schedule:

def run_dashboard(symbol="SPY"):
    """Run the full max pain dashboard for a symbol."""
    data = get_maxpain(symbol)

    # Print summary
    print_dealer_alignment(data)

    # Print calendar
    print_maxpain_calendar(data)

    # Plot pain curve
    plot_pain_curve(data)

    # Plot OI
    plot_oi_by_strike(data)

    return data

# Run it
data = run_dashboard("SPY")

API Pricing and Access

Plan Max Pain Access Daily Requests Price
Free No 5 $0
Basic Full max pain endpoint 100 $79/mo
Growth Full max pain + 0DTE, vol analytics, options chain, screener 2,500 $299/mo
Alpha Everything in Growth + SVI surfaces, VRP, unlimited requests Unlimited $1,499/mo

Get API Access Max Pain API Docs Try in Playground

Related Reading

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!