aether-quant 0.0.1__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.
- aether_quant-0.0.1.dist-info/METADATA +7 -0
- aether_quant-0.0.1.dist-info/RECORD +99 -0
- aether_quant-0.0.1.dist-info/WHEEL +5 -0
- aether_quant-0.0.1.dist-info/entry_points.txt +2 -0
- aether_quant-0.0.1.dist-info/licenses/LICENSE +133 -0
- aether_quant-0.0.1.dist-info/top_level.txt +13 -0
- analyzer/README.md +69 -0
- analyzer/__init__.py +17 -0
- analyzer/market_analyzer.py +521 -0
- aq_cli.py +3633 -0
- audit/README.md +47 -0
- audit/__init__.py +38 -0
- audit/hash_chain.py +52 -0
- audit/postgres_audit.py +61 -0
- audit/postgres_worker.py +402 -0
- audit/redis_queue.py +124 -0
- audit/status_export.py +62 -0
- data_pipeline/README.md +257 -0
- data_pipeline/__init__.py +6 -0
- data_pipeline/bar_synthesis.py +97 -0
- data_pipeline/dividend_backfill.py +243 -0
- data_pipeline/factor_file_backfill.py +276 -0
- data_pipeline/fetch.py +206 -0
- data_pipeline/fred_backfill.py +435 -0
- data_pipeline/ib_backfill.py +287 -0
- data_pipeline/v2_manifest.py +75 -0
- data_pipeline/yfinance_backfill.py +501 -0
- evaluation/README.md +152 -0
- evaluation/__init__.py +73 -0
- evaluation/ablation.py +310 -0
- evaluation/benchmark_comparison.py +195 -0
- evaluation/confidence_threshold_calibration.py +157 -0
- evaluation/factor_neutralization_check.py +94 -0
- evaluation/feature_parity.py +321 -0
- evaluation/feature_reconciliation.py +286 -0
- evaluation/impact_model.py +77 -0
- evaluation/kill_switch_replay.py +186 -0
- evaluation/limit_fill_simulator.py +152 -0
- evaluation/model_predictions.py +192 -0
- evaluation/monte_carlo.py +161 -0
- evaluation/public_benchmarks.py +117 -0
- evaluation/rank_book_simulator.py +494 -0
- evaluation/rank_ic_core.py +204 -0
- evaluation/rank_signal_calibration.py +690 -0
- evaluation/rolling_ic_gate_calibration.py +190 -0
- evaluation/rolling_ic_gate_replay.py +138 -0
- execution/README.md +281 -0
- execution/__init__.py +80 -0
- execution/config_cache.py +48 -0
- execution/cost_model.py +213 -0
- execution/lean_config_render.py +131 -0
- execution/live_credentials.py +38 -0
- execution/live_credentials_io.py +52 -0
- execution/order_gate.py +388 -0
- execution/paper_readiness.py +181 -0
- execution/paper_readiness_io.py +54 -0
- execution/paper_readiness_report.py +103 -0
- execution/paper_readiness_scheduler.py +137 -0
- execution/reconciliation.py +160 -0
- execution/runtime_config_io.py +38 -0
- execution/secret_scan.py +73 -0
- features/README.md +77 -0
- features/__init__.py +137 -0
- features/alt_data_features.py +154 -0
- features/bond_features.py +275 -0
- features/cross_asset_sensitivity.py +79 -0
- features/derivatives_macro_features.py +72 -0
- features/macro_features.py +79 -0
- features/options_greeks.py +329 -0
- features/sector_map.py +53 -0
- features/technical_indicators.py +185 -0
- generate_backtest_report.py +311 -0
- generate_evaluation_report.py +712 -0
- inference/README.md +105 -0
- inference/__init__.py +36 -0
- inference/exported_model.py +929 -0
- inference/parallel_inference.py +160 -0
- inference/strategy_selector_inference.py +75 -0
- json_safety.py +130 -0
- portfolio/README.md +447 -0
- portfolio/__init__.py +41 -0
- portfolio/book_construction.py +786 -0
- portfolio/book_neutrality.py +178 -0
- portfolio/hrp_allocation.py +159 -0
- portfolio/options_arbitrage_detector.py +232 -0
- portfolio/options_assignment_risk.py +119 -0
- portfolio/options_margin_sizing.py +333 -0
- portfolio/options_strategy.py +1999 -0
- portfolio/rank_signal.py +219 -0
- portfolio/rolling_ic_gate.py +183 -0
- risk/README.md +275 -0
- risk/__init__.py +38 -0
- risk/asset_class_router.py +584 -0
- risk/forex_risk.py +158 -0
- risk/futures_risk.py +202 -0
- risk/kill_switch.py +261 -0
- risk/manual_override.py +118 -0
- risk/position_sizing.py +303 -0
- risk/rl_sizing.py +189 -0
|
@@ -0,0 +1,1999 @@
|
|
|
1
|
+
"""Greeks-aware single-leg options position sizing.
|
|
2
|
+
|
|
3
|
+
Satisfies "options need a fundamentally different model output" WITHOUT a
|
|
4
|
+
new model architecture or training target: the EXISTING direction +
|
|
5
|
+
confidence (+ magnitude, unused here) prediction every model in this
|
|
6
|
+
codebase already produces becomes the *input* to a deterministic, non-ML
|
|
7
|
+
greeks-sizing function. Target delta scales linearly with confidence
|
|
8
|
+
(0 confidence -> 0 delta -> no position; full confidence ->
|
|
9
|
+
target_delta_at_full_confidence), and contract count is capped by a vega
|
|
10
|
+
risk budget (percent of portfolio equity), not a plain notional/weight cap
|
|
11
|
+
- vega, not price exposure, is the right risk unit for an options position.
|
|
12
|
+
|
|
13
|
+
Deliberately single-leg only (long calls or long puts, never short options
|
|
14
|
+
or multi-leg spreads/verticals/straddles/condors) - automatic multi-leg
|
|
15
|
+
spread SELECTION via ML is an explicit non-goal of this pass (see
|
|
16
|
+
development/Problems.md); this module is the sizing/selection layer for
|
|
17
|
+
the single-leg case only.
|
|
18
|
+
|
|
19
|
+
Its own package (not risk/ - options_strategy.py needs the whole option
|
|
20
|
+
chain, not just a scalar signal; not features/ - this is a decision layer,
|
|
21
|
+
not a feature) - same "new package, off by default, explicit safety-tier
|
|
22
|
+
extension" precedent portfolio/book_construction.py already established
|
|
23
|
+
for this codebase's newest decision-layer additions.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import math
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class OptionsPositionDecision:
|
|
34
|
+
contracts: int
|
|
35
|
+
right: str
|
|
36
|
+
strike: float
|
|
37
|
+
expiry: str
|
|
38
|
+
target_delta: float
|
|
39
|
+
actual_delta: float
|
|
40
|
+
vega_budget_used: float
|
|
41
|
+
sizing_reason: str
|
|
42
|
+
# The actual Lean contract Symbol (from the selected chain row's
|
|
43
|
+
# "symbol" key, see main.py::_build_options_chains_payload()) - needed
|
|
44
|
+
# by main.py::_apply_signal() to place an order on the SPECIFIC
|
|
45
|
+
# contract, not the canonical option chain Symbol. None whenever the
|
|
46
|
+
# chain row didn't carry one (e.g. a synthetic test fixture) - callers
|
|
47
|
+
# must treat that as "no order can be placed", never a crash.
|
|
48
|
+
contract_symbol: object = None
|
|
49
|
+
|
|
50
|
+
def to_dict(self) -> dict:
|
|
51
|
+
"""Deliberately NOT dataclasses.asdict(self) - asdict() deep-copies
|
|
52
|
+
every field, and a raw Lean Symbol (a C#/.NET interop object) isn't
|
|
53
|
+
guaranteed deepcopy-safe. Manual field access avoids that risk
|
|
54
|
+
entirely and stringifies contract_symbol so the result is always
|
|
55
|
+
JSON-serializable - required for main.py's dashboard state writer
|
|
56
|
+
(see main.py::on_data()'s "dynamic_sizing_for_state" sanitization,
|
|
57
|
+
which calls this before signals[...] ever reaches json.dumps())."""
|
|
58
|
+
return {
|
|
59
|
+
"contracts": self.contracts,
|
|
60
|
+
"right": self.right,
|
|
61
|
+
"strike": self.strike,
|
|
62
|
+
"expiry": self.expiry,
|
|
63
|
+
"target_delta": self.target_delta,
|
|
64
|
+
"actual_delta": self.actual_delta,
|
|
65
|
+
"vega_budget_used": self.vega_budget_used,
|
|
66
|
+
"sizing_reason": self.sizing_reason,
|
|
67
|
+
"contract_symbol": str(self.contract_symbol) if self.contract_symbol is not None else None,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def select_single_leg_contract(available_chain: list[dict], target_delta: float, right: str) -> dict | None:
|
|
72
|
+
"""Picks the chain entry of the requested `right` ("call"/"put") whose
|
|
73
|
+
|delta| is closest to |target_delta| - nearest-strike-by-delta
|
|
74
|
+
selection, no ML, no spread construction. Each entry in
|
|
75
|
+
available_chain must already carry a computed "delta" key (see
|
|
76
|
+
features/options_greeks.py::compute_greeks() - this function does no
|
|
77
|
+
pricing itself, purely a selection over already-priced rows).
|
|
78
|
+
|
|
79
|
+
Returns None when available_chain is empty or has no entry of the
|
|
80
|
+
requested right (e.g. IB disabled -> empty chain, or a chain with only
|
|
81
|
+
calls when a put was requested) - degrade-to-absent, never a crash."""
|
|
82
|
+
right_normalized = right.lower()
|
|
83
|
+
candidates = [
|
|
84
|
+
row for row in available_chain
|
|
85
|
+
if str(row.get("right", "")).lower() == right_normalized and row.get("delta") is not None
|
|
86
|
+
]
|
|
87
|
+
if not candidates:
|
|
88
|
+
return None
|
|
89
|
+
return min(candidates, key=lambda row: abs(abs(row["delta"]) - abs(target_delta)))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _size_single_leg_contract(
|
|
93
|
+
contract: dict,
|
|
94
|
+
right: str,
|
|
95
|
+
target_delta: float,
|
|
96
|
+
portfolio_value: float,
|
|
97
|
+
max_vega_budget_pct_of_equity: float,
|
|
98
|
+
sizing_reason: str,
|
|
99
|
+
) -> OptionsPositionDecision | None:
|
|
100
|
+
"""Shared vega-budget arithmetic for a single ALREADY-RESOLVED contract
|
|
101
|
+
row - factored out of build_options_position_sizing() so
|
|
102
|
+
build_options_position_sizing_for_contract() (V4.4, sizes an
|
|
103
|
+
already-HELD contract on its own greeks instead of re-selecting one)
|
|
104
|
+
reuses the identical budget math rather than duplicating it. Returns
|
|
105
|
+
None when the contract's vega is non-positive or the budget rounds
|
|
106
|
+
down to zero contracts - never raises."""
|
|
107
|
+
contract_vega = float(contract.get("vega", 0.0) or 0.0)
|
|
108
|
+
if contract_vega <= 0.0:
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
vega_budget = max_vega_budget_pct_of_equity * portfolio_value
|
|
112
|
+
contracts = int(vega_budget // contract_vega)
|
|
113
|
+
if contracts <= 0:
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
return OptionsPositionDecision(
|
|
117
|
+
contracts=contracts,
|
|
118
|
+
right=right,
|
|
119
|
+
strike=float(contract["strike"]),
|
|
120
|
+
expiry=str(contract["expiry"]),
|
|
121
|
+
target_delta=target_delta,
|
|
122
|
+
actual_delta=float(contract["delta"]),
|
|
123
|
+
vega_budget_used=(contracts * contract_vega) / portfolio_value,
|
|
124
|
+
sizing_reason=sizing_reason,
|
|
125
|
+
contract_symbol=contract.get("symbol"),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def build_options_position_sizing(
|
|
130
|
+
signal_direction: str,
|
|
131
|
+
confidence: float,
|
|
132
|
+
available_chain: list[dict],
|
|
133
|
+
portfolio_value: float,
|
|
134
|
+
target_delta_at_full_confidence: float = 0.60,
|
|
135
|
+
max_vega_budget_pct_of_equity: float = 0.02,
|
|
136
|
+
) -> OptionsPositionDecision | None:
|
|
137
|
+
"""Translates the existing direction+confidence prediction into ONE
|
|
138
|
+
single-leg options position. signal_direction == "buy" selects calls;
|
|
139
|
+
{"sell", "short"} select puts (matches
|
|
140
|
+
portfolio/book_construction.py's own "buy"/"short" role vocabulary and
|
|
141
|
+
main.py::_apply_signal()'s signal_name values).
|
|
142
|
+
|
|
143
|
+
target_delta scales linearly with confidence:
|
|
144
|
+
target_delta_at_full_confidence * confidence. contract count is capped
|
|
145
|
+
so total position vega stays under
|
|
146
|
+
max_vega_budget_pct_of_equity * portfolio_value / contract_vega -
|
|
147
|
+
vega (not notional) is the risk unit an options book should budget
|
|
148
|
+
against, since a small-notional far-OTM option can carry
|
|
149
|
+
disproportionate vega risk relative to its price.
|
|
150
|
+
|
|
151
|
+
Returns None when: confidence is 0, signal_direction doesn't map to a
|
|
152
|
+
right, available_chain has no usable contract for that right (e.g. IB
|
|
153
|
+
disabled -> empty chain, or select_single_leg_contract() found no
|
|
154
|
+
match), the selected contract's vega is non-positive, or
|
|
155
|
+
portfolio_value is non-positive - degrade-to-absent, never a crash,
|
|
156
|
+
matching risk/futures_risk.py::build_futures_position_sizing()'s
|
|
157
|
+
"no active signal -> None/zero" convention."""
|
|
158
|
+
confidence = max(0.0, min(float(confidence), 1.0))
|
|
159
|
+
portfolio_value = max(float(portfolio_value), 0.0)
|
|
160
|
+
|
|
161
|
+
if signal_direction == "buy":
|
|
162
|
+
right = "call"
|
|
163
|
+
elif signal_direction in ("sell", "short"):
|
|
164
|
+
right = "put"
|
|
165
|
+
else:
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
if confidence == 0.0 or portfolio_value <= 0.0:
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
target_delta = target_delta_at_full_confidence * confidence
|
|
172
|
+
contract = select_single_leg_contract(available_chain, target_delta, right)
|
|
173
|
+
if contract is None:
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
return _size_single_leg_contract(
|
|
177
|
+
contract, right, target_delta, portfolio_value, max_vega_budget_pct_of_equity,
|
|
178
|
+
"delta_targeted_vega_budgeted_sizing",
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def build_options_position_sizing_for_contract(
|
|
183
|
+
held_contract: dict,
|
|
184
|
+
portfolio_value: float,
|
|
185
|
+
max_vega_budget_pct_of_equity: float = 0.02,
|
|
186
|
+
) -> OptionsPositionDecision | None:
|
|
187
|
+
"""V4.4 - sizes the EXACT held contract on its own current greeks,
|
|
188
|
+
skipping select_single_leg_contract() entirely (no re-selection).
|
|
189
|
+
|
|
190
|
+
Closes a real architectural gap: without this, a position whose ideal
|
|
191
|
+
strike has drifted away from what main.py is currently holding could
|
|
192
|
+
only ever be handled by RE-SELECTING a contract (which picks a
|
|
193
|
+
DIFFERENT one, forcing a binary rotate-or-freeze choice) - this lets
|
|
194
|
+
the held position keep being managed - scaled up or down - on its own
|
|
195
|
+
delta/vega, on bars where main.py chooses not to rotate.
|
|
196
|
+
|
|
197
|
+
held_contract is a live chain row for the currently-held contract -
|
|
198
|
+
same shape select_single_leg_contract() returns (must carry
|
|
199
|
+
right/strike/expiry/delta/vega/symbol, e.g. found by matching the
|
|
200
|
+
held contract_symbol against this bar's available_chain). There is no
|
|
201
|
+
fresh confidence-scaled target here - the position already exists -
|
|
202
|
+
so target_delta/actual_delta are both just the held contract's own
|
|
203
|
+
current delta.
|
|
204
|
+
|
|
205
|
+
Same degrade-to-None contract as build_options_position_sizing():
|
|
206
|
+
non-positive portfolio_value, missing/non-positive vega, or a budget
|
|
207
|
+
that rounds to zero contracts all return None, never raise."""
|
|
208
|
+
portfolio_value = max(float(portfolio_value), 0.0)
|
|
209
|
+
if portfolio_value <= 0.0:
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
right = str(held_contract.get("right", "")).lower()
|
|
213
|
+
held_delta = float(held_contract.get("delta", 0.0) or 0.0)
|
|
214
|
+
return _size_single_leg_contract(
|
|
215
|
+
held_contract, right, held_delta, portfolio_value, max_vega_budget_pct_of_equity,
|
|
216
|
+
"held_contract_own_greeks_sizing",
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# ---------------------------------------------------------------------------
|
|
221
|
+
# 2-leg vertical spreads (call vertical / bull_call_spread, put vertical /
|
|
222
|
+
# bear_put_spread) - a deliberately minimal, explicit scope-in of the
|
|
223
|
+
# "multi-leg spread selection is a non-goal" note above. Straddles/
|
|
224
|
+
# strangles/iron condors/butterflies remain out of scope (see
|
|
225
|
+
# development/Problems.md #29/#38). OptionsPositionDecision/
|
|
226
|
+
# select_single_leg_contract()/build_options_position_sizing() above are
|
|
227
|
+
# completely untouched - these are new, parallel additions, not a
|
|
228
|
+
# generalization of the single-leg path.
|
|
229
|
+
# ---------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@dataclass(frozen=True)
|
|
233
|
+
class OptionsSpreadLeg:
|
|
234
|
+
"""One leg of a 2-leg vertical spread. side is "long"/"short" (never a
|
|
235
|
+
signed quantity) - keeps this symmetric with how Lean's own
|
|
236
|
+
OptionStrategies/Leg model a spread: a strike, a side, a contract."""
|
|
237
|
+
|
|
238
|
+
strike: float
|
|
239
|
+
right: str # "call" | "put" - both legs of a vertical share this
|
|
240
|
+
side: str # "long" | "short"
|
|
241
|
+
# Same None-safe contract as OptionsPositionDecision.contract_symbol
|
|
242
|
+
# above - a synthetic test fixture with no "symbol" key degrades to
|
|
243
|
+
# None, never a crash.
|
|
244
|
+
contract_symbol: object = None
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@dataclass(frozen=True)
|
|
248
|
+
class OptionsSpreadPositionDecision:
|
|
249
|
+
"""2-leg vertical spread sibling of OptionsPositionDecision - a
|
|
250
|
+
SEPARATE dataclass, not a generalization of it (OptionsPositionDecision
|
|
251
|
+
stays single-leg-shaped, every existing caller untouched)."""
|
|
252
|
+
|
|
253
|
+
strategy_name: str # "bull_call_spread" | "bear_put_spread" - matches Lean's OptionStrategies factory name exactly, main.py uses this to pick which one to call
|
|
254
|
+
legs: tuple[OptionsSpreadLeg, OptionsSpreadLeg] # (long, short), exactly 2 for this pass's scope
|
|
255
|
+
expiry: str
|
|
256
|
+
contracts: int # spread QUANTITY (number of spread units), not a per-leg contract count
|
|
257
|
+
# Best-effort diagnostic only (mid/bid-ask based, per spread unit) -
|
|
258
|
+
# 0.0 when bid/ask are absent from the chain rows. NEVER gates sizing,
|
|
259
|
+
# matching this module's existing "never crash on missing chain
|
|
260
|
+
# fields" contract.
|
|
261
|
+
net_debit_or_credit: float
|
|
262
|
+
net_delta: float # long leg's delta minus short leg's delta, per spread unit
|
|
263
|
+
net_vega: float # fraction of portfolio_value the sized position's net vega consumes - same "budget used" semantic as OptionsPositionDecision.vega_budget_used, not a raw per-unit vega number
|
|
264
|
+
sizing_reason: str
|
|
265
|
+
|
|
266
|
+
def to_dict(self) -> dict:
|
|
267
|
+
"""Same manual-field-access convention as OptionsPositionDecision.to_dict()
|
|
268
|
+
above - never dataclasses.asdict(), the legs' contract_symbol
|
|
269
|
+
fields are real Lean Symbol objects, not deepcopy-safe."""
|
|
270
|
+
return {
|
|
271
|
+
"strategy_name": self.strategy_name,
|
|
272
|
+
"legs": [
|
|
273
|
+
{
|
|
274
|
+
"strike": leg.strike,
|
|
275
|
+
"right": leg.right,
|
|
276
|
+
"side": leg.side,
|
|
277
|
+
"contract_symbol": str(leg.contract_symbol) if leg.contract_symbol is not None else None,
|
|
278
|
+
}
|
|
279
|
+
for leg in self.legs
|
|
280
|
+
],
|
|
281
|
+
"expiry": self.expiry,
|
|
282
|
+
"contracts": self.contracts,
|
|
283
|
+
"net_debit_or_credit": self.net_debit_or_credit,
|
|
284
|
+
"net_delta": self.net_delta,
|
|
285
|
+
"net_vega": self.net_vega,
|
|
286
|
+
"sizing_reason": self.sizing_reason,
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def select_vertical_spread_legs(
|
|
291
|
+
available_chain: list[dict],
|
|
292
|
+
target_delta: float,
|
|
293
|
+
right: str,
|
|
294
|
+
short_leg_delta_offset: float,
|
|
295
|
+
) -> tuple[dict, dict] | None:
|
|
296
|
+
"""Picks two same-expiry, same-right chain rows for a vertical spread:
|
|
297
|
+
the long leg via the EXACT same nearest-|delta| selection
|
|
298
|
+
select_single_leg_contract() already uses (no new selection logic
|
|
299
|
+
duplicated for it), and the short leg as the nearest-|delta| match to
|
|
300
|
+
(target_delta - short_leg_delta_offset) among rows of the SAME expiry
|
|
301
|
+
as the long leg, filtered to the side that caps risk (strike > long
|
|
302
|
+
strike for a call vertical, strike < long strike for a put vertical -
|
|
303
|
+
enforced explicitly by filtering on strike, not inferred from delta
|
|
304
|
+
ordering, since assuming monotonic real-market delta-vs-strike
|
|
305
|
+
ordering shouldn't be load-bearing here).
|
|
306
|
+
|
|
307
|
+
Returns None when: no usable long leg (same degrade-to-absent
|
|
308
|
+
condition as select_single_leg_contract()), or no candidate short leg
|
|
309
|
+
exists at the long leg's expiry on the risk-capping side - a chain
|
|
310
|
+
with only one usable strike degrades to "no spread", never a crash."""
|
|
311
|
+
long_leg = select_single_leg_contract(available_chain, target_delta, right)
|
|
312
|
+
if long_leg is None:
|
|
313
|
+
return None
|
|
314
|
+
|
|
315
|
+
long_strike = float(long_leg["strike"])
|
|
316
|
+
long_expiry = long_leg["expiry"]
|
|
317
|
+
right_normalized = right.lower()
|
|
318
|
+
short_target_delta = target_delta - short_leg_delta_offset
|
|
319
|
+
|
|
320
|
+
def _is_risk_capping_side(strike: float) -> bool:
|
|
321
|
+
return strike > long_strike if right_normalized == "call" else strike < long_strike
|
|
322
|
+
|
|
323
|
+
short_candidates = [
|
|
324
|
+
row
|
|
325
|
+
for row in available_chain
|
|
326
|
+
if str(row.get("right", "")).lower() == right_normalized
|
|
327
|
+
and row.get("delta") is not None
|
|
328
|
+
and row.get("expiry") == long_expiry
|
|
329
|
+
and _is_risk_capping_side(float(row["strike"]))
|
|
330
|
+
]
|
|
331
|
+
if not short_candidates:
|
|
332
|
+
return None
|
|
333
|
+
|
|
334
|
+
short_leg = min(short_candidates, key=lambda row: abs(abs(row["delta"]) - abs(short_target_delta)))
|
|
335
|
+
return long_leg, short_leg
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _size_vertical_spread(
|
|
339
|
+
long_row: dict,
|
|
340
|
+
short_row: dict,
|
|
341
|
+
right: str,
|
|
342
|
+
strategy_name: str,
|
|
343
|
+
portfolio_value: float,
|
|
344
|
+
max_vega_budget_pct_of_equity: float,
|
|
345
|
+
sizing_reason: str,
|
|
346
|
+
) -> OptionsSpreadPositionDecision | None:
|
|
347
|
+
"""Shared net-vega-budget arithmetic for an already-resolved leg pair -
|
|
348
|
+
factored out of build_vertical_spread_position_sizing() so
|
|
349
|
+
build_vertical_spread_position_sizing_for_legs() (V4.4, sizes an
|
|
350
|
+
already-HELD spread on its own greeks instead of re-selecting legs)
|
|
351
|
+
reuses the identical budget math. Returns None when net_vega is
|
|
352
|
+
non-positive or the budget rounds down to zero spread units."""
|
|
353
|
+
long_vega = float(long_row.get("vega", 0.0) or 0.0)
|
|
354
|
+
short_vega = float(short_row.get("vega", 0.0) or 0.0)
|
|
355
|
+
net_vega_per_spread = long_vega - short_vega
|
|
356
|
+
if net_vega_per_spread <= 0.0:
|
|
357
|
+
return None
|
|
358
|
+
|
|
359
|
+
vega_budget = max_vega_budget_pct_of_equity * portfolio_value
|
|
360
|
+
contracts = int(vega_budget // net_vega_per_spread)
|
|
361
|
+
if contracts <= 0:
|
|
362
|
+
return None
|
|
363
|
+
|
|
364
|
+
long_ask = long_row.get("ask")
|
|
365
|
+
short_bid = short_row.get("bid")
|
|
366
|
+
net_debit_or_credit = (
|
|
367
|
+
float(long_ask) - float(short_bid) if long_ask is not None and short_bid is not None else 0.0
|
|
368
|
+
)
|
|
369
|
+
net_delta = float(long_row.get("delta", 0.0) or 0.0) - float(short_row.get("delta", 0.0) or 0.0)
|
|
370
|
+
|
|
371
|
+
return OptionsSpreadPositionDecision(
|
|
372
|
+
strategy_name=strategy_name,
|
|
373
|
+
legs=(
|
|
374
|
+
OptionsSpreadLeg(strike=float(long_row["strike"]), right=right, side="long", contract_symbol=long_row.get("symbol")),
|
|
375
|
+
OptionsSpreadLeg(strike=float(short_row["strike"]), right=right, side="short", contract_symbol=short_row.get("symbol")),
|
|
376
|
+
),
|
|
377
|
+
expiry=str(long_row["expiry"]),
|
|
378
|
+
contracts=contracts,
|
|
379
|
+
net_debit_or_credit=net_debit_or_credit,
|
|
380
|
+
net_delta=net_delta,
|
|
381
|
+
net_vega=(contracts * net_vega_per_spread) / portfolio_value,
|
|
382
|
+
sizing_reason=sizing_reason,
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def build_vertical_spread_position_sizing(
|
|
387
|
+
signal_direction: str,
|
|
388
|
+
confidence: float,
|
|
389
|
+
available_chain: list[dict],
|
|
390
|
+
portfolio_value: float,
|
|
391
|
+
target_delta_at_full_confidence: float = 0.60,
|
|
392
|
+
short_leg_delta_offset: float = 0.20,
|
|
393
|
+
max_vega_budget_pct_of_equity: float = 0.02,
|
|
394
|
+
) -> OptionsSpreadPositionDecision | None:
|
|
395
|
+
"""Vertical-spread sibling of build_options_position_sizing() above -
|
|
396
|
+
same signal_direction -> right mapping ("buy" -> call vertical/
|
|
397
|
+
bull_call_spread, "sell"/"short" -> put vertical/bear_put_spread),
|
|
398
|
+
same confidence-scaled target_delta for the long leg.
|
|
399
|
+
|
|
400
|
+
Sizes by NET vega (long leg vega minus short leg vega) rather than
|
|
401
|
+
the long leg's vega alone - a vertical's defining risk reduction
|
|
402
|
+
versus a single leg, and the correct risk unit to budget a spread
|
|
403
|
+
position against. Returns None on the same degrade-to-absent
|
|
404
|
+
conditions build_options_position_sizing() already documents, plus:
|
|
405
|
+
no usable short leg found (select_vertical_spread_legs() returned
|
|
406
|
+
None), or net_vega is non-positive (a spread with ~zero or negative
|
|
407
|
+
net vega is unsizeable by this budget, not an error)."""
|
|
408
|
+
confidence = max(0.0, min(float(confidence), 1.0))
|
|
409
|
+
portfolio_value = max(float(portfolio_value), 0.0)
|
|
410
|
+
|
|
411
|
+
if signal_direction == "buy":
|
|
412
|
+
right, strategy_name = "call", "bull_call_spread"
|
|
413
|
+
elif signal_direction in ("sell", "short"):
|
|
414
|
+
right, strategy_name = "put", "bear_put_spread"
|
|
415
|
+
else:
|
|
416
|
+
return None
|
|
417
|
+
|
|
418
|
+
if confidence == 0.0 or portfolio_value <= 0.0:
|
|
419
|
+
return None
|
|
420
|
+
|
|
421
|
+
target_delta = target_delta_at_full_confidence * confidence
|
|
422
|
+
legs = select_vertical_spread_legs(available_chain, target_delta, right, short_leg_delta_offset)
|
|
423
|
+
if legs is None:
|
|
424
|
+
return None
|
|
425
|
+
long_row, short_row = legs
|
|
426
|
+
|
|
427
|
+
return _size_vertical_spread(
|
|
428
|
+
long_row, short_row, right, strategy_name, portfolio_value, max_vega_budget_pct_of_equity,
|
|
429
|
+
"delta_targeted_net_vega_budgeted_vertical_spread_sizing",
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def build_vertical_spread_position_sizing_for_legs(
|
|
434
|
+
held_long: dict,
|
|
435
|
+
held_short: dict,
|
|
436
|
+
portfolio_value: float,
|
|
437
|
+
max_vega_budget_pct_of_equity: float = 0.02,
|
|
438
|
+
) -> OptionsSpreadPositionDecision | None:
|
|
439
|
+
"""V4.4 - net-vega sibling of build_options_position_sizing_for_contract()
|
|
440
|
+
for a held 2-leg vertical spread: sizes the EXACT held long/short legs
|
|
441
|
+
on their own current greeks, skipping select_vertical_spread_legs()
|
|
442
|
+
entirely. held_long/held_short are live chain rows for the currently-
|
|
443
|
+
held legs (same shape select_vertical_spread_legs() returns each of
|
|
444
|
+
its pair). strategy_name/right are derived from held_long's own
|
|
445
|
+
"right" field rather than passed in - there is no fresh signal here,
|
|
446
|
+
the spread already exists. Same degrade-to-None contract as
|
|
447
|
+
build_vertical_spread_position_sizing()."""
|
|
448
|
+
portfolio_value = max(float(portfolio_value), 0.0)
|
|
449
|
+
if portfolio_value <= 0.0:
|
|
450
|
+
return None
|
|
451
|
+
|
|
452
|
+
right = str(held_long.get("right", "")).lower()
|
|
453
|
+
strategy_name = "bull_call_spread" if right == "call" else "bear_put_spread"
|
|
454
|
+
return _size_vertical_spread(
|
|
455
|
+
held_long, held_short, right, strategy_name, portfolio_value, max_vega_budget_pct_of_equity,
|
|
456
|
+
"held_legs_own_greeks_sizing",
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
# ---------------------------------------------------------------------------
|
|
461
|
+
# V4.5 - full OptionStrategies coverage (41 additional factories beyond the
|
|
462
|
+
# 2-leg verticals above). Everything above this line is UNTOUCHED - existing
|
|
463
|
+
# callers (single-leg, bull_call_spread/bear_put_spread) keep working
|
|
464
|
+
# byte-identically; this section is purely additive.
|
|
465
|
+
#
|
|
466
|
+
# Design: one small MULTI_LEG_STRATEGY_REGISTRY data table (one StrategySpec
|
|
467
|
+
# per factory, sourced directly from QuantConnect.Securities.Option.
|
|
468
|
+
# OptionStrategies.cs - the real Lean C# leg quantities, not guessed from
|
|
469
|
+
# the factory's positional strike names) replaces what would otherwise be
|
|
470
|
+
# 41 near-duplicate selection/sizing functions. A handful of shared
|
|
471
|
+
# shape-family selectors (vertical/straddle/strangle/butterfly/iron/
|
|
472
|
+
# calendar/backspread/ladder/naked/covered-protective) dispatch off each
|
|
473
|
+
# StrategySpec's `shape_family` tag, parameterized by its `legs` (each
|
|
474
|
+
# already carrying the correct long/short side and call/put right straight
|
|
475
|
+
# from the registry) - so e.g. all 4 ladder variants or all 4 calendar
|
|
476
|
+
# variants share ONE selector implementation apiece, not four.
|
|
477
|
+
#
|
|
478
|
+
# Two real risk-tier corrections were found while transcribing the ACTUAL
|
|
479
|
+
# Lean leg quantities (not assumed from strike-name positions alone) -
|
|
480
|
+
# recorded here since they contradict this feature's own initial plan text:
|
|
481
|
+
# - Of the 4 ladder strategies, only bull_call_ladder/bear_put_ladder are
|
|
482
|
+
# genuinely net-short (one extra uncovered leg beyond a 1:1 hedge) and
|
|
483
|
+
# unbounded-risk; bear_call_ladder/bull_put_ladder are net-long (an
|
|
484
|
+
# extra long leg only adds cost, never risk) and belong in the SAME
|
|
485
|
+
# vega-budget tier as verticals/butterflies, not the margin family.
|
|
486
|
+
# - Of the 4 backspread strategies, short_call_backspread/
|
|
487
|
+
# short_put_backspread are the genuinely unbounded ones (inverting a
|
|
488
|
+
# backspread turns its 1:2 long-heavy ratio into a 1:2 SHORT-heavy
|
|
489
|
+
# ratio) - naked-style margin, not the bounded-max-loss formula;
|
|
490
|
+
# call_backspread/put_backspread (the un-inverted, long-heavy originals)
|
|
491
|
+
# really are bounded and keep the bounded-max-loss treatment.
|
|
492
|
+
# See development/Problems.md for the full writeup.
|
|
493
|
+
# ---------------------------------------------------------------------------
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
@dataclass(frozen=True)
|
|
498
|
+
class MultiLegSpec:
|
|
499
|
+
"""One leg of a multi-leg OptionStrategy factory result. `side`/`ratio`
|
|
500
|
+
are transcribed directly from OptionStrategies.cs's actual Quantity
|
|
501
|
+
field for that leg (e.g. a butterfly's body leg is ratio=2, side=
|
|
502
|
+
"short" for a long butterfly) - never inferred from the factory's
|
|
503
|
+
positional strike-name alone (a real bug this pass found and fixed,
|
|
504
|
+
see the module-level note above). `is_anchor` marks a leg selected
|
|
505
|
+
FIRST, by confidence-scaled target delta; every other leg in the same
|
|
506
|
+
strategy is selected as a strike/expiry offset from an anchor leg.
|
|
507
|
+
Iron condor/butterfly have TWO anchor legs (short_put, short_call) -
|
|
508
|
+
one per composed vertical selection."""
|
|
509
|
+
|
|
510
|
+
role: str # unique per leg within one strategy, e.g. "short_call"
|
|
511
|
+
right: str # "call" | "put"
|
|
512
|
+
side: str # "long" | "short" - the real Lean leg direction
|
|
513
|
+
ratio: int # contracts per spread unit (1, or 2 for a butterfly body / backspread's heavy leg)
|
|
514
|
+
strike_role: str # key into the strikes-by-role dict this leg's strike is selected/passed under
|
|
515
|
+
expiry_role: str = "single" # "single" | "near" | "far"
|
|
516
|
+
is_anchor: bool = False
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
@dataclass(frozen=True)
|
|
520
|
+
class StrategySpec:
|
|
521
|
+
"""One registry entry - everything main.py's generic order-placement
|
|
522
|
+
layer needs to resolve and call the real OptionStrategies factory, plus
|
|
523
|
+
everything the shape-family selectors below need to pick strikes/
|
|
524
|
+
expiries for it. factory_name is resolved via
|
|
525
|
+
getattr(OptionStrategies, factory_name) at the main.py call site (never
|
|
526
|
+
imported per-strategy here - this module must not import Lean's
|
|
527
|
+
AlgorithmImports, matching every other pure function in this file)."""
|
|
528
|
+
|
|
529
|
+
factory_name: str
|
|
530
|
+
arg_order: tuple[str, ...] # exact positional strike-role order the real factory expects
|
|
531
|
+
has_expiry_pair: bool
|
|
532
|
+
legs: tuple[MultiLegSpec, ...]
|
|
533
|
+
shape_family: str # dispatch key for the shared selectors below
|
|
534
|
+
risk_tier: str # "vega_budget" | "margin_naked" | "margin_uncovered_leg" | "margin_bounded_backspread" | "covered_protective" | "unreachable_arbitrage"
|
|
535
|
+
covering_equity_side: str | None = None # "long" | "short" - only set for risk_tier == "covered_protective"
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _leg(role, right, side, ratio, strike_role, expiry_role="single", is_anchor=False):
|
|
539
|
+
return MultiLegSpec(role, right, side, ratio, strike_role, expiry_role, is_anchor)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
MULTI_LEG_STRATEGY_REGISTRY: dict[str, StrategySpec] = {
|
|
543
|
+
# -- existing 2-leg verticals (registry entries needed so main.py's
|
|
544
|
+
# generic order-placement/liquidation/bookkeeping can treat these
|
|
545
|
+
# uniformly with the other 41; their SIZING decision stays the
|
|
546
|
+
# dedicated build_vertical_spread_position_sizing() above, untouched) --
|
|
547
|
+
"bull_call_spread": StrategySpec(
|
|
548
|
+
"bull_call_spread", ("long", "short"), False,
|
|
549
|
+
(_leg("long", "call", "long", 1, "long", is_anchor=True), _leg("short", "call", "short", 1, "short")),
|
|
550
|
+
"vertical", "vega_budget",
|
|
551
|
+
),
|
|
552
|
+
"bear_put_spread": StrategySpec(
|
|
553
|
+
"bear_put_spread", ("long", "short"), False,
|
|
554
|
+
(_leg("long", "put", "long", 1, "long", is_anchor=True), _leg("short", "put", "short", 1, "short")),
|
|
555
|
+
"vertical", "vega_budget",
|
|
556
|
+
),
|
|
557
|
+
# -- credit verticals --
|
|
558
|
+
"bear_call_spread": StrategySpec(
|
|
559
|
+
"bear_call_spread", ("short", "long"), False,
|
|
560
|
+
(_leg("short", "call", "short", 1, "short", is_anchor=True), _leg("long", "call", "long", 1, "long")),
|
|
561
|
+
"vertical", "vega_budget",
|
|
562
|
+
),
|
|
563
|
+
"bull_put_spread": StrategySpec(
|
|
564
|
+
"bull_put_spread", ("short", "long"), False,
|
|
565
|
+
(_leg("short", "put", "short", 1, "short", is_anchor=True), _leg("long", "put", "long", 1, "long")),
|
|
566
|
+
"vertical", "vega_budget",
|
|
567
|
+
),
|
|
568
|
+
# -- straddles --
|
|
569
|
+
"straddle": StrategySpec(
|
|
570
|
+
"straddle", ("strike",), False,
|
|
571
|
+
(_leg("call", "call", "long", 1, "strike", is_anchor=True), _leg("put", "put", "long", 1, "strike")),
|
|
572
|
+
"straddle", "vega_budget",
|
|
573
|
+
),
|
|
574
|
+
"short_straddle": StrategySpec(
|
|
575
|
+
"short_straddle", ("strike",), False,
|
|
576
|
+
(_leg("call", "call", "short", 1, "strike", is_anchor=True), _leg("put", "put", "short", 1, "strike")),
|
|
577
|
+
"straddle", "margin_naked",
|
|
578
|
+
),
|
|
579
|
+
# -- strangles --
|
|
580
|
+
"strangle": StrategySpec(
|
|
581
|
+
"strangle", ("call", "put"), False,
|
|
582
|
+
(_leg("call", "call", "long", 1, "call", is_anchor=True), _leg("put", "put", "long", 1, "put")),
|
|
583
|
+
"strangle", "vega_budget",
|
|
584
|
+
),
|
|
585
|
+
"short_strangle": StrategySpec(
|
|
586
|
+
"short_strangle", ("call", "put"), False,
|
|
587
|
+
(_leg("call", "call", "short", 1, "call", is_anchor=True), _leg("put", "put", "short", 1, "put")),
|
|
588
|
+
"strangle", "margin_naked",
|
|
589
|
+
),
|
|
590
|
+
# -- call butterflies (butterfly_call/call_butterfly are real, separate
|
|
591
|
+
# aliased factory attrs on OptionStrategies - both registered) --
|
|
592
|
+
"butterfly_call": StrategySpec(
|
|
593
|
+
"butterfly_call", ("higher", "middle", "lower"), False,
|
|
594
|
+
(
|
|
595
|
+
_leg("higher", "call", "long", 1, "higher"),
|
|
596
|
+
_leg("middle", "call", "short", 2, "middle", is_anchor=True),
|
|
597
|
+
_leg("lower", "call", "long", 1, "lower"),
|
|
598
|
+
),
|
|
599
|
+
"butterfly", "vega_budget",
|
|
600
|
+
),
|
|
601
|
+
"call_butterfly": StrategySpec(
|
|
602
|
+
"call_butterfly", ("higher", "middle", "lower"), False,
|
|
603
|
+
(
|
|
604
|
+
_leg("higher", "call", "long", 1, "higher"),
|
|
605
|
+
_leg("middle", "call", "short", 2, "middle", is_anchor=True),
|
|
606
|
+
_leg("lower", "call", "long", 1, "lower"),
|
|
607
|
+
),
|
|
608
|
+
"butterfly", "vega_budget",
|
|
609
|
+
),
|
|
610
|
+
"short_butterfly_call": StrategySpec(
|
|
611
|
+
"short_butterfly_call", ("higher", "middle", "lower"), False,
|
|
612
|
+
(
|
|
613
|
+
_leg("higher", "call", "short", 1, "higher"),
|
|
614
|
+
_leg("middle", "call", "long", 2, "middle", is_anchor=True),
|
|
615
|
+
_leg("lower", "call", "short", 1, "lower"),
|
|
616
|
+
),
|
|
617
|
+
"butterfly", "vega_budget",
|
|
618
|
+
),
|
|
619
|
+
# -- put butterflies --
|
|
620
|
+
"butterfly_put": StrategySpec(
|
|
621
|
+
"butterfly_put", ("higher", "middle", "lower"), False,
|
|
622
|
+
(
|
|
623
|
+
_leg("higher", "put", "long", 1, "higher"),
|
|
624
|
+
_leg("middle", "put", "short", 2, "middle", is_anchor=True),
|
|
625
|
+
_leg("lower", "put", "long", 1, "lower"),
|
|
626
|
+
),
|
|
627
|
+
"butterfly", "vega_budget",
|
|
628
|
+
),
|
|
629
|
+
"put_butterfly": StrategySpec(
|
|
630
|
+
"put_butterfly", ("higher", "middle", "lower"), False,
|
|
631
|
+
(
|
|
632
|
+
_leg("higher", "put", "long", 1, "higher"),
|
|
633
|
+
_leg("middle", "put", "short", 2, "middle", is_anchor=True),
|
|
634
|
+
_leg("lower", "put", "long", 1, "lower"),
|
|
635
|
+
),
|
|
636
|
+
"butterfly", "vega_budget",
|
|
637
|
+
),
|
|
638
|
+
"short_butterfly_put": StrategySpec(
|
|
639
|
+
"short_butterfly_put", ("higher", "middle", "lower"), False,
|
|
640
|
+
(
|
|
641
|
+
_leg("higher", "put", "short", 1, "higher"),
|
|
642
|
+
_leg("middle", "put", "long", 2, "middle", is_anchor=True),
|
|
643
|
+
_leg("lower", "put", "short", 1, "lower"),
|
|
644
|
+
),
|
|
645
|
+
"butterfly", "vega_budget",
|
|
646
|
+
),
|
|
647
|
+
# -- calendar spreads (2 distinct expiries, 1 strike role shared by both legs) --
|
|
648
|
+
"call_calendar_spread": StrategySpec(
|
|
649
|
+
"call_calendar_spread", ("strike",), True,
|
|
650
|
+
(
|
|
651
|
+
_leg("near", "call", "short", 1, "strike", "near", is_anchor=True),
|
|
652
|
+
_leg("far", "call", "long", 1, "strike", "far"),
|
|
653
|
+
),
|
|
654
|
+
"calendar", "vega_budget",
|
|
655
|
+
),
|
|
656
|
+
"short_call_calendar_spread": StrategySpec(
|
|
657
|
+
"short_call_calendar_spread", ("strike",), True,
|
|
658
|
+
(
|
|
659
|
+
_leg("near", "call", "long", 1, "strike", "near", is_anchor=True),
|
|
660
|
+
_leg("far", "call", "short", 1, "strike", "far"),
|
|
661
|
+
),
|
|
662
|
+
"calendar", "vega_budget",
|
|
663
|
+
),
|
|
664
|
+
"put_calendar_spread": StrategySpec(
|
|
665
|
+
"put_calendar_spread", ("strike",), True,
|
|
666
|
+
(
|
|
667
|
+
_leg("near", "put", "short", 1, "strike", "near", is_anchor=True),
|
|
668
|
+
_leg("far", "put", "long", 1, "strike", "far"),
|
|
669
|
+
),
|
|
670
|
+
"calendar", "vega_budget",
|
|
671
|
+
),
|
|
672
|
+
"short_put_calendar_spread": StrategySpec(
|
|
673
|
+
"short_put_calendar_spread", ("strike",), True,
|
|
674
|
+
(
|
|
675
|
+
_leg("near", "put", "long", 1, "strike", "near", is_anchor=True),
|
|
676
|
+
_leg("far", "put", "short", 1, "strike", "far"),
|
|
677
|
+
),
|
|
678
|
+
"calendar", "vega_budget",
|
|
679
|
+
),
|
|
680
|
+
# -- iron condors (composed from 2 independent credit verticals) --
|
|
681
|
+
"iron_condor": StrategySpec(
|
|
682
|
+
"iron_condor", ("long_put", "short_put", "short_call", "long_call"), False,
|
|
683
|
+
(
|
|
684
|
+
_leg("long_put", "put", "long", 1, "long_put"),
|
|
685
|
+
_leg("short_put", "put", "short", 1, "short_put", is_anchor=True),
|
|
686
|
+
_leg("short_call", "call", "short", 1, "short_call", is_anchor=True),
|
|
687
|
+
_leg("long_call", "call", "long", 1, "long_call"),
|
|
688
|
+
),
|
|
689
|
+
"iron_condor", "vega_budget",
|
|
690
|
+
),
|
|
691
|
+
"short_iron_condor": StrategySpec(
|
|
692
|
+
"short_iron_condor", ("short_put", "long_put", "long_call", "short_call"), False,
|
|
693
|
+
(
|
|
694
|
+
_leg("short_put", "put", "short", 1, "short_put", is_anchor=True),
|
|
695
|
+
_leg("long_put", "put", "long", 1, "long_put"),
|
|
696
|
+
_leg("long_call", "call", "long", 1, "long_call"),
|
|
697
|
+
_leg("short_call", "call", "short", 1, "short_call", is_anchor=True),
|
|
698
|
+
),
|
|
699
|
+
"iron_condor", "vega_budget",
|
|
700
|
+
),
|
|
701
|
+
# -- iron butterflies (shared ATM strike between the 2 short/long-middle legs) --
|
|
702
|
+
"iron_butterfly": StrategySpec(
|
|
703
|
+
"iron_butterfly", ("otm_put", "atm", "otm_call"), False,
|
|
704
|
+
(
|
|
705
|
+
_leg("long_put", "put", "long", 1, "otm_put"),
|
|
706
|
+
_leg("short_put", "put", "short", 1, "atm", is_anchor=True),
|
|
707
|
+
_leg("short_call", "call", "short", 1, "atm", is_anchor=True),
|
|
708
|
+
_leg("long_call", "call", "long", 1, "otm_call"),
|
|
709
|
+
),
|
|
710
|
+
"iron_butterfly", "vega_budget",
|
|
711
|
+
),
|
|
712
|
+
"short_iron_butterfly": StrategySpec(
|
|
713
|
+
"short_iron_butterfly", ("otm_put", "atm", "otm_call"), False,
|
|
714
|
+
(
|
|
715
|
+
_leg("short_put", "put", "short", 1, "otm_put", is_anchor=True),
|
|
716
|
+
_leg("long_put", "put", "long", 1, "atm"),
|
|
717
|
+
_leg("long_call", "call", "long", 1, "atm"),
|
|
718
|
+
_leg("short_call", "call", "short", 1, "otm_call", is_anchor=True),
|
|
719
|
+
),
|
|
720
|
+
"iron_butterfly", "vega_budget",
|
|
721
|
+
),
|
|
722
|
+
# -- backspreads (1:2 ratio; call_backspread/put_backspread are the
|
|
723
|
+
# genuinely bounded, long-heavy originals; short_* are the inverted,
|
|
724
|
+
# genuinely unbounded, short-heavy variants - see module note) --
|
|
725
|
+
"call_backspread": StrategySpec(
|
|
726
|
+
"call_backspread", ("lower", "higher"), False,
|
|
727
|
+
(
|
|
728
|
+
_leg("lower", "call", "short", 1, "lower", is_anchor=True),
|
|
729
|
+
_leg("higher", "call", "long", 2, "higher"),
|
|
730
|
+
),
|
|
731
|
+
"backspread", "margin_bounded_backspread",
|
|
732
|
+
),
|
|
733
|
+
"short_call_backspread": StrategySpec(
|
|
734
|
+
"short_call_backspread", ("lower", "higher"), False,
|
|
735
|
+
(
|
|
736
|
+
_leg("lower", "call", "long", 1, "lower", is_anchor=True),
|
|
737
|
+
_leg("higher", "call", "short", 2, "higher"),
|
|
738
|
+
),
|
|
739
|
+
"backspread", "margin_naked",
|
|
740
|
+
),
|
|
741
|
+
"put_backspread": StrategySpec(
|
|
742
|
+
"put_backspread", ("higher", "lower"), False,
|
|
743
|
+
(
|
|
744
|
+
_leg("higher", "put", "short", 1, "higher", is_anchor=True),
|
|
745
|
+
_leg("lower", "put", "long", 2, "lower"),
|
|
746
|
+
),
|
|
747
|
+
"backspread", "margin_bounded_backspread",
|
|
748
|
+
),
|
|
749
|
+
"short_put_backspread": StrategySpec(
|
|
750
|
+
"short_put_backspread", ("higher", "lower"), False,
|
|
751
|
+
(
|
|
752
|
+
_leg("higher", "put", "long", 1, "higher", is_anchor=True),
|
|
753
|
+
_leg("lower", "put", "short", 2, "lower"),
|
|
754
|
+
),
|
|
755
|
+
"backspread", "margin_naked",
|
|
756
|
+
),
|
|
757
|
+
# -- ladders (1:1:1; bear_call_ladder/bull_put_ladder are net-long and
|
|
758
|
+
# bounded; bull_call_ladder/bear_put_ladder are net-short and
|
|
759
|
+
# unbounded - see module note) --
|
|
760
|
+
"bear_call_ladder": StrategySpec(
|
|
761
|
+
"bear_call_ladder", ("lower", "middle", "higher"), False,
|
|
762
|
+
(
|
|
763
|
+
_leg("lower", "call", "short", 1, "lower", is_anchor=True),
|
|
764
|
+
_leg("middle", "call", "long", 1, "middle"),
|
|
765
|
+
_leg("higher", "call", "long", 1, "higher"),
|
|
766
|
+
),
|
|
767
|
+
"ladder", "vega_budget",
|
|
768
|
+
),
|
|
769
|
+
"bull_call_ladder": StrategySpec(
|
|
770
|
+
"bull_call_ladder", ("lower", "middle", "higher"), False,
|
|
771
|
+
(
|
|
772
|
+
_leg("lower", "call", "long", 1, "lower", is_anchor=True),
|
|
773
|
+
_leg("middle", "call", "short", 1, "middle"),
|
|
774
|
+
_leg("higher", "call", "short", 1, "higher"),
|
|
775
|
+
),
|
|
776
|
+
"ladder", "margin_uncovered_leg",
|
|
777
|
+
),
|
|
778
|
+
"bear_put_ladder": StrategySpec(
|
|
779
|
+
"bear_put_ladder", ("higher", "middle", "lower"), False,
|
|
780
|
+
(
|
|
781
|
+
_leg("higher", "put", "long", 1, "higher", is_anchor=True),
|
|
782
|
+
_leg("middle", "put", "short", 1, "middle"),
|
|
783
|
+
_leg("lower", "put", "short", 1, "lower"),
|
|
784
|
+
),
|
|
785
|
+
"ladder", "margin_uncovered_leg",
|
|
786
|
+
),
|
|
787
|
+
"bull_put_ladder": StrategySpec(
|
|
788
|
+
"bull_put_ladder", ("higher", "middle", "lower"), False,
|
|
789
|
+
(
|
|
790
|
+
_leg("higher", "put", "short", 1, "higher", is_anchor=True),
|
|
791
|
+
_leg("middle", "put", "long", 1, "middle"),
|
|
792
|
+
_leg("lower", "put", "long", 1, "lower"),
|
|
793
|
+
),
|
|
794
|
+
"ladder", "vega_budget",
|
|
795
|
+
),
|
|
796
|
+
# -- naked single-leg shorts --
|
|
797
|
+
"naked_call": StrategySpec(
|
|
798
|
+
"naked_call", ("strike",), False, (_leg("call", "call", "short", 1, "strike", is_anchor=True),),
|
|
799
|
+
"naked", "margin_naked",
|
|
800
|
+
),
|
|
801
|
+
"naked_put": StrategySpec(
|
|
802
|
+
"naked_put", ("strike",), False, (_leg("put", "put", "short", 1, "strike", is_anchor=True),),
|
|
803
|
+
"naked", "margin_naked",
|
|
804
|
+
),
|
|
805
|
+
# -- covered/protective/collar (equity leg tracked separately, see
|
|
806
|
+
# main.py's corrected cross-asset design - never the bundled factory) --
|
|
807
|
+
"covered_call": StrategySpec(
|
|
808
|
+
"covered_call", ("strike",), False, (_leg("call", "call", "short", 1, "strike", is_anchor=True),),
|
|
809
|
+
"covered_protective", "covered_protective", covering_equity_side="long",
|
|
810
|
+
),
|
|
811
|
+
"covered_put": StrategySpec(
|
|
812
|
+
"covered_put", ("strike",), False, (_leg("put", "put", "short", 1, "strike", is_anchor=True),),
|
|
813
|
+
"covered_protective", "covered_protective", covering_equity_side="short",
|
|
814
|
+
),
|
|
815
|
+
"protective_call": StrategySpec(
|
|
816
|
+
"protective_call", ("strike",), False, (_leg("call", "call", "long", 1, "strike", is_anchor=True),),
|
|
817
|
+
"covered_protective", "covered_protective", covering_equity_side="short",
|
|
818
|
+
),
|
|
819
|
+
"protective_put": StrategySpec(
|
|
820
|
+
"protective_put", ("strike",), False, (_leg("put", "put", "long", 1, "strike", is_anchor=True),),
|
|
821
|
+
"covered_protective", "covered_protective", covering_equity_side="long",
|
|
822
|
+
),
|
|
823
|
+
"protective_collar": StrategySpec(
|
|
824
|
+
"protective_collar", ("call", "put"), False,
|
|
825
|
+
(
|
|
826
|
+
_leg("call", "call", "short", 1, "call", is_anchor=True),
|
|
827
|
+
_leg("put", "put", "long", 1, "put", is_anchor=True),
|
|
828
|
+
),
|
|
829
|
+
"collar", "covered_protective", covering_equity_side="long",
|
|
830
|
+
),
|
|
831
|
+
# -- arbitrage (stubbed - registry/selector/tests exist; never in the
|
|
832
|
+
# default enabled_strategy_names, and no signal source ever produces
|
|
833
|
+
# these strategy_name values, see development/Problems.md) --
|
|
834
|
+
"box_spread": StrategySpec(
|
|
835
|
+
"box_spread", ("higher", "lower"), False,
|
|
836
|
+
(
|
|
837
|
+
_leg("long_put", "put", "long", 1, "higher", is_anchor=True),
|
|
838
|
+
_leg("short_put", "put", "short", 1, "lower"),
|
|
839
|
+
_leg("long_call", "call", "long", 1, "lower"),
|
|
840
|
+
_leg("short_call", "call", "short", 1, "higher"),
|
|
841
|
+
),
|
|
842
|
+
"arbitrage_box", "unreachable_arbitrage",
|
|
843
|
+
),
|
|
844
|
+
"short_box_spread": StrategySpec(
|
|
845
|
+
"short_box_spread", ("higher", "lower"), False,
|
|
846
|
+
(
|
|
847
|
+
_leg("short_put", "put", "short", 1, "higher", is_anchor=True),
|
|
848
|
+
_leg("long_put", "put", "long", 1, "lower"),
|
|
849
|
+
_leg("short_call", "call", "short", 1, "lower"),
|
|
850
|
+
_leg("long_call", "call", "long", 1, "higher"),
|
|
851
|
+
),
|
|
852
|
+
"arbitrage_box", "unreachable_arbitrage",
|
|
853
|
+
),
|
|
854
|
+
"conversion": StrategySpec(
|
|
855
|
+
"conversion", ("strike",), False,
|
|
856
|
+
(
|
|
857
|
+
_leg("short_call", "call", "short", 1, "strike", is_anchor=True),
|
|
858
|
+
_leg("long_put", "put", "long", 1, "strike"),
|
|
859
|
+
),
|
|
860
|
+
"arbitrage_conversion", "unreachable_arbitrage", covering_equity_side="long",
|
|
861
|
+
),
|
|
862
|
+
"reverse_conversion": StrategySpec(
|
|
863
|
+
"reverse_conversion", ("strike",), False,
|
|
864
|
+
(
|
|
865
|
+
_leg("long_call", "call", "long", 1, "strike", is_anchor=True),
|
|
866
|
+
_leg("short_put", "put", "short", 1, "strike"),
|
|
867
|
+
),
|
|
868
|
+
"arbitrage_conversion", "unreachable_arbitrage", covering_equity_side="short",
|
|
869
|
+
),
|
|
870
|
+
"jelly_roll": StrategySpec(
|
|
871
|
+
"jelly_roll", ("strike",), True,
|
|
872
|
+
(
|
|
873
|
+
_leg("near_call", "call", "short", 1, "strike", "near", is_anchor=True),
|
|
874
|
+
_leg("far_call", "call", "long", 1, "strike", "far"),
|
|
875
|
+
_leg("near_put", "put", "long", 1, "strike", "near"),
|
|
876
|
+
_leg("far_put", "put", "short", 1, "strike", "far"),
|
|
877
|
+
),
|
|
878
|
+
"arbitrage_jelly_roll", "unreachable_arbitrage",
|
|
879
|
+
),
|
|
880
|
+
"short_jelly_roll": StrategySpec(
|
|
881
|
+
"short_jelly_roll", ("strike",), True,
|
|
882
|
+
(
|
|
883
|
+
_leg("near_call", "call", "long", 1, "strike", "near", is_anchor=True),
|
|
884
|
+
_leg("far_call", "call", "short", 1, "strike", "far"),
|
|
885
|
+
_leg("near_put", "put", "short", 1, "strike", "near"),
|
|
886
|
+
_leg("far_put", "put", "long", 1, "strike", "far"),
|
|
887
|
+
),
|
|
888
|
+
"arbitrage_jelly_roll", "unreachable_arbitrage",
|
|
889
|
+
),
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def group_chain_by_expiry(available_chain: list[dict]) -> dict[str, list[dict]]:
|
|
894
|
+
"""Single O(n) pass grouping chain rows by expiry - built ONCE per
|
|
895
|
+
selector call and reused for every leg lookup within it, rather than
|
|
896
|
+
re-scanning the full chain per leg (this is the "latency-efficient"
|
|
897
|
+
requirement for a function that runs every bar, per candidate
|
|
898
|
+
strategy, per symbol). Also the mechanism that makes expiry-anchoring
|
|
899
|
+
possible: every leg after the anchor is selected ONLY from
|
|
900
|
+
grouped[anchor_expiry], never re-scanning the other expiries.
|
|
901
|
+
|
|
902
|
+
Public (V4.9 Priority 3, renamed from the original _group_chain_by_expiry)
|
|
903
|
+
since risk/asset_class_router.py::route_multi_leg_option_sizing() now
|
|
904
|
+
calls this ONCE per routing call (not per selector call) when the
|
|
905
|
+
calendar shape family is in play, avoiding a repeated regroup of the
|
|
906
|
+
same chain across multiple candidate strategies tried in the same
|
|
907
|
+
bar - see select_calendar_legs()'s/select_arbitrage_jelly_roll_legs()'s
|
|
908
|
+
own grouped_chain_by_expiry parameter."""
|
|
909
|
+
grouped: dict[str, list[dict]] = {}
|
|
910
|
+
for row in available_chain:
|
|
911
|
+
grouped.setdefault(row.get("expiry"), []).append(row)
|
|
912
|
+
return grouped
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
def _nearest_by_delta(rows: list[dict], right: str, target_delta: float) -> dict | None:
|
|
916
|
+
candidates = [r for r in rows if str(r.get("right", "")).lower() == right and r.get("delta") is not None]
|
|
917
|
+
if not candidates:
|
|
918
|
+
return None
|
|
919
|
+
return min(candidates, key=lambda r: abs(abs(r["delta"]) - abs(target_delta)))
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
def _nearest_by_strike(rows: list[dict], right: str, target_strike: float) -> dict | None:
|
|
923
|
+
candidates = [r for r in rows if str(r.get("right", "")).lower() == right and r.get("strike") is not None]
|
|
924
|
+
if not candidates:
|
|
925
|
+
return None
|
|
926
|
+
return min(candidates, key=lambda r: abs(float(r["strike"]) - target_strike))
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
def _outer_strike_predicate(right: str, anchor_strike: float):
|
|
930
|
+
"""True for a strike further OTM than anchor_strike, for `right` -
|
|
931
|
+
higher for calls, lower for puts. Pure geometry, independent of which
|
|
932
|
+
leg is long/short (the fix for the debit/credit leg-role inversion
|
|
933
|
+
bug: the OLD select_vertical_spread_legs() baked "outer == short" in
|
|
934
|
+
by always returning long=anchor - this predicate is direction-neutral,
|
|
935
|
+
role assignment happens separately in select_vertical_legs() below)."""
|
|
936
|
+
if right == "call":
|
|
937
|
+
return lambda strike: strike > anchor_strike
|
|
938
|
+
return lambda strike: strike < anchor_strike
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def select_vertical_legs(
|
|
942
|
+
spec: StrategySpec, available_chain: list[dict], target_delta: float, short_leg_delta_offset: float = 0.20,
|
|
943
|
+
) -> dict[str, dict] | None:
|
|
944
|
+
"""Shared 2-leg vertical selector for shape_family == "vertical" -
|
|
945
|
+
covers debit (bull_call_spread/bear_put_spread) AND credit
|
|
946
|
+
(bear_call_spread/bull_put_spread) verticals identically: the ANCHOR
|
|
947
|
+
leg (spec's is_anchor=True role) is picked by nearest-|delta| exactly
|
|
948
|
+
like the existing single-leg selector; the OUTER leg is the
|
|
949
|
+
nearest-|delta - short_leg_delta_offset| match among the SAME expiry's
|
|
950
|
+
rows on the further-OTM side (_outer_strike_predicate). Which role
|
|
951
|
+
ends up "long" vs "short" is irrelevant to this SELECTION geometry -
|
|
952
|
+
that's already fixed by the registry (spec.legs[*].side) - so the same
|
|
953
|
+
function is correct for both debit and credit shapes, closing the
|
|
954
|
+
debit/credit leg-role-inversion bug without needing 2 separate
|
|
955
|
+
selection algorithms."""
|
|
956
|
+
anchor_leg = next(leg for leg in spec.legs if leg.is_anchor)
|
|
957
|
+
outer_leg = next(leg for leg in spec.legs if not leg.is_anchor)
|
|
958
|
+
|
|
959
|
+
anchor_row = _nearest_by_delta(available_chain, anchor_leg.right, target_delta)
|
|
960
|
+
if anchor_row is None:
|
|
961
|
+
return None
|
|
962
|
+
anchor_expiry = anchor_row.get("expiry")
|
|
963
|
+
anchor_strike = float(anchor_row["strike"])
|
|
964
|
+
|
|
965
|
+
same_expiry_rows = [r for r in available_chain if r.get("expiry") == anchor_expiry]
|
|
966
|
+
is_outer = _outer_strike_predicate(outer_leg.right, anchor_strike)
|
|
967
|
+
outer_candidates = [
|
|
968
|
+
r for r in same_expiry_rows
|
|
969
|
+
if str(r.get("right", "")).lower() == outer_leg.right and r.get("delta") is not None
|
|
970
|
+
and is_outer(float(r["strike"]))
|
|
971
|
+
]
|
|
972
|
+
if not outer_candidates:
|
|
973
|
+
return None
|
|
974
|
+
outer_target_delta = target_delta - short_leg_delta_offset
|
|
975
|
+
outer_row = min(outer_candidates, key=lambda r: abs(abs(r["delta"]) - abs(outer_target_delta)))
|
|
976
|
+
return {anchor_leg.role: anchor_row, outer_leg.role: outer_row}
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def select_straddle_legs(spec: StrategySpec, available_chain: list[dict], target_delta: float) -> dict[str, dict] | None:
|
|
980
|
+
"""shape_family == "straddle" (straddle/short_straddle) - same-strike,
|
|
981
|
+
same-expiry call+put pair. Anchors on the call leg by delta, then
|
|
982
|
+
requires a put at the EXACT same strike/expiry (never a nearest-delta
|
|
983
|
+
put pick - a straddle's defining property is one shared strike)."""
|
|
984
|
+
call_leg = next(leg for leg in spec.legs if leg.right == "call")
|
|
985
|
+
put_leg = next(leg for leg in spec.legs if leg.right == "put")
|
|
986
|
+
|
|
987
|
+
call_row = _nearest_by_delta(available_chain, "call", target_delta)
|
|
988
|
+
if call_row is None:
|
|
989
|
+
return None
|
|
990
|
+
same_expiry_rows = [r for r in available_chain if r.get("expiry") == call_row.get("expiry")]
|
|
991
|
+
put_row = next(
|
|
992
|
+
(r for r in same_expiry_rows if str(r.get("right", "")).lower() == "put" and float(r.get("strike", -1)) == float(call_row["strike"])),
|
|
993
|
+
None,
|
|
994
|
+
)
|
|
995
|
+
if put_row is None:
|
|
996
|
+
return None
|
|
997
|
+
return {call_leg.role: call_row, put_leg.role: put_row}
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
def select_strangle_legs(
|
|
1001
|
+
spec: StrategySpec, available_chain: list[dict], target_delta: float, strangle_delta_offset: float = 0.15,
|
|
1002
|
+
) -> dict[str, dict] | None:
|
|
1003
|
+
"""shape_family == "strangle" (strangle/short_strangle) - offset-strike,
|
|
1004
|
+
same-expiry call+put pair (call strike above put strike, enforced by
|
|
1005
|
+
_outer_strike_predicate-style filtering anchored on the call leg's
|
|
1006
|
+
expiry, so both legs land on the same expiry by construction)."""
|
|
1007
|
+
call_leg = next(leg for leg in spec.legs if leg.right == "call")
|
|
1008
|
+
put_leg = next(leg for leg in spec.legs if leg.right == "put")
|
|
1009
|
+
|
|
1010
|
+
call_row = _nearest_by_delta(available_chain, "call", target_delta)
|
|
1011
|
+
if call_row is None:
|
|
1012
|
+
return None
|
|
1013
|
+
call_strike = float(call_row["strike"])
|
|
1014
|
+
same_expiry_rows = [r for r in available_chain if r.get("expiry") == call_row.get("expiry")]
|
|
1015
|
+
put_candidates = [
|
|
1016
|
+
r for r in same_expiry_rows
|
|
1017
|
+
if str(r.get("right", "")).lower() == "put" and r.get("delta") is not None and float(r["strike"]) < call_strike
|
|
1018
|
+
]
|
|
1019
|
+
if not put_candidates:
|
|
1020
|
+
return None
|
|
1021
|
+
put_target_delta = target_delta - strangle_delta_offset
|
|
1022
|
+
put_row = min(put_candidates, key=lambda r: abs(abs(r["delta"]) - abs(put_target_delta)))
|
|
1023
|
+
return {call_leg.role: call_row, put_leg.role: put_row}
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
def select_butterfly_legs(
|
|
1027
|
+
spec: StrategySpec,
|
|
1028
|
+
available_chain: list[dict],
|
|
1029
|
+
target_delta: float,
|
|
1030
|
+
wing_width_delta_offset: float = 0.20,
|
|
1031
|
+
max_strike_asymmetry_tolerance: float = 0.15,
|
|
1032
|
+
) -> dict[str, dict] | None:
|
|
1033
|
+
"""shape_family == "butterfly" - middle strike anchored by delta (the
|
|
1034
|
+
2x-ratio body leg), then wings picked as the nearest available strikes
|
|
1035
|
+
ABOVE/BELOW it within the same expiry, degrading to None (never a
|
|
1036
|
+
forced/asymmetric fill) when the two wing strikes aren't within
|
|
1037
|
+
max_strike_asymmetry_tolerance of being equidistant from the middle -
|
|
1038
|
+
real discrete strike ladders widen away from spot, so "best-effort
|
|
1039
|
+
near-symmetric" is documented here as the honest contract, not exact
|
|
1040
|
+
equidistance."""
|
|
1041
|
+
middle_leg = next(leg for leg in spec.legs if leg.is_anchor)
|
|
1042
|
+
higher_leg = next(leg for leg in spec.legs if leg.strike_role == "higher")
|
|
1043
|
+
lower_leg = next(leg for leg in spec.legs if leg.strike_role == "lower")
|
|
1044
|
+
right = middle_leg.right
|
|
1045
|
+
|
|
1046
|
+
middle_row = _nearest_by_delta(available_chain, right, target_delta)
|
|
1047
|
+
if middle_row is None:
|
|
1048
|
+
return None
|
|
1049
|
+
middle_strike = float(middle_row["strike"])
|
|
1050
|
+
same_expiry_rows = [r for r in available_chain if r.get("expiry") == middle_row.get("expiry")]
|
|
1051
|
+
|
|
1052
|
+
higher_candidates = sorted(
|
|
1053
|
+
(r for r in same_expiry_rows if str(r.get("right", "")).lower() == right and float(r.get("strike", middle_strike)) > middle_strike),
|
|
1054
|
+
key=lambda r: float(r["strike"]),
|
|
1055
|
+
)
|
|
1056
|
+
lower_candidates = sorted(
|
|
1057
|
+
(r for r in same_expiry_rows if str(r.get("right", "")).lower() == right and float(r.get("strike", middle_strike)) < middle_strike),
|
|
1058
|
+
key=lambda r: float(r["strike"]),
|
|
1059
|
+
reverse=True,
|
|
1060
|
+
)
|
|
1061
|
+
if not higher_candidates or not lower_candidates:
|
|
1062
|
+
return None
|
|
1063
|
+
higher_row = higher_candidates[0]
|
|
1064
|
+
lower_row = lower_candidates[0]
|
|
1065
|
+
|
|
1066
|
+
higher_width = float(higher_row["strike"]) - middle_strike
|
|
1067
|
+
lower_width = middle_strike - float(lower_row["strike"])
|
|
1068
|
+
wider, narrower = max(higher_width, lower_width), min(higher_width, lower_width)
|
|
1069
|
+
if wider <= 0 or abs(wider - narrower) / wider > max_strike_asymmetry_tolerance:
|
|
1070
|
+
return None
|
|
1071
|
+
|
|
1072
|
+
return {middle_leg.role: middle_row, higher_leg.role: higher_row, lower_leg.role: lower_row}
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
def select_iron_condor_legs(
|
|
1076
|
+
spec: StrategySpec,
|
|
1077
|
+
available_chain: list[dict],
|
|
1078
|
+
target_delta: float,
|
|
1079
|
+
short_leg_delta_offset: float = 0.20,
|
|
1080
|
+
wing_delta_offset: float = 0.10,
|
|
1081
|
+
) -> dict[str, dict] | None:
|
|
1082
|
+
"""shape_family == "iron_condor" - composed from 2 independent
|
|
1083
|
+
credit-vertical-style selections (put side, call side), both anchored
|
|
1084
|
+
to the SAME expiry (the put side's anchor expiry is picked first and
|
|
1085
|
+
passed down to the call side, closing the same expiry-drift risk
|
|
1086
|
+
select_vertical_legs() closes for a plain 2-leg vertical)."""
|
|
1087
|
+
short_put_leg = next(leg for leg in spec.legs if leg.role == "short_put")
|
|
1088
|
+
long_put_leg = next(leg for leg in spec.legs if leg.role == "long_put")
|
|
1089
|
+
short_call_leg = next(leg for leg in spec.legs if leg.role == "short_call")
|
|
1090
|
+
long_call_leg = next(leg for leg in spec.legs if leg.role == "long_call")
|
|
1091
|
+
|
|
1092
|
+
short_put_row = _nearest_by_delta(available_chain, "put", target_delta)
|
|
1093
|
+
if short_put_row is None:
|
|
1094
|
+
return None
|
|
1095
|
+
anchor_expiry = short_put_row.get("expiry")
|
|
1096
|
+
same_expiry_rows = [r for r in available_chain if r.get("expiry") == anchor_expiry]
|
|
1097
|
+
short_put_strike = float(short_put_row["strike"])
|
|
1098
|
+
|
|
1099
|
+
long_put_candidates = [
|
|
1100
|
+
r for r in same_expiry_rows
|
|
1101
|
+
if str(r.get("right", "")).lower() == "put" and r.get("delta") is not None and float(r["strike"]) < short_put_strike
|
|
1102
|
+
]
|
|
1103
|
+
if not long_put_candidates:
|
|
1104
|
+
return None
|
|
1105
|
+
long_put_row = min(long_put_candidates, key=lambda r: abs(abs(r["delta"]) - abs(target_delta - wing_delta_offset)))
|
|
1106
|
+
|
|
1107
|
+
short_call_row = _nearest_by_delta(same_expiry_rows, "call", target_delta - short_leg_delta_offset)
|
|
1108
|
+
if short_call_row is None:
|
|
1109
|
+
return None
|
|
1110
|
+
short_call_strike = float(short_call_row["strike"])
|
|
1111
|
+
long_call_candidates = [
|
|
1112
|
+
r for r in same_expiry_rows
|
|
1113
|
+
if str(r.get("right", "")).lower() == "call" and r.get("delta") is not None and float(r["strike"]) > short_call_strike
|
|
1114
|
+
]
|
|
1115
|
+
if not long_call_candidates:
|
|
1116
|
+
return None
|
|
1117
|
+
long_call_row = min(long_call_candidates, key=lambda r: abs(abs(r["delta"]) - abs(target_delta - short_leg_delta_offset - wing_delta_offset)))
|
|
1118
|
+
|
|
1119
|
+
return {
|
|
1120
|
+
short_put_leg.role: short_put_row, long_put_leg.role: long_put_row,
|
|
1121
|
+
short_call_leg.role: short_call_row, long_call_leg.role: long_call_row,
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
|
|
1125
|
+
def select_iron_butterfly_legs(
|
|
1126
|
+
spec: StrategySpec,
|
|
1127
|
+
available_chain: list[dict],
|
|
1128
|
+
target_delta: float,
|
|
1129
|
+
wing_delta_offset: float = 0.20,
|
|
1130
|
+
max_strike_asymmetry_tolerance: float = 0.15,
|
|
1131
|
+
) -> dict[str, dict] | None:
|
|
1132
|
+
"""shape_family == "iron_butterfly" - the ATM strike is shared by BOTH
|
|
1133
|
+
middle legs (short_put and short_call both sit at the same strike, per
|
|
1134
|
+
OptionStrategies.cs's IronButterfly == IronCondor with its 2 middle
|
|
1135
|
+
strikes collapsed together), with the OTM wings picked equidistant
|
|
1136
|
+
(tolerance-based, same contract as select_butterfly_legs()) rather than
|
|
1137
|
+
independently re-selected by delta on each side."""
|
|
1138
|
+
call_middle_leg = next(leg for leg in spec.legs if leg.right == "call" and leg.strike_role == "atm")
|
|
1139
|
+
put_middle_leg = next(leg for leg in spec.legs if leg.right == "put" and leg.strike_role == "atm")
|
|
1140
|
+
otm_call_leg = next(leg for leg in spec.legs if leg.strike_role == "otm_call")
|
|
1141
|
+
otm_put_leg = next(leg for leg in spec.legs if leg.strike_role == "otm_put")
|
|
1142
|
+
|
|
1143
|
+
atm_call_row = _nearest_by_delta(available_chain, "call", target_delta)
|
|
1144
|
+
if atm_call_row is None:
|
|
1145
|
+
return None
|
|
1146
|
+
atm_strike = float(atm_call_row["strike"])
|
|
1147
|
+
same_expiry_rows = [r for r in available_chain if r.get("expiry") == atm_call_row.get("expiry")]
|
|
1148
|
+
atm_put_row = next(
|
|
1149
|
+
(r for r in same_expiry_rows if str(r.get("right", "")).lower() == "put" and float(r.get("strike", -1)) == atm_strike),
|
|
1150
|
+
None,
|
|
1151
|
+
)
|
|
1152
|
+
if atm_put_row is None:
|
|
1153
|
+
return None
|
|
1154
|
+
|
|
1155
|
+
otm_call_candidates = sorted(
|
|
1156
|
+
(r for r in same_expiry_rows if str(r.get("right", "")).lower() == "call" and float(r.get("strike", atm_strike)) > atm_strike),
|
|
1157
|
+
key=lambda r: float(r["strike"]),
|
|
1158
|
+
)
|
|
1159
|
+
otm_put_candidates = sorted(
|
|
1160
|
+
(r for r in same_expiry_rows if str(r.get("right", "")).lower() == "put" and float(r.get("strike", atm_strike)) < atm_strike),
|
|
1161
|
+
key=lambda r: float(r["strike"]),
|
|
1162
|
+
reverse=True,
|
|
1163
|
+
)
|
|
1164
|
+
if not otm_call_candidates or not otm_put_candidates:
|
|
1165
|
+
return None
|
|
1166
|
+
otm_call_row = otm_call_candidates[0]
|
|
1167
|
+
otm_put_row = otm_put_candidates[0]
|
|
1168
|
+
|
|
1169
|
+
call_wing_width = float(otm_call_row["strike"]) - atm_strike
|
|
1170
|
+
put_wing_width = atm_strike - float(otm_put_row["strike"])
|
|
1171
|
+
wider, narrower = max(call_wing_width, put_wing_width), min(call_wing_width, put_wing_width)
|
|
1172
|
+
if wider <= 0 or abs(wider - narrower) / wider > max_strike_asymmetry_tolerance:
|
|
1173
|
+
return None
|
|
1174
|
+
|
|
1175
|
+
return {
|
|
1176
|
+
call_middle_leg.role: atm_call_row, put_middle_leg.role: atm_put_row,
|
|
1177
|
+
otm_call_leg.role: otm_call_row, otm_put_leg.role: otm_put_row,
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
|
|
1181
|
+
def select_calendar_legs(
|
|
1182
|
+
spec: StrategySpec,
|
|
1183
|
+
available_chain: list[dict],
|
|
1184
|
+
target_delta: float,
|
|
1185
|
+
grouped_chain_by_expiry: dict[str, list[dict]] | None = None,
|
|
1186
|
+
) -> dict[str, dict] | None:
|
|
1187
|
+
"""shape_family == "calendar" - a genuinely new primitive (nothing
|
|
1188
|
+
before this pass compares two DIFFERENT expiries): groups the chain by
|
|
1189
|
+
expiry first (group_chain_by_expiry(), O(n) once), requires at least
|
|
1190
|
+
2 distinct expiries, picks the near leg's strike by delta within the
|
|
1191
|
+
nearest expiry, then requires the SAME strike be present in every
|
|
1192
|
+
LATER expiry group - the far leg uses the closest-dated expiry that
|
|
1193
|
+
has it. Returns None for a chain with fewer than 2 expiries (routine
|
|
1194
|
+
for a thinly configured universe, never a crash).
|
|
1195
|
+
|
|
1196
|
+
`grouped_chain_by_expiry` (V4.9 Priority 3), when given, is used
|
|
1197
|
+
INSTEAD OF recomputing group_chain_by_expiry(available_chain) here -
|
|
1198
|
+
lets a caller trying several calendar-family candidates against the
|
|
1199
|
+
SAME chain in one bar (risk/asset_class_router.py::
|
|
1200
|
+
route_multi_leg_option_sizing()) group it once and reuse it. Default
|
|
1201
|
+
None reproduces the exact original per-call regroup behavior."""
|
|
1202
|
+
near_leg = next(leg for leg in spec.legs if leg.expiry_role == "near")
|
|
1203
|
+
far_leg = next(leg for leg in spec.legs if leg.expiry_role == "far")
|
|
1204
|
+
right = near_leg.right
|
|
1205
|
+
|
|
1206
|
+
grouped = grouped_chain_by_expiry if grouped_chain_by_expiry is not None else group_chain_by_expiry(available_chain)
|
|
1207
|
+
expiries = sorted(e for e in grouped if e is not None)
|
|
1208
|
+
if len(expiries) < 2:
|
|
1209
|
+
return None
|
|
1210
|
+
|
|
1211
|
+
near_expiry = expiries[0]
|
|
1212
|
+
near_row = _nearest_by_delta(grouped[near_expiry], right, target_delta)
|
|
1213
|
+
if near_row is None:
|
|
1214
|
+
return None
|
|
1215
|
+
strike = float(near_row["strike"])
|
|
1216
|
+
|
|
1217
|
+
for far_expiry in expiries[1:]:
|
|
1218
|
+
far_row = _nearest_by_strike(grouped[far_expiry], right, strike)
|
|
1219
|
+
if far_row is not None and float(far_row["strike"]) == strike:
|
|
1220
|
+
return {near_leg.role: near_row, far_leg.role: far_row}
|
|
1221
|
+
return None
|
|
1222
|
+
|
|
1223
|
+
|
|
1224
|
+
def select_backspread_legs(
|
|
1225
|
+
spec: StrategySpec, available_chain: list[dict], target_delta: float, backspread_delta_offset: float = 0.20,
|
|
1226
|
+
) -> dict[str, dict] | None:
|
|
1227
|
+
"""shape_family == "backspread" - 1:2-ratio 2-leg selector shared by
|
|
1228
|
+
all 4 variants (call_backspread/short_call_backspread/put_backspread/
|
|
1229
|
+
short_put_backspread); which strike_role ("lower"/"higher") is the
|
|
1230
|
+
anchor and which side each role maps to both come from the registry,
|
|
1231
|
+
so this one function is correct for every variant without branching
|
|
1232
|
+
on strategy_name."""
|
|
1233
|
+
anchor_leg = next(leg for leg in spec.legs if leg.is_anchor)
|
|
1234
|
+
other_leg = next(leg for leg in spec.legs if not leg.is_anchor)
|
|
1235
|
+
right = anchor_leg.right
|
|
1236
|
+
|
|
1237
|
+
anchor_row = _nearest_by_delta(available_chain, right, target_delta)
|
|
1238
|
+
if anchor_row is None:
|
|
1239
|
+
return None
|
|
1240
|
+
anchor_strike = float(anchor_row["strike"])
|
|
1241
|
+
same_expiry_rows = [r for r in available_chain if r.get("expiry") == anchor_row.get("expiry")]
|
|
1242
|
+
is_outer = _outer_strike_predicate(right, anchor_strike) if anchor_leg.strike_role == "lower" else (
|
|
1243
|
+
lambda strike: strike < anchor_strike
|
|
1244
|
+
)
|
|
1245
|
+
other_candidates = [
|
|
1246
|
+
r for r in same_expiry_rows
|
|
1247
|
+
if str(r.get("right", "")).lower() == right and r.get("delta") is not None and is_outer(float(r["strike"]))
|
|
1248
|
+
]
|
|
1249
|
+
if not other_candidates:
|
|
1250
|
+
return None
|
|
1251
|
+
other_target_delta = target_delta - backspread_delta_offset
|
|
1252
|
+
other_row = min(other_candidates, key=lambda r: abs(abs(r["delta"]) - abs(other_target_delta)))
|
|
1253
|
+
return {anchor_leg.role: anchor_row, other_leg.role: other_row}
|
|
1254
|
+
|
|
1255
|
+
|
|
1256
|
+
def select_ladder_legs(
|
|
1257
|
+
spec: StrategySpec, available_chain: list[dict], target_delta: float, ladder_wing_delta_offset: float = 0.15,
|
|
1258
|
+
) -> dict[str, dict] | None:
|
|
1259
|
+
"""shape_family == "ladder" - 3-strike, 1:1:1-ratio selector shared by
|
|
1260
|
+
all 4 variants (bear_call_ladder/bull_call_ladder/bear_put_ladder/
|
|
1261
|
+
bull_put_ladder). Anchors on the spec's anchor leg by delta, then picks
|
|
1262
|
+
the other 2 legs as the nearest available strikes further from the
|
|
1263
|
+
anchor in the direction _outer_strike_predicate defines for this
|
|
1264
|
+
right - same expiry-anchoring guarantee as every other selector here."""
|
|
1265
|
+
anchor_leg = next(leg for leg in spec.legs if leg.is_anchor)
|
|
1266
|
+
other_legs = [leg for leg in spec.legs if not leg.is_anchor]
|
|
1267
|
+
right = anchor_leg.right
|
|
1268
|
+
|
|
1269
|
+
anchor_row = _nearest_by_delta(available_chain, right, target_delta)
|
|
1270
|
+
if anchor_row is None:
|
|
1271
|
+
return None
|
|
1272
|
+
anchor_strike = float(anchor_row["strike"])
|
|
1273
|
+
same_expiry_rows = [r for r in available_chain if r.get("expiry") == anchor_row.get("expiry")]
|
|
1274
|
+
is_outer = _outer_strike_predicate(right, anchor_strike)
|
|
1275
|
+
outer_candidates = sorted(
|
|
1276
|
+
(r for r in same_expiry_rows if str(r.get("right", "")).lower() == right and is_outer(float(r.get("strike", anchor_strike)))),
|
|
1277
|
+
key=lambda r: abs(float(r["strike"]) - anchor_strike),
|
|
1278
|
+
)
|
|
1279
|
+
if len(outer_candidates) < 2:
|
|
1280
|
+
return None
|
|
1281
|
+
|
|
1282
|
+
result = {anchor_leg.role: anchor_row}
|
|
1283
|
+
# other_legs is exactly 2 roles (registry order matches increasing
|
|
1284
|
+
# distance from the anchor strike, e.g. "middle" then "higher" for
|
|
1285
|
+
# bear_call_ladder) - zip against the 2 nearest outer candidates by
|
|
1286
|
+
# strike distance, honoring that ordering.
|
|
1287
|
+
for leg, row in zip(other_legs, outer_candidates[:2]):
|
|
1288
|
+
result[leg.role] = row
|
|
1289
|
+
return result
|
|
1290
|
+
|
|
1291
|
+
|
|
1292
|
+
def select_naked_leg(spec: StrategySpec, available_chain: list[dict], target_delta: float) -> dict[str, dict] | None:
|
|
1293
|
+
"""shape_family == "naked" (naked_call/naked_put) - a single short leg,
|
|
1294
|
+
nearest-|delta| selection, no expiry-anchoring needed (only 1 leg)."""
|
|
1295
|
+
leg = spec.legs[0]
|
|
1296
|
+
row = _nearest_by_delta(available_chain, leg.right, target_delta)
|
|
1297
|
+
if row is None:
|
|
1298
|
+
return None
|
|
1299
|
+
return {leg.role: row}
|
|
1300
|
+
|
|
1301
|
+
|
|
1302
|
+
def select_covered_protective_leg(spec: StrategySpec, available_chain: list[dict], target_delta: float) -> dict[str, dict] | None:
|
|
1303
|
+
"""shape_family in {"covered_protective", "collar"} - the OPTION leg(s)
|
|
1304
|
+
only (never the equity side - see main.py's corrected cross-asset
|
|
1305
|
+
design, the equity leg is read from the currently-held quantity, not
|
|
1306
|
+
selected from a chain). protective_collar has 2 anchor legs (call and
|
|
1307
|
+
put), each selected independently by delta since a collar's call/put
|
|
1308
|
+
strikes are deliberately NOT required to be equidistant from spot
|
|
1309
|
+
(call_strike > put_strike is the only real constraint, enforced
|
|
1310
|
+
upstream by OptionStrategies.ProtectiveCollar itself)."""
|
|
1311
|
+
result = {}
|
|
1312
|
+
for leg in spec.legs:
|
|
1313
|
+
row = _nearest_by_delta(available_chain, leg.right, target_delta)
|
|
1314
|
+
if row is None:
|
|
1315
|
+
return None
|
|
1316
|
+
result[leg.role] = row
|
|
1317
|
+
if spec.shape_family == "collar" and len(result) == 2:
|
|
1318
|
+
rows = list(result.values())
|
|
1319
|
+
if float(rows[0]["strike"]) == float(rows[1]["strike"]):
|
|
1320
|
+
# ProtectiveCollar requires call_strike > put_strike strictly -
|
|
1321
|
+
# degrade to None rather than let main.py hand Lean's factory
|
|
1322
|
+
# two equal strikes it will itself reject with an exception.
|
|
1323
|
+
return None
|
|
1324
|
+
return result
|
|
1325
|
+
|
|
1326
|
+
|
|
1327
|
+
def select_arbitrage_box_legs(spec: StrategySpec, available_chain: list[dict], target_delta: float) -> dict[str, dict] | None:
|
|
1328
|
+
"""shape_family == "arbitrage_box" (box_spread/short_box_spread) -
|
|
1329
|
+
wired-but-unreachable (development/Problems.md): registry/selector
|
|
1330
|
+
exist and are unit-tested, but no signal source in this codebase ever
|
|
1331
|
+
calls this - a real box spread requires a mispricing detector (rich/
|
|
1332
|
+
cheap vs. the risk-free-rate-implied fair value), a separate future
|
|
1333
|
+
project. Reuses select_vertical_legs()-style same-expiry anchoring:
|
|
1334
|
+
the "higher"-strike-role legs (long_put, short_call) anchor by delta,
|
|
1335
|
+
the "lower"-strike-role legs (short_put, long_call) share the SAME
|
|
1336
|
+
strike as one of the anchor legs by construction (a box spread is a
|
|
1337
|
+
single strike pair, not 4 independent strikes)."""
|
|
1338
|
+
long_put_leg = next(leg for leg in spec.legs if leg.role == "long_put")
|
|
1339
|
+
short_put_leg = next(leg for leg in spec.legs if leg.role == "short_put")
|
|
1340
|
+
long_call_leg = next(leg for leg in spec.legs if leg.role == "long_call")
|
|
1341
|
+
short_call_leg = next(leg for leg in spec.legs if leg.role == "short_call")
|
|
1342
|
+
|
|
1343
|
+
put_anchor_row = _nearest_by_delta(available_chain, "put", target_delta)
|
|
1344
|
+
if put_anchor_row is None:
|
|
1345
|
+
return None
|
|
1346
|
+
higher_strike = float(put_anchor_row["strike"])
|
|
1347
|
+
same_expiry_rows = [r for r in available_chain if r.get("expiry") == put_anchor_row.get("expiry")]
|
|
1348
|
+
lower_candidates = sorted(
|
|
1349
|
+
(r for r in same_expiry_rows if str(r.get("right", "")).lower() == "put" and float(r.get("strike", higher_strike)) < higher_strike),
|
|
1350
|
+
key=lambda r: float(r["strike"]),
|
|
1351
|
+
reverse=True,
|
|
1352
|
+
)
|
|
1353
|
+
if not lower_candidates:
|
|
1354
|
+
return None
|
|
1355
|
+
lower_strike = float(lower_candidates[0]["strike"])
|
|
1356
|
+
|
|
1357
|
+
put_rows = {"higher": put_anchor_row, "lower": lower_candidates[0]}
|
|
1358
|
+
call_higher_row = _nearest_by_strike(same_expiry_rows, "call", higher_strike)
|
|
1359
|
+
call_lower_row = _nearest_by_strike(same_expiry_rows, "call", lower_strike)
|
|
1360
|
+
if call_higher_row is None or call_lower_row is None:
|
|
1361
|
+
return None
|
|
1362
|
+
call_rows = {"higher": call_higher_row, "lower": call_lower_row}
|
|
1363
|
+
|
|
1364
|
+
return {
|
|
1365
|
+
long_put_leg.role: put_rows[long_put_leg.strike_role],
|
|
1366
|
+
short_put_leg.role: put_rows[short_put_leg.strike_role],
|
|
1367
|
+
long_call_leg.role: call_rows[long_call_leg.strike_role],
|
|
1368
|
+
short_call_leg.role: call_rows[short_call_leg.strike_role],
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
|
|
1372
|
+
def select_arbitrage_conversion_legs(spec: StrategySpec, available_chain: list[dict], target_delta: float) -> dict[str, dict] | None:
|
|
1373
|
+
"""shape_family == "arbitrage_conversion" (conversion/reverse_conversion)
|
|
1374
|
+
- wired-but-unreachable, same caveat as select_arbitrage_box_legs()
|
|
1375
|
+
above. Single shared strike for both legs (same-expiry, same-strike
|
|
1376
|
+
call+put pair - identical selection shape to select_straddle_legs())."""
|
|
1377
|
+
call_leg = next(leg for leg in spec.legs if leg.right == "call")
|
|
1378
|
+
put_leg = next(leg for leg in spec.legs if leg.right == "put")
|
|
1379
|
+
anchor_leg, other_leg = (call_leg, put_leg) if call_leg.is_anchor else (put_leg, call_leg)
|
|
1380
|
+
|
|
1381
|
+
anchor_row = _nearest_by_delta(available_chain, anchor_leg.right, target_delta)
|
|
1382
|
+
if anchor_row is None:
|
|
1383
|
+
return None
|
|
1384
|
+
same_expiry_rows = [r for r in available_chain if r.get("expiry") == anchor_row.get("expiry")]
|
|
1385
|
+
other_row = next(
|
|
1386
|
+
(
|
|
1387
|
+
r for r in same_expiry_rows
|
|
1388
|
+
if str(r.get("right", "")).lower() == other_leg.right and float(r.get("strike", -1)) == float(anchor_row["strike"])
|
|
1389
|
+
),
|
|
1390
|
+
None,
|
|
1391
|
+
)
|
|
1392
|
+
if other_row is None:
|
|
1393
|
+
return None
|
|
1394
|
+
return {anchor_leg.role: anchor_row, other_leg.role: other_row}
|
|
1395
|
+
|
|
1396
|
+
|
|
1397
|
+
def select_arbitrage_jelly_roll_legs(
|
|
1398
|
+
spec: StrategySpec,
|
|
1399
|
+
available_chain: list[dict],
|
|
1400
|
+
target_delta: float,
|
|
1401
|
+
grouped_chain_by_expiry: dict[str, list[dict]] | None = None,
|
|
1402
|
+
) -> dict[str, dict] | None:
|
|
1403
|
+
"""shape_family == "arbitrage_jelly_roll" (jelly_roll/short_jelly_roll)
|
|
1404
|
+
- wired-but-unreachable, same caveat as the other 2 arbitrage selectors
|
|
1405
|
+
above. Same expiry-grouping primitive as select_calendar_legs(), but
|
|
1406
|
+
requires all 4 legs (near call, far call, near put, far put) to share
|
|
1407
|
+
ONE strike across both expiries.
|
|
1408
|
+
|
|
1409
|
+
`grouped_chain_by_expiry` (V4.9 Priority 3) - same optional
|
|
1410
|
+
precomputed-grouping parameter as select_calendar_legs(), same default-
|
|
1411
|
+
None-reproduces-original-behavior contract. In practice never actually
|
|
1412
|
+
passed today (this shape family is permanently unreachable via
|
|
1413
|
+
route_multi_leg_option_sizing()'s own risk_tier routing - see its
|
|
1414
|
+
"unreachable_arbitrage" branch), kept for interface symmetry with
|
|
1415
|
+
select_calendar_legs() and in case a future caller reaches it directly."""
|
|
1416
|
+
near_call_leg = next(leg for leg in spec.legs if leg.role == "near_call")
|
|
1417
|
+
far_call_leg = next(leg for leg in spec.legs if leg.role == "far_call")
|
|
1418
|
+
near_put_leg = next(leg for leg in spec.legs if leg.role == "near_put")
|
|
1419
|
+
far_put_leg = next(leg for leg in spec.legs if leg.role == "far_put")
|
|
1420
|
+
|
|
1421
|
+
grouped = grouped_chain_by_expiry if grouped_chain_by_expiry is not None else group_chain_by_expiry(available_chain)
|
|
1422
|
+
expiries = sorted(e for e in grouped if e is not None)
|
|
1423
|
+
if len(expiries) < 2:
|
|
1424
|
+
return None
|
|
1425
|
+
near_expiry = expiries[0]
|
|
1426
|
+
near_call_row = _nearest_by_delta(grouped[near_expiry], "call", target_delta)
|
|
1427
|
+
if near_call_row is None:
|
|
1428
|
+
return None
|
|
1429
|
+
strike = float(near_call_row["strike"])
|
|
1430
|
+
|
|
1431
|
+
for far_expiry in expiries[1:]:
|
|
1432
|
+
far_call_row = _nearest_by_strike(grouped[far_expiry], "call", strike)
|
|
1433
|
+
near_put_row = _nearest_by_strike(grouped[near_expiry], "put", strike)
|
|
1434
|
+
far_put_row = _nearest_by_strike(grouped[far_expiry], "put", strike)
|
|
1435
|
+
if (
|
|
1436
|
+
far_call_row is not None and float(far_call_row["strike"]) == strike
|
|
1437
|
+
and near_put_row is not None and float(near_put_row["strike"]) == strike
|
|
1438
|
+
and far_put_row is not None and float(far_put_row["strike"]) == strike
|
|
1439
|
+
):
|
|
1440
|
+
return {
|
|
1441
|
+
near_call_leg.role: near_call_row, far_call_leg.role: far_call_row,
|
|
1442
|
+
near_put_leg.role: near_put_row, far_put_leg.role: far_put_row,
|
|
1443
|
+
}
|
|
1444
|
+
return None
|
|
1445
|
+
|
|
1446
|
+
|
|
1447
|
+
_SHAPE_FAMILY_SELECTORS = {
|
|
1448
|
+
"straddle": select_straddle_legs,
|
|
1449
|
+
"strangle": select_strangle_legs,
|
|
1450
|
+
"butterfly": select_butterfly_legs,
|
|
1451
|
+
"iron_condor": select_iron_condor_legs,
|
|
1452
|
+
"iron_butterfly": select_iron_butterfly_legs,
|
|
1453
|
+
"calendar": select_calendar_legs,
|
|
1454
|
+
"backspread": select_backspread_legs,
|
|
1455
|
+
"ladder": select_ladder_legs,
|
|
1456
|
+
"naked": select_naked_leg,
|
|
1457
|
+
"covered_protective": select_covered_protective_leg,
|
|
1458
|
+
"collar": select_covered_protective_leg,
|
|
1459
|
+
"arbitrage_box": select_arbitrage_box_legs,
|
|
1460
|
+
"arbitrage_conversion": select_arbitrage_conversion_legs,
|
|
1461
|
+
"arbitrage_jelly_roll": select_arbitrage_jelly_roll_legs,
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
|
|
1465
|
+
_GROUPED_CHAIN_SHAPE_FAMILIES = {"calendar", "arbitrage_jelly_roll"}
|
|
1466
|
+
|
|
1467
|
+
|
|
1468
|
+
def select_strategy_legs(
|
|
1469
|
+
strategy_name: str,
|
|
1470
|
+
available_chain: list[dict],
|
|
1471
|
+
target_delta: float,
|
|
1472
|
+
grouped_chain_by_expiry: dict[str, list[dict]] | None = None,
|
|
1473
|
+
**tuning_kwargs,
|
|
1474
|
+
) -> dict[str, dict] | None:
|
|
1475
|
+
"""Single dispatch point every caller (main.py, tests) should use
|
|
1476
|
+
instead of calling a shape-family selector directly - looks up
|
|
1477
|
+
strategy_name's StrategySpec then its shape_family's selector, a plain
|
|
1478
|
+
dict lookup (O(1)) rather than an if/elif chain over 43 names, matching
|
|
1479
|
+
this module's "registry, not 41 near-duplicate functions" design goal.
|
|
1480
|
+
"vertical" strategies use select_vertical_legs() directly (not in the
|
|
1481
|
+
dispatch dict above, since it takes the full spec + is reused by 4
|
|
1482
|
+
strategy_names identically). tuning_kwargs passes through to whichever
|
|
1483
|
+
selector is chosen (e.g. short_leg_delta_offset, wing_width_delta_offset)
|
|
1484
|
+
- unrecognized kwargs for a given selector are simply not in its
|
|
1485
|
+
signature and would raise, exactly like any other Python call; callers
|
|
1486
|
+
should only pass kwargs relevant to the strategy family they're
|
|
1487
|
+
invoking, or none at all to accept every selector's own defaults.
|
|
1488
|
+
|
|
1489
|
+
`grouped_chain_by_expiry` (V4.9 Priority 3) is a DEDICATED top-level
|
|
1490
|
+
parameter, not folded into tuning_kwargs - it's only meaningful for the
|
|
1491
|
+
2 shape families in _GROUPED_CHAIN_SHAPE_FAMILIES (calendar,
|
|
1492
|
+
arbitrage_jelly_roll) and is deliberately swallowed (never forwarded)
|
|
1493
|
+
for every other strategy_name, since blindly passing it through
|
|
1494
|
+
tuning_kwargs would raise a TypeError for the 12 selectors that don't
|
|
1495
|
+
accept it. Default None reproduces the exact original per-call regroup
|
|
1496
|
+
behavior for every strategy_name, unchanged."""
|
|
1497
|
+
spec = MULTI_LEG_STRATEGY_REGISTRY.get(strategy_name)
|
|
1498
|
+
if spec is None:
|
|
1499
|
+
return None
|
|
1500
|
+
if spec.shape_family == "vertical":
|
|
1501
|
+
return select_vertical_legs(spec, available_chain, target_delta, **tuning_kwargs)
|
|
1502
|
+
selector = _SHAPE_FAMILY_SELECTORS.get(spec.shape_family)
|
|
1503
|
+
if selector is None:
|
|
1504
|
+
return None
|
|
1505
|
+
if spec.shape_family in _GROUPED_CHAIN_SHAPE_FAMILIES:
|
|
1506
|
+
return selector(
|
|
1507
|
+
spec, available_chain, target_delta, grouped_chain_by_expiry=grouped_chain_by_expiry, **tuning_kwargs
|
|
1508
|
+
)
|
|
1509
|
+
return selector(spec, available_chain, target_delta, **tuning_kwargs)
|
|
1510
|
+
|
|
1511
|
+
|
|
1512
|
+
@dataclass(frozen=True)
|
|
1513
|
+
class OptionsMultiLegPositionDecision:
|
|
1514
|
+
"""N-leg sibling of OptionsSpreadPositionDecision - generalized to any
|
|
1515
|
+
leg count/ratio and to 1 OR 2 distinct expiries (the calendar family),
|
|
1516
|
+
rather than OptionsSpreadPositionDecision's fixed 2-leg/1-expiry shape.
|
|
1517
|
+
A SEPARATE dataclass, not a generalization of it - every existing
|
|
1518
|
+
caller of OptionsSpreadPositionDecision (bull_call_spread/
|
|
1519
|
+
bear_put_spread's own dedicated sizing path) is completely untouched."""
|
|
1520
|
+
|
|
1521
|
+
strategy_name: str
|
|
1522
|
+
legs: tuple[OptionsSpreadLeg, ...] # one per StrategySpec.legs entry, in registry order
|
|
1523
|
+
expiries: tuple[str, ...] # 1 entry for every family except calendar/jelly-roll (2: near, far)
|
|
1524
|
+
contracts: int # spread-unit quantity, NOT a per-leg contract count (ratio legs bake their own multiple in via main.py's factory call, same convention the 2-leg vertical already established)
|
|
1525
|
+
net_debit_or_credit: float # best-effort diagnostic only, 0.0 when bid/ask are absent
|
|
1526
|
+
net_delta: float # sum of side-signed, ratio-weighted per-leg deltas
|
|
1527
|
+
net_vega: float # fraction of portfolio_value the sized position's net vega consumes
|
|
1528
|
+
sizing_reason: str
|
|
1529
|
+
|
|
1530
|
+
def to_dict(self) -> dict:
|
|
1531
|
+
"""Same manual-field-access convention as every other decision
|
|
1532
|
+
dataclass in this module - never dataclasses.asdict()."""
|
|
1533
|
+
return {
|
|
1534
|
+
"strategy_name": self.strategy_name,
|
|
1535
|
+
"legs": [
|
|
1536
|
+
{
|
|
1537
|
+
"strike": leg.strike,
|
|
1538
|
+
"right": leg.right,
|
|
1539
|
+
"side": leg.side,
|
|
1540
|
+
"contract_symbol": str(leg.contract_symbol) if leg.contract_symbol is not None else None,
|
|
1541
|
+
}
|
|
1542
|
+
for leg in self.legs
|
|
1543
|
+
],
|
|
1544
|
+
"expiries": list(self.expiries),
|
|
1545
|
+
"contracts": self.contracts,
|
|
1546
|
+
"net_debit_or_credit": self.net_debit_or_credit,
|
|
1547
|
+
"net_delta": self.net_delta,
|
|
1548
|
+
"net_vega": self.net_vega,
|
|
1549
|
+
"sizing_reason": self.sizing_reason,
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
|
|
1553
|
+
def _legs_tuple_from_roles(spec: StrategySpec, legs_by_role: dict[str, dict]) -> tuple[OptionsSpreadLeg, ...]:
|
|
1554
|
+
"""Shared OptionsSpreadLeg-tuple builder - every multi-leg sizing path
|
|
1555
|
+
(vega-budget, margin, covered/protective) produces the exact same leg
|
|
1556
|
+
shape from a role->chain-row dict, so this is written once."""
|
|
1557
|
+
return tuple(
|
|
1558
|
+
OptionsSpreadLeg(
|
|
1559
|
+
strike=float(legs_by_role[leg_spec.role]["strike"]),
|
|
1560
|
+
right=leg_spec.right,
|
|
1561
|
+
side=leg_spec.side,
|
|
1562
|
+
contract_symbol=legs_by_role[leg_spec.role].get("symbol"),
|
|
1563
|
+
)
|
|
1564
|
+
for leg_spec in spec.legs
|
|
1565
|
+
)
|
|
1566
|
+
|
|
1567
|
+
|
|
1568
|
+
def _expiries_tuple_from_roles(spec: StrategySpec, legs_by_role: dict[str, dict]) -> tuple[str, ...]:
|
|
1569
|
+
"""1-entry tuple for every family except the 2 calendar-shaped ones
|
|
1570
|
+
(near, far) - shared by every multi-leg sizing path, same rationale as
|
|
1571
|
+
_legs_tuple_from_roles() above."""
|
|
1572
|
+
if spec.has_expiry_pair:
|
|
1573
|
+
near_role = next(leg.role for leg in spec.legs if leg.expiry_role == "near")
|
|
1574
|
+
far_role = next(leg.role for leg in spec.legs if leg.expiry_role == "far")
|
|
1575
|
+
return (str(legs_by_role[near_role]["expiry"]), str(legs_by_role[far_role]["expiry"]))
|
|
1576
|
+
any_row = next(iter(legs_by_role.values()))
|
|
1577
|
+
return (str(any_row["expiry"]),)
|
|
1578
|
+
|
|
1579
|
+
|
|
1580
|
+
def _net_vega_and_delta_per_unit(spec: StrategySpec, legs_by_role: dict[str, dict]) -> tuple[float, float]:
|
|
1581
|
+
"""Signed, ratio-weighted sum of vega/delta across every leg - the
|
|
1582
|
+
generalization of the 2-leg vertical's `long_vega - short_vega` to N
|
|
1583
|
+
legs at arbitrary ratios (a butterfly's body leg contributes
|
|
1584
|
+
side_sign * 2 * its own vega, not 1x)."""
|
|
1585
|
+
net_vega = 0.0
|
|
1586
|
+
net_delta = 0.0
|
|
1587
|
+
for leg_spec in spec.legs:
|
|
1588
|
+
row = legs_by_role.get(leg_spec.role)
|
|
1589
|
+
if row is None:
|
|
1590
|
+
continue
|
|
1591
|
+
side_sign = 1.0 if leg_spec.side == "long" else -1.0
|
|
1592
|
+
net_vega += side_sign * leg_spec.ratio * float(row.get("vega", 0.0) or 0.0)
|
|
1593
|
+
net_delta += side_sign * leg_spec.ratio * float(row.get("delta", 0.0) or 0.0)
|
|
1594
|
+
return net_vega, net_delta
|
|
1595
|
+
|
|
1596
|
+
|
|
1597
|
+
def net_debit_or_credit_for_legs(spec: StrategySpec, legs_by_role: dict[str, dict]) -> float | None:
|
|
1598
|
+
"""Signed net premium to trade every leg in spec.legs at
|
|
1599
|
+
legs_by_role's current bid/ask - long legs value at ask, short legs
|
|
1600
|
+
at bid (the conservative/executable-price convention _size_multi_leg()
|
|
1601
|
+
below already uses, and options_arbitrage_detector.py's own
|
|
1602
|
+
_actual_net_cost() also uses independently). Positive = net DEBIT
|
|
1603
|
+
(paying more for long legs than received for short legs), negative =
|
|
1604
|
+
net CREDIT - same sign convention as OptionsMultiLegPositionDecision's
|
|
1605
|
+
own net_debit_or_credit field.
|
|
1606
|
+
|
|
1607
|
+
Returns None (not 0.0) when any leg's role is missing a bid/ask row -
|
|
1608
|
+
"cannot compute this bar" must not be indistinguishable from "computed
|
|
1609
|
+
a genuine 0.0 premium", the same None-vs-neutral-default distinction
|
|
1610
|
+
features/bond_features.py's module docstring and
|
|
1611
|
+
features/options_greeks.py::implied_volatility() already draw.
|
|
1612
|
+
_size_multi_leg() below deliberately keeps ITS OWN reset-to-0.0-on-
|
|
1613
|
+
missing-price behavior (a documented best-effort SIZING diagnostic,
|
|
1614
|
+
unchanged) by coercing this function's None to 0.0 at its own call
|
|
1615
|
+
site - this function is the stricter sibling, extracted for a caller
|
|
1616
|
+
(main.py's per-strategy realized-outcome computation, V4.7) where
|
|
1617
|
+
"unknown" must never silently look like "breakeven"."""
|
|
1618
|
+
net = 0.0
|
|
1619
|
+
for leg_spec in spec.legs:
|
|
1620
|
+
row = legs_by_role.get(leg_spec.role, {})
|
|
1621
|
+
price = row.get("ask") if leg_spec.side == "long" else row.get("bid")
|
|
1622
|
+
if price is None:
|
|
1623
|
+
return None
|
|
1624
|
+
side_sign = 1.0 if leg_spec.side == "long" else -1.0
|
|
1625
|
+
net += side_sign * leg_spec.ratio * float(price)
|
|
1626
|
+
return net
|
|
1627
|
+
|
|
1628
|
+
|
|
1629
|
+
def _size_multi_leg(
|
|
1630
|
+
strategy_name: str,
|
|
1631
|
+
legs_by_role: dict[str, dict],
|
|
1632
|
+
portfolio_value: float,
|
|
1633
|
+
max_vega_budget_pct_of_equity: float,
|
|
1634
|
+
sizing_reason: str,
|
|
1635
|
+
) -> OptionsMultiLegPositionDecision | None:
|
|
1636
|
+
"""Shared net-vega-budget arithmetic for an already-resolved leg set -
|
|
1637
|
+
the N-leg generalization of _size_vertical_spread() above. Only used
|
|
1638
|
+
for risk_tier == "vega_budget" strategies (margin-tier strategies are
|
|
1639
|
+
sized by portfolio/options_margin_sizing.py instead - kept OUT of this
|
|
1640
|
+
function, matching the plan's hard separation between the two sizing
|
|
1641
|
+
paradigms). Returns None when net_vega is non-positive or the budget
|
|
1642
|
+
rounds down to zero spread units - never raises."""
|
|
1643
|
+
spec = MULTI_LEG_STRATEGY_REGISTRY[strategy_name]
|
|
1644
|
+
net_vega, net_delta = _net_vega_and_delta_per_unit(spec, legs_by_role)
|
|
1645
|
+
# UNLIKE the 2-leg debit vertical's long_vega - short_vega (always
|
|
1646
|
+
# positive by construction there - the anchor/long leg is always
|
|
1647
|
+
# nearer money, hence higher-vega, than the short leg), a credit
|
|
1648
|
+
# vertical or iron condor/butterfly's anchor leg is the SHORT
|
|
1649
|
+
# (near-money, higher-vega) one - net_vega is structurally NEGATIVE
|
|
1650
|
+
# for those, by design (a short-vol structure IS a net-short-vega
|
|
1651
|
+
# position). Budget by magnitude, not sign - only a truly flat (zero)
|
|
1652
|
+
# net vega is unsizeable.
|
|
1653
|
+
abs_net_vega = abs(net_vega)
|
|
1654
|
+
if abs_net_vega == 0.0:
|
|
1655
|
+
return None
|
|
1656
|
+
|
|
1657
|
+
vega_budget = max_vega_budget_pct_of_equity * portfolio_value
|
|
1658
|
+
contracts = int(vega_budget // abs_net_vega)
|
|
1659
|
+
if contracts <= 0:
|
|
1660
|
+
return None
|
|
1661
|
+
|
|
1662
|
+
legs = _legs_tuple_from_roles(spec, legs_by_role)
|
|
1663
|
+
expiries = _expiries_tuple_from_roles(spec, legs_by_role)
|
|
1664
|
+
|
|
1665
|
+
computed_net_debit_or_credit = net_debit_or_credit_for_legs(spec, legs_by_role)
|
|
1666
|
+
net_debit_or_credit = computed_net_debit_or_credit if computed_net_debit_or_credit is not None else 0.0
|
|
1667
|
+
|
|
1668
|
+
return OptionsMultiLegPositionDecision(
|
|
1669
|
+
strategy_name=strategy_name,
|
|
1670
|
+
legs=legs,
|
|
1671
|
+
expiries=expiries,
|
|
1672
|
+
contracts=contracts,
|
|
1673
|
+
net_debit_or_credit=net_debit_or_credit,
|
|
1674
|
+
net_delta=net_delta,
|
|
1675
|
+
net_vega=(contracts * abs_net_vega) / portfolio_value,
|
|
1676
|
+
sizing_reason=sizing_reason,
|
|
1677
|
+
)
|
|
1678
|
+
|
|
1679
|
+
|
|
1680
|
+
def build_multi_leg_position_sizing(
|
|
1681
|
+
strategy_name: str,
|
|
1682
|
+
signal_direction: str,
|
|
1683
|
+
confidence: float,
|
|
1684
|
+
available_chain: list[dict],
|
|
1685
|
+
portfolio_value: float,
|
|
1686
|
+
target_delta_at_full_confidence: float = 0.60,
|
|
1687
|
+
max_vega_budget_pct_of_equity: float = 0.02,
|
|
1688
|
+
grouped_chain_by_expiry: dict[str, list[dict]] | None = None,
|
|
1689
|
+
**selector_tuning_kwargs,
|
|
1690
|
+
) -> OptionsMultiLegPositionDecision | None:
|
|
1691
|
+
"""The single public entry point for every NEW (V4.5) vega-budget-tier
|
|
1692
|
+
strategy - dispatches leg selection via select_strategy_legs(), then
|
|
1693
|
+
sizes via _size_multi_leg(). Returns None for any margin-tier or
|
|
1694
|
+
unreachable-arbitrage strategy_name (those are sized by
|
|
1695
|
+
portfolio/options_margin_sizing.py, or never sized at all,
|
|
1696
|
+
respectively) - a caller passing the wrong tier's strategy_name here
|
|
1697
|
+
gets a clean None, not a wrong/unsafe budget calculation.
|
|
1698
|
+
|
|
1699
|
+
`grouped_chain_by_expiry` (V4.9 Priority 3) passes straight through to
|
|
1700
|
+
select_strategy_legs() - see its own docstring. Default None
|
|
1701
|
+
reproduces the exact original per-call regroup behavior."""
|
|
1702
|
+
spec = MULTI_LEG_STRATEGY_REGISTRY.get(strategy_name)
|
|
1703
|
+
if spec is None or spec.risk_tier != "vega_budget":
|
|
1704
|
+
return None
|
|
1705
|
+
|
|
1706
|
+
confidence = max(0.0, min(float(confidence), 1.0))
|
|
1707
|
+
portfolio_value = max(float(portfolio_value), 0.0)
|
|
1708
|
+
if confidence == 0.0 or portfolio_value <= 0.0:
|
|
1709
|
+
return None
|
|
1710
|
+
if signal_direction not in ("buy", "sell", "short"):
|
|
1711
|
+
return None
|
|
1712
|
+
|
|
1713
|
+
target_delta = target_delta_at_full_confidence * confidence
|
|
1714
|
+
legs_by_role = select_strategy_legs(
|
|
1715
|
+
strategy_name, available_chain, target_delta,
|
|
1716
|
+
grouped_chain_by_expiry=grouped_chain_by_expiry, **selector_tuning_kwargs,
|
|
1717
|
+
)
|
|
1718
|
+
if legs_by_role is None:
|
|
1719
|
+
return None
|
|
1720
|
+
|
|
1721
|
+
return _size_multi_leg(
|
|
1722
|
+
strategy_name, legs_by_role, portfolio_value, max_vega_budget_pct_of_equity,
|
|
1723
|
+
"delta_targeted_net_vega_budgeted_multi_leg_sizing",
|
|
1724
|
+
)
|
|
1725
|
+
|
|
1726
|
+
|
|
1727
|
+
def build_multi_leg_position_sizing_for_legs(
|
|
1728
|
+
strategy_name: str,
|
|
1729
|
+
held_rows_by_role: dict[str, dict],
|
|
1730
|
+
portfolio_value: float,
|
|
1731
|
+
max_vega_budget_pct_of_equity: float = 0.02,
|
|
1732
|
+
) -> OptionsMultiLegPositionDecision | None:
|
|
1733
|
+
"""Resize-in-place sibling of build_multi_leg_position_sizing() - sizes
|
|
1734
|
+
an ALREADY-HELD position on its legs' own current greeks, skipping
|
|
1735
|
+
selection entirely. held_rows_by_role must carry an entry for every
|
|
1736
|
+
role in MULTI_LEG_STRATEGY_REGISTRY[strategy_name].legs (same shape
|
|
1737
|
+
select_strategy_legs() returns) - same degrade-to-None contract as
|
|
1738
|
+
build_multi_leg_position_sizing() for non-positive portfolio_value or
|
|
1739
|
+
non-positive net vega."""
|
|
1740
|
+
portfolio_value = max(float(portfolio_value), 0.0)
|
|
1741
|
+
if portfolio_value <= 0.0:
|
|
1742
|
+
return None
|
|
1743
|
+
if strategy_name not in MULTI_LEG_STRATEGY_REGISTRY:
|
|
1744
|
+
return None
|
|
1745
|
+
return _size_multi_leg(
|
|
1746
|
+
strategy_name, held_rows_by_role, portfolio_value, max_vega_budget_pct_of_equity,
|
|
1747
|
+
"held_legs_own_greeks_multi_leg_sizing",
|
|
1748
|
+
)
|
|
1749
|
+
|
|
1750
|
+
|
|
1751
|
+
# ---------------------------------------------------------------------------
|
|
1752
|
+
# Volatility-view signal (drives straddle/strangle/iron condor/butterfly/
|
|
1753
|
+
# credit-vs-debit selection within the enabled_strategy_names allowlist).
|
|
1754
|
+
# ---------------------------------------------------------------------------
|
|
1755
|
+
|
|
1756
|
+
ANNUALIZATION_FACTOR_DAILY_TO_ANNUAL = math.sqrt(252)
|
|
1757
|
+
|
|
1758
|
+
|
|
1759
|
+
def atm_implied_volatility(available_chain: list[dict], expiry: str) -> float | None:
|
|
1760
|
+
"""Nearest-to-spot call+put average IV for one expiry - "nearest to
|
|
1761
|
+
spot" approximated here as the LOWEST |delta - 0.5| call combined with
|
|
1762
|
+
its same-strike put (a call delta near 0.50 is the standard ATM proxy
|
|
1763
|
+
when a real spot/strike distance isn't threaded through this function).
|
|
1764
|
+
Returns None when the expiry has no usable call (chain empty, IB
|
|
1765
|
+
disabled, or no "iv"/"implied_volatility" field present) - never a
|
|
1766
|
+
crash, matching this module's degrade-to-absent convention."""
|
|
1767
|
+
rows = [r for r in available_chain if r.get("expiry") == expiry]
|
|
1768
|
+
calls = [r for r in rows if str(r.get("right", "")).lower() == "call" and r.get("delta") is not None]
|
|
1769
|
+
if not calls:
|
|
1770
|
+
return None
|
|
1771
|
+
atm_call = min(calls, key=lambda r: abs(abs(r["delta"]) - 0.50))
|
|
1772
|
+
call_iv = atm_call.get("iv", atm_call.get("implied_volatility"))
|
|
1773
|
+
put_row = next(
|
|
1774
|
+
(r for r in rows if str(r.get("right", "")).lower() == "put" and float(r.get("strike", -1)) == float(atm_call["strike"])),
|
|
1775
|
+
None,
|
|
1776
|
+
)
|
|
1777
|
+
put_iv = put_row.get("iv", put_row.get("implied_volatility")) if put_row is not None else None
|
|
1778
|
+
ivs = [float(v) for v in (call_iv, put_iv) if v is not None]
|
|
1779
|
+
if not ivs:
|
|
1780
|
+
return None
|
|
1781
|
+
return sum(ivs) / len(ivs)
|
|
1782
|
+
|
|
1783
|
+
|
|
1784
|
+
def classify_volatility_view(predicted_volatility_annualized: float, atm_iv: float, margin: float = 0.05) -> str:
|
|
1785
|
+
"""Pure, unit-agnostic classifier: "long_vol" when the model's own
|
|
1786
|
+
annualized volatility forecast exceeds the chain's current ATM implied
|
|
1787
|
+
vol by more than `margin`, "short_vol" when it's below by more than
|
|
1788
|
+
`margin`, else "neutral". The annualization (predicted_volatility *
|
|
1789
|
+
ANNUALIZATION_FACTOR_DAILY_TO_ANNUAL) MUST happen at the call site,
|
|
1790
|
+
never inside this function - train.py's target_volatility_next_day is
|
|
1791
|
+
a raw daily high-low-range proxy, not annualized, and comparing it
|
|
1792
|
+
against chain IV (always annualized) unit-mismatched would produce an
|
|
1793
|
+
almost-always-false signal. Documented approximation regardless of
|
|
1794
|
+
units: a range-based estimator and a lognormal-return-implied vol
|
|
1795
|
+
aren't the same statistical object even after unit-matching."""
|
|
1796
|
+
if predicted_volatility_annualized > atm_iv + margin:
|
|
1797
|
+
return "long_vol"
|
|
1798
|
+
if predicted_volatility_annualized < atm_iv - margin:
|
|
1799
|
+
return "short_vol"
|
|
1800
|
+
return "neutral"
|
|
1801
|
+
|
|
1802
|
+
|
|
1803
|
+
# Which strategy_names are eligible for each volatility_view bucket -
|
|
1804
|
+
# purely a lookup over shape_family/risk_tier, no chain/confidence
|
|
1805
|
+
# involved (main.py tries each eligible, ENABLED name in priority order
|
|
1806
|
+
# via build_multi_leg_position_sizing()/margin sizing until one actually
|
|
1807
|
+
# sizes - see order_enabled_strategies() below for the ordering itself).
|
|
1808
|
+
_LONG_VOL_SHAPE_FAMILIES = frozenset({"straddle_long", "strangle_long"})
|
|
1809
|
+
_SHORT_VOL_SHAPE_FAMILIES = frozenset({"straddle_short", "strangle_short", "iron_condor", "iron_butterfly"})
|
|
1810
|
+
|
|
1811
|
+
|
|
1812
|
+
def strategies_for_volatility_view(volatility_view: str) -> frozenset[str]:
|
|
1813
|
+
"""Pure lookup: which registered strategy_names express `volatility_view`
|
|
1814
|
+
("long_vol" -> long straddle/strangle; "short_vol" -> short straddle/
|
|
1815
|
+
strangle + iron condor/butterfly, both defined- and unbounded-risk
|
|
1816
|
+
variants included so risk_tier_preference below can choose between
|
|
1817
|
+
them; "neutral" -> empty, no strategy actively expresses a neutral
|
|
1818
|
+
view here). Iron condor/butterfly are "short_vol" regardless of the
|
|
1819
|
+
long/short spelling of their own name (short_iron_condor is itself
|
|
1820
|
+
already the credit/short-vol side; there is no long_iron_condor
|
|
1821
|
+
concept in this shape family)."""
|
|
1822
|
+
if volatility_view == "long_vol":
|
|
1823
|
+
return frozenset({"straddle", "strangle"})
|
|
1824
|
+
if volatility_view == "short_vol":
|
|
1825
|
+
return frozenset({
|
|
1826
|
+
"short_straddle", "short_strangle",
|
|
1827
|
+
"iron_condor", "short_iron_condor", "iron_butterfly", "short_iron_butterfly",
|
|
1828
|
+
})
|
|
1829
|
+
return frozenset()
|
|
1830
|
+
|
|
1831
|
+
|
|
1832
|
+
_DEFINED_RISK_STRATEGY_NAMES = frozenset({"iron_condor", "short_iron_condor", "iron_butterfly", "short_iron_butterfly"})
|
|
1833
|
+
_UNBOUNDED_RISK_STRATEGY_NAMES = frozenset({"short_straddle", "short_strangle"})
|
|
1834
|
+
|
|
1835
|
+
|
|
1836
|
+
def order_enabled_strategies(enabled_strategy_names: list[str], risk_tier_preference: str = "defined_risk_first") -> list[str]:
|
|
1837
|
+
"""Pure reordering (§9.2): enabled_strategy_names is a PRIORITY list,
|
|
1838
|
+
not just a membership set - the first enabled name (in the returned
|
|
1839
|
+
order) that matches the current bucket and successfully sizes wins.
|
|
1840
|
+
"defined_risk_first" (the safe default) stable-sorts every
|
|
1841
|
+
defined-risk name (iron condor/butterfly variants) ahead of every
|
|
1842
|
+
unbounded-risk name (short straddle/strangle), preserving the
|
|
1843
|
+
caller's own relative order within each group;
|
|
1844
|
+
"premium_maximizing_first" inverts that. Names outside both known sets
|
|
1845
|
+
(every other strategy) keep their original relative position,
|
|
1846
|
+
interleaved after the reordered ones - this function only ever
|
|
1847
|
+
reorders, never drops or adds a name."""
|
|
1848
|
+
if risk_tier_preference == "premium_maximizing_first":
|
|
1849
|
+
primary, secondary = _UNBOUNDED_RISK_STRATEGY_NAMES, _DEFINED_RISK_STRATEGY_NAMES
|
|
1850
|
+
else:
|
|
1851
|
+
primary, secondary = _DEFINED_RISK_STRATEGY_NAMES, _UNBOUNDED_RISK_STRATEGY_NAMES
|
|
1852
|
+
|
|
1853
|
+
primary_names = [name for name in enabled_strategy_names if name in primary]
|
|
1854
|
+
secondary_names = [name for name in enabled_strategy_names if name in secondary]
|
|
1855
|
+
other_names = [name for name in enabled_strategy_names if name not in primary and name not in secondary]
|
|
1856
|
+
return primary_names + secondary_names + other_names
|
|
1857
|
+
|
|
1858
|
+
|
|
1859
|
+
def rerank_enabled_strategies_by_score(
|
|
1860
|
+
enabled_strategy_names: list[str], strategy_selector_scores: dict[str, float]
|
|
1861
|
+
) -> list[str]:
|
|
1862
|
+
"""V4.7 (development/Problems.md #29's own framing) - the learned
|
|
1863
|
+
strategy-selector model's live inference plug point: a drop-in
|
|
1864
|
+
ALTERNATIVE to order_enabled_strategies()'s static reordering above,
|
|
1865
|
+
not a replacement for it. Stable-sorts enabled_strategy_names
|
|
1866
|
+
descending by strategy_selector_scores.get(name, float("-inf")) -
|
|
1867
|
+
names with no score (the model hasn't seen that strategy_name yet, or
|
|
1868
|
+
strategy_selector_scores is a stale/partial version) sink to the end
|
|
1869
|
+
in their ORIGINAL relative order, never dropped - same "only ever
|
|
1870
|
+
reorders, never drops or adds a name" contract
|
|
1871
|
+
order_enabled_strategies() itself keeps.
|
|
1872
|
+
|
|
1873
|
+
Callers must only invoke this when strategy_selector_scores is
|
|
1874
|
+
truthy (non-empty) - risk/asset_class_router.py::route_multi_leg_option_sizing()
|
|
1875
|
+
falls back to order_enabled_strategies() itself whenever no trained
|
|
1876
|
+
model's scores are available, so this function has no fallback path
|
|
1877
|
+
of its own; it is never the byte-identical default."""
|
|
1878
|
+
return sorted(
|
|
1879
|
+
enabled_strategy_names,
|
|
1880
|
+
key=lambda name: strategy_selector_scores.get(name, float("-inf")),
|
|
1881
|
+
reverse=True,
|
|
1882
|
+
)
|
|
1883
|
+
|
|
1884
|
+
|
|
1885
|
+
def resolve_enabled_strategy_names(asset: dict, global_default: list[str]) -> list[str]:
|
|
1886
|
+
"""V4.6 (development/Problems.md #59's own deferred item) - per-asset
|
|
1887
|
+
`enabled_strategy_names` override: an option asset's `phase1.universe.
|
|
1888
|
+
assets` entry may carry an optional
|
|
1889
|
+
`"options_strategy_override": {"enabled_strategy_names": [...]}` key
|
|
1890
|
+
to trade a DIFFERENT strategy set than the global default (e.g. one
|
|
1891
|
+
ticker trades iron condors, another trades covered calls). Returns
|
|
1892
|
+
the override when present and non-empty, else `global_default`
|
|
1893
|
+
unchanged - byte-identical to every asset with no override key at
|
|
1894
|
+
all, which is every asset today (V4.6 adds no default overrides).
|
|
1895
|
+
Never raises on a malformed override (falls back to global_default)."""
|
|
1896
|
+
override = (asset.get("options_strategy_override") or {}).get("enabled_strategy_names")
|
|
1897
|
+
if isinstance(override, list) and override:
|
|
1898
|
+
return override
|
|
1899
|
+
return global_default
|
|
1900
|
+
|
|
1901
|
+
|
|
1902
|
+
# ---------------------------------------------------------------------------
|
|
1903
|
+
# Covered/protective/collar sizing (§6/§9.3/§9.6) - a THIRD sizing paradigm
|
|
1904
|
+
# alongside vega-budget and margin: the option leg(s) are sized as a ratio
|
|
1905
|
+
# against the EQUITY leg's currently-held quantity, never against a
|
|
1906
|
+
# confidence-scaled vega/margin budget of their own. main.py never submits
|
|
1907
|
+
# the bundled OptionStrategies.covered_call/etc. factory as an order (it
|
|
1908
|
+
# would bundle an independent equity trade into the combo, fighting the
|
|
1909
|
+
# standalone equity asset's own signal) - only this sizing decision's
|
|
1910
|
+
# option leg(s) are ever placed, via the existing single-leg order
|
|
1911
|
+
# machinery, see main.py's own corrected design note.
|
|
1912
|
+
# ---------------------------------------------------------------------------
|
|
1913
|
+
|
|
1914
|
+
|
|
1915
|
+
def build_covered_protective_position_sizing(
|
|
1916
|
+
strategy_name: str,
|
|
1917
|
+
confidence: float,
|
|
1918
|
+
available_chain: list[dict],
|
|
1919
|
+
equity_quantity: float,
|
|
1920
|
+
contract_multiplier: float = 100.0,
|
|
1921
|
+
target_delta_at_full_confidence: float = 0.30,
|
|
1922
|
+
) -> OptionsMultiLegPositionDecision | None:
|
|
1923
|
+
"""Sizes covered_call/covered_put/protective_call/protective_put/
|
|
1924
|
+
protective_collar's option leg(s) purely as floor(|equity_quantity| /
|
|
1925
|
+
contract_multiplier) - the number of contracts the CURRENTLY HELD
|
|
1926
|
+
equity position actually covers, never a vega/confidence-scaled
|
|
1927
|
+
target of its own (§9.3's floor-rounding: any fractional remainder is
|
|
1928
|
+
carried as plain uncovered equity, never partially hedged).
|
|
1929
|
+
|
|
1930
|
+
equity_quantity must carry the correct SIGN for this strategy's
|
|
1931
|
+
covering_equity_side (positive/long for covered_call, protective_put,
|
|
1932
|
+
protective_collar; negative/short for covered_put, protective_call) -
|
|
1933
|
+
a mismatched sign (e.g. a short equity holding passed for
|
|
1934
|
+
covered_call, which needs a LONG holding) returns None rather than
|
|
1935
|
+
sizing against the wrong side of the book. Returns None for any
|
|
1936
|
+
strategy_name whose risk_tier isn't "covered_protective", for
|
|
1937
|
+
confidence <= 0, or when floor rounding or leg selection yields
|
|
1938
|
+
nothing - never raises."""
|
|
1939
|
+
spec = MULTI_LEG_STRATEGY_REGISTRY.get(strategy_name)
|
|
1940
|
+
if spec is None or spec.risk_tier != "covered_protective":
|
|
1941
|
+
return None
|
|
1942
|
+
|
|
1943
|
+
confidence = max(0.0, min(float(confidence), 1.0))
|
|
1944
|
+
if confidence == 0.0 or contract_multiplier <= 0.0:
|
|
1945
|
+
return None
|
|
1946
|
+
if spec.covering_equity_side == "long" and equity_quantity <= 0.0:
|
|
1947
|
+
return None
|
|
1948
|
+
if spec.covering_equity_side == "short" and equity_quantity >= 0.0:
|
|
1949
|
+
return None
|
|
1950
|
+
|
|
1951
|
+
contracts = int(math.floor(abs(equity_quantity) / contract_multiplier))
|
|
1952
|
+
if contracts <= 0:
|
|
1953
|
+
return None
|
|
1954
|
+
|
|
1955
|
+
target_delta = target_delta_at_full_confidence * confidence
|
|
1956
|
+
legs_by_role = select_strategy_legs(strategy_name, available_chain, target_delta)
|
|
1957
|
+
if legs_by_role is None:
|
|
1958
|
+
return None
|
|
1959
|
+
|
|
1960
|
+
return OptionsMultiLegPositionDecision(
|
|
1961
|
+
strategy_name=strategy_name,
|
|
1962
|
+
legs=_legs_tuple_from_roles(spec, legs_by_role),
|
|
1963
|
+
expiries=_expiries_tuple_from_roles(spec, legs_by_role),
|
|
1964
|
+
contracts=contracts,
|
|
1965
|
+
net_debit_or_credit=0.0,
|
|
1966
|
+
net_delta=0.0,
|
|
1967
|
+
net_vega=0.0,
|
|
1968
|
+
sizing_reason="equity_ratio_covered_protective_sizing",
|
|
1969
|
+
)
|
|
1970
|
+
|
|
1971
|
+
|
|
1972
|
+
def option_auto_close_due(current_date, expiry_date, auto_close_days_before_expiry: int = 2) -> bool:
|
|
1973
|
+
"""§9.5 - the narrow expiry-day safety net: True once `expiry_date` is
|
|
1974
|
+
within auto_close_days_before_expiry calendar days of current_date.
|
|
1975
|
+
Pure date comparison, mirroring risk/futures_risk.py::rollover_due()'s
|
|
1976
|
+
own pattern exactly (generalized from futures rollover to options
|
|
1977
|
+
expiry) - a diagnostic/trigger signal only, main.py's own force-
|
|
1978
|
+
liquidate call is what actually acts on it. Returns False when
|
|
1979
|
+
expiry_date is unparseable/unknown - never raises, matching every
|
|
1980
|
+
other pure predicate in this module's degrade-to-absent convention."""
|
|
1981
|
+
if current_date is None or expiry_date is None:
|
|
1982
|
+
return False
|
|
1983
|
+
return (expiry_date - current_date).days <= auto_close_days_before_expiry
|
|
1984
|
+
|
|
1985
|
+
|
|
1986
|
+
def rotation_cooldown_active(current_bar_index: int, last_rotation_bar: int | None, cooldown_bars: int) -> bool:
|
|
1987
|
+
"""V4.6 (development/Problems.md #57/#58/#59) - anti-thrashing guard
|
|
1988
|
+
for options rotation: True while `current_bar_index` is within
|
|
1989
|
+
`cooldown_bars` of the last bar a rotation (liquidate + re-enter)
|
|
1990
|
+
actually executed for this symbol_key. Pure bar-index arithmetic,
|
|
1991
|
+
mirroring option_auto_close_due()'s own pure-predicate-only pattern -
|
|
1992
|
+
main.py's rotate_on_drift branch is what actually degrades to the
|
|
1993
|
+
"manage nearest position in place" fallback when this returns True,
|
|
1994
|
+
never a hard block. Returns False (cooldown never active) when
|
|
1995
|
+
`last_rotation_bar` is None - no rotation has ever fired for this
|
|
1996
|
+
symbol_key yet, never a crash on the first-ever rotation."""
|
|
1997
|
+
if last_rotation_bar is None:
|
|
1998
|
+
return False
|
|
1999
|
+
return (current_bar_index - last_rotation_bar) < cooldown_bars
|