optiondesk-engine 0.3.0__tar.gz

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.
Files changed (40) hide show
  1. optiondesk_engine-0.3.0/LICENSE +76 -0
  2. optiondesk_engine-0.3.0/PKG-INFO +10 -0
  3. optiondesk_engine-0.3.0/README.md +34 -0
  4. optiondesk_engine-0.3.0/pyproject.toml +21 -0
  5. optiondesk_engine-0.3.0/src/optiondesk_engine/__init__.py +57 -0
  6. optiondesk_engine-0.3.0/src/optiondesk_engine/analytics/__init__.py +31 -0
  7. optiondesk_engine-0.3.0/src/optiondesk_engine/analytics/compare.py +176 -0
  8. optiondesk_engine-0.3.0/src/optiondesk_engine/analytics/exposure.py +227 -0
  9. optiondesk_engine-0.3.0/src/optiondesk_engine/analytics/ranking.py +362 -0
  10. optiondesk_engine-0.3.0/src/optiondesk_engine/analytics/smile.py +144 -0
  11. optiondesk_engine-0.3.0/src/optiondesk_engine/backtest/__init__.py +43 -0
  12. optiondesk_engine-0.3.0/src/optiondesk_engine/backtest/forward.py +160 -0
  13. optiondesk_engine-0.3.0/src/optiondesk_engine/backtest/runner.py +210 -0
  14. optiondesk_engine-0.3.0/src/optiondesk_engine/backtest/stats.py +188 -0
  15. optiondesk_engine-0.3.0/src/optiondesk_engine/pricing/__init__.py +6 -0
  16. optiondesk_engine-0.3.0/src/optiondesk_engine/pricing/black_scholes.py +291 -0
  17. optiondesk_engine-0.3.0/src/optiondesk_engine/pricing/forwards.py +177 -0
  18. optiondesk_engine-0.3.0/src/optiondesk_engine/pricing/greeks_full.py +185 -0
  19. optiondesk_engine-0.3.0/src/optiondesk_engine/simulation/__init__.py +29 -0
  20. optiondesk_engine-0.3.0/src/optiondesk_engine/simulation/garch.py +402 -0
  21. optiondesk_engine-0.3.0/src/optiondesk_engine/simulation/paths.py +277 -0
  22. optiondesk_engine-0.3.0/src/optiondesk_engine/strategies/__init__.py +62 -0
  23. optiondesk_engine-0.3.0/src/optiondesk_engine/strategies/friction.py +156 -0
  24. optiondesk_engine-0.3.0/src/optiondesk_engine/strategies/outlook.py +102 -0
  25. optiondesk_engine-0.3.0/src/optiondesk_engine/strategies/payoff.py +349 -0
  26. optiondesk_engine-0.3.0/src/optiondesk_engine/strategies/playbook.py +1070 -0
  27. optiondesk_engine-0.3.0/src/optiondesk_engine/strategies/timespread.py +700 -0
  28. optiondesk_engine-0.3.0/tests/test_asymmetric_structures.py +525 -0
  29. optiondesk_engine-0.3.0/tests/test_audit_regressions.py +426 -0
  30. optiondesk_engine-0.3.0/tests/test_backtest.py +282 -0
  31. optiondesk_engine-0.3.0/tests/test_compare.py +125 -0
  32. optiondesk_engine-0.3.0/tests/test_exposure.py +125 -0
  33. optiondesk_engine-0.3.0/tests/test_forward.py +135 -0
  34. optiondesk_engine-0.3.0/tests/test_forwards.py +199 -0
  35. optiondesk_engine-0.3.0/tests/test_greeks_full.py +400 -0
  36. optiondesk_engine-0.3.0/tests/test_ranking.py +481 -0
  37. optiondesk_engine-0.3.0/tests/test_simulation.py +230 -0
  38. optiondesk_engine-0.3.0/tests/test_smile.py +81 -0
  39. optiondesk_engine-0.3.0/tests/test_strategies.py +462 -0
  40. optiondesk_engine-0.3.0/tests/test_timespread.py +580 -0
