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
risk/forex_risk.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Forex position sizing - leverage-utilization-targeted, the FX sibling
|
|
2
|
+
of risk/futures_risk.py's margin-utilization-targeted model (not the
|
|
3
|
+
volatility-of-notional model risk/position_sizing.py uses for equity/
|
|
4
|
+
crypto/bond).
|
|
5
|
+
|
|
6
|
+
Forex trades in whole LOTS (this module follows futures' own "whole
|
|
7
|
+
units, never fractional weights" convention for consistency, rather than
|
|
8
|
+
supporting fractional/micro lots), and the real constraint is how much of
|
|
9
|
+
the account's margin a position consumes at the pair's max leverage - the
|
|
10
|
+
same "budget, not volatility" shape risk/futures_risk.py already
|
|
11
|
+
established for futures, adapted to lot_size/leverage_max/margin_pct
|
|
12
|
+
instead of a fixed per-contract multiplier/margin (forex margin scales
|
|
13
|
+
with the pair's CURRENT price, unlike futures' fixed initial_margin_usd
|
|
14
|
+
per contract - "one lot" is a fixed base-currency notional, not a fixed
|
|
15
|
+
dollar margin).
|
|
16
|
+
|
|
17
|
+
V4.6 (development/Problems.md, Roadmap "Assets") - code-complete,
|
|
18
|
+
IB/Lean-backtest-unverified, the exact same status futures/options
|
|
19
|
+
shipped with: main.py::_add_asset() wires self.add_forex(), but zero
|
|
20
|
+
forex tickers are configured in phase1.universe.assets and
|
|
21
|
+
phase_v2.forex_risk.enabled defaults False.
|
|
22
|
+
|
|
23
|
+
Confirmed via direct Lean source inspection that Forex is fully
|
|
24
|
+
first-class in this Lean version (SecurityType.Forex,
|
|
25
|
+
Common/Securities/Forex/Forex.cs, real pip-size/lot-size symbol
|
|
26
|
+
properties) - unlike individual bonds (see features/bond_features.py's
|
|
27
|
+
own module docstring), there is no native-support question here."""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
import logging
|
|
33
|
+
from dataclasses import asdict, dataclass
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
DEFAULT_PAIR_SPECS_PATH = Path(__file__).resolve().parents[1] / "data" / "reference" / "forex_pair_specs.json"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class ForexSizingDecision:
|
|
43
|
+
lot_count: int
|
|
44
|
+
notional_value: float
|
|
45
|
+
margin_required: float
|
|
46
|
+
margin_utilization: float
|
|
47
|
+
target_weight: float
|
|
48
|
+
base_target_weight: float
|
|
49
|
+
confidence_multiplier: float
|
|
50
|
+
max_leverage_utilization: float
|
|
51
|
+
sizing_reason: str
|
|
52
|
+
|
|
53
|
+
def to_dict(self) -> dict:
|
|
54
|
+
return asdict(self)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def load_forex_pair_specs(path: Path = DEFAULT_PAIR_SPECS_PATH) -> dict:
|
|
58
|
+
"""Defensive load - returns {} (not a raised exception) on a missing or
|
|
59
|
+
unparseable specs file, same convention as
|
|
60
|
+
risk/futures_risk.py::load_futures_contract_specs(). A missing spec
|
|
61
|
+
for a given pair means build_forex_position_sizing() can't size it -
|
|
62
|
+
callers must treat that as "no position", not a crash."""
|
|
63
|
+
try:
|
|
64
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
65
|
+
data = json.load(handle)
|
|
66
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
67
|
+
logger.warning("load_forex_pair_specs(%s): unreadable — %s", path, exc)
|
|
68
|
+
return {}
|
|
69
|
+
return {key: value for key, value in data.items() if not key.startswith("_")}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def build_forex_position_sizing(
|
|
73
|
+
base_target_weight: float,
|
|
74
|
+
confidence: float,
|
|
75
|
+
price: float,
|
|
76
|
+
pair_spec: dict | None,
|
|
77
|
+
portfolio_value: float,
|
|
78
|
+
target_leverage_utilization: float = 0.20,
|
|
79
|
+
max_leverage_utilization: float = 0.40,
|
|
80
|
+
) -> ForexSizingDecision:
|
|
81
|
+
"""Leverage-utilization-targeted sizing: computes the max lots
|
|
82
|
+
affordable at max_leverage_utilization (the hard ceiling), then scales
|
|
83
|
+
toward target_leverage_utilization by confidence using the same
|
|
84
|
+
"0.5 + 0.5*confidence" shape as
|
|
85
|
+
risk/futures_risk.py::build_futures_position_sizing()'s
|
|
86
|
+
confidence_multiplier, and floors to an integer lot_count.
|
|
87
|
+
|
|
88
|
+
Per-lot margin is `lot_size * price * margin_pct` (equivalently
|
|
89
|
+
`lot_size * price / leverage_max`, since margin_pct and leverage_max
|
|
90
|
+
are reciprocals by construction in forex_pair_specs.json).
|
|
91
|
+
|
|
92
|
+
target_weight is a DERIVED reconciliation value for downstream
|
|
93
|
+
weight-based consumers (exposure caps) - lot_count is the source of
|
|
94
|
+
truth and is what main.py::_apply_signal() actually executes via
|
|
95
|
+
MarketOrder(), never fed back into SetHoldings().
|
|
96
|
+
|
|
97
|
+
Returns an all-zero decision (lot_count=0) when pair_spec is
|
|
98
|
+
missing/empty, confidence is 0, base_target_weight is 0, or
|
|
99
|
+
portfolio_value/price/lot_size/margin_pct are non-positive - never
|
|
100
|
+
raises, matching risk/futures_risk.py's "no active signal -> zero
|
|
101
|
+
position" convention."""
|
|
102
|
+
confidence = max(0.0, min(float(confidence), 1.0))
|
|
103
|
+
base_target_weight = float(base_target_weight)
|
|
104
|
+
portfolio_value = max(float(portfolio_value), 0.0)
|
|
105
|
+
price = max(float(price), 0.0)
|
|
106
|
+
max_leverage_utilization = max(float(max_leverage_utilization), 0.0)
|
|
107
|
+
target_leverage_utilization = max(0.0, min(float(target_leverage_utilization), max_leverage_utilization))
|
|
108
|
+
|
|
109
|
+
pair_spec = pair_spec or {}
|
|
110
|
+
lot_size = float(pair_spec.get("lot_size", 0.0))
|
|
111
|
+
margin_pct = float(pair_spec.get("margin_pct", 0.0))
|
|
112
|
+
|
|
113
|
+
if (
|
|
114
|
+
base_target_weight == 0.0
|
|
115
|
+
or confidence == 0.0
|
|
116
|
+
or portfolio_value <= 0.0
|
|
117
|
+
or price <= 0.0
|
|
118
|
+
or lot_size <= 0.0
|
|
119
|
+
or margin_pct <= 0.0
|
|
120
|
+
):
|
|
121
|
+
return ForexSizingDecision(
|
|
122
|
+
lot_count=0,
|
|
123
|
+
notional_value=0.0,
|
|
124
|
+
margin_required=0.0,
|
|
125
|
+
margin_utilization=0.0,
|
|
126
|
+
target_weight=0.0,
|
|
127
|
+
base_target_weight=base_target_weight,
|
|
128
|
+
confidence_multiplier=0.0,
|
|
129
|
+
max_leverage_utilization=max_leverage_utilization,
|
|
130
|
+
sizing_reason="no_active_signal_or_missing_pair_spec",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
margin_per_lot = lot_size * price * margin_pct
|
|
134
|
+
confidence_multiplier = 0.5 + 0.5 * confidence
|
|
135
|
+
max_lots_affordable = int(portfolio_value * max_leverage_utilization // margin_per_lot)
|
|
136
|
+
lots_at_target = int(portfolio_value * target_leverage_utilization // margin_per_lot)
|
|
137
|
+
lot_count = min(int(lots_at_target * confidence_multiplier), max_lots_affordable)
|
|
138
|
+
lot_count = max(lot_count, 0)
|
|
139
|
+
|
|
140
|
+
direction = 1.0 if base_target_weight >= 0.0 else -1.0
|
|
141
|
+
notional_value = lot_count * lot_size * price
|
|
142
|
+
margin_required = lot_count * margin_per_lot
|
|
143
|
+
margin_utilization = margin_required / portfolio_value if portfolio_value > 0.0 else 0.0
|
|
144
|
+
target_weight = direction * (notional_value / portfolio_value) if portfolio_value > 0.0 else 0.0
|
|
145
|
+
|
|
146
|
+
sizing_reason = "no_active_signal_or_missing_pair_spec" if lot_count == 0 else "leverage_utilization_scaled_sizing"
|
|
147
|
+
|
|
148
|
+
return ForexSizingDecision(
|
|
149
|
+
lot_count=int(direction) * lot_count,
|
|
150
|
+
notional_value=notional_value,
|
|
151
|
+
margin_required=margin_required,
|
|
152
|
+
margin_utilization=margin_utilization,
|
|
153
|
+
target_weight=target_weight,
|
|
154
|
+
base_target_weight=base_target_weight,
|
|
155
|
+
confidence_multiplier=confidence_multiplier,
|
|
156
|
+
max_leverage_utilization=max_leverage_utilization,
|
|
157
|
+
sizing_reason=sizing_reason,
|
|
158
|
+
)
|
risk/futures_risk.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Futures position sizing - margin-utilization-targeted, not the
|
|
2
|
+
volatility-of-notional model risk/position_sizing.py uses for equity/crypto/
|
|
3
|
+
bond.
|
|
4
|
+
|
|
5
|
+
Margin, contract count, and mark-to-market are fundamentally different from
|
|
6
|
+
a portfolio-weight-scaled sizing model: Lean trades futures in whole
|
|
7
|
+
contracts (not fractional weights), and the real constraint is how much of
|
|
8
|
+
the account's margin budget a position consumes, not how much daily-return
|
|
9
|
+
volatility it contributes. This module is deliberately separate from
|
|
10
|
+
risk/position_sizing.py rather than a new branch inside it - see
|
|
11
|
+
risk/asset_class_router.py for how its FuturesSizingDecision output gets
|
|
12
|
+
adapted onto the shared PositionSizingDecision shape everything downstream
|
|
13
|
+
(portfolio/book_construction.py, liquidity, analyzer, _apply_signal())
|
|
14
|
+
consumes.
|
|
15
|
+
|
|
16
|
+
Continuous-futures rollover/mark-to-market itself is NOT reimplemented here
|
|
17
|
+
- that's entirely delegated to Lean's native add_future() + continuous-
|
|
18
|
+
contract SetFilter() mapping (main.py::_add_asset()). rollover_due() below
|
|
19
|
+
is a diagnostic/logging signal only, never a trade trigger - a second,
|
|
20
|
+
hand-rolled rollover state machine racing against Lean's own would be a bug
|
|
21
|
+
class, not a feature.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import logging
|
|
28
|
+
from dataclasses import asdict, dataclass
|
|
29
|
+
from datetime import date
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
DEFAULT_CONTRACT_SPECS_PATH = Path(__file__).resolve().parents[1] / "data" / "reference" / "futures_contract_specs.json"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class FuturesSizingDecision:
|
|
39
|
+
contract_count: int
|
|
40
|
+
notional_value: float
|
|
41
|
+
margin_required: float
|
|
42
|
+
margin_utilization: float
|
|
43
|
+
target_weight: float
|
|
44
|
+
base_target_weight: float
|
|
45
|
+
confidence_multiplier: float
|
|
46
|
+
max_margin_utilization: float
|
|
47
|
+
sizing_reason: str
|
|
48
|
+
|
|
49
|
+
def to_dict(self) -> dict:
|
|
50
|
+
return asdict(self)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def load_futures_contract_specs(path: Path = DEFAULT_CONTRACT_SPECS_PATH) -> dict:
|
|
54
|
+
"""Defensive load - returns {} (not a raised exception) on a missing or
|
|
55
|
+
unparseable specs file, same convention as train.py::load_sector_mapping().
|
|
56
|
+
A missing spec for a given ticker means build_futures_position_sizing()
|
|
57
|
+
can't size it - callers must treat that as "no position", not a crash."""
|
|
58
|
+
try:
|
|
59
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
60
|
+
data = json.load(handle)
|
|
61
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
62
|
+
logger.warning("load_futures_contract_specs(%s): unreadable — %s", path, exc)
|
|
63
|
+
return {}
|
|
64
|
+
return {key: value for key, value in data.items() if not key.startswith("_")}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def build_futures_position_sizing(
|
|
68
|
+
base_target_weight: float,
|
|
69
|
+
confidence: float,
|
|
70
|
+
price: float,
|
|
71
|
+
contract_spec: dict | None,
|
|
72
|
+
portfolio_value: float,
|
|
73
|
+
target_margin_utilization: float = 0.20,
|
|
74
|
+
max_margin_utilization: float = 0.40,
|
|
75
|
+
) -> FuturesSizingDecision:
|
|
76
|
+
"""Margin-utilization-targeted sizing: computes the max contracts
|
|
77
|
+
affordable at max_margin_utilization (the hard ceiling), then scales
|
|
78
|
+
toward target_margin_utilization by confidence using the same
|
|
79
|
+
"0.5 + 0.5*confidence" shape as
|
|
80
|
+
risk/position_sizing.py::build_dynamic_position_sizing()'s
|
|
81
|
+
confidence_multiplier, and floors to an integer contract_count (Lean
|
|
82
|
+
trades futures in whole contracts, never fractional weights).
|
|
83
|
+
|
|
84
|
+
target_weight is a DERIVED reconciliation value for downstream weight-
|
|
85
|
+
based consumers (exposure caps, book construction) - contract_count is
|
|
86
|
+
the source of truth and is what main.py::_apply_signal() actually
|
|
87
|
+
executes via MarketOrder(), never fed back into SetHoldings().
|
|
88
|
+
|
|
89
|
+
Returns an all-zero decision (contract_count=0) when contract_spec is
|
|
90
|
+
missing/empty, confidence is 0, base_target_weight is 0, or
|
|
91
|
+
portfolio_value/price/multiplier/margin are non-positive - never
|
|
92
|
+
raises, matching risk/position_sizing.py's "no active signal -> zero
|
|
93
|
+
weight" convention."""
|
|
94
|
+
confidence = max(0.0, min(float(confidence), 1.0))
|
|
95
|
+
base_target_weight = float(base_target_weight)
|
|
96
|
+
portfolio_value = max(float(portfolio_value), 0.0)
|
|
97
|
+
price = max(float(price), 0.0)
|
|
98
|
+
max_margin_utilization = max(float(max_margin_utilization), 0.0)
|
|
99
|
+
target_margin_utilization = max(0.0, min(float(target_margin_utilization), max_margin_utilization))
|
|
100
|
+
|
|
101
|
+
contract_spec = contract_spec or {}
|
|
102
|
+
multiplier = float(contract_spec.get("multiplier", 0.0))
|
|
103
|
+
margin_per_contract = float(contract_spec.get("initial_margin_usd", 0.0))
|
|
104
|
+
|
|
105
|
+
if (
|
|
106
|
+
base_target_weight == 0.0
|
|
107
|
+
or confidence == 0.0
|
|
108
|
+
or portfolio_value <= 0.0
|
|
109
|
+
or price <= 0.0
|
|
110
|
+
or multiplier <= 0.0
|
|
111
|
+
or margin_per_contract <= 0.0
|
|
112
|
+
):
|
|
113
|
+
return FuturesSizingDecision(
|
|
114
|
+
contract_count=0,
|
|
115
|
+
notional_value=0.0,
|
|
116
|
+
margin_required=0.0,
|
|
117
|
+
margin_utilization=0.0,
|
|
118
|
+
target_weight=0.0,
|
|
119
|
+
base_target_weight=base_target_weight,
|
|
120
|
+
confidence_multiplier=0.0,
|
|
121
|
+
max_margin_utilization=max_margin_utilization,
|
|
122
|
+
sizing_reason="no_active_signal_or_missing_contract_spec",
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
confidence_multiplier = 0.5 + 0.5 * confidence
|
|
126
|
+
max_contracts_affordable = int(portfolio_value * max_margin_utilization // margin_per_contract)
|
|
127
|
+
contracts_at_target = int(portfolio_value * target_margin_utilization // margin_per_contract)
|
|
128
|
+
contract_count = min(int(contracts_at_target * confidence_multiplier), max_contracts_affordable)
|
|
129
|
+
contract_count = max(contract_count, 0)
|
|
130
|
+
|
|
131
|
+
direction = 1.0 if base_target_weight >= 0.0 else -1.0
|
|
132
|
+
notional_value = contract_count * multiplier * price
|
|
133
|
+
margin_required = contract_count * margin_per_contract
|
|
134
|
+
margin_utilization = margin_required / portfolio_value if portfolio_value > 0.0 else 0.0
|
|
135
|
+
target_weight = direction * (notional_value / portfolio_value) if portfolio_value > 0.0 else 0.0
|
|
136
|
+
|
|
137
|
+
sizing_reason = "no_active_signal_or_missing_contract_spec" if contract_count == 0 else "margin_utilization_scaled_sizing"
|
|
138
|
+
|
|
139
|
+
return FuturesSizingDecision(
|
|
140
|
+
contract_count=int(direction) * contract_count,
|
|
141
|
+
notional_value=notional_value,
|
|
142
|
+
margin_required=margin_required,
|
|
143
|
+
margin_utilization=margin_utilization,
|
|
144
|
+
target_weight=target_weight,
|
|
145
|
+
base_target_weight=base_target_weight,
|
|
146
|
+
confidence_multiplier=confidence_multiplier,
|
|
147
|
+
max_margin_utilization=max_margin_utilization,
|
|
148
|
+
sizing_reason=sizing_reason,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def resolve_futures_margin_source(raw_value: str) -> str:
|
|
153
|
+
"""Validates phase_v2.futures_risk.margin_source - "static" (default,
|
|
154
|
+
the offline data/reference/futures_contract_specs.json file) or
|
|
155
|
+
"live" (Lean's own IB-calibrated BuyingPowerModel, resolved once per
|
|
156
|
+
security at add-time and queried per sizing call - see main.py::
|
|
157
|
+
_resolve_futures_contract_spec()). "live" here means Lean's own
|
|
158
|
+
LOCAL margin-model calculation (InteractiveBrokersBrokerageModel's
|
|
159
|
+
bundled margin rules), never a live network round-trip to IB's
|
|
160
|
+
servers - that would be far too slow inside on_data() and is not
|
|
161
|
+
what this option does. Any unrecognized value (a config typo) falls
|
|
162
|
+
back to "static" rather than raising - never crashes the algorithm
|
|
163
|
+
over a bad string."""
|
|
164
|
+
normalized = str(raw_value).strip().lower()
|
|
165
|
+
return normalized if normalized in ("static", "live") else "static"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def build_live_contract_spec(
|
|
169
|
+
multiplier: float,
|
|
170
|
+
initial_margin_usd: float,
|
|
171
|
+
description: str = "",
|
|
172
|
+
exchange: str = "",
|
|
173
|
+
) -> dict:
|
|
174
|
+
"""Constructs a contract_spec dict in the exact shape
|
|
175
|
+
build_futures_position_sizing() already consumes (same keys
|
|
176
|
+
load_futures_contract_specs() produces from the static JSON file),
|
|
177
|
+
from live values main.py resolves via Lean's own
|
|
178
|
+
Security.SymbolProperties/BuyingPowerModel. Pure - no Lean coupling
|
|
179
|
+
itself; only main.py's caller touches self.Securities. description/
|
|
180
|
+
exchange default to "" (informational only, never read by
|
|
181
|
+
build_futures_position_sizing()'s own sizing math) since Lean's
|
|
182
|
+
BuyingPowerModel query has no equivalent of either - callers
|
|
183
|
+
typically pass the static spec's own values through for display
|
|
184
|
+
continuity when a live spec replaces it."""
|
|
185
|
+
return {
|
|
186
|
+
"description": description,
|
|
187
|
+
"multiplier": float(multiplier),
|
|
188
|
+
"initial_margin_usd": float(initial_margin_usd),
|
|
189
|
+
"exchange": exchange,
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def rollover_due(current_date: date, contract_expiry_date: date | None, rollover_days_before_expiry: int = 5) -> bool:
|
|
194
|
+
"""Pure date comparison - a diagnostic/logging signal only. Actual
|
|
195
|
+
contract rollover is entirely delegated to Lean's native add_future() +
|
|
196
|
+
continuous-contract SetFilter() mapping (main.py::_add_asset()); this
|
|
197
|
+
function must never itself trigger a trade. Returns False when
|
|
198
|
+
contract_expiry_date is unknown (e.g. Lean's continuous-contract
|
|
199
|
+
subscription hasn't surfaced the mapped contract's expiry yet)."""
|
|
200
|
+
if contract_expiry_date is None:
|
|
201
|
+
return False
|
|
202
|
+
return (contract_expiry_date - current_date).days <= rollover_days_before_expiry
|
risk/kill_switch.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""V5.1 Phase 6 (production safety): the automatic, per-bar circuit
|
|
2
|
+
breaker. Deliberately trips the EXISTING trade-lock machinery (main.py's
|
|
3
|
+
self.trade_lock_active/self.trade_lock_reason, already consumed by
|
|
4
|
+
execution/order_gate.py::resolve_order_permission()'s risk_locks_healthy
|
|
5
|
+
input, analyzer/market_analyzer.py's Priority 1 reduce_risk check, state.json,
|
|
6
|
+
and the audit log) rather than adding a second, parallel lock - a second
|
|
7
|
+
lock would create two sources of truth that can disagree, and `aq
|
|
8
|
+
trade-lock --off`/`--auto` would no longer be a complete story.
|
|
9
|
+
|
|
10
|
+
Pure, deterministic, cheap: evaluate_kill_switch() does only dict/threshold
|
|
11
|
+
comparisons over ALREADY-COMPUTED inputs (a rolling per-bar return history,
|
|
12
|
+
a drawdown-velocity figure, etc. - all assembled by main.py once per bar
|
|
13
|
+
from state it already tracks) - no I/O, no per-bar work heavier than a
|
|
14
|
+
handful of float comparisons, respecting main.py's per-bar latency budget
|
|
15
|
+
the same way every other pure risk/analyzer module in this codebase does.
|
|
16
|
+
|
|
17
|
+
GUARDRAIL - fail-open at two levels, matching execution/cost_model.py::
|
|
18
|
+
build_net_edge_decision()'s own contract:
|
|
19
|
+
1. config["enabled"] is False (the function-level default) -> never trips,
|
|
20
|
+
full no-op, regardless of any other input.
|
|
21
|
+
2. Even when enabled, each INDIVIDUAL condition's own threshold defaults
|
|
22
|
+
to a value that condition can never cross - so enabling the kill
|
|
23
|
+
switch with only some thresholds configured only activates those
|
|
24
|
+
specific checks, never accidentally activates an unconfigured one.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import math
|
|
30
|
+
from dataclasses import asdict, dataclass, field
|
|
31
|
+
|
|
32
|
+
# Deliberately extreme "this condition can never fire" sentinels - not 0.0
|
|
33
|
+
# or None, which would either behave surprisingly (0.0 is a real threshold
|
|
34
|
+
# some configs might genuinely want) or require every comparison below to
|
|
35
|
+
# be null-guarded twice. A value this far outside any plausible input
|
|
36
|
+
# reliably degrades a single unconfigured condition to a no-op without
|
|
37
|
+
# touching the others.
|
|
38
|
+
_NEVER_BELOW = -1e12
|
|
39
|
+
_NEVER_ABOVE = 1e12
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class KillSwitchDecision:
|
|
44
|
+
tripped: bool
|
|
45
|
+
severity: str
|
|
46
|
+
triggers: list[str] = field(default_factory=list)
|
|
47
|
+
reason: str = ""
|
|
48
|
+
recommended_action: str = "none"
|
|
49
|
+
observed: dict = field(default_factory=dict)
|
|
50
|
+
thresholds: dict = field(default_factory=dict)
|
|
51
|
+
|
|
52
|
+
def to_dict(self) -> dict:
|
|
53
|
+
return asdict(self)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _finite_or_none(value) -> float | None:
|
|
57
|
+
"""V5.4.5 (development/Problems.md #117): normalizes a runtime metric to
|
|
58
|
+
a finite float or None. The individual trigger guards below only check
|
|
59
|
+
`is not None` before a one-sided comparison - and every one of those
|
|
60
|
+
comparisons is False for NaN (`nan < floor` / `nan > cap`), so a NaN
|
|
61
|
+
metric silently skipped its own condition while the switch appeared
|
|
62
|
+
armed. NaN now degrades exactly like the documented None path."""
|
|
63
|
+
if value is None:
|
|
64
|
+
return None
|
|
65
|
+
try:
|
|
66
|
+
as_float = float(value)
|
|
67
|
+
except (TypeError, ValueError):
|
|
68
|
+
return None
|
|
69
|
+
return as_float if math.isfinite(as_float) else None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _rolling_sharpe(
|
|
73
|
+
returns: list[float], trading_days_per_year: int = 252, min_return_std_floor: float = 0.0
|
|
74
|
+
) -> float | None:
|
|
75
|
+
"""None (never 0.0) when there are fewer than 2 observations - the
|
|
76
|
+
caller must be able to tell "not enough data yet" apart from
|
|
77
|
+
"genuinely zero Sharpe" (a std of exactly 0.0 with >=2 observations IS
|
|
78
|
+
reported as 0.0, same convention evaluation/rank_book_simulator.py::
|
|
79
|
+
_annualized_sharpe() already uses).
|
|
80
|
+
|
|
81
|
+
GUARDRAIL (Problems.md - a sticky kill-switch trip from a numerically
|
|
82
|
+
meaningless reading): std <= max(0.0, min_return_std_floor) also
|
|
83
|
+
returns a clean 0.0 - widening the exact-zero guard above into a
|
|
84
|
+
near-zero guard. mean/std*sqrt(trading_days_per_year) is numerically
|
|
85
|
+
explosive whenever std is small but nonzero: a return window that's
|
|
86
|
+
mostly flat/cash (e.g. a mostly-uninvested portfolio) with just a
|
|
87
|
+
couple of small real values mixed in can swing to wildly extreme
|
|
88
|
+
Sharpe readings - both directions - purely from which few nonzero
|
|
89
|
+
values happen to still be inside the window, not from genuine risk-
|
|
90
|
+
adjusted performance. Reproduced directly against a real backtest's
|
|
91
|
+
equity curve: a near-flat window produced Sharpe swings of +2.25 and
|
|
92
|
+
-2.28 bar-to-bar from noise alone. min_return_std_floor=0.0 (default)
|
|
93
|
+
reproduces today's exact behavior for any caller that doesn't pass it."""
|
|
94
|
+
if len(returns) < 2:
|
|
95
|
+
return None
|
|
96
|
+
# V5.4.5 (Problems.md #117): one non-finite return poisons mean/std into
|
|
97
|
+
# NaN, which then silently skipped the Sharpe comparison downstream.
|
|
98
|
+
# A window containing any non-finite value is not a readable window -
|
|
99
|
+
# degrade to None (the documented "insufficient data" path).
|
|
100
|
+
if any(not math.isfinite(float(value)) for value in returns):
|
|
101
|
+
return None
|
|
102
|
+
mean = sum(returns) / len(returns)
|
|
103
|
+
variance = sum((value - mean) ** 2 for value in returns) / (len(returns) - 1)
|
|
104
|
+
std = math.sqrt(variance)
|
|
105
|
+
if std <= max(0.0, min_return_std_floor):
|
|
106
|
+
return 0.0
|
|
107
|
+
sharpe = (mean / std) * math.sqrt(trading_days_per_year)
|
|
108
|
+
return sharpe if math.isfinite(sharpe) else None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def evaluate_kill_switch(runtime_metrics: dict, config: dict) -> KillSwitchDecision:
|
|
112
|
+
"""Evaluated once per bar by main.py, fed a `runtime_metrics` dict it
|
|
113
|
+
assembles from state it already tracks:
|
|
114
|
+
- "recent_bar_returns": list[float] - a rolling per-bar portfolio
|
|
115
|
+
return history (main.py's own bounded deque), used to compute a
|
|
116
|
+
rolling Sharpe over the trailing `evaluation_bars` window.
|
|
117
|
+
- "drawdown_velocity_pct_per_bar": float | None - this bar's total
|
|
118
|
+
drawdown minus the previous bar's (a one-bar rate of change, not
|
|
119
|
+
an average - a single sharp bar is exactly what this exists to
|
|
120
|
+
catch quickly).
|
|
121
|
+
- "live_rank_ic": float | None - the most recent rank-IC-decay
|
|
122
|
+
trigger's metric_value, if the LATEST entry in
|
|
123
|
+
visualization/grafana/retraining_status.json's last_trigger
|
|
124
|
+
happens to be a rank_ic_decay_trigger (main.py never opens its own
|
|
125
|
+
Postgres connection - this is the same "read a locally-cached file
|
|
126
|
+
a separate Postgres-connected worker refreshes" pattern
|
|
127
|
+
read_manual_trade_lock_override()/load_cached_fred_series() already
|
|
128
|
+
use). None whenever the latest trigger isn't rank-IC-shaped, not
|
|
129
|
+
just when the file is missing - a stale/wrong-shaped signal must
|
|
130
|
+
never be treated as a real reading.
|
|
131
|
+
- "consecutive_losses": int | None - main.py's own realized-trade
|
|
132
|
+
streak counter.
|
|
133
|
+
- "slippage_divergence_bps": float | None - realized-vs-expected
|
|
134
|
+
round-trip cost divergence from the most recent fill(s)
|
|
135
|
+
(execution/cost_model.py's expected estimate vs. the actual fill
|
|
136
|
+
price main.py's OnOrderEvent observes). None until at least one
|
|
137
|
+
fill has been observed this session.
|
|
138
|
+
- "model_age_days": float | None - age of the active model artifact
|
|
139
|
+
on disk.
|
|
140
|
+
- "reconciliation_breach": bool - True when execution/reconciliation.py::
|
|
141
|
+
reconcile_positions()'s max_abs_weight_drift exceeded
|
|
142
|
+
phase_v2.reconciliation.max_tolerated_drift this bar (Step 6.2)
|
|
143
|
+
and phase_v2.reconciliation.trips_kill_switch is true - reconciliation
|
|
144
|
+
failures trip the switch rather than being logged and ignored.
|
|
145
|
+
|
|
146
|
+
Any key's value being None simply skips that one condition (never
|
|
147
|
+
raises, never counts as a trip) - a bar where a given signal isn't
|
|
148
|
+
available yet degrades that ONE condition, not the whole switch."""
|
|
149
|
+
if not bool(config.get("enabled", False)):
|
|
150
|
+
return KillSwitchDecision(tripped=False, severity="none", reason="kill_switch_disabled")
|
|
151
|
+
|
|
152
|
+
evaluation_bars = int(config.get("evaluation_bars", 60))
|
|
153
|
+
# GUARDRAIL (Problems.md - kill switch tripped on bar 2-3 of every
|
|
154
|
+
# run): evaluation_bars was previously used ONLY as a slice bound
|
|
155
|
+
# ([-evaluation_bars:]), never as a minimum-sample requirement, so
|
|
156
|
+
# _rolling_sharpe() happily annualized a 2-observation mean/std by
|
|
157
|
+
# sqrt(252) into an absurd magnitude and tripped the switch almost
|
|
158
|
+
# immediately. min_bars_for_sharpe defaults to evaluation_bars itself
|
|
159
|
+
# (the value's own documented intent - "the trailing window size") and
|
|
160
|
+
# gates whether the rolling-Sharpe trigger is evaluated AT ALL this
|
|
161
|
+
# bar, not just how much history it's computed over.
|
|
162
|
+
min_bars_for_sharpe = int(config.get("min_bars_for_sharpe", evaluation_bars))
|
|
163
|
+
# GUARDRAIL (Problems.md - a real backtest's kill switch stuck sticky
|
|
164
|
+
# on a spurious trip) - independent of min_bars_for_sharpe above:
|
|
165
|
+
# count and variance are orthogonal failure modes. A window with
|
|
166
|
+
# plenty of samples that are almost all exactly zero (e.g. a mostly-
|
|
167
|
+
# cash portfolio) clears the count floor fine but is still numerically
|
|
168
|
+
# degenerate for mean/std - see _rolling_sharpe()'s own docstring.
|
|
169
|
+
# Default 0.0005 (std*sqrt(252) ~= 1% annualized) - below this, daily
|
|
170
|
+
# returns are behaviorally indistinguishable from sitting in cash, far
|
|
171
|
+
# more likely a near-zero-return artifact than a real low-vol edge.
|
|
172
|
+
min_return_std_for_sharpe = float(config.get("min_return_std_for_sharpe", 0.0005))
|
|
173
|
+
min_rolling_sharpe = float(config.get("min_rolling_sharpe", _NEVER_BELOW))
|
|
174
|
+
max_drawdown_velocity_pct_per_bar = float(config.get("max_drawdown_velocity_pct_per_bar", _NEVER_ABOVE))
|
|
175
|
+
min_live_rank_ic = float(config.get("min_live_rank_ic", _NEVER_BELOW))
|
|
176
|
+
max_consecutive_losses = float(config.get("max_consecutive_losses", _NEVER_ABOVE))
|
|
177
|
+
max_slippage_divergence_bps = float(config.get("max_slippage_divergence_bps", _NEVER_ABOVE))
|
|
178
|
+
max_model_age_days = float(config.get("max_model_age_days", _NEVER_ABOVE))
|
|
179
|
+
|
|
180
|
+
triggers: list[str] = []
|
|
181
|
+
observed: dict = {}
|
|
182
|
+
|
|
183
|
+
# evaluation_bars <= 0 previously meant "use the entire history" via
|
|
184
|
+
# Python's own `[-0:]` == `[0:]` slice semantics - not a real window,
|
|
185
|
+
# and not what a misconfigured 0/negative value should do. Treat it as
|
|
186
|
+
# no window at all (insufficient data, trigger never evaluated) rather
|
|
187
|
+
# than silently falling back to unbounded history.
|
|
188
|
+
all_returns = list(runtime_metrics.get("recent_bar_returns") or [])
|
|
189
|
+
recent_returns = all_returns[-evaluation_bars:] if evaluation_bars > 0 else []
|
|
190
|
+
observed["rolling_sharpe_num_bars"] = len(recent_returns)
|
|
191
|
+
rolling_sharpe = (
|
|
192
|
+
_rolling_sharpe(recent_returns, min_return_std_floor=min_return_std_for_sharpe)
|
|
193
|
+
if len(recent_returns) >= min_bars_for_sharpe
|
|
194
|
+
else None
|
|
195
|
+
)
|
|
196
|
+
observed["rolling_sharpe"] = rolling_sharpe
|
|
197
|
+
if rolling_sharpe is not None and rolling_sharpe < min_rolling_sharpe:
|
|
198
|
+
triggers.append("rolling_sharpe_below_floor")
|
|
199
|
+
|
|
200
|
+
drawdown_velocity = _finite_or_none(runtime_metrics.get("drawdown_velocity_pct_per_bar"))
|
|
201
|
+
observed["drawdown_velocity_pct_per_bar"] = drawdown_velocity
|
|
202
|
+
if drawdown_velocity is not None and drawdown_velocity > max_drawdown_velocity_pct_per_bar:
|
|
203
|
+
triggers.append("drawdown_velocity_above_cap")
|
|
204
|
+
|
|
205
|
+
live_rank_ic = _finite_or_none(runtime_metrics.get("live_rank_ic"))
|
|
206
|
+
observed["live_rank_ic"] = live_rank_ic
|
|
207
|
+
if live_rank_ic is not None and live_rank_ic < min_live_rank_ic:
|
|
208
|
+
triggers.append("live_rank_ic_below_floor")
|
|
209
|
+
|
|
210
|
+
consecutive_losses = _finite_or_none(runtime_metrics.get("consecutive_losses"))
|
|
211
|
+
observed["consecutive_losses"] = consecutive_losses
|
|
212
|
+
if consecutive_losses is not None and consecutive_losses > max_consecutive_losses:
|
|
213
|
+
triggers.append("consecutive_losses_above_cap")
|
|
214
|
+
|
|
215
|
+
slippage_divergence_bps = _finite_or_none(runtime_metrics.get("slippage_divergence_bps"))
|
|
216
|
+
observed["slippage_divergence_bps"] = slippage_divergence_bps
|
|
217
|
+
if slippage_divergence_bps is not None and slippage_divergence_bps > max_slippage_divergence_bps:
|
|
218
|
+
triggers.append("slippage_divergence_above_cap")
|
|
219
|
+
|
|
220
|
+
model_age_days = _finite_or_none(runtime_metrics.get("model_age_days"))
|
|
221
|
+
observed["model_age_days"] = model_age_days
|
|
222
|
+
if model_age_days is not None and model_age_days > max_model_age_days:
|
|
223
|
+
triggers.append("model_age_above_cap")
|
|
224
|
+
|
|
225
|
+
if bool(runtime_metrics.get("reconciliation_breach", False)):
|
|
226
|
+
triggers.append("reconciliation_breach")
|
|
227
|
+
observed["reconciliation_breach"] = True
|
|
228
|
+
|
|
229
|
+
thresholds = {
|
|
230
|
+
"evaluation_bars": evaluation_bars,
|
|
231
|
+
"min_bars_for_sharpe": min_bars_for_sharpe,
|
|
232
|
+
"min_return_std_for_sharpe": min_return_std_for_sharpe,
|
|
233
|
+
"min_rolling_sharpe": min_rolling_sharpe,
|
|
234
|
+
"max_drawdown_velocity_pct_per_bar": max_drawdown_velocity_pct_per_bar,
|
|
235
|
+
"min_live_rank_ic": min_live_rank_ic,
|
|
236
|
+
"max_consecutive_losses": max_consecutive_losses,
|
|
237
|
+
"max_slippage_divergence_bps": max_slippage_divergence_bps,
|
|
238
|
+
"max_model_age_days": max_model_age_days,
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if not triggers:
|
|
242
|
+
return KillSwitchDecision(
|
|
243
|
+
tripped=False,
|
|
244
|
+
severity="none",
|
|
245
|
+
triggers=[],
|
|
246
|
+
reason="all_checks_within_thresholds",
|
|
247
|
+
recommended_action="none",
|
|
248
|
+
observed=observed,
|
|
249
|
+
thresholds=thresholds,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
severity = "critical" if len(triggers) > 1 else "warning"
|
|
253
|
+
return KillSwitchDecision(
|
|
254
|
+
tripped=True,
|
|
255
|
+
severity=severity,
|
|
256
|
+
triggers=triggers,
|
|
257
|
+
reason=f"kill_switch_{'_and_'.join(triggers)}",
|
|
258
|
+
recommended_action=str(config.get("action", "trade_lock")),
|
|
259
|
+
observed=observed,
|
|
260
|
+
thresholds=thresholds,
|
|
261
|
+
)
|