aether-quant 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aether_quant-0.0.1.dist-info/METADATA +7 -0
- aether_quant-0.0.1.dist-info/RECORD +99 -0
- aether_quant-0.0.1.dist-info/WHEEL +5 -0
- aether_quant-0.0.1.dist-info/entry_points.txt +2 -0
- aether_quant-0.0.1.dist-info/licenses/LICENSE +133 -0
- aether_quant-0.0.1.dist-info/top_level.txt +13 -0
- analyzer/README.md +69 -0
- analyzer/__init__.py +17 -0
- analyzer/market_analyzer.py +521 -0
- aq_cli.py +3633 -0
- audit/README.md +47 -0
- audit/__init__.py +38 -0
- audit/hash_chain.py +52 -0
- audit/postgres_audit.py +61 -0
- audit/postgres_worker.py +402 -0
- audit/redis_queue.py +124 -0
- audit/status_export.py +62 -0
- data_pipeline/README.md +257 -0
- data_pipeline/__init__.py +6 -0
- data_pipeline/bar_synthesis.py +97 -0
- data_pipeline/dividend_backfill.py +243 -0
- data_pipeline/factor_file_backfill.py +276 -0
- data_pipeline/fetch.py +206 -0
- data_pipeline/fred_backfill.py +435 -0
- data_pipeline/ib_backfill.py +287 -0
- data_pipeline/v2_manifest.py +75 -0
- data_pipeline/yfinance_backfill.py +501 -0
- evaluation/README.md +152 -0
- evaluation/__init__.py +73 -0
- evaluation/ablation.py +310 -0
- evaluation/benchmark_comparison.py +195 -0
- evaluation/confidence_threshold_calibration.py +157 -0
- evaluation/factor_neutralization_check.py +94 -0
- evaluation/feature_parity.py +321 -0
- evaluation/feature_reconciliation.py +286 -0
- evaluation/impact_model.py +77 -0
- evaluation/kill_switch_replay.py +186 -0
- evaluation/limit_fill_simulator.py +152 -0
- evaluation/model_predictions.py +192 -0
- evaluation/monte_carlo.py +161 -0
- evaluation/public_benchmarks.py +117 -0
- evaluation/rank_book_simulator.py +494 -0
- evaluation/rank_ic_core.py +204 -0
- evaluation/rank_signal_calibration.py +690 -0
- evaluation/rolling_ic_gate_calibration.py +190 -0
- evaluation/rolling_ic_gate_replay.py +138 -0
- execution/README.md +281 -0
- execution/__init__.py +80 -0
- execution/config_cache.py +48 -0
- execution/cost_model.py +213 -0
- execution/lean_config_render.py +131 -0
- execution/live_credentials.py +38 -0
- execution/live_credentials_io.py +52 -0
- execution/order_gate.py +388 -0
- execution/paper_readiness.py +181 -0
- execution/paper_readiness_io.py +54 -0
- execution/paper_readiness_report.py +103 -0
- execution/paper_readiness_scheduler.py +137 -0
- execution/reconciliation.py +160 -0
- execution/runtime_config_io.py +38 -0
- execution/secret_scan.py +73 -0
- features/README.md +77 -0
- features/__init__.py +137 -0
- features/alt_data_features.py +154 -0
- features/bond_features.py +275 -0
- features/cross_asset_sensitivity.py +79 -0
- features/derivatives_macro_features.py +72 -0
- features/macro_features.py +79 -0
- features/options_greeks.py +329 -0
- features/sector_map.py +53 -0
- features/technical_indicators.py +185 -0
- generate_backtest_report.py +311 -0
- generate_evaluation_report.py +712 -0
- inference/README.md +105 -0
- inference/__init__.py +36 -0
- inference/exported_model.py +929 -0
- inference/parallel_inference.py +160 -0
- inference/strategy_selector_inference.py +75 -0
- json_safety.py +130 -0
- portfolio/README.md +447 -0
- portfolio/__init__.py +41 -0
- portfolio/book_construction.py +786 -0
- portfolio/book_neutrality.py +178 -0
- portfolio/hrp_allocation.py +159 -0
- portfolio/options_arbitrage_detector.py +232 -0
- portfolio/options_assignment_risk.py +119 -0
- portfolio/options_margin_sizing.py +333 -0
- portfolio/options_strategy.py +1999 -0
- portfolio/rank_signal.py +219 -0
- portfolio/rolling_ic_gate.py +183 -0
- risk/README.md +275 -0
- risk/__init__.py +38 -0
- risk/asset_class_router.py +584 -0
- risk/forex_risk.py +158 -0
- risk/futures_risk.py +202 -0
- risk/kill_switch.py +261 -0
- risk/manual_override.py +118 -0
- risk/position_sizing.py +303 -0
- risk/rl_sizing.py +189 -0
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
"""Single dispatch point routing a symbol's position sizing to the right
|
|
2
|
+
asset-class-specific model, while every downstream consumer
|
|
3
|
+
(portfolio/book_construction.py, liquidity, analyzer, main.py::_apply_signal())
|
|
4
|
+
keeps working against ONE shared PositionSizingDecision shape
|
|
5
|
+
(risk/position_sizing.py:13-28) regardless of which asset class produced it.
|
|
6
|
+
|
|
7
|
+
Design: equity/crypto/bond all resolve via the EXISTING, unchanged
|
|
8
|
+
build_dynamic_position_sizing() - bonds get no new sizing formula in this
|
|
9
|
+
pass, only better upstream features (features/bond_features.py); a
|
|
10
|
+
duration-sensitive ETF is still correctly modeled as a vol-scaled weight on
|
|
11
|
+
a liquid, longable-only instrument. future/option resolve via
|
|
12
|
+
risk/futures_risk.py / portfolio/options_strategy.py, then get ADAPTED
|
|
13
|
+
onto the shared PositionSizingDecision shape by the two functions below.
|
|
14
|
+
|
|
15
|
+
The adapters can't express everything a futures/options decision carries
|
|
16
|
+
(an integer contract_count, a specific option contract) inside
|
|
17
|
+
PositionSizingDecision's fixed field set - route_position_sizing() returns
|
|
18
|
+
a (PositionSizingDecision, extra) pair, where `extra` carries whatever
|
|
19
|
+
asset-class-specific raw decision main.py::_apply_signal() needs to place
|
|
20
|
+
the RIGHT kind of order (MarketOrder(symbol, contract_count) for futures,
|
|
21
|
+
vs. SetHoldings(symbol, target_weight) for everything else) - `extra` is
|
|
22
|
+
empty for equity/crypto/bond, so existing callers that only ever look at
|
|
23
|
+
the PositionSizingDecision stay completely unaffected.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from risk.forex_risk import ForexSizingDecision, build_forex_position_sizing
|
|
29
|
+
from risk.futures_risk import FuturesSizingDecision, build_futures_position_sizing
|
|
30
|
+
from risk.position_sizing import PositionSizingDecision, build_dynamic_position_sizing
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
from portfolio.options_strategy import (
|
|
34
|
+
MULTI_LEG_STRATEGY_REGISTRY,
|
|
35
|
+
OptionsPositionDecision,
|
|
36
|
+
OptionsSpreadPositionDecision,
|
|
37
|
+
build_multi_leg_position_sizing,
|
|
38
|
+
build_options_position_sizing,
|
|
39
|
+
build_vertical_spread_position_sizing,
|
|
40
|
+
group_chain_by_expiry,
|
|
41
|
+
order_enabled_strategies,
|
|
42
|
+
rerank_enabled_strategies_by_score,
|
|
43
|
+
strategies_for_volatility_view,
|
|
44
|
+
)
|
|
45
|
+
except ImportError: # pragma: no cover - portfolio package always present in this repo; defensive only
|
|
46
|
+
MULTI_LEG_STRATEGY_REGISTRY = {}
|
|
47
|
+
OptionsPositionDecision = None
|
|
48
|
+
OptionsSpreadPositionDecision = None
|
|
49
|
+
build_options_position_sizing = None
|
|
50
|
+
build_vertical_spread_position_sizing = None
|
|
51
|
+
build_multi_leg_position_sizing = None
|
|
52
|
+
group_chain_by_expiry = None
|
|
53
|
+
order_enabled_strategies = None
|
|
54
|
+
rerank_enabled_strategies_by_score = None
|
|
55
|
+
strategies_for_volatility_view = None
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
from portfolio.options_margin_sizing import build_margin_position_sizing
|
|
59
|
+
except ImportError: # pragma: no cover - portfolio package always present in this repo; defensive only
|
|
60
|
+
build_margin_position_sizing = None
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
from portfolio.options_arbitrage_detector import build_arbitrage_position_sizing
|
|
64
|
+
except ImportError: # pragma: no cover - portfolio package always present in this repo; defensive only
|
|
65
|
+
build_arbitrage_position_sizing = None
|
|
66
|
+
|
|
67
|
+
FUTURES_OPTIONS_SENTINEL_VOLATILITY_REGIME = "not_applicable_non_volatility_sizing"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _futures_decision_to_position_sizing(decision: FuturesSizingDecision) -> PositionSizingDecision:
|
|
71
|
+
"""Maps FuturesSizingDecision onto PositionSizingDecision's exact field
|
|
72
|
+
set. volatility fields are 0.0/sentinel-string (futures sizing isn't
|
|
73
|
+
volatility-driven at all - a real 0.0, not a missing value).
|
|
74
|
+
leverage_factor repurposes its existing "how much of the cap did we
|
|
75
|
+
use" semantic as margin_utilization / max_margin_utilization."""
|
|
76
|
+
max_margin_utilization = decision.max_margin_utilization or 1.0
|
|
77
|
+
return PositionSizingDecision(
|
|
78
|
+
base_target_weight=decision.base_target_weight,
|
|
79
|
+
target_weight=decision.target_weight,
|
|
80
|
+
rolling_volatility=0.0,
|
|
81
|
+
annualized_volatility=0.0,
|
|
82
|
+
volatility_regime=FUTURES_OPTIONS_SENTINEL_VOLATILITY_REGIME,
|
|
83
|
+
volatility_multiplier=0.0,
|
|
84
|
+
confidence_multiplier=decision.confidence_multiplier,
|
|
85
|
+
topology_multiplier=1.0,
|
|
86
|
+
leverage_factor=decision.margin_utilization / max_margin_utilization,
|
|
87
|
+
max_leverage=max_margin_utilization,
|
|
88
|
+
sizing_reason=decision.sizing_reason,
|
|
89
|
+
topology_sizing_reason="not_applicable_futures",
|
|
90
|
+
volatility_source="not_applicable",
|
|
91
|
+
rank_multiplier=1.0,
|
|
92
|
+
rank_sizing_reason="not_applicable_futures",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _forex_decision_to_position_sizing(decision: ForexSizingDecision) -> PositionSizingDecision:
|
|
97
|
+
"""V4.6 - the Forex sibling of _futures_decision_to_position_sizing()
|
|
98
|
+
above, same mapping shape (volatility fields sentinel/zeroed - forex
|
|
99
|
+
sizing here isn't volatility-driven either; leverage_factor repurposes
|
|
100
|
+
"how much of the cap did we use" as margin_utilization /
|
|
101
|
+
max_leverage_utilization)."""
|
|
102
|
+
max_leverage_utilization = decision.max_leverage_utilization or 1.0
|
|
103
|
+
return PositionSizingDecision(
|
|
104
|
+
base_target_weight=decision.base_target_weight,
|
|
105
|
+
target_weight=decision.target_weight,
|
|
106
|
+
rolling_volatility=0.0,
|
|
107
|
+
annualized_volatility=0.0,
|
|
108
|
+
volatility_regime=FUTURES_OPTIONS_SENTINEL_VOLATILITY_REGIME,
|
|
109
|
+
volatility_multiplier=0.0,
|
|
110
|
+
confidence_multiplier=decision.confidence_multiplier,
|
|
111
|
+
topology_multiplier=1.0,
|
|
112
|
+
leverage_factor=decision.margin_utilization / max_leverage_utilization,
|
|
113
|
+
max_leverage=max_leverage_utilization,
|
|
114
|
+
sizing_reason=decision.sizing_reason,
|
|
115
|
+
topology_sizing_reason="not_applicable_forex",
|
|
116
|
+
volatility_source="not_applicable",
|
|
117
|
+
rank_multiplier=1.0,
|
|
118
|
+
rank_sizing_reason="not_applicable_forex",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _options_decision_to_position_sizing(decision, portfolio_value: float, max_vega_budget_pct_of_equity: float) -> PositionSizingDecision:
|
|
123
|
+
"""Maps target_delta -> volatility_multiplier's slot (delta IS the
|
|
124
|
+
options-world analogue of "how much of the underlying's move this
|
|
125
|
+
position captures"), and vega_budget_used / max budget ->
|
|
126
|
+
leverage_factor (same "how much of the cap did we use" semantic as the
|
|
127
|
+
futures adapter)."""
|
|
128
|
+
max_budget = max_vega_budget_pct_of_equity or 1.0
|
|
129
|
+
direction = 1.0 if decision.right == "call" else -1.0
|
|
130
|
+
return PositionSizingDecision(
|
|
131
|
+
base_target_weight=direction * decision.target_delta,
|
|
132
|
+
target_weight=direction * (decision.vega_budget_used),
|
|
133
|
+
rolling_volatility=0.0,
|
|
134
|
+
annualized_volatility=0.0,
|
|
135
|
+
volatility_regime=FUTURES_OPTIONS_SENTINEL_VOLATILITY_REGIME,
|
|
136
|
+
volatility_multiplier=decision.actual_delta,
|
|
137
|
+
confidence_multiplier=1.0,
|
|
138
|
+
topology_multiplier=1.0,
|
|
139
|
+
leverage_factor=decision.vega_budget_used / max_budget,
|
|
140
|
+
max_leverage=max_budget,
|
|
141
|
+
sizing_reason=decision.sizing_reason,
|
|
142
|
+
topology_sizing_reason="not_applicable_options",
|
|
143
|
+
volatility_source="not_applicable",
|
|
144
|
+
rank_multiplier=1.0,
|
|
145
|
+
rank_sizing_reason="not_applicable_options",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _options_spread_decision_to_position_sizing(
|
|
150
|
+
decision, portfolio_value: float, max_vega_budget_pct_of_equity: float
|
|
151
|
+
) -> PositionSizingDecision:
|
|
152
|
+
"""Vertical-spread sibling of _options_decision_to_position_sizing()
|
|
153
|
+
above - same mapping semantics (net delta -> volatility_multiplier's
|
|
154
|
+
slot, net vega budget-used -> leverage_factor), spread-level instead
|
|
155
|
+
of single-leg. decision.net_vega is already a budget-used FRACTION
|
|
156
|
+
(see OptionsSpreadPositionDecision's own field docstring), not a raw
|
|
157
|
+
vega number - used directly here, matching how the single-leg
|
|
158
|
+
adapter above uses vega_budget_used directly too."""
|
|
159
|
+
max_budget = max_vega_budget_pct_of_equity or 1.0
|
|
160
|
+
direction = 1.0 if decision.legs[0].right == "call" else -1.0
|
|
161
|
+
return PositionSizingDecision(
|
|
162
|
+
base_target_weight=direction * decision.net_delta,
|
|
163
|
+
target_weight=direction * decision.net_vega,
|
|
164
|
+
rolling_volatility=0.0,
|
|
165
|
+
annualized_volatility=0.0,
|
|
166
|
+
volatility_regime=FUTURES_OPTIONS_SENTINEL_VOLATILITY_REGIME,
|
|
167
|
+
volatility_multiplier=decision.net_delta,
|
|
168
|
+
confidence_multiplier=1.0,
|
|
169
|
+
topology_multiplier=1.0,
|
|
170
|
+
leverage_factor=decision.net_vega / max_budget,
|
|
171
|
+
max_leverage=max_budget,
|
|
172
|
+
sizing_reason=decision.sizing_reason,
|
|
173
|
+
topology_sizing_reason="not_applicable_options",
|
|
174
|
+
volatility_source="not_applicable",
|
|
175
|
+
rank_multiplier=1.0,
|
|
176
|
+
rank_sizing_reason="not_applicable_options",
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _multi_leg_decision_to_position_sizing(
|
|
181
|
+
decision, portfolio_value: float, max_vega_budget_pct_of_equity: float
|
|
182
|
+
) -> PositionSizingDecision:
|
|
183
|
+
"""OptionsMultiLegPositionDecision sibling of
|
|
184
|
+
_options_spread_decision_to_position_sizing() above - N-leg
|
|
185
|
+
generalization of the identical net-delta/net-vega mapping. Direction
|
|
186
|
+
is read off the FIRST leg's right (matches the 2-leg adapter's own
|
|
187
|
+
"legs[0].right" convention) - a reasonable single-scalar proxy for a
|
|
188
|
+
decision this codebase's shared PositionSizingDecision shape can't
|
|
189
|
+
fully represent regardless of leg count, exactly the same limitation
|
|
190
|
+
the 2-leg adapter already documents."""
|
|
191
|
+
max_budget = max_vega_budget_pct_of_equity or 1.0
|
|
192
|
+
direction = 1.0 if decision.legs[0].right == "call" else -1.0
|
|
193
|
+
return PositionSizingDecision(
|
|
194
|
+
base_target_weight=direction * decision.net_delta,
|
|
195
|
+
target_weight=direction * decision.net_vega,
|
|
196
|
+
rolling_volatility=0.0,
|
|
197
|
+
annualized_volatility=0.0,
|
|
198
|
+
volatility_regime=FUTURES_OPTIONS_SENTINEL_VOLATILITY_REGIME,
|
|
199
|
+
volatility_multiplier=decision.net_delta,
|
|
200
|
+
confidence_multiplier=1.0,
|
|
201
|
+
topology_multiplier=1.0,
|
|
202
|
+
leverage_factor=decision.net_vega / max_budget,
|
|
203
|
+
max_leverage=max_budget,
|
|
204
|
+
sizing_reason=decision.sizing_reason,
|
|
205
|
+
topology_sizing_reason="not_applicable_options",
|
|
206
|
+
volatility_source="not_applicable",
|
|
207
|
+
rank_multiplier=1.0,
|
|
208
|
+
rank_sizing_reason="not_applicable_options",
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _margin_decision_to_position_sizing(decision, portfolio_value: float) -> PositionSizingDecision:
|
|
213
|
+
"""MarginSizingDecision adapter - margin_utilization (already a
|
|
214
|
+
budget-used FRACTION, see options_margin_sizing.py) maps onto
|
|
215
|
+
leverage_factor/max_leverage exactly like the futures adapter's
|
|
216
|
+
margin_utilization/max_margin_utilization mapping above, since both
|
|
217
|
+
are genuinely margin-based sizing, not vega-based."""
|
|
218
|
+
direction = 1.0 if decision.legs[0].right == "call" else -1.0
|
|
219
|
+
return PositionSizingDecision(
|
|
220
|
+
base_target_weight=direction * decision.margin_utilization,
|
|
221
|
+
target_weight=direction * decision.margin_utilization,
|
|
222
|
+
rolling_volatility=0.0,
|
|
223
|
+
annualized_volatility=0.0,
|
|
224
|
+
volatility_regime=FUTURES_OPTIONS_SENTINEL_VOLATILITY_REGIME,
|
|
225
|
+
volatility_multiplier=0.0,
|
|
226
|
+
confidence_multiplier=1.0,
|
|
227
|
+
topology_multiplier=1.0,
|
|
228
|
+
leverage_factor=decision.margin_utilization,
|
|
229
|
+
max_leverage=1.0,
|
|
230
|
+
sizing_reason=decision.sizing_reason,
|
|
231
|
+
topology_sizing_reason="not_applicable_options",
|
|
232
|
+
volatility_source="not_applicable",
|
|
233
|
+
rank_multiplier=1.0,
|
|
234
|
+
rank_sizing_reason="not_applicable_options",
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def route_multi_leg_option_sizing(
|
|
239
|
+
enabled_strategy_names: list,
|
|
240
|
+
signal_name: str,
|
|
241
|
+
confidence: float,
|
|
242
|
+
available_chain: list,
|
|
243
|
+
portfolio_value: float,
|
|
244
|
+
underlying_price: float,
|
|
245
|
+
volatility_view: str,
|
|
246
|
+
risk_tier_preference: str,
|
|
247
|
+
margin_family_enabled: bool,
|
|
248
|
+
*,
|
|
249
|
+
target_delta_at_full_confidence: float,
|
|
250
|
+
max_vega_budget_pct_of_equity: float,
|
|
251
|
+
short_leg_delta_offset: float,
|
|
252
|
+
contract_multiplier: float,
|
|
253
|
+
target_margin_utilization: float,
|
|
254
|
+
max_margin_utilization: float,
|
|
255
|
+
pct_of_underlying_value: float,
|
|
256
|
+
min_pct_of_underlying_value: float,
|
|
257
|
+
current_date=None,
|
|
258
|
+
risk_free_rate: float = 0.045,
|
|
259
|
+
arbitrage_detector_enabled: bool = False,
|
|
260
|
+
min_mispricing_bps: float = 15.0,
|
|
261
|
+
arbitrage_max_contracts_per_signal: int = 1,
|
|
262
|
+
strategy_selector_scores: dict | None = None,
|
|
263
|
+
) -> tuple[PositionSizingDecision, dict] | None:
|
|
264
|
+
"""V4.5 - the full-coverage sibling of route_position_sizing()'s
|
|
265
|
+
option branch: tries `enabled_strategy_names`, reordered by
|
|
266
|
+
order_enabled_strategies() (§9.2's priority-list semantic), stopping
|
|
267
|
+
at the FIRST candidate that actually sizes. "single_leg" has no
|
|
268
|
+
MULTI_LEG_STRATEGY_REGISTRY entry (it's not an OptionStrategies
|
|
269
|
+
factory) and is special-cased to the existing
|
|
270
|
+
build_options_position_sizing() path; every other name is looked up
|
|
271
|
+
in the registry and dispatched by risk_tier:
|
|
272
|
+
- "vega_budget" -> build_multi_leg_position_sizing() (this includes
|
|
273
|
+
bull_call_spread/bear_put_spread too when reached via this NEW
|
|
274
|
+
router - the registry-driven selector is provably identical
|
|
275
|
+
geometry to the OLD dedicated build_vertical_spread_position_sizing()
|
|
276
|
+
for those 2, see options_strategy.py's select_vertical_legs()).
|
|
277
|
+
- margin tiers -> build_margin_position_sizing(), but ONLY when
|
|
278
|
+
margin_family_enabled (§5's hard gate - main.py additionally
|
|
279
|
+
requires runtime_mode == "backtest" before ever setting this True,
|
|
280
|
+
see main.py's own gate).
|
|
281
|
+
- "covered_protective" -> skipped here entirely; main.py's
|
|
282
|
+
corrected cross-asset design sizes these separately (needs the
|
|
283
|
+
held EQUITY quantity, not available_chain alone - see
|
|
284
|
+
build_covered_protective_position_sizing()).
|
|
285
|
+
- "unreachable_arbitrage" -> always skipped, by design (§8).
|
|
286
|
+
|
|
287
|
+
strategy_selector_scores (V4.7, development/Problems.md #29's own
|
|
288
|
+
framing) - the learned strategy-selector model's live inference plug
|
|
289
|
+
point: when truthy (a trained model is loaded AND
|
|
290
|
+
phase_v2.strategy_selector.enabled), reranks enabled_strategy_names by
|
|
291
|
+
score (portfolio/options_strategy.py::rerank_enabled_strategies_by_score())
|
|
292
|
+
INSTEAD OF order_enabled_strategies()'s static risk-tier-preference
|
|
293
|
+
reordering. Defaults to None - the only value ever passed until both
|
|
294
|
+
conditions hold, reproducing today's exact ordered_names output
|
|
295
|
+
byte-identically. Either way, the actual per-strategy_name dispatch
|
|
296
|
+
loop below (leg construction, sizing, risk math) is completely
|
|
297
|
+
unchanged - only WHICH NAME IS TRIED FIRST can differ.
|
|
298
|
+
|
|
299
|
+
straddle/strangle/iron_condor/iron_butterfly shape families are
|
|
300
|
+
additionally gated to strategies_for_volatility_view(volatility_view)
|
|
301
|
+
- every other shape family (vertical, butterfly, calendar, backspread,
|
|
302
|
+
ladder, naked) is NOT volatility-gated at all, firing whenever it's
|
|
303
|
+
the enabled candidate and its own selector finds a usable chain
|
|
304
|
+
(§4's "within-bucket selection follows directly from the allowlist").
|
|
305
|
+
|
|
306
|
+
Returns None when nothing in the (possibly empty) enabled list sizes
|
|
307
|
+
- callers must treat that as "no option position this bar", same
|
|
308
|
+
degrade-to-absent contract as every other branch in this module."""
|
|
309
|
+
if order_enabled_strategies is None or strategies_for_volatility_view is None:
|
|
310
|
+
return None
|
|
311
|
+
|
|
312
|
+
if strategy_selector_scores and rerank_enabled_strategies_by_score is not None:
|
|
313
|
+
ordered_names = rerank_enabled_strategies_by_score(enabled_strategy_names, strategy_selector_scores)
|
|
314
|
+
else:
|
|
315
|
+
ordered_names = order_enabled_strategies(enabled_strategy_names, risk_tier_preference)
|
|
316
|
+
view_gated_families = {"straddle", "strangle", "iron_condor", "iron_butterfly"}
|
|
317
|
+
eligible_view_names = strategies_for_volatility_view(volatility_view)
|
|
318
|
+
|
|
319
|
+
# V4.9 Priority 3 - group the chain by expiry ONCE per routing call
|
|
320
|
+
# (not once per calendar-family candidate tried below) when at least
|
|
321
|
+
# one calendar-family strategy is actually in play this bar -
|
|
322
|
+
# group_chain_by_expiry()'s own O(n) cost is cheap, but this avoids
|
|
323
|
+
# paying it again for each calendar-family name order_enabled_strategies()/
|
|
324
|
+
# rerank_enabled_strategies_by_score() might try in the same bar.
|
|
325
|
+
# arbitrage_jelly_roll is the only OTHER shape family that would use
|
|
326
|
+
# this grouping, but it's permanently unreachable via this router's own
|
|
327
|
+
# "unreachable_arbitrage" branch below - never worth precomputing for.
|
|
328
|
+
precomputed_grouped_chain_by_expiry = None
|
|
329
|
+
if group_chain_by_expiry is not None and available_chain:
|
|
330
|
+
has_calendar_candidate = False
|
|
331
|
+
for name in ordered_names:
|
|
332
|
+
candidate_spec = MULTI_LEG_STRATEGY_REGISTRY.get(name)
|
|
333
|
+
if candidate_spec is not None and candidate_spec.shape_family == "calendar":
|
|
334
|
+
has_calendar_candidate = True
|
|
335
|
+
break
|
|
336
|
+
if has_calendar_candidate:
|
|
337
|
+
precomputed_grouped_chain_by_expiry = group_chain_by_expiry(available_chain)
|
|
338
|
+
|
|
339
|
+
for strategy_name in ordered_names:
|
|
340
|
+
if strategy_name == "single_leg":
|
|
341
|
+
if build_options_position_sizing is None:
|
|
342
|
+
continue
|
|
343
|
+
decision = build_options_position_sizing(
|
|
344
|
+
signal_direction=signal_name,
|
|
345
|
+
confidence=confidence,
|
|
346
|
+
available_chain=available_chain or [],
|
|
347
|
+
portfolio_value=portfolio_value,
|
|
348
|
+
target_delta_at_full_confidence=target_delta_at_full_confidence,
|
|
349
|
+
max_vega_budget_pct_of_equity=max_vega_budget_pct_of_equity,
|
|
350
|
+
)
|
|
351
|
+
if decision is not None:
|
|
352
|
+
return (
|
|
353
|
+
_options_decision_to_position_sizing(decision, portfolio_value, max_vega_budget_pct_of_equity),
|
|
354
|
+
{"options_decision": decision},
|
|
355
|
+
)
|
|
356
|
+
continue
|
|
357
|
+
|
|
358
|
+
spec = MULTI_LEG_STRATEGY_REGISTRY.get(strategy_name)
|
|
359
|
+
if spec is None or spec.risk_tier == "covered_protective":
|
|
360
|
+
continue
|
|
361
|
+
if spec.risk_tier == "unreachable_arbitrage":
|
|
362
|
+
# V4.6 (development/Problems.md #59/Roadmap) - the mispricing
|
|
363
|
+
# detector is the ONLY way one of the 6 arbitrage strategies
|
|
364
|
+
# ever becomes reachable: off by default
|
|
365
|
+
# (arbitrage_detector_enabled), and even when on, only fires
|
|
366
|
+
# for THIS bar's chain when select_arbitrage_signal() confirms
|
|
367
|
+
# a real, threshold-exceeding mispricing - never unconditionally
|
|
368
|
+
# just because the strategy_name is enabled.
|
|
369
|
+
if not arbitrage_detector_enabled or build_arbitrage_position_sizing is None:
|
|
370
|
+
continue
|
|
371
|
+
decision = build_arbitrage_position_sizing(
|
|
372
|
+
strategy_name, available_chain or [], current_date, risk_free_rate, min_mispricing_bps,
|
|
373
|
+
arbitrage_max_contracts_per_signal,
|
|
374
|
+
)
|
|
375
|
+
if decision is not None:
|
|
376
|
+
return (
|
|
377
|
+
_multi_leg_decision_to_position_sizing(decision, portfolio_value, max_vega_budget_pct_of_equity),
|
|
378
|
+
{"options_decision": decision},
|
|
379
|
+
)
|
|
380
|
+
continue
|
|
381
|
+
if spec.shape_family in view_gated_families and strategy_name not in eligible_view_names:
|
|
382
|
+
continue
|
|
383
|
+
|
|
384
|
+
if spec.risk_tier == "vega_budget":
|
|
385
|
+
if build_multi_leg_position_sizing is None:
|
|
386
|
+
continue
|
|
387
|
+
selector_kwargs = {"short_leg_delta_offset": short_leg_delta_offset} if spec.shape_family == "vertical" else {}
|
|
388
|
+
decision = build_multi_leg_position_sizing(
|
|
389
|
+
strategy_name, signal_name, confidence, available_chain or [], portfolio_value,
|
|
390
|
+
target_delta_at_full_confidence, max_vega_budget_pct_of_equity,
|
|
391
|
+
grouped_chain_by_expiry=precomputed_grouped_chain_by_expiry, **selector_kwargs,
|
|
392
|
+
)
|
|
393
|
+
if decision is not None:
|
|
394
|
+
return (
|
|
395
|
+
_multi_leg_decision_to_position_sizing(decision, portfolio_value, max_vega_budget_pct_of_equity),
|
|
396
|
+
{"options_decision": decision},
|
|
397
|
+
)
|
|
398
|
+
continue
|
|
399
|
+
|
|
400
|
+
# margin tiers (margin_naked / margin_uncovered_leg / margin_bounded_backspread)
|
|
401
|
+
if not margin_family_enabled or build_margin_position_sizing is None:
|
|
402
|
+
continue
|
|
403
|
+
decision = build_margin_position_sizing(
|
|
404
|
+
strategy_name, signal_name, confidence, available_chain or [], underlying_price, portfolio_value,
|
|
405
|
+
contract_multiplier, target_delta_at_full_confidence, target_margin_utilization, max_margin_utilization,
|
|
406
|
+
pct_of_underlying_value, min_pct_of_underlying_value,
|
|
407
|
+
)
|
|
408
|
+
if decision is not None:
|
|
409
|
+
return _margin_decision_to_position_sizing(decision, portfolio_value), {"options_decision": decision}
|
|
410
|
+
|
|
411
|
+
return None
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _zero_position_sizing(reason: str) -> PositionSizingDecision:
|
|
415
|
+
return PositionSizingDecision(
|
|
416
|
+
base_target_weight=0.0,
|
|
417
|
+
target_weight=0.0,
|
|
418
|
+
rolling_volatility=0.0,
|
|
419
|
+
annualized_volatility=0.0,
|
|
420
|
+
volatility_regime=FUTURES_OPTIONS_SENTINEL_VOLATILITY_REGIME,
|
|
421
|
+
volatility_multiplier=0.0,
|
|
422
|
+
confidence_multiplier=0.0,
|
|
423
|
+
topology_multiplier=1.0,
|
|
424
|
+
leverage_factor=0.0,
|
|
425
|
+
max_leverage=0.0,
|
|
426
|
+
sizing_reason=reason,
|
|
427
|
+
topology_sizing_reason="not_applicable",
|
|
428
|
+
volatility_source="not_applicable",
|
|
429
|
+
rank_multiplier=1.0,
|
|
430
|
+
rank_sizing_reason="not_applicable",
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def resolve_asset_class_enabled(
|
|
435
|
+
asset_class: str | None,
|
|
436
|
+
futures_risk_enabled: bool,
|
|
437
|
+
options_risk_enabled: bool,
|
|
438
|
+
forex_risk_enabled: bool = True,
|
|
439
|
+
) -> bool:
|
|
440
|
+
"""True iff main.py may open/maintain a position in `asset_class`
|
|
441
|
+
right now (used by main.py's per-bar
|
|
442
|
+
_liquidate_positions_for_disabled_asset_classes() sweep).
|
|
443
|
+
|
|
444
|
+
Equity/crypto/bond (and any unrecognized asset_class) have NO
|
|
445
|
+
enable/disable flag anywhere in this codebase - always True for those,
|
|
446
|
+
by construction, matching route_position_sizing()'s own "fall back to
|
|
447
|
+
equity behavior for anything unrecognized" default above. future ->
|
|
448
|
+
futures_risk_enabled (phase_v2.futures_risk.enabled); option ->
|
|
449
|
+
options_risk_enabled (phase_v2.options_risk.enabled); forex ->
|
|
450
|
+
forex_risk_enabled (phase_v2.forex_risk.enabled, V4.6). Pure lookup,
|
|
451
|
+
never raises. forex_risk_enabled defaults True only to keep this
|
|
452
|
+
signature backward-compatible for any caller that hasn't been updated
|
|
453
|
+
to pass it explicitly - main.py's own call site always passes it."""
|
|
454
|
+
if asset_class == "future":
|
|
455
|
+
return futures_risk_enabled
|
|
456
|
+
if asset_class == "option":
|
|
457
|
+
return options_risk_enabled
|
|
458
|
+
if asset_class == "forex":
|
|
459
|
+
return forex_risk_enabled
|
|
460
|
+
return True
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def should_liquidate_disabled_asset_class_position(asset_class_enabled: bool, is_invested: bool) -> bool:
|
|
464
|
+
"""Pure predicate: True iff a currently-open position must be
|
|
465
|
+
liquidated purely because its asset class is disabled this bar -
|
|
466
|
+
independent of whatever buy/sell/hold signal this bar's model output
|
|
467
|
+
computed. Equity/crypto/bond positions can never satisfy this
|
|
468
|
+
(resolve_asset_class_enabled() always returns True for them, so a
|
|
469
|
+
caller never even reaches this predicate with asset_class_enabled=False
|
|
470
|
+
for those classes) - this predicate doesn't special-case class names
|
|
471
|
+
itself, it trusts asset_class_enabled as already resolved."""
|
|
472
|
+
return (not asset_class_enabled) and is_invested
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def route_position_sizing(
|
|
476
|
+
asset_class: str,
|
|
477
|
+
signal_name: str,
|
|
478
|
+
confidence: float,
|
|
479
|
+
base_target_weight: float,
|
|
480
|
+
*,
|
|
481
|
+
equity_crypto_kwargs: dict | None = None,
|
|
482
|
+
price: float = 0.0,
|
|
483
|
+
portfolio_value: float = 0.0,
|
|
484
|
+
contract_spec: dict | None = None,
|
|
485
|
+
futures_kwargs: dict | None = None,
|
|
486
|
+
pair_spec: dict | None = None,
|
|
487
|
+
forex_kwargs: dict | None = None,
|
|
488
|
+
available_chain: list[dict] | None = None,
|
|
489
|
+
options_kwargs: dict | None = None,
|
|
490
|
+
) -> tuple[PositionSizingDecision, dict]:
|
|
491
|
+
"""Dispatches on asset_class (falls back to "equity" behavior for any
|
|
492
|
+
unrecognized value - the existing, safest, most-tested path). Returns
|
|
493
|
+
(PositionSizingDecision, extra) - extra is {} for equity/crypto/bond,
|
|
494
|
+
{"contract_count": int} for future, {"lot_count": int} for forex
|
|
495
|
+
(V4.6),
|
|
496
|
+
{"options_decision": OptionsPositionDecision | OptionsSpreadPositionDecision}
|
|
497
|
+
for option (only when a position was actually sized; {} when the
|
|
498
|
+
asset-class-specific sizer returned no position, e.g. an empty options
|
|
499
|
+
chain because IB is disabled). options_kwargs["spread_strategy"]
|
|
500
|
+
("single_leg" default, or "vertical") picks which sizer runs - default
|
|
501
|
+
reproduces the exact single-leg behavior every existing caller already
|
|
502
|
+
depends on, by construction."""
|
|
503
|
+
if asset_class == "future":
|
|
504
|
+
decision = build_futures_position_sizing(
|
|
505
|
+
base_target_weight=base_target_weight,
|
|
506
|
+
confidence=confidence,
|
|
507
|
+
price=price,
|
|
508
|
+
contract_spec=contract_spec,
|
|
509
|
+
portfolio_value=portfolio_value,
|
|
510
|
+
**(futures_kwargs or {}),
|
|
511
|
+
)
|
|
512
|
+
return _futures_decision_to_position_sizing(decision), {"contract_count": decision.contract_count}
|
|
513
|
+
|
|
514
|
+
if asset_class == "forex":
|
|
515
|
+
decision = build_forex_position_sizing(
|
|
516
|
+
base_target_weight=base_target_weight,
|
|
517
|
+
confidence=confidence,
|
|
518
|
+
price=price,
|
|
519
|
+
pair_spec=pair_spec,
|
|
520
|
+
portfolio_value=portfolio_value,
|
|
521
|
+
**(forex_kwargs or {}),
|
|
522
|
+
)
|
|
523
|
+
# Phase 4.8 - pair_spec is already resolved by the caller (main.py's
|
|
524
|
+
# self.forex_pair_specs.get(...)) and passed in above purely for
|
|
525
|
+
# build_forex_position_sizing()'s own sizing math - also surfacing
|
|
526
|
+
# it here in `extra` replaces the webui's previous raw "N forex
|
|
527
|
+
# lots" string with real pip/lot/leverage detail, at zero extra
|
|
528
|
+
# computation cost (same dict, no new lookup).
|
|
529
|
+
return _forex_decision_to_position_sizing(decision), {"lot_count": decision.lot_count, "pair_spec": pair_spec}
|
|
530
|
+
|
|
531
|
+
if asset_class == "option":
|
|
532
|
+
if build_options_position_sizing is None:
|
|
533
|
+
return _zero_position_sizing("options_strategy_module_unavailable"), {}
|
|
534
|
+
spread_strategy = (options_kwargs or {}).get("spread_strategy", "single_leg")
|
|
535
|
+
max_vega_budget = (options_kwargs or {}).get("max_vega_budget_pct_of_equity", 0.02)
|
|
536
|
+
|
|
537
|
+
if spread_strategy == "vertical":
|
|
538
|
+
if build_vertical_spread_position_sizing is None:
|
|
539
|
+
return _zero_position_sizing("options_strategy_module_unavailable"), {}
|
|
540
|
+
# short_leg_delta_offset is legitimately accepted here but NOT
|
|
541
|
+
# by build_options_position_sizing() below - only
|
|
542
|
+
# spread_strategy itself is stripped, everything else passes
|
|
543
|
+
# through as-is.
|
|
544
|
+
vertical_kwargs = {k: v for k, v in (options_kwargs or {}).items() if k != "spread_strategy"}
|
|
545
|
+
spread_decision = build_vertical_spread_position_sizing(
|
|
546
|
+
signal_direction=signal_name,
|
|
547
|
+
confidence=confidence,
|
|
548
|
+
available_chain=available_chain or [],
|
|
549
|
+
portfolio_value=portfolio_value,
|
|
550
|
+
**vertical_kwargs,
|
|
551
|
+
)
|
|
552
|
+
if spread_decision is None:
|
|
553
|
+
return _zero_position_sizing("no_usable_vertical_spread_or_zero_signal"), {}
|
|
554
|
+
return (
|
|
555
|
+
_options_spread_decision_to_position_sizing(spread_decision, portfolio_value, max_vega_budget),
|
|
556
|
+
{"options_decision": spread_decision},
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
# single_leg (default): spread_strategy/short_leg_delta_offset are
|
|
560
|
+
# both stripped - build_options_position_sizing() accepts neither,
|
|
561
|
+
# and short_leg_delta_offset being present in config (e.g. for a
|
|
562
|
+
# future switch to "vertical") must never leak into this call and
|
|
563
|
+
# raise a TypeError.
|
|
564
|
+
single_leg_kwargs = {
|
|
565
|
+
k: v for k, v in (options_kwargs or {}).items() if k not in ("spread_strategy", "short_leg_delta_offset")
|
|
566
|
+
}
|
|
567
|
+
decision = build_options_position_sizing(
|
|
568
|
+
signal_direction=signal_name,
|
|
569
|
+
confidence=confidence,
|
|
570
|
+
available_chain=available_chain or [],
|
|
571
|
+
portfolio_value=portfolio_value,
|
|
572
|
+
**single_leg_kwargs,
|
|
573
|
+
)
|
|
574
|
+
if decision is None:
|
|
575
|
+
return _zero_position_sizing("no_usable_option_contract_or_zero_signal"), {}
|
|
576
|
+
return _options_decision_to_position_sizing(decision, portfolio_value, max_vega_budget), {"options_decision": decision}
|
|
577
|
+
|
|
578
|
+
# equity / crypto / bond / anything unrecognized: existing, unchanged path.
|
|
579
|
+
decision = build_dynamic_position_sizing(
|
|
580
|
+
base_target_weight=base_target_weight,
|
|
581
|
+
confidence=confidence,
|
|
582
|
+
**(equity_crypto_kwargs or {}),
|
|
583
|
+
)
|
|
584
|
+
return decision, {}
|