optiondesk-engine 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- optiondesk_engine/__init__.py +57 -0
- optiondesk_engine/analytics/__init__.py +31 -0
- optiondesk_engine/analytics/compare.py +176 -0
- optiondesk_engine/analytics/exposure.py +227 -0
- optiondesk_engine/analytics/ranking.py +362 -0
- optiondesk_engine/analytics/smile.py +144 -0
- optiondesk_engine/backtest/__init__.py +43 -0
- optiondesk_engine/backtest/forward.py +160 -0
- optiondesk_engine/backtest/runner.py +210 -0
- optiondesk_engine/backtest/stats.py +188 -0
- optiondesk_engine/pricing/__init__.py +6 -0
- optiondesk_engine/pricing/black_scholes.py +291 -0
- optiondesk_engine/pricing/forwards.py +177 -0
- optiondesk_engine/pricing/greeks_full.py +185 -0
- optiondesk_engine/simulation/__init__.py +29 -0
- optiondesk_engine/simulation/garch.py +402 -0
- optiondesk_engine/simulation/paths.py +277 -0
- optiondesk_engine/strategies/__init__.py +62 -0
- optiondesk_engine/strategies/friction.py +156 -0
- optiondesk_engine/strategies/outlook.py +102 -0
- optiondesk_engine/strategies/payoff.py +349 -0
- optiondesk_engine/strategies/playbook.py +1070 -0
- optiondesk_engine/strategies/timespread.py +700 -0
- optiondesk_engine-0.3.0.dist-info/METADATA +10 -0
- optiondesk_engine-0.3.0.dist-info/RECORD +27 -0
- optiondesk_engine-0.3.0.dist-info/WHEEL +4 -0
- optiondesk_engine-0.3.0.dist-info/licenses/LICENSE +76 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""The analytics engine.
|
|
2
|
+
|
|
3
|
+
Copyright (C) 2026 Iman Samizadeh.
|
|
4
|
+
|
|
5
|
+
Licensed under the PolyForm Noncommercial License 1.0.0. Any noncommercial
|
|
6
|
+
purpose is permitted, including personal study, research, and use by
|
|
7
|
+
charitable, educational, public research, health, environmental and
|
|
8
|
+
government organisations. Commercial use, which includes any use for
|
|
9
|
+
commercial advantage or private monetary compensation, requires a separate
|
|
10
|
+
written agreement with the copyright holder.
|
|
11
|
+
|
|
12
|
+
The full terms are in LICENSE at the root of this package and at
|
|
13
|
+
https://polyformproject.org/licenses/noncommercial/1.0.0
|
|
14
|
+
|
|
15
|
+
This is research software. It is not investment advice, not a
|
|
16
|
+
recommendation and not a solicitation. See DISCLAIMER.md.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
__version__ = "0.3.0"
|
|
20
|
+
LICENSE = "PolyForm-Noncommercial-1.0.0"
|
|
21
|
+
|
|
22
|
+
from optiondesk_engine.pricing.black_scholes import (
|
|
23
|
+
bs_price,
|
|
24
|
+
implied_vol,
|
|
25
|
+
)
|
|
26
|
+
from optiondesk_engine.pricing.greeks_full import (
|
|
27
|
+
GREEK_KEYS,
|
|
28
|
+
all_greeks,
|
|
29
|
+
)
|
|
30
|
+
from optiondesk_engine.backtest import (
|
|
31
|
+
bootstrap_mean_interval,
|
|
32
|
+
performance_stats,
|
|
33
|
+
permutation_p_value,
|
|
34
|
+
run_backtest,
|
|
35
|
+
)
|
|
36
|
+
from optiondesk_engine.strategies import (
|
|
37
|
+
PLAYBOOK,
|
|
38
|
+
Leg,
|
|
39
|
+
Outlook,
|
|
40
|
+
analyze,
|
|
41
|
+
build,
|
|
42
|
+
describe,
|
|
43
|
+
payoff_curve,
|
|
44
|
+
plan_friction,
|
|
45
|
+
probability_of_profit,
|
|
46
|
+
recommend,
|
|
47
|
+
split_chain,
|
|
48
|
+
tail_metrics,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
__all__ = ["bs_price", "implied_vol", "all_greeks", "GREEK_KEYS",
|
|
52
|
+
"PLAYBOOK", "Leg", "Outlook", "analyze", "build", "describe",
|
|
53
|
+
"payoff_curve", "plan_friction", "probability_of_profit",
|
|
54
|
+
"recommend", "split_chain", "tail_metrics",
|
|
55
|
+
"run_backtest", "performance_stats", "permutation_p_value",
|
|
56
|
+
"bootstrap_mean_interval",
|
|
57
|
+
"__version__", "LICENSE"]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Chain-level analytics: positioning, exposure and pain.
|
|
2
|
+
|
|
3
|
+
Copyright (C) 2026 Iman Samizadeh. Licensed under the PolyForm Noncommercial License 1.0.0.
|
|
4
|
+
Commercial use requires a separate written agreement.
|
|
5
|
+
|
|
6
|
+
Where the pricing modules answer "what is this contract worth", these
|
|
7
|
+
answer "where is the open interest, and what does hedging it imply".
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from optiondesk_engine.analytics.exposure import (
|
|
11
|
+
chain_exposure,
|
|
12
|
+
max_pain,
|
|
13
|
+
)
|
|
14
|
+
from optiondesk_engine.analytics.compare import rank_strategies, score_plan
|
|
15
|
+
from optiondesk_engine.analytics.ranking import (
|
|
16
|
+
FORMULA,
|
|
17
|
+
MIN_ABS_PREMIUM,
|
|
18
|
+
RR_CAP,
|
|
19
|
+
SCORE_WEIGHTS,
|
|
20
|
+
THIN_MULTIPLIER,
|
|
21
|
+
VRP_TILT,
|
|
22
|
+
rank_rows,
|
|
23
|
+
row_from_comparison,
|
|
24
|
+
score_row,
|
|
25
|
+
)
|
|
26
|
+
from optiondesk_engine.analytics.smile import smile_metrics
|
|
27
|
+
|
|
28
|
+
__all__ = ["chain_exposure", "max_pain", "smile_metrics", "rank_strategies",
|
|
29
|
+
"score_plan", "score_row", "rank_rows", "row_from_comparison",
|
|
30
|
+
"SCORE_WEIGHTS", "RR_CAP", "VRP_TILT", "THIN_MULTIPLIER",
|
|
31
|
+
"MIN_ABS_PREMIUM", "FORMULA"]
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Rank structures against each other under a stated, visible criterion.
|
|
2
|
+
|
|
3
|
+
Copyright (C) 2026 Iman Samizadeh. Licensed under the PolyForm Noncommercial License 1.0.0.
|
|
4
|
+
Commercial use requires a separate written agreement.
|
|
5
|
+
|
|
6
|
+
WHAT THIS IS. Given several plans built from the same chain, it puts them in
|
|
7
|
+
one table and orders them by expected profit per unit of capital at risk,
|
|
8
|
+
under the same lognormal model that produced their probabilities. Every
|
|
9
|
+
component of the score is returned, so the order can be argued with rather
|
|
10
|
+
than merely accepted.
|
|
11
|
+
|
|
12
|
+
WHAT THIS IS NOT, and the caveat matters more than the ranking. The expected
|
|
13
|
+
values come from a model, and the model disagrees with the market in a
|
|
14
|
+
specific way: it prices every strike from one at-the-money volatility, while
|
|
15
|
+
the market prices each strike with its own. That disagreement is the smile.
|
|
16
|
+
Any structure that looks profitable in expectation is, to a first
|
|
17
|
+
approximation, one that sells strikes where the market's volatility exceeds
|
|
18
|
+
the single number the model used. That is a measurement of the smile, not a
|
|
19
|
+
discovered edge, and it disappears the moment the model prices each strike
|
|
20
|
+
at the volatility the market actually quotes.
|
|
21
|
+
|
|
22
|
+
Two more reasons the top row is not a recommendation: mid-quote premiums are
|
|
23
|
+
not fills, and the friction estimate is the only part of this that touches
|
|
24
|
+
the cost of trading. A structure is excluded from the ranking outright when
|
|
25
|
+
friction says it is untradeable, because a positive expectation that cannot
|
|
26
|
+
be entered is not an opportunity.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import math
|
|
30
|
+
|
|
31
|
+
CAVEAT = (
|
|
32
|
+
"Ranked by model expected profit per unit of capital at risk, under a "
|
|
33
|
+
"lognormal settlement model at a single at-the-money volatility. The "
|
|
34
|
+
"market prices each strike with its own volatility, so a positive "
|
|
35
|
+
"expectation here largely measures that disagreement rather than an "
|
|
36
|
+
"edge. Premiums are mid quotes, not fills. This is an ordering of "
|
|
37
|
+
"structures under stated assumptions, not a recommendation to trade "
|
|
38
|
+
"any of them."
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
UNBOUNDED = "unlimited"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _risk_capital(plan):
|
|
45
|
+
"""Capital genuinely at risk, or None when the loss is unbounded.
|
|
46
|
+
|
|
47
|
+
A structure with unbounded loss has no denominator, so it cannot be
|
|
48
|
+
ranked on return per unit of risk and is reported separately rather
|
|
49
|
+
than being given a flattering finite number.
|
|
50
|
+
"""
|
|
51
|
+
analysis = plan.get("analysis") or {}
|
|
52
|
+
max_loss = analysis.get("max_loss")
|
|
53
|
+
if max_loss is None or isinstance(max_loss, str):
|
|
54
|
+
return None
|
|
55
|
+
try:
|
|
56
|
+
loss = float(max_loss)
|
|
57
|
+
except (TypeError, ValueError):
|
|
58
|
+
return None
|
|
59
|
+
# The engine returns float("-inf") for an unbounded loss; the shell
|
|
60
|
+
# serialises that to the string "unlimited". Checking only the string
|
|
61
|
+
# let an infinity through as a capital-at-risk of inf, which produced a
|
|
62
|
+
# return on risk of exactly 0.0 and ranked a naked short call above a
|
|
63
|
+
# defined-risk structure.
|
|
64
|
+
if not math.isfinite(loss):
|
|
65
|
+
return None
|
|
66
|
+
# A worst case that is a profit is not capital at risk.
|
|
67
|
+
if loss >= 0:
|
|
68
|
+
return None
|
|
69
|
+
loss = abs(loss)
|
|
70
|
+
return loss if loss > 1e-9 else None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def score_plan(plan):
|
|
74
|
+
"""Score components for one plan. Every field can be None."""
|
|
75
|
+
analysis = plan.get("analysis") or {}
|
|
76
|
+
probability = plan.get("probability") or {}
|
|
77
|
+
friction = plan.get("friction") or {}
|
|
78
|
+
greeks = plan.get("net_greeks") or {}
|
|
79
|
+
|
|
80
|
+
risk = _risk_capital(plan)
|
|
81
|
+
expected = probability.get("expected_pnl")
|
|
82
|
+
expected_return = (expected / risk
|
|
83
|
+
if expected is not None and risk else None)
|
|
84
|
+
|
|
85
|
+
reasons = []
|
|
86
|
+
tradeable = friction.get("verdict") not in ("untradeable",)
|
|
87
|
+
if not tradeable:
|
|
88
|
+
reasons.append("friction says untradeable: " +
|
|
89
|
+
str(friction.get("reason", "")))
|
|
90
|
+
if risk is None:
|
|
91
|
+
max_loss = analysis.get("max_loss")
|
|
92
|
+
if isinstance(max_loss, str) or (
|
|
93
|
+
isinstance(max_loss, (int, float))
|
|
94
|
+
and not math.isfinite(float(max_loss))):
|
|
95
|
+
reasons.append("loss is unbounded, so return on risk is "
|
|
96
|
+
"undefined")
|
|
97
|
+
elif isinstance(max_loss, (int, float)) and float(max_loss) >= 0:
|
|
98
|
+
reasons.append("the worst outcome is a profit, so there is no "
|
|
99
|
+
"capital at risk to divide by")
|
|
100
|
+
else:
|
|
101
|
+
reasons.append("no capital at risk could be established")
|
|
102
|
+
if expected is None:
|
|
103
|
+
reasons.append("no model expectation available")
|
|
104
|
+
elif not math.isfinite(float(expected)):
|
|
105
|
+
reasons.append("the model expectation is not a finite number")
|
|
106
|
+
if (plan.get("meta") or {}).get("degraded"):
|
|
107
|
+
reasons.append("built from a degraded snapshot")
|
|
108
|
+
|
|
109
|
+
# Redundant today, kept on purpose. rankable below also demands a
|
|
110
|
+
# finite expected_return, and expected_return is this expectation over
|
|
111
|
+
# a finite risk, so a NaN here is caught there whatever this line says.
|
|
112
|
+
# Mutation testing confirms it: removing the isfinite call changes no
|
|
113
|
+
# observable behaviour. It stays because the second test depends on how
|
|
114
|
+
# expected_return happens to be derived, and this one does not.
|
|
115
|
+
finite_expectation = (expected is not None
|
|
116
|
+
and math.isfinite(float(expected)))
|
|
117
|
+
return {
|
|
118
|
+
"strategy": plan.get("strategy"),
|
|
119
|
+
"trade_type": analysis.get("trade_type"),
|
|
120
|
+
"net_cash": analysis.get("net_cash"),
|
|
121
|
+
"max_gain": analysis.get("max_gain"),
|
|
122
|
+
"max_loss": analysis.get("max_loss"),
|
|
123
|
+
"capital_at_risk": risk,
|
|
124
|
+
"reward_risk": analysis.get("reward_risk"),
|
|
125
|
+
"breakevens": analysis.get("breakevens"),
|
|
126
|
+
"probability_of_profit": probability.get("profit"),
|
|
127
|
+
"probability_of_loss": probability.get("loss"),
|
|
128
|
+
"expected_pnl": expected,
|
|
129
|
+
"expected_loss": probability.get("expected_loss"),
|
|
130
|
+
"expected_return_on_risk": expected_return,
|
|
131
|
+
"net_delta": greeks.get("delta"),
|
|
132
|
+
"net_theta": greeks.get("theta"),
|
|
133
|
+
"net_vega": greeks.get("vega"),
|
|
134
|
+
"net_gamma": greeks.get("gamma"),
|
|
135
|
+
"friction_verdict": friction.get("verdict"),
|
|
136
|
+
"friction_cost": friction.get("round_trip"),
|
|
137
|
+
"rankable": bool(tradeable and risk and finite_expectation
|
|
138
|
+
and expected_return is not None
|
|
139
|
+
and math.isfinite(expected_return)),
|
|
140
|
+
"excluded_because": reasons,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def rank_strategies(plans):
|
|
145
|
+
"""Compare plans and order the rankable ones, best first.
|
|
146
|
+
|
|
147
|
+
Returns the full table, the ordered subset, the leader, and the caveat
|
|
148
|
+
that must travel with any of it. The leader is None when nothing is
|
|
149
|
+
rankable, which is a real outcome and not an error.
|
|
150
|
+
"""
|
|
151
|
+
rows = [score_plan(plan) for plan in plans]
|
|
152
|
+
rankable = [row for row in rows if row["rankable"]]
|
|
153
|
+
rankable.sort(key=lambda r: (-(r["expected_return_on_risk"] or 0.0),
|
|
154
|
+
-(r["probability_of_profit"] or 0.0),
|
|
155
|
+
r["strategy"] or ""))
|
|
156
|
+
for position, row in enumerate(rankable, start=1):
|
|
157
|
+
row["rank"] = position
|
|
158
|
+
|
|
159
|
+
leader = rankable[0] if rankable else None
|
|
160
|
+
margin = None
|
|
161
|
+
if len(rankable) > 1:
|
|
162
|
+
first = rankable[0]["expected_return_on_risk"]
|
|
163
|
+
second = rankable[1]["expected_return_on_risk"]
|
|
164
|
+
margin = first - second
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
"rows": rows,
|
|
168
|
+
"ranked": rankable,
|
|
169
|
+
"leader": leader,
|
|
170
|
+
"margin_over_runner_up": margin,
|
|
171
|
+
"rankable_count": len(rankable),
|
|
172
|
+
"excluded_count": len(rows) - len(rankable),
|
|
173
|
+
"criterion": ("model expected profit divided by capital at risk, "
|
|
174
|
+
"ties broken by model probability of profit"),
|
|
175
|
+
"caveat": CAVEAT,
|
|
176
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Dealer gamma exposure, walls, and max pain from an option chain.
|
|
2
|
+
|
|
3
|
+
Copyright (C) 2026 Iman Samizadeh. Licensed under the PolyForm Noncommercial License 1.0.0.
|
|
4
|
+
Commercial use requires a separate written agreement.
|
|
5
|
+
The concepts follow the dealer gamma work in the author's 001-qaunt desk
|
|
6
|
+
(smartsheep.witty.analytics.gex and gamma_regime), reimplemented here
|
|
7
|
+
against this project's chain contract.
|
|
8
|
+
|
|
9
|
+
WHAT THIS IS, AND THE ASSUMPTION IT RESTS ON. Gamma exposure asks what
|
|
10
|
+
market makers would have to buy or sell to stay delta neutral as the
|
|
11
|
+
underlying moves. The arithmetic is exact; the sign convention is not. It
|
|
12
|
+
assumes dealers are long calls and short puts against the public, which is
|
|
13
|
+
the conventional assumption and is regularly wrong for individual names,
|
|
14
|
+
particularly around events and in heavily retail-traded tickers. Positive
|
|
15
|
+
net exposure is read as hedging that dampens moves, negative as hedging
|
|
16
|
+
that amplifies them.
|
|
17
|
+
|
|
18
|
+
That assumption is stated in the output as well as here, because a number
|
|
19
|
+
this easy to quote is a number that travels without its caveat.
|
|
20
|
+
|
|
21
|
+
Units: exposure is expressed per one percent move in the underlying, in
|
|
22
|
+
the underlying's quote currency, which is the convention that makes the
|
|
23
|
+
numbers comparable across strikes and underlyings.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
CONTRACT_MULTIPLIER = 100.0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _usable(contract):
|
|
30
|
+
return (contract.get("gamma") is not None
|
|
31
|
+
and contract.get("open_interest") is not None
|
|
32
|
+
and contract.get("strike") is not None)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def chain_exposure(contracts, spot, multiplier=CONTRACT_MULTIPLIER):
|
|
36
|
+
"""Gamma exposure by strike, the walls, and the flip level.
|
|
37
|
+
|
|
38
|
+
contracts must carry gamma (from the Greek ladder) plus open interest
|
|
39
|
+
and strike. Contracts missing any of the three are counted as skipped
|
|
40
|
+
rather than treated as zero: an absent open interest is not the same
|
|
41
|
+
as no open interest, and treating it as zero quietly moves every wall.
|
|
42
|
+
|
|
43
|
+
Returns per-strike rows, the call and put walls, the estimated gamma
|
|
44
|
+
flip level, and the totals, with the sign convention stated inline.
|
|
45
|
+
"""
|
|
46
|
+
if spot is None or spot <= 0:
|
|
47
|
+
raise ValueError("spot must be positive")
|
|
48
|
+
|
|
49
|
+
by_strike = {}
|
|
50
|
+
skipped = 0
|
|
51
|
+
# Counted apart because they are different facts and a note that gives
|
|
52
|
+
# one cause for the other is wrong. On a live SPY chain every contract
|
|
53
|
+
# carried open interest and the skips were all missing gamma, while the
|
|
54
|
+
# artifact said the opposite.
|
|
55
|
+
skipped_reasons = {"no_gamma": 0, "no_open_interest": 0,
|
|
56
|
+
"no_strike": 0, "not_an_option": 0}
|
|
57
|
+
call_oi = put_oi = 0
|
|
58
|
+
call_volume = put_volume = 0
|
|
59
|
+
|
|
60
|
+
for contract in contracts:
|
|
61
|
+
kind = contract.get("type")
|
|
62
|
+
if kind not in ("call", "put"):
|
|
63
|
+
skipped += 1
|
|
64
|
+
skipped_reasons["not_an_option"] += 1
|
|
65
|
+
continue
|
|
66
|
+
volume = contract.get("volume") or 0
|
|
67
|
+
if kind == "call":
|
|
68
|
+
call_volume += int(volume)
|
|
69
|
+
else:
|
|
70
|
+
put_volume += int(volume)
|
|
71
|
+
if not _usable(contract):
|
|
72
|
+
skipped += 1
|
|
73
|
+
if contract.get("gamma") is None:
|
|
74
|
+
skipped_reasons["no_gamma"] += 1
|
|
75
|
+
elif contract.get("open_interest") is None:
|
|
76
|
+
skipped_reasons["no_open_interest"] += 1
|
|
77
|
+
else:
|
|
78
|
+
skipped_reasons["no_strike"] += 1
|
|
79
|
+
continue
|
|
80
|
+
strike = float(contract["strike"])
|
|
81
|
+
open_interest = int(contract["open_interest"])
|
|
82
|
+
gamma = float(contract["gamma"])
|
|
83
|
+
if kind == "call":
|
|
84
|
+
call_oi += open_interest
|
|
85
|
+
else:
|
|
86
|
+
put_oi += open_interest
|
|
87
|
+
|
|
88
|
+
# Exposure per one percent move, in the underlying's quote
|
|
89
|
+
# currency. gamma * S * 0.01 is the delta change over a one percent
|
|
90
|
+
# move in share terms; multiplying by S again converts that share
|
|
91
|
+
# delta into currency, and the multiplier and open interest scale
|
|
92
|
+
# it to the whole strike.
|
|
93
|
+
exposure = gamma * open_interest * multiplier * spot * spot * 0.01
|
|
94
|
+
signed = exposure if kind == "call" else -exposure
|
|
95
|
+
|
|
96
|
+
row = by_strike.setdefault(strike, {
|
|
97
|
+
"strike": strike, "call_gex": 0.0, "put_gex": 0.0,
|
|
98
|
+
"net_gex": 0.0, "call_oi": 0, "put_oi": 0,
|
|
99
|
+
})
|
|
100
|
+
row["call_gex" if kind == "call" else "put_gex"] += signed
|
|
101
|
+
row["net_gex"] += signed
|
|
102
|
+
row["call_oi" if kind == "call" else "put_oi"] += open_interest
|
|
103
|
+
|
|
104
|
+
rows = sorted(by_strike.values(), key=lambda r: r["strike"])
|
|
105
|
+
if not rows:
|
|
106
|
+
return {
|
|
107
|
+
"rows": [], "net_gex": 0.0, "call_wall": None, "put_wall": None,
|
|
108
|
+
"gamma_flip": None, "skipped": skipped,
|
|
109
|
+
"skipped_reasons": dict(skipped_reasons),
|
|
110
|
+
"put_call_oi_ratio": None, "put_call_volume_ratio": None,
|
|
111
|
+
"assumption": _ASSUMPTION,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
running = 0.0
|
|
115
|
+
for row in rows:
|
|
116
|
+
running += row["net_gex"]
|
|
117
|
+
row["cumulative_gex"] = running
|
|
118
|
+
|
|
119
|
+
# A chain with no calls at all made max() return the first row with a
|
|
120
|
+
# gamma of exactly zero, which was then reported as the call wall.
|
|
121
|
+
call_candidates = [r for r in rows if r["call_gex"] > 0]
|
|
122
|
+
put_candidates = [r for r in rows if r["put_gex"] < 0]
|
|
123
|
+
call_wall = (max(call_candidates, key=lambda r: r["call_gex"])
|
|
124
|
+
if call_candidates else None)
|
|
125
|
+
put_wall = (min(put_candidates, key=lambda r: r["put_gex"])
|
|
126
|
+
if put_candidates else None)
|
|
127
|
+
|
|
128
|
+
# Every level where cumulative exposure crosses zero, linearly
|
|
129
|
+
# interpolated between the straddling strikes. An earlier version
|
|
130
|
+
# returned the first crossing and stopped, so a profile crossing five
|
|
131
|
+
# times reported the lowest one: at spot 100 it read "flip 81.67" while
|
|
132
|
+
# the nearest crossings were at 96.79 and 102.50. The headline is now
|
|
133
|
+
# the crossing nearest spot, and the rest are returned with it.
|
|
134
|
+
crossings = []
|
|
135
|
+
for previous, current in zip(rows, rows[1:]):
|
|
136
|
+
y0, y1 = previous["cumulative_gex"], current["cumulative_gex"]
|
|
137
|
+
if (y0 < 0 <= y1) or (y0 > 0 >= y1):
|
|
138
|
+
span = y1 - y0
|
|
139
|
+
if span == 0:
|
|
140
|
+
crossings.append(current["strike"])
|
|
141
|
+
else:
|
|
142
|
+
crossings.append(previous["strike"] + (0 - y0) * (
|
|
143
|
+
current["strike"] - previous["strike"]) / span)
|
|
144
|
+
flip = (min(crossings, key=lambda level: abs(level - spot))
|
|
145
|
+
if crossings else None)
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
"rows": rows,
|
|
149
|
+
"net_gex": running,
|
|
150
|
+
"call_wall": ({"strike": call_wall["strike"],
|
|
151
|
+
"gex": call_wall["call_gex"]} if call_wall else None),
|
|
152
|
+
"put_wall": ({"strike": put_wall["strike"],
|
|
153
|
+
"gex": put_wall["put_gex"]} if put_wall else None),
|
|
154
|
+
"gamma_flip": flip,
|
|
155
|
+
"gamma_flip_all": crossings,
|
|
156
|
+
"gamma_flip_note": (
|
|
157
|
+
"The crossing nearest spot. There {} {} crossing(s) in total, "
|
|
158
|
+
"listed in gamma_flip_all. The cumulative profile starts at the "
|
|
159
|
+
"lowest LISTED strike rather than at zero, so extending the "
|
|
160
|
+
"strike ladder shifts the whole profile and can move every "
|
|
161
|
+
"level.".format("is" if len(crossings) == 1 else "are",
|
|
162
|
+
len(crossings))),
|
|
163
|
+
"spot": spot,
|
|
164
|
+
"regime": ("dampening" if running > 0 else "amplifying"),
|
|
165
|
+
"skipped": skipped,
|
|
166
|
+
"skipped_reasons": dict(skipped_reasons),
|
|
167
|
+
"call_open_interest": call_oi,
|
|
168
|
+
"put_open_interest": put_oi,
|
|
169
|
+
"put_call_oi_ratio": (put_oi / call_oi) if call_oi else None,
|
|
170
|
+
"put_call_volume_ratio": ((put_volume / call_volume)
|
|
171
|
+
if call_volume else None),
|
|
172
|
+
"assumption": _ASSUMPTION,
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
_ASSUMPTION = (
|
|
177
|
+
"Signs assume dealers are long calls and short puts against the "
|
|
178
|
+
"public. That convention is often wrong for a single name, especially "
|
|
179
|
+
"around events and in heavily retail-traded tickers, and the walls "
|
|
180
|
+
"move with it. Exposure is per one percent move in the underlying."
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def max_pain(contracts, multiplier=CONTRACT_MULTIPLIER):
|
|
185
|
+
"""The strike where option holders collect the least at expiry.
|
|
186
|
+
|
|
187
|
+
For every listed strike, sum what all open contracts would pay out if
|
|
188
|
+
the underlying settled there. The minimum is the conventional max pain
|
|
189
|
+
level. It is a description of where open interest sits, not a forecast
|
|
190
|
+
of where price goes, and the evidence that price gravitates to it is
|
|
191
|
+
thin. It is reported because traders ask for it, with that caveat
|
|
192
|
+
attached.
|
|
193
|
+
"""
|
|
194
|
+
priced = [c for c in contracts
|
|
195
|
+
if c.get("open_interest") is not None
|
|
196
|
+
and c.get("strike") is not None
|
|
197
|
+
and c.get("type") in ("call", "put")]
|
|
198
|
+
if not priced:
|
|
199
|
+
return None
|
|
200
|
+
# Every strike pays out zero when nothing is open, and the minimum then
|
|
201
|
+
# falls on whichever strike happens to be lowest. That is a number
|
|
202
|
+
# derived from no information, so it is refused.
|
|
203
|
+
if sum(int(c["open_interest"]) for c in priced) <= 0:
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
strikes = sorted({float(c["strike"]) for c in priced})
|
|
207
|
+
profile = []
|
|
208
|
+
for settle in strikes:
|
|
209
|
+
payout = 0.0
|
|
210
|
+
for contract in priced:
|
|
211
|
+
strike = float(contract["strike"])
|
|
212
|
+
open_interest = int(contract["open_interest"])
|
|
213
|
+
if contract["type"] == "call":
|
|
214
|
+
payout += max(settle - strike, 0.0) * open_interest
|
|
215
|
+
else:
|
|
216
|
+
payout += max(strike - settle, 0.0) * open_interest
|
|
217
|
+
profile.append({"strike": settle, "payout": payout * multiplier})
|
|
218
|
+
|
|
219
|
+
best = min(profile, key=lambda p: p["payout"])
|
|
220
|
+
return {
|
|
221
|
+
"strike": best["strike"],
|
|
222
|
+
"payout_at_strike": best["payout"],
|
|
223
|
+
"profile": profile,
|
|
224
|
+
"note": ("The strike at which open contracts pay out least. It "
|
|
225
|
+
"describes where open interest sits, not where price is "
|
|
226
|
+
"going."),
|
|
227
|
+
}
|