Skip to content

Commit 5f7cfec

Browse files
tdobrowolski1claude
andcommitted
Add MaxPain typed model with rich field docs
- Add MaxPainResponse and supporting TypedDict types for GET /v1/maxpain/{symbol}: MaxPainDistance, MaxPainCurveRow, MaxPainOiRow, MaxPainByExpirationRow, MaxPainDealerAlignment, MaxPainExpectedMove. Every field has rich docstrings with the published heuristics (pin_probability inputs 30/25/25/20, signal thresholds, dealer alignment categories, etc.). - Re-export from __init__.py. - Update integration tests + docs/api.md for the new endpoint. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d79ca7d commit 5f7cfec

4 files changed

Lines changed: 300 additions & 5 deletions

File tree

docs/api.md

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -654,12 +654,12 @@ curl -H "X-Api-Key: YOUR_API_KEY" \
654654
"hedging_estimate": {
655655
"spot_down_1pct": {
656656
"dealer_shares_to_trade": 4780000,
657-
"direction": "BUY",
657+
"direction": "buy",
658658
"notional_usd": 2852000000
659659
},
660660
"spot_up_1pct": {
661661
"dealer_shares_to_trade": -4780000,
662-
"direction": "SELL",
662+
"direction": "sell",
663663
"notional_usd": 2852000000
664664
}
665665
},
@@ -675,9 +675,27 @@ curl -H "X-Api-Key: YOUR_API_KEY" \
675675

676676
| Field | Description |
677677
|-------|-------------|
678-
| `regime` | `positive_gamma`, `negative_gamma`, or `undetermined` |
679-
| `hedging_estimate` | Estimated dealer hedging flow if spot moves +/-1% |
680-
| `zero_dte` | Same-day expiration contribution to total GEX |
678+
| `symbol` | Requested underlying symbol |
679+
| `underlying_price` | Spot price used for the exposure calculation |
680+
| `as_of` | UTC timestamp of the exposure snapshot |
681+
| `gamma_flip` | Strike where net GEX crosses zero |
682+
| `regime` | `positive_gamma`, `negative_gamma`, `neutral`, or `undetermined` |
683+
| `exposures.net_gex` | Net gamma exposure in USD notional |
684+
| `exposures.net_dex` | Net delta exposure in USD notional |
685+
| `exposures.net_vex` | Net vanna exposure in USD notional |
686+
| `exposures.net_chex` | Net charm exposure in USD notional |
687+
| `interpretation.gamma` | Gamma-regime interpretation |
688+
| `interpretation.vanna` | Vanna-flow interpretation |
689+
| `interpretation.charm` | Charm/time-decay interpretation |
690+
| `hedging_estimate.spot_down_1pct.dealer_shares_to_trade` | Estimated shares dealers need to trade after a -1% spot move |
691+
| `hedging_estimate.spot_down_1pct.direction` | Lowercase `buy` or `sell` |
692+
| `hedging_estimate.spot_down_1pct.notional_usd` | Estimated USD notional of the hedge |
693+
| `hedging_estimate.spot_up_1pct.dealer_shares_to_trade` | Estimated shares dealers need to trade after a +1% spot move |
694+
| `hedging_estimate.spot_up_1pct.direction` | Lowercase `buy` or `sell` |
695+
| `hedging_estimate.spot_up_1pct.notional_usd` | Estimated USD notional of the hedge |
696+
| `zero_dte.net_gex` | Same-day expiration net GEX contribution |
697+
| `zero_dte.pct_of_total_gex` | Same-day expiration GEX as a percentage of total GEX |
698+
| `zero_dte.expiration` | Same-day expiration date, or `null` when unavailable |
681699

682700
---
683701

src/flashalpha/__init__.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@
2525
VrpStrategyScores,
2626
VrpTermItem,
2727
VrpVannaConditioned,
28+
MaxPainResponse,
29+
MaxPainDistance,
30+
MaxPainCurveRow,
31+
MaxPainOiRow,
32+
MaxPainByExpirationRow,
33+
MaxPainDealerAlignment,
34+
MaxPainExpectedMove,
2835
ZeroDteDecay,
2936
ZeroDteExpectedMove,
3037
ZeroDteExposures,
@@ -83,4 +90,12 @@
8390
"VrpRegime",
8491
"VrpStrategyScores",
8592
"VrpMacro",
93+
# ── MaxPain ──
94+
"MaxPainResponse",
95+
"MaxPainDistance",
96+
"MaxPainCurveRow",
97+
"MaxPainOiRow",
98+
"MaxPainByExpirationRow",
99+
"MaxPainDealerAlignment",
100+
"MaxPainExpectedMove",
86101
]