@@ -0,0 +1,76 @@
1
+ # PolyForm Noncommercial License 1.0.0
2
+
3
+ <https://polyformproject.org/licenses/noncommercial/1.0.0>
4
+
5
+ Required Notice: Copyright 2026 Iman Samizadeh
6
+ (https://github.com/Iman/agent-driven-options-desk-and-skills)
7
+
8
+ ## Acceptance
9
+
10
+ In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses.
11
+
12
+ ## Copyright License
13
+
14
+ The licensor grants you a copyright license for the software to do everything you might do with the software that would otherwise infringe the licensor's copyright in it for any permitted purpose. However, you may only distribute the software according to [Distribution License](#distribution-license) and make changes or new works based on the software according to [Changes and New Works License](#changes-and-new-works-license).
15
+
16
+ ## Distribution License
17
+
18
+ The licensor grants you an additional copyright license to distribute copies of the software. Your license to distribute covers distributing the software with changes and new works permitted by [Changes and New Works License](#changes-and-new-works-license).
19
+
20
+ ## Notices
21
+
22
+ You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms or the URL for them above, as well as copies of any plain-text lines beginning with `Required Notice:` that the licensor provided with the software. For example:
23
+
24
+ > Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
25
+
26
+ ## Changes and New Works License
27
+
28
+ The licensor grants you an additional copyright license to make changes and new works based on the software for any permitted purpose.
29
+
30
+ ## Patent License
31
+
32
+ The licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software.
33
+
34
+ ## Noncommercial Purposes
35
+
36
+ Any noncommercial purpose is a permitted purpose.
37
+
38
+ ## Personal Uses
39
+
40
+ Personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, without any anticipated commercial application, is use for a permitted purpose.
41
+
42
+ ## Noncommercial Organizations
43
+
44
+ Use by any charitable organization, educational institution, public research organization, public safety or health organization, environmental protection organization, or government institution is use for a permitted purpose regardless of the source of funding or obligations resulting from the funding.
45
+
46
+ ## Fair Use
47
+
48
+ You may have "fair use" rights for the software under the law. These terms do not limit them.
49
+
50
+ ## No Other Rights
51
+
52
+ These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses.
53
+
54
+ ## Patent Defense
55
+
56
+ If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.
57
+
58
+ ## Violations
59
+
60
+ The first time you are notified in writing that you have violated any of these terms, or done anything with the software not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 32 days of receiving notice. Otherwise, all your licenses end immediately.
61
+
62
+ ## No Liability
63
+
64
+ ***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.***
65
+
66
+ ## Definitions
67
+
68
+ The **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms.
69
+
70
+ **You** refers to the individual or entity agreeing to these terms.
71
+
72
+ **Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization. **Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect.
73
+
74
+ **Your licenses** are all the licenses granted to you for the software under these terms.
75
+
76
+ **Use** means anything you do with the software requiring one of your licenses.
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.5
2
+ Name: optiondesk-engine
3
+ Version: 0.3.0
4
+ Summary: Option pricing and Greek analytics engine for the option desk
5
+ Author: Iman Samizadeh
6
+ License: PolyForm-Noncommercial-1.0.0
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.11
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest; extra == 'dev'
@@ -0,0 +1,34 @@
1
+ # optiondesk-engine
2
+
3
+ Pricing and analytics for the option desk. Licensed under the PolyForm
4
+ Noncommercial License 1.0.0, the same terms as the rest of the project. See
5
+ `LICENSE` for the full text and `../LICENSES.md` for what that permits.
6
+
7
+ The engine is pure computation. It has no network access, no data providers,
8
+ no file system layout opinions and no dependencies outside the standard
9
+ library. It takes numbers and returns numbers, which is what makes it testable
10
+ against closed-form and finite-difference benchmarks.
11
+
12
+ The shell fetches data and writes artifacts. The engine turns the data
13
+ into analytics. Neither imports the other's internals: the shell reaches the
14
+ engine through one adapter module, and degrades with an explicit message when
15
+ the engine is not installed.
16
+
17
+ Current contents:
18
+
19
+ - `pricing/`: European Black-Scholes-Merton with a continuous dividend
20
+ yield, a Newton-Raphson implied volatility solver with a bisection
21
+ fallback that refuses prices carrying no volatility information, and the
22
+ full analytic Greek ladder from first to third order in documented units.
23
+ - `strategies/`: the expiry payoff engine, the five-direction outlook
24
+ framework, the structure playbook, and the friction and liquidity gate.
25
+ - `analytics/`: dealer gamma exposure with walls and flip levels, max pain,
26
+ volatility smile geometry, and the structure ranking.
27
+ - `simulation/`: GARCH(1,1) with Student-t innovations sampled by adaptive
28
+ random-walk Metropolis, posterior predictive paths, value at risk and
29
+ expected shortfall, and position profit distributions.
30
+ - `backtest/`: the historical runner with modelled premiums, the
31
+ performance and significance statistics, and forward-test marking.
32
+
33
+ Read DISCLAIMER.md at the repository root before using any of it. Modelled
34
+ premiums are not achievable prices.
@@ -0,0 +1,21 @@
1
+ [project]
2
+ name = "optiondesk-engine"
3
+ version = "0.3.0"
4
+ description = "Option pricing and Greek analytics engine for the option desk"
5
+ requires-python = ">=3.11"
6
+ license = { text = "PolyForm-Noncommercial-1.0.0" }
7
+ authors = [{ name = "Iman Samizadeh" }]
8
+ dependencies = []
9
+
10
+ [project.optional-dependencies]
11
+ dev = ["pytest"]
12
+
13
+ [build-system]
14
+ requires = ["hatchling"]
15
+ build-backend = "hatchling.build"
16
+
17
+ [tool.hatch.build.targets.wheel]
18
+ packages = ["src/optiondesk_engine"]
19
+
20
+ [tool.pytest.ini_options]
21
+ testpaths = ["tests"]
@@ -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
+ }