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.
@@ -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
+ }