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.
- optiondesk_engine-0.3.0/LICENSE +76 -0
- optiondesk_engine-0.3.0/PKG-INFO +10 -0
- optiondesk_engine-0.3.0/README.md +34 -0
- optiondesk_engine-0.3.0/pyproject.toml +21 -0
- optiondesk_engine-0.3.0/src/optiondesk_engine/__init__.py +57 -0
- optiondesk_engine-0.3.0/src/optiondesk_engine/analytics/__init__.py +31 -0
- optiondesk_engine-0.3.0/src/optiondesk_engine/analytics/compare.py +176 -0
- optiondesk_engine-0.3.0/src/optiondesk_engine/analytics/exposure.py +227 -0
- optiondesk_engine-0.3.0/src/optiondesk_engine/analytics/ranking.py +362 -0
- optiondesk_engine-0.3.0/src/optiondesk_engine/analytics/smile.py +144 -0
- optiondesk_engine-0.3.0/src/optiondesk_engine/backtest/__init__.py +43 -0
- optiondesk_engine-0.3.0/src/optiondesk_engine/backtest/forward.py +160 -0
- optiondesk_engine-0.3.0/src/optiondesk_engine/backtest/runner.py +210 -0
- optiondesk_engine-0.3.0/src/optiondesk_engine/backtest/stats.py +188 -0
- optiondesk_engine-0.3.0/src/optiondesk_engine/pricing/__init__.py +6 -0
- optiondesk_engine-0.3.0/src/optiondesk_engine/pricing/black_scholes.py +291 -0
- optiondesk_engine-0.3.0/src/optiondesk_engine/pricing/forwards.py +177 -0
- optiondesk_engine-0.3.0/src/optiondesk_engine/pricing/greeks_full.py +185 -0
- optiondesk_engine-0.3.0/src/optiondesk_engine/simulation/__init__.py +29 -0
- optiondesk_engine-0.3.0/src/optiondesk_engine/simulation/garch.py +402 -0
- optiondesk_engine-0.3.0/src/optiondesk_engine/simulation/paths.py +277 -0
- optiondesk_engine-0.3.0/src/optiondesk_engine/strategies/__init__.py +62 -0
- optiondesk_engine-0.3.0/src/optiondesk_engine/strategies/friction.py +156 -0
- optiondesk_engine-0.3.0/src/optiondesk_engine/strategies/outlook.py +102 -0
- optiondesk_engine-0.3.0/src/optiondesk_engine/strategies/payoff.py +349 -0
- optiondesk_engine-0.3.0/src/optiondesk_engine/strategies/playbook.py +1070 -0
- optiondesk_engine-0.3.0/src/optiondesk_engine/strategies/timespread.py +700 -0
- optiondesk_engine-0.3.0/tests/test_asymmetric_structures.py +525 -0
- optiondesk_engine-0.3.0/tests/test_audit_regressions.py +426 -0
- optiondesk_engine-0.3.0/tests/test_backtest.py +282 -0
- optiondesk_engine-0.3.0/tests/test_compare.py +125 -0
- optiondesk_engine-0.3.0/tests/test_exposure.py +125 -0
- optiondesk_engine-0.3.0/tests/test_forward.py +135 -0
- optiondesk_engine-0.3.0/tests/test_forwards.py +199 -0
- optiondesk_engine-0.3.0/tests/test_greeks_full.py +400 -0
- optiondesk_engine-0.3.0/tests/test_ranking.py +481 -0
- optiondesk_engine-0.3.0/tests/test_simulation.py +230 -0
- optiondesk_engine-0.3.0/tests/test_smile.py +81 -0
- optiondesk_engine-0.3.0/tests/test_strategies.py +462 -0
- 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
|
+
}
|