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.
Files changed (99) hide show
  1. aether_quant-0.0.1.dist-info/METADATA +7 -0
  2. aether_quant-0.0.1.dist-info/RECORD +99 -0
  3. aether_quant-0.0.1.dist-info/WHEEL +5 -0
  4. aether_quant-0.0.1.dist-info/entry_points.txt +2 -0
  5. aether_quant-0.0.1.dist-info/licenses/LICENSE +133 -0
  6. aether_quant-0.0.1.dist-info/top_level.txt +13 -0
  7. analyzer/README.md +69 -0
  8. analyzer/__init__.py +17 -0
  9. analyzer/market_analyzer.py +521 -0
  10. aq_cli.py +3633 -0
  11. audit/README.md +47 -0
  12. audit/__init__.py +38 -0
  13. audit/hash_chain.py +52 -0
  14. audit/postgres_audit.py +61 -0
  15. audit/postgres_worker.py +402 -0
  16. audit/redis_queue.py +124 -0
  17. audit/status_export.py +62 -0
  18. data_pipeline/README.md +257 -0
  19. data_pipeline/__init__.py +6 -0
  20. data_pipeline/bar_synthesis.py +97 -0
  21. data_pipeline/dividend_backfill.py +243 -0
  22. data_pipeline/factor_file_backfill.py +276 -0
  23. data_pipeline/fetch.py +206 -0
  24. data_pipeline/fred_backfill.py +435 -0
  25. data_pipeline/ib_backfill.py +287 -0
  26. data_pipeline/v2_manifest.py +75 -0
  27. data_pipeline/yfinance_backfill.py +501 -0
  28. evaluation/README.md +152 -0
  29. evaluation/__init__.py +73 -0
  30. evaluation/ablation.py +310 -0
  31. evaluation/benchmark_comparison.py +195 -0
  32. evaluation/confidence_threshold_calibration.py +157 -0
  33. evaluation/factor_neutralization_check.py +94 -0
  34. evaluation/feature_parity.py +321 -0
  35. evaluation/feature_reconciliation.py +286 -0
  36. evaluation/impact_model.py +77 -0
  37. evaluation/kill_switch_replay.py +186 -0
  38. evaluation/limit_fill_simulator.py +152 -0
  39. evaluation/model_predictions.py +192 -0
  40. evaluation/monte_carlo.py +161 -0
  41. evaluation/public_benchmarks.py +117 -0
  42. evaluation/rank_book_simulator.py +494 -0
  43. evaluation/rank_ic_core.py +204 -0
  44. evaluation/rank_signal_calibration.py +690 -0
  45. evaluation/rolling_ic_gate_calibration.py +190 -0
  46. evaluation/rolling_ic_gate_replay.py +138 -0
  47. execution/README.md +281 -0
  48. execution/__init__.py +80 -0
  49. execution/config_cache.py +48 -0
  50. execution/cost_model.py +213 -0
  51. execution/lean_config_render.py +131 -0
  52. execution/live_credentials.py +38 -0
  53. execution/live_credentials_io.py +52 -0
  54. execution/order_gate.py +388 -0
  55. execution/paper_readiness.py +181 -0
  56. execution/paper_readiness_io.py +54 -0
  57. execution/paper_readiness_report.py +103 -0
  58. execution/paper_readiness_scheduler.py +137 -0
  59. execution/reconciliation.py +160 -0
  60. execution/runtime_config_io.py +38 -0
  61. execution/secret_scan.py +73 -0
  62. features/README.md +77 -0
  63. features/__init__.py +137 -0
  64. features/alt_data_features.py +154 -0
  65. features/bond_features.py +275 -0
  66. features/cross_asset_sensitivity.py +79 -0
  67. features/derivatives_macro_features.py +72 -0
  68. features/macro_features.py +79 -0
  69. features/options_greeks.py +329 -0
  70. features/sector_map.py +53 -0
  71. features/technical_indicators.py +185 -0
  72. generate_backtest_report.py +311 -0
  73. generate_evaluation_report.py +712 -0
  74. inference/README.md +105 -0
  75. inference/__init__.py +36 -0
  76. inference/exported_model.py +929 -0
  77. inference/parallel_inference.py +160 -0
  78. inference/strategy_selector_inference.py +75 -0
  79. json_safety.py +130 -0
  80. portfolio/README.md +447 -0
  81. portfolio/__init__.py +41 -0
  82. portfolio/book_construction.py +786 -0
  83. portfolio/book_neutrality.py +178 -0
  84. portfolio/hrp_allocation.py +159 -0
  85. portfolio/options_arbitrage_detector.py +232 -0
  86. portfolio/options_assignment_risk.py +119 -0
  87. portfolio/options_margin_sizing.py +333 -0
  88. portfolio/options_strategy.py +1999 -0
  89. portfolio/rank_signal.py +219 -0
  90. portfolio/rolling_ic_gate.py +183 -0
  91. risk/README.md +275 -0
  92. risk/__init__.py +38 -0
  93. risk/asset_class_router.py +584 -0
  94. risk/forex_risk.py +158 -0
  95. risk/futures_risk.py +202 -0
  96. risk/kill_switch.py +261 -0
  97. risk/manual_override.py +118 -0
  98. risk/position_sizing.py +303 -0
  99. 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