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
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
+ )