src/flashalpha/types.py

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -709,3 +709,193 @@ class VrpResponse(TypedDict, total=False):
709709
warnings: List[str]
710710
# Macro context. See ``VrpMacro``.
711711
macro: VrpMacro
712+
713+
714+
# ─── MaxPain ─────────────────────────────────────────────────────────────────
715+
#
716+
# Typed model for ``GET /v1/maxpain/{symbol}`` (Basic+).
717+
#
718+
# Max pain is the strike where total option-holder intrinsic value across all
719+
# OI in the chain is minimized — equivalently, the strike at which dealers
720+
# (the counterparty) lose the least to expiring contracts. The endpoint also
721+
# overlays GEX-based dealer alignment, a multi-expiry calendar (full chain
722+
# only), and a 0-100 pin probability score.
723+
724+
725+
class MaxPainDistance(TypedDict, total=False):
726+
"""Distance from spot to the max-pain strike."""
727+
728+
# Dollar distance: ``|underlying_price - max_pain_strike|``.
729+
absolute: Optional[float]
730+
# Percent of spot: ``absolute / underlying_price * 100``. Use this to
731+
# compare across symbols of different price levels.
732+
percent: Optional[float]
733+
# ``"above"`` (spot > max_pain), ``"below"`` (spot < max_pain), or
734+
# ``"at"`` (within rounding). The signal field uses this + a 5%
735+
# threshold to derive the bullish/bearish/neutral classification.
736+
direction: Optional[Literal["above", "below", "at"]]
737+
738+
739+
class MaxPainCurveRow(TypedDict, total=False):
740+
"""One row of the strike-by-strike pain curve.
741+
742+
Each row is the dollar pain (intrinsic value × OI × 100 contract
743+
multiplier) summed across all expirations at that strike. The strike
744+
where ``total_pain`` is minimized is the max-pain strike.
745+
"""
746+
747+
strike: Optional[float]
748+
# Dollar intrinsic value of all calls at this strike summed across the
749+
# chain (when spot pins here, this is what call holders collectively win
750+
# vs the dealer side).
751+
call_pain: Optional[float]
752+
# Dollar intrinsic value of all puts at this strike. Mirror of
753+
# ``call_pain`` for the put side.
754+
put_pain: Optional[float]
755+
# ``call_pain + put_pain``. The pain curve's minimum identifies max pain.
756+
total_pain: Optional[float]
757+
758+
759+
class MaxPainOiRow(TypedDict, total=False):
760+
"""One row of the OI-by-strike breakdown.
761+
762+
Per-strike open interest and volume by side. Lets you see where the
763+
OI is concentrated independent of the dollar-weighted pain calculation.
764+
765+
Note: on the Historical API, ``call_volume`` and ``put_volume`` are
766+
always ``0`` (placeholder fields — the minute table doesn't carry
767+
intraday volume).
768+
"""
769+
770+
strike: Optional[float]
771+
call_oi: Optional[int]
772+
put_oi: Optional[int]
773+
total_oi: Optional[int]
774+
call_volume: Optional[int]
775+
put_volume: Optional[int]
776+
777+
778+
class MaxPainByExpirationRow(TypedDict, total=False):
779+
"""Per-expiry max-pain breakdown when no ``expiration`` filter is applied.
780+
781+
Lets you see how max pain shifts across the term structure — useful for
782+
spotting cases where the front-week max pain differs sharply from the
783+
LEAP max pain (often a sign of where the dealer flow is most active).
784+
785+
This list is ``None`` when the request specifies an ``expiration``
786+
filter — the response is then scoped to that single expiry and the
787+
multi-expiry view is suppressed.
788+
"""
789+
790+
# ``"yyyy-MM-dd"`` of this expiry.
791+
expiration: Optional[str]
792+
# Max-pain strike for this expiry's option chain alone.
793+
max_pain_strike: Optional[float]
794+
# Days to expiry (counting from ``as_of``).
795+
dte: Optional[int]
796+
# Sum of OI across all contracts at this expiry.
797+
total_oi: Optional[int]
798+
799+
800+
class MaxPainDealerAlignment(TypedDict, total=False):
801+
"""GEX-based dealer-alignment overlay on the max-pain view.
802+
803+
Re-uses the same gamma-exposure inputs as ``/v1/exposure/levels`` and
804+
``/v1/exposure/summary``. The headline ``alignment`` label tells you
805+
whether dealer hedging will REINFORCE the max-pain pin or fight it:
806+
807+
- ``"converging"``: max pain near gamma flip and between the
808+
walls — dealer hedging supports the pin (strongest pin setup).
809+
- ``"moderate"``: max pain between the walls but far from flip.
810+
- ``"diverging"``: max pain outside the wall range — dealer
811+
hedging actively pushes spot away from max pain.
812+
- ``"unknown"``: insufficient data to classify.
813+
"""
814+
815+
alignment: Optional[Literal["converging", "moderate", "diverging", "unknown"]]
816+
# Plain-English explanation. Safe to surface verbatim.
817+
description: Optional[str]
818+
# Strike where net dealer gamma crosses zero. Same definition as
819+
# ``exposure_summary.gamma_flip``.
820+
gamma_flip: Optional[float]
821+
# Strike with highest absolute call GEX (dealer-side resistance).
822+
call_wall: Optional[float]
823+
# Strike with highest absolute put GEX (dealer-side support).
824+
put_wall: Optional[float]
825+
826+
827+
class MaxPainExpectedMove(TypedDict, total=False):
828+
"""Implied move from the ATM straddle, contextualized vs max pain.
829+
830+
Tells you whether the max-pain strike is even reachable within the
831+
options-implied 1σ move. If ``max_pain_within_expected_range`` is
832+
``False``, the pin is unlikely to play out by expiry under the current
833+
IV regime — the magnet exists but spot probably can't get there.
834+
"""
835+
836+
# ATM straddle mid in dollars. Rough proxy for the 1σ implied move.
837+
straddle_price: Optional[float]
838+
# ATM implied volatility (annualised %, e.g. 18.5 = 18.5%).
839+
atm_iv: Optional[float]
840+
# ``True`` when ``|spot - max_pain_strike| <= straddle_price``.
841+
max_pain_within_expected_range: Optional[bool]
842+
843+
844+
class MaxPainResponse(TypedDict, total=False):
845+
"""Max pain dashboard from ``GET /v1/maxpain/{symbol}``.
846+
847+
Returns the strike where total option-holder pain (intrinsic value × OI)
848+
is minimized, plus:
849+
850+
- per-strike pain curve and OI breakdown
851+
- per-expiry calendar (when no ``expiration`` filter is set)
852+
- GEX-based dealer alignment overlay (call wall / put wall /
853+
gamma flip — same numbers as ``/v1/exposure/levels``)
854+
- expected move from the ATM straddle
855+
- 0-100 pin probability composite
856+
857+
The endpoint accepts an optional ``expiration`` query filter
858+
(``yyyy-MM-dd``). When present, the response is scoped to that single
859+
expiry and ``max_pain_by_expiration`` is ``None``. When absent, the
860+
full-chain max pain is returned alongside the multi-expiry calendar.
861+
862+
Returns 403 ``tier_restricted`` for Free-tier users; requires Basic+.
863+
"""
864+
865+
symbol: str
866+
underlying_price: Optional[float]
867+
as_of: str
868+
# The headline number. Strike where total chain pain is minimized.
869+
max_pain_strike: Optional[float]
870+
# Distance from spot to ``max_pain_strike`` (absolute, percent, direction).
871+
distance: MaxPainDistance
872+
# ``"bullish"`` (spot >= 5% below max_pain — pin attracts upside),
873+
# ``"bearish"`` (>= 5% above), or ``"neutral"`` (within 5%).
874+
signal: Optional[Literal["bullish", "bearish", "neutral"]]
875+
# Expiration this view is scoped to. When the request omits the
876+
# ``expiration`` filter, this field is the front-month expiry the
877+
# full-chain max pain happened to land on.
878+
expiration: Optional[str]
879+
# Total put OI / total call OI across the relevant chain. >1.0 means
880+
# put-heavy positioning. Often correlates with ``signal == "bullish"``
881+
# (puts are protection; heavy-put chains often have spot below max pain).
882+
put_call_oi_ratio: Optional[float]
883+
# Strike-by-strike pain curve. The minimum is at ``max_pain_strike``.
884+
pain_curve: List[MaxPainCurveRow]
885+
# Per-strike OI + volume breakdown. Same strike grid as ``pain_curve``.
886+
oi_by_strike: List[MaxPainOiRow]
887+
# Per-expiry calendar. ``None`` when the request specified an expiry.
888+
max_pain_by_expiration: Optional[List[MaxPainByExpirationRow]]
889+
# GEX-based dealer alignment overlay. See ``MaxPainDealerAlignment``.
890+
dealer_alignment: MaxPainDealerAlignment
891+
# Same gamma classification as on ``exposure_summary``:
892+
# positive_gamma | negative_gamma | neutral | undetermined.
893+
regime: Optional[Literal["positive_gamma", "negative_gamma",
894+
"neutral", "undetermined"]]
895+
# Expected move from the ATM straddle, contextualized vs max pain.
896+
expected_move: MaxPainExpectedMove
897+
# 0-100 composite — likelihood of pinning to ``max_pain_strike``.
898+
# Inputs: OI concentration (30%), magnet proximity (25%), time
899+
# remaining (25%), gamma magnitude (20%). Most meaningful for near-term
900+
# expiries — for LEAPs this score will be low regardless of OI shape.
901+
pin_probability: Optional[int]

tests/test_integration.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -554,6 +554,78 @@ def test_max_pain_multi_expiry_calendar(fa):
554554
assert "dte" in entry
555555

556556

557+
def test_max_pain_every_field_declared_in_poco_must_be_referenced(fa):
558+
"""Every leaf field declared in MaxPainResponse must be referenced.
559+
560+
100% field-coverage discipline (mirrors VRP / ExposureSummary contract).
561+
Customer traps explicitly checked: signal/regime literal sets,
562+
distance.direction casing, oi_by_strike row shape, dealer_alignment
563+
structure, expected_move structure.
564+
"""
565+
r = fa.max_pain("SPY")
566+
567+
# ── top-level scalars ──
568+
assert r["symbol"] == "SPY"
569+
assert isinstance(r["underlying_price"], (int, float)) and r["underlying_price"] > 0
570+
assert isinstance(r["as_of"], str) and r["as_of"]
571+
assert isinstance(r["max_pain_strike"], (int, float))
572+
assert r["signal"] in ("bullish", "bearish", "neutral")
573+
assert isinstance(r["expiration"], str) and r["expiration"]
574+
assert isinstance(r["put_call_oi_ratio"], (int, float))
575+
assert r["regime"] in ("positive_gamma", "negative_gamma", "neutral", "undetermined")
576+
assert isinstance(r["pin_probability"], int) and 0 <= r["pin_probability"] <= 100
577+
578+
# ── distance{absolute, percent, direction} ──
579+
dist = r["distance"]
580+
assert isinstance(dist["absolute"], (int, float))
581+
assert isinstance(dist["percent"], (int, float))
582+
assert dist["direction"] in ("above", "below", "at")
583+
584+
# ── pain_curve[]{strike, call_pain, put_pain, total_pain} ──
585+
pc = r["pain_curve"]
586+
assert isinstance(pc, list) and len(pc) > 0
587+
row = pc[0]
588+
assert isinstance(row["strike"], (int, float))
589+
assert isinstance(row["call_pain"], (int, float))
590+
assert isinstance(row["put_pain"], (int, float))
591+
assert isinstance(row["total_pain"], (int, float))
592+
593+
# ── oi_by_strike[]{strike, call_oi, put_oi, total_oi, call_volume, put_volume} ──
594+
oi = r["oi_by_strike"]
595+
assert isinstance(oi, list) and len(oi) > 0
596+
oirow = oi[0]
597+
assert isinstance(oirow["strike"], (int, float))
598+
assert isinstance(oirow["call_oi"], int)
599+
assert isinstance(oirow["put_oi"], int)
600+
assert isinstance(oirow["total_oi"], int)
601+
assert isinstance(oirow["call_volume"], int)
602+
assert isinstance(oirow["put_volume"], int)
603+
604+
# ── max_pain_by_expiration[]{expiration, max_pain_strike, dte, total_oi} ──
605+
# Populated when no expiration filter is applied (this call uses none).
606+
mpe = r["max_pain_by_expiration"]
607+
assert isinstance(mpe, list) and len(mpe) > 0
608+
mrow = mpe[0]
609+
assert isinstance(mrow["expiration"], str) and mrow["expiration"]
610+
assert isinstance(mrow["max_pain_strike"], (int, float))
611+
assert isinstance(mrow["dte"], int)
612+
assert isinstance(mrow["total_oi"], int)
613+
614+
# ── dealer_alignment{alignment, description, gamma_flip, call_wall, put_wall} ──
615+
da = r["dealer_alignment"]
616+
assert da["alignment"] in ("converging", "moderate", "diverging", "unknown")
617+
assert isinstance(da["description"], str) and da["description"]
618+
assert isinstance(da["gamma_flip"], (int, float))
619+
assert isinstance(da["call_wall"], (int, float))
620+
assert isinstance(da["put_wall"], (int, float))
621+
622+
# ── expected_move{straddle_price, atm_iv, max_pain_within_expected_range} ──
623+
em = r["expected_move"]
624+
assert isinstance(em["straddle_price"], (int, float))
625+
assert isinstance(em["atm_iv"], (int, float))
626+
assert isinstance(em["max_pain_within_expected_range"], bool)
627+
628+
557629
# ── Screener ────────────────────────────────────────────────────────
558630

559631

0 commit comments

Comments
 (0)