aether-quant 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. aether_quant-0.0.1.dist-info/METADATA +7 -0
  2. aether_quant-0.0.1.dist-info/RECORD +99 -0
  3. aether_quant-0.0.1.dist-info/WHEEL +5 -0
  4. aether_quant-0.0.1.dist-info/entry_points.txt +2 -0
  5. aether_quant-0.0.1.dist-info/licenses/LICENSE +133 -0
  6. aether_quant-0.0.1.dist-info/top_level.txt +13 -0
  7. analyzer/README.md +69 -0
  8. analyzer/__init__.py +17 -0
  9. analyzer/market_analyzer.py +521 -0
  10. aq_cli.py +3633 -0
  11. audit/README.md +47 -0
  12. audit/__init__.py +38 -0
  13. audit/hash_chain.py +52 -0
  14. audit/postgres_audit.py +61 -0
  15. audit/postgres_worker.py +402 -0
  16. audit/redis_queue.py +124 -0
  17. audit/status_export.py +62 -0
  18. data_pipeline/README.md +257 -0
  19. data_pipeline/__init__.py +6 -0
  20. data_pipeline/bar_synthesis.py +97 -0
  21. data_pipeline/dividend_backfill.py +243 -0
  22. data_pipeline/factor_file_backfill.py +276 -0
  23. data_pipeline/fetch.py +206 -0
  24. data_pipeline/fred_backfill.py +435 -0
  25. data_pipeline/ib_backfill.py +287 -0
  26. data_pipeline/v2_manifest.py +75 -0
  27. data_pipeline/yfinance_backfill.py +501 -0
  28. evaluation/README.md +152 -0
  29. evaluation/__init__.py +73 -0
  30. evaluation/ablation.py +310 -0
  31. evaluation/benchmark_comparison.py +195 -0
  32. evaluation/confidence_threshold_calibration.py +157 -0
  33. evaluation/factor_neutralization_check.py +94 -0
  34. evaluation/feature_parity.py +321 -0
  35. evaluation/feature_reconciliation.py +286 -0
  36. evaluation/impact_model.py +77 -0
  37. evaluation/kill_switch_replay.py +186 -0
  38. evaluation/limit_fill_simulator.py +152 -0
  39. evaluation/model_predictions.py +192 -0
  40. evaluation/monte_carlo.py +161 -0
  41. evaluation/public_benchmarks.py +117 -0
  42. evaluation/rank_book_simulator.py +494 -0
  43. evaluation/rank_ic_core.py +204 -0
  44. evaluation/rank_signal_calibration.py +690 -0
  45. evaluation/rolling_ic_gate_calibration.py +190 -0
  46. evaluation/rolling_ic_gate_replay.py +138 -0
  47. execution/README.md +281 -0
  48. execution/__init__.py +80 -0
  49. execution/config_cache.py +48 -0
  50. execution/cost_model.py +213 -0
  51. execution/lean_config_render.py +131 -0
  52. execution/live_credentials.py +38 -0
  53. execution/live_credentials_io.py +52 -0
  54. execution/order_gate.py +388 -0
  55. execution/paper_readiness.py +181 -0
  56. execution/paper_readiness_io.py +54 -0
  57. execution/paper_readiness_report.py +103 -0
  58. execution/paper_readiness_scheduler.py +137 -0
  59. execution/reconciliation.py +160 -0
  60. execution/runtime_config_io.py +38 -0
  61. execution/secret_scan.py +73 -0
  62. features/README.md +77 -0
  63. features/__init__.py +137 -0
  64. features/alt_data_features.py +154 -0
  65. features/bond_features.py +275 -0
  66. features/cross_asset_sensitivity.py +79 -0
  67. features/derivatives_macro_features.py +72 -0
  68. features/macro_features.py +79 -0
  69. features/options_greeks.py +329 -0
  70. features/sector_map.py +53 -0
  71. features/technical_indicators.py +185 -0
  72. generate_backtest_report.py +311 -0
  73. generate_evaluation_report.py +712 -0
  74. inference/README.md +105 -0
  75. inference/__init__.py +36 -0
  76. inference/exported_model.py +929 -0
  77. inference/parallel_inference.py +160 -0
  78. inference/strategy_selector_inference.py +75 -0
  79. json_safety.py +130 -0
  80. portfolio/README.md +447 -0
  81. portfolio/__init__.py +41 -0
  82. portfolio/book_construction.py +786 -0
  83. portfolio/book_neutrality.py +178 -0
  84. portfolio/hrp_allocation.py +159 -0
  85. portfolio/options_arbitrage_detector.py +232 -0
  86. portfolio/options_assignment_risk.py +119 -0
  87. portfolio/options_margin_sizing.py +333 -0
  88. portfolio/options_strategy.py +1999 -0
  89. portfolio/rank_signal.py +219 -0
  90. portfolio/rolling_ic_gate.py +183 -0
  91. risk/README.md +275 -0
  92. risk/__init__.py +38 -0
  93. risk/asset_class_router.py +584 -0
  94. risk/forex_risk.py +158 -0
  95. risk/futures_risk.py +202 -0
  96. risk/kill_switch.py +261 -0
  97. risk/manual_override.py +118 -0
  98. risk/position_sizing.py +303 -0
  99. risk/rl_sizing.py +189 -0
@@ -0,0 +1,118 @@
1
+ """Manual trade-lock override for Aether Quant V2.
2
+
3
+ `main.py`'s sticky total-drawdown lock (see `_refresh_risk_state()`) never
4
+ auto-clears on its own once tripped - by design, a capital-preservation
5
+ circuit breaker requiring a deliberate decision to resume trading. This
6
+ module is that deliberate decision, expressed as a single config value:
7
+ `phase_v2.risk.manual_trade_lock_override` (`true` = force-lock, `false` =
8
+ force-clear, absent/`null` = leave today's automatic behavior untouched).
9
+
10
+ Two callers, both read-modify-write the same key:
11
+ - `aq_cli.py`'s `trade-lock` command - a human flips the switch directly.
12
+ - `retraining/orchestrator.py::promote()` - a successful promotion clears it
13
+ automatically, tying "trading resumes" to "a genuinely new model shipped".
14
+
15
+ This is the first place in the codebase where a Python process writes to
16
+ `config.json` (every other reader treats it as human-edited, read-only
17
+ input) - a deliberate, narrow exception, not a new general pattern. It is a
18
+ *standing* switch, not one-shot: it stays in effect until explicitly changed
19
+ again, so `main.py` itself never writes back to `config.json` - it only
20
+ reads this one key once per session rollover (see `_refresh_risk_state()`).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ from pathlib import Path
27
+
28
+ from execution.config_cache import read_cached
29
+
30
+ _PHASE_V2_KEY = "phase_v2"
31
+ _RISK_KEY = "risk"
32
+ _OVERRIDE_KEY = "manual_trade_lock_override"
33
+
34
+
35
+ def read_manual_trade_lock_override(config_path: Path) -> bool | None:
36
+ """Returns True/False if a manual override is set, None if absent/unset
37
+ or the config file doesn't exist yet - never raises. Mtime-gated cache
38
+ (see execution/config_cache.py) - picks up an edit as soon as the file's
39
+ mtime changes, not on a fixed schedule."""
40
+ return read_cached(config_path, _read_manual_trade_lock_override_uncached)
41
+
42
+
43
+ def _read_manual_trade_lock_override_uncached(config_path: Path) -> bool | None:
44
+ if not config_path.exists():
45
+ return None
46
+ with config_path.open("r", encoding="utf-8") as f:
47
+ config = json.load(f)
48
+ value = config.get(_PHASE_V2_KEY, {}).get(_RISK_KEY, {}).get(_OVERRIDE_KEY)
49
+ return value if isinstance(value, bool) else None
50
+
51
+
52
+ def write_manual_trade_lock_override(value: bool | None, config_path: Path) -> None:
53
+ """Read-modify-write config.json, setting only
54
+ phase_v2.risk.manual_trade_lock_override - every other key is preserved
55
+ untouched. value=None removes the override key entirely (returns to
56
+ fully automatic behavior)."""
57
+ with config_path.open("r", encoding="utf-8") as f:
58
+ config = json.load(f)
59
+
60
+ phase_v2 = config.setdefault(_PHASE_V2_KEY, {})
61
+ risk = phase_v2.setdefault(_RISK_KEY, {})
62
+ if value is None:
63
+ risk.pop(_OVERRIDE_KEY, None)
64
+ else:
65
+ risk[_OVERRIDE_KEY] = value
66
+
67
+ with config_path.open("w", encoding="utf-8") as f:
68
+ json.dump(config, f, indent=4)
69
+ f.write("\n")
70
+
71
+
72
+ # V5.1 Phase 6 (production safety) - the kill switch's own arm/disarm
73
+ # override, deliberately the EXACT same read/write/cache shape as the
74
+ # manual trade-lock override above (same GUARDRAIL the plan states for
75
+ # `aq kill-switch`: reuse this convention so the two can never disagree -
76
+ # an operator can force-disarm the kill switch's own evaluation without
77
+ # touching phase_v2.risk.kill_switch.enabled in config.json at all, the
78
+ # same way manual_trade_lock_override sits ON TOP of the automatic
79
+ # drawdown-lock logic rather than replacing it).
80
+ _KILL_SWITCH_OVERRIDE_KEY = "kill_switch_manual_override"
81
+
82
+
83
+ def read_kill_switch_manual_override(config_path: Path) -> bool | None:
84
+ """True = force-armed, False = force-disarmed, None = defer entirely to
85
+ config.json's own phase_v2.risk.kill_switch.enabled. Never raises -
86
+ same missing-file/missing-key degrade-to-None contract as
87
+ read_manual_trade_lock_override()."""
88
+ return read_cached(config_path, _read_kill_switch_manual_override_uncached)
89
+
90
+
91
+ def _read_kill_switch_manual_override_uncached(config_path: Path) -> bool | None:
92
+ if not config_path.exists():
93
+ return None
94
+ with config_path.open("r", encoding="utf-8") as f:
95
+ config = json.load(f)
96
+ value = config.get(_PHASE_V2_KEY, {}).get(_RISK_KEY, {}).get(_KILL_SWITCH_OVERRIDE_KEY)
97
+ return value if isinstance(value, bool) else None
98
+
99
+
100
+ def write_kill_switch_manual_override(value: bool | None, config_path: Path) -> None:
101
+ """Read-modify-write config.json, setting only
102
+ phase_v2.risk.kill_switch_manual_override - every other key (including
103
+ phase_v2.risk.manual_trade_lock_override) is preserved untouched.
104
+ value=None removes the override key entirely (returns to fully
105
+ config-driven kill_switch.enabled behavior)."""
106
+ with config_path.open("r", encoding="utf-8") as f:
107
+ config = json.load(f)
108
+
109
+ phase_v2 = config.setdefault(_PHASE_V2_KEY, {})
110
+ risk = phase_v2.setdefault(_RISK_KEY, {})
111
+ if value is None:
112
+ risk.pop(_KILL_SWITCH_OVERRIDE_KEY, None)
113
+ else:
114
+ risk[_KILL_SWITCH_OVERRIDE_KEY] = value
115
+
116
+ with config_path.open("w", encoding="utf-8") as f:
117
+ json.dump(config, f, indent=4)
118
+ f.write("\n")
@@ -0,0 +1,303 @@
1
+ """Dynamic position sizing for Aether Quant V2."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from dataclasses import asdict, dataclass
7
+
8
+ from .rl_sizing import rl_sizing_multiplier as _rl_sizing_multiplier
9
+
10
+ TRADING_DAYS_PER_YEAR = 252
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class PositionSizingDecision:
15
+ base_target_weight: float
16
+ target_weight: float
17
+ rolling_volatility: float
18
+ annualized_volatility: float
19
+ volatility_regime: str
20
+ volatility_multiplier: float
21
+ confidence_multiplier: float
22
+ topology_multiplier: float
23
+ leverage_factor: float
24
+ max_leverage: float
25
+ sizing_reason: str
26
+ topology_sizing_reason: str
27
+ volatility_source: str = "rolling"
28
+ rank_multiplier: float = 1.0
29
+ rank_sizing_reason: str = "rank_sizing_disabled_or_absent"
30
+ rl_multiplier: float = 1.0
31
+ rl_sizing_reason: str = "rl_sizing_disabled_or_absent"
32
+ # V5.1 Phase 1 (development/Problems.md, item 3) - see
33
+ # cost_sizing_multiplier() below.
34
+ cost_multiplier: float = 1.0
35
+ cost_sizing_reason: str = "cost_sizing_disabled_or_absent"
36
+
37
+ def to_dict(self) -> dict:
38
+ return asdict(self)
39
+
40
+
41
+ def classify_volatility_regime(
42
+ rolling_volatility: float,
43
+ low_volatility_threshold: float,
44
+ high_volatility_threshold: float,
45
+ ) -> str:
46
+ volatility = abs(float(rolling_volatility))
47
+ if volatility >= high_volatility_threshold:
48
+ return "high_volatility"
49
+ if volatility <= low_volatility_threshold:
50
+ return "low_volatility"
51
+ return "normal_volatility"
52
+
53
+
54
+ def _clamp01(value: float) -> float:
55
+ return max(0.0, min(float(value), 1.0))
56
+
57
+
58
+ def topology_sizing_multiplier(
59
+ topology_source: str | None,
60
+ topology_confidence: float | None,
61
+ topology_disagreement: float | None,
62
+ min_topology_multiplier: float = 0.5,
63
+ max_topology_multiplier: float = 1.0,
64
+ ) -> tuple[float, str]:
65
+ """Bounded, continuous, shrink-only adjustment - never above
66
+ max_topology_multiplier (1.0 by default), i.e. never amplifies size
67
+ beyond what the deterministic-only sizing already computed. Only
68
+ engages when topology_source == "learned" - topology/learned_topology.py
69
+ already gates that label on confidence clearing
70
+ min_confidence_for_learned upstream, so "fallback"/missing/None here is
71
+ always a strict no-op, matching the same "a missing/degraded learned
72
+ model never changes trading behavior" convention already established
73
+ for the topology overlay itself (see analyzer/README.md and
74
+ development/architecture.md's V2-17.5 safety rule: probabilistic
75
+ scoring may only ever produce a bounded, continuous adjustment, never
76
+ a randomized or amplified decision)."""
77
+ if topology_source != "learned" or topology_confidence is None:
78
+ return 1.0, "topology_absent_or_fallback_no_adjustment"
79
+
80
+ confidence = _clamp01(topology_confidence)
81
+ disagreement = _clamp01(topology_disagreement) if topology_disagreement is not None else 0.0
82
+ raw = confidence * (1.0 - disagreement)
83
+ multiplier = min_topology_multiplier + (max_topology_multiplier - min_topology_multiplier) * raw
84
+ return multiplier, "topology_confidence_scaled_sizing"
85
+
86
+
87
+ def rank_sizing_multiplier(
88
+ rank_prediction: float | None,
89
+ rank_sizing_enabled: bool,
90
+ min_rank_multiplier: float = 0.75,
91
+ max_rank_multiplier: float = 1.25,
92
+ ) -> tuple[float, str]:
93
+ """Bounded, continuous, direction-preserving size adjustment driven by
94
+ the sequence/multitask model's rank_20d head (predicted cross-
95
+ sectional percentile rank of 20-day forward return, [0, 1] - see
96
+ train.py::compute_rank_ic()/development/Changelog.md's "frontier-model
97
+ edge investigation" entry: the one signal in the whole system with a
98
+ statistically significant backtest rank-IC, 0.073 mean IC / t-stat 4.40
99
+ on the sequence model's full backtest series).
100
+
101
+ A predicted rank near 1.0 (top of the universe) scales size UP toward
102
+ max_rank_multiplier; a rank near 0.0 (predicted bottom) scales it DOWN
103
+ toward min_rank_multiplier; a rank of exactly 0.5 (predicted median) is
104
+ a no-op (multiplier 1.0). Never flips direction/sign - this only ever
105
+ scales the SAME direction the existing 1d-direction gating decision
106
+ already picked, matching topology_sizing_multiplier()'s "probabilistic
107
+ scoring may only ever produce a bounded, continuous adjustment, never
108
+ a randomized or amplified decision" convention.
109
+
110
+ Config-gated, additive, default OFF
111
+ (phase_v2.dynamic_risk.rank_sizing_enabled) - this signal's own
112
+ non-overlapping-date backtest subsample was not yet independently
113
+ significant (t-stat 1.20 on 28 independent 20-day windows, vs. 4.40 on
114
+ the full autocorrelated series), so it ships available, tested, and
115
+ wired end-to-end, but not defaulted on until validated further."""
116
+ if not rank_sizing_enabled or rank_prediction is None:
117
+ return 1.0, "rank_sizing_disabled_or_absent"
118
+
119
+ rank = _clamp01(rank_prediction)
120
+ multiplier = min_rank_multiplier + (max_rank_multiplier - min_rank_multiplier) * rank
121
+ return multiplier, "rank_prediction_scaled_sizing"
122
+
123
+
124
+ def cost_sizing_multiplier(
125
+ expected_edge_bps: float,
126
+ expected_cost_bps: float,
127
+ cost_sizing_enabled: bool,
128
+ min_cost_multiplier: float = 0.25,
129
+ ) -> tuple[float, str]:
130
+ """Bounded, continuous, shrink-only size adjustment (V5.1 Phase 1,
131
+ development/Problems.md, item 3) - follows topology_sizing_multiplier()'s
132
+ exact contract: never above 1.0 (never amplifies size beyond what the
133
+ deterministic sizing already computed), and a disabled/absent input is
134
+ always a strict no-op.
135
+
136
+ multiplier = clamp((edge - cost) / max(edge, eps), min_cost_multiplier, 1.0) -
137
+ a trade whose edge comfortably clears its cost sizes at full conviction
138
+ (multiplier 1.0); one whose edge is barely above cost (or below it -
139
+ analyzer/market_analyzer.py's Priority 6.5 tier is the actual entry
140
+ veto for that case, this is a size-scaling BACKSTOP for whatever
141
+ reaches sizing regardless, e.g. an already-open position being
142
+ resized) shrinks toward min_cost_multiplier rather than sizing at full
143
+ weight on a marginal edge.
144
+
145
+ Config-gated, additive, default OFF (phase_v2.costs.cost_sizing_enabled) -
146
+ same off-by-default precedent as rank_sizing_enabled/rl_sizing_enabled,
147
+ since this depends on the same not-yet-calibrated edge_bps_per_rank_unit
148
+ execution/cost_model.py::build_net_edge_decision() documents."""
149
+ if not cost_sizing_enabled or expected_edge_bps == 0.0:
150
+ return 1.0, "cost_sizing_disabled_or_absent"
151
+
152
+ raw_ratio = (expected_edge_bps - expected_cost_bps) / max(abs(expected_edge_bps), 1e-9)
153
+ multiplier = max(min_cost_multiplier, min(raw_ratio, 1.0))
154
+ return multiplier, "net_edge_scaled_sizing"
155
+
156
+
157
+ def _resolve_effective_volatility(
158
+ rolling_volatility: float,
159
+ predicted_volatility: float | None,
160
+ use_predicted_volatility: bool,
161
+ ) -> tuple[float, str]:
162
+ """Picks which volatility number actually drives sizing this bar.
163
+ predicted_volatility (the multitask model's forward-looking volatility
164
+ head, see train_multitask.py) only ever swaps in when both the config
165
+ flag is on AND a real prediction is available - any other case falls
166
+ back to the existing backward-looking rolling_volatility_20d average,
167
+ byte-identical to pre-flag behavior. Config-gated, additive, default
168
+ off - see phase_v2.dynamic_risk.use_predicted_volatility."""
169
+ if use_predicted_volatility and predicted_volatility is not None:
170
+ return abs(float(predicted_volatility)), "predicted"
171
+ return abs(float(rolling_volatility)), "rolling"
172
+
173
+
174
+ def build_dynamic_position_sizing(
175
+ base_target_weight: float,
176
+ confidence: float,
177
+ rolling_volatility: float,
178
+ max_position_weight: float,
179
+ target_daily_volatility: float = 0.015,
180
+ min_position_weight: float = 0.0,
181
+ low_volatility_threshold: float = 0.01,
182
+ high_volatility_threshold: float = 0.03,
183
+ min_volatility_multiplier: float = 0.35,
184
+ max_volatility_multiplier: float = 1.25,
185
+ max_leverage: float = 1.0,
186
+ topology_source: str | None = None,
187
+ topology_confidence: float | None = None,
188
+ topology_disagreement: float | None = None,
189
+ min_topology_multiplier: float = 0.5,
190
+ max_topology_multiplier: float = 1.0,
191
+ predicted_volatility: float | None = None,
192
+ use_predicted_volatility: bool = False,
193
+ predicted_rank_20d: float | None = None,
194
+ rank_sizing_enabled: bool = False,
195
+ min_rank_multiplier: float = 0.75,
196
+ max_rank_multiplier: float = 1.25,
197
+ rl_model: dict | None = None,
198
+ rl_state: dict | None = None,
199
+ rl_sizing_enabled: bool = False,
200
+ min_rl_multiplier: float = 0.6,
201
+ max_rl_multiplier: float = 1.0,
202
+ ) -> PositionSizingDecision:
203
+ base_target_weight = float(base_target_weight)
204
+ confidence = max(0.0, min(float(confidence), 1.0))
205
+ max_position_weight = max(float(max_position_weight), 0.0)
206
+ abs_base_target = abs(base_target_weight)
207
+ volatility, volatility_source = _resolve_effective_volatility(
208
+ rolling_volatility, predicted_volatility, use_predicted_volatility
209
+ )
210
+ annualized_volatility = volatility * math.sqrt(TRADING_DAYS_PER_YEAR)
211
+ volatility_regime = classify_volatility_regime(
212
+ volatility,
213
+ low_volatility_threshold,
214
+ high_volatility_threshold,
215
+ )
216
+ topology_multiplier, topology_sizing_reason = topology_sizing_multiplier(
217
+ topology_source,
218
+ topology_confidence,
219
+ topology_disagreement,
220
+ min_topology_multiplier,
221
+ max_topology_multiplier,
222
+ )
223
+ rank_multiplier, rank_sizing_reason = rank_sizing_multiplier(
224
+ predicted_rank_20d,
225
+ rank_sizing_enabled,
226
+ min_rank_multiplier,
227
+ max_rank_multiplier,
228
+ )
229
+ rl_multiplier, rl_sizing_reason = _rl_sizing_multiplier(
230
+ rl_model,
231
+ rl_state,
232
+ rl_sizing_enabled,
233
+ min_rl_multiplier,
234
+ max_rl_multiplier,
235
+ )
236
+
237
+ if abs_base_target == 0.0 or confidence == 0.0 or max_position_weight == 0.0:
238
+ return PositionSizingDecision(
239
+ base_target_weight=base_target_weight,
240
+ target_weight=0.0,
241
+ rolling_volatility=volatility,
242
+ annualized_volatility=annualized_volatility,
243
+ volatility_regime=volatility_regime,
244
+ volatility_multiplier=0.0,
245
+ confidence_multiplier=0.0,
246
+ topology_multiplier=topology_multiplier,
247
+ leverage_factor=0.0,
248
+ max_leverage=float(max_leverage),
249
+ sizing_reason="no_active_signal",
250
+ topology_sizing_reason=topology_sizing_reason,
251
+ volatility_source=volatility_source,
252
+ rank_multiplier=rank_multiplier,
253
+ rank_sizing_reason=rank_sizing_reason,
254
+ rl_multiplier=rl_multiplier,
255
+ rl_sizing_reason=rl_sizing_reason,
256
+ )
257
+
258
+ safe_volatility = max(volatility, 1e-6)
259
+ volatility_multiplier = target_daily_volatility / safe_volatility
260
+ volatility_multiplier = max(min_volatility_multiplier, min(volatility_multiplier, max_volatility_multiplier))
261
+ confidence_multiplier = 0.5 + 0.5 * confidence
262
+
263
+ sized_weight = (
264
+ abs_base_target * volatility_multiplier * confidence_multiplier
265
+ * topology_multiplier * rank_multiplier * rl_multiplier
266
+ )
267
+ sized_weight = min(sized_weight, max_position_weight)
268
+ if sized_weight > 0.0 and min_position_weight > 0.0:
269
+ # V5.4.5: the floor is re-clamped by the cap - a misconfigured
270
+ # min > max previously pushed the sized weight ABOVE the leverage
271
+ # cap. Cap wins (conservative); direction preserved below either way.
272
+ sized_weight = min(max(sized_weight, min_position_weight), max_position_weight)
273
+
274
+ direction = 1.0 if base_target_weight >= 0.0 else -1.0
275
+ target_weight = direction * sized_weight
276
+ leverage_factor = min(float(max_leverage), sized_weight / max(abs_base_target, 1e-9))
277
+
278
+ if volatility_regime == "high_volatility":
279
+ reason = "reduced_for_high_volatility"
280
+ elif volatility_regime == "low_volatility" and volatility_multiplier > 1.0:
281
+ reason = "expanded_for_low_volatility"
282
+ else:
283
+ reason = "normal_volatility_sizing"
284
+
285
+ return PositionSizingDecision(
286
+ base_target_weight=base_target_weight,
287
+ target_weight=target_weight,
288
+ rolling_volatility=volatility,
289
+ annualized_volatility=annualized_volatility,
290
+ volatility_regime=volatility_regime,
291
+ volatility_multiplier=volatility_multiplier,
292
+ confidence_multiplier=confidence_multiplier,
293
+ topology_multiplier=topology_multiplier,
294
+ leverage_factor=leverage_factor,
295
+ max_leverage=float(max_leverage),
296
+ sizing_reason=reason,
297
+ topology_sizing_reason=topology_sizing_reason,
298
+ volatility_source=volatility_source,
299
+ rank_multiplier=rank_multiplier,
300
+ rank_sizing_reason=rank_sizing_reason,
301
+ rl_multiplier=rl_multiplier,
302
+ rl_sizing_reason=rl_sizing_reason,
303
+ )
risk/rl_sizing.py ADDED
@@ -0,0 +1,189 @@
1
+ """Offline-trained contextual-bandit sizing overlay (development/Problems.md
2
+ #71, Phase 4.12, Component E) - a SHRINK-ONLY multiplier layered on top of
3
+ risk/position_sizing.py::build_dynamic_position_sizing()'s output, never
4
+ replacing it. Pure Python, no numpy/torch at runtime, matching
5
+ topology/learned_topology.py's exact "pure inference, model trained offline,
6
+ degrades to a strict no-op when the model is missing/malformed/disabled"
7
+ split.
8
+
9
+ Honest framing, not oversold (see train_rl_sizing.py's own module docstring
10
+ for the full data-source story): this is a full-information contextual
11
+ bandit fit offline over ml/datasets/*.csv (the SAME rows train.py already
12
+ builds), NOT online/off-policy reinforcement learning against live
13
+ exploration data - main.py has never logged a stochastic sizing action, and
14
+ risk/position_sizing.py's own established rule (topology_sizing_multiplier()'s
15
+ docstring) forbids ever randomizing a live sizing decision. The offline
16
+ reward for every candidate action is computable in closed form from the
17
+ already-known forward return, so no exploration is needed to fit this - see
18
+ train_rl_sizing.py.
19
+
20
+ What this CAN plausibly do: learn to shrink size in STATE-CONDITIONAL
21
+ low-edge regimes (e.g. high implied volatility + backwardation + tightening
22
+ financial conditions - exactly the alt-data features Problems.md #71's
23
+ Component D added), reducing turnover/fees in the states this project's own
24
+ Backtest 1 measured were the most costly. What it CANNOT do: manufacture
25
+ alpha where none exists - if the underlying signal doesn't clear costs
26
+ anywhere, the reward-maximizing policy over a shrink-only action set
27
+ degenerates to "always pick the smallest multiplier," which is a `max_*_multiplier`
28
+ config change, not a model, and is the intended "give up gracefully" behavior,
29
+ not a bug.
30
+
31
+ Default OFF (phase_v2.dynamic_risk.rl_sizing_enabled) - see
32
+ risk/position_sizing.py's rl_multiplier param and main.py's wiring."""
33
+
34
+ from __future__ import annotations
35
+
36
+ import json
37
+ from pathlib import Path
38
+
39
+ # Fixed, order-significant state key list - the trained artifact carries
40
+ # its OWN copy of this same list (model["state_keys"]) and
41
+ # rl_sizing_multiplier() below refuses to score anything (falls back to
42
+ # 1.0) if the two disagree, rather than silently reordering/misaligning
43
+ # a state vector against weights trained for a different order. Every key
44
+ # here is already computed once per bar as part of the existing base
45
+ # feature/topology payload - no new computation, no new data source.
46
+ RL_SIZING_STATE_KEYS = [
47
+ "confidence",
48
+ "rolling_volatility_20d",
49
+ "regime_signal_risk_score",
50
+ "topology_correlation_strength",
51
+ "liquidity_spread_proxy",
52
+ "bond_credit_spread_level",
53
+ "alt_implied_volatility_level",
54
+ "alt_implied_vol_term_structure",
55
+ "alt_financial_conditions_change",
56
+ # V5.4.1 - regime trend one-hots: lets the linear policy learn
57
+ # regime-conditional sizing (e.g. be more conservative in bearish)
58
+ # rather than a single global multiplier that averages across regimes.
59
+ "regime_trend_bullish",
60
+ "regime_trend_bearish",
61
+ "regime_trend_sideways",
62
+ ]
63
+
64
+ DEFAULT_MIN_RL_MULTIPLIER = 0.6
65
+ DEFAULT_MAX_RL_MULTIPLIER = 1.0
66
+
67
+
68
+ def build_rl_sizing_state(base_features: dict, confidence: float) -> dict | None:
69
+ """Pulls RL_SIZING_STATE_KEYS out of the SAME base_features dict
70
+ main.py's _build_model_input_impl() already assembled this bar (plus
71
+ the model's own separately-computed confidence score, not itself a
72
+ base_feature) - no new computation, no new data source. Returns None
73
+ if any key is absent (e.g. Component D's alt-data features not yet in
74
+ input_set/retrained), which rl_sizing_multiplier() treats identically
75
+ to "no model" - a strict 1.0 no-op, never a crash or a partially-built
76
+ state vector."""
77
+ state = {}
78
+ for key in RL_SIZING_STATE_KEYS:
79
+ if key == "confidence":
80
+ continue
81
+ if key not in base_features:
82
+ return None
83
+ state[key] = float(base_features[key])
84
+ state["confidence"] = float(confidence)
85
+ return state
86
+
87
+
88
+ def _softmax_argmax_index(scores: list[float]) -> int:
89
+ """Deterministic argmax - NOT a sampled draw from the softmax
90
+ distribution. topology_sizing_multiplier()'s docstring establishes the
91
+ project-wide rule this module also follows: "probabilistic scoring may
92
+ only ever produce a bounded, continuous adjustment, never a randomized
93
+ or amplified decision." A stochastic policy is legitimate during
94
+ OFFLINE training (see train_rl_sizing.py - exploration there costs
95
+ nothing, it's arithmetic over historical rows) and forbidden at live
96
+ inference. Ties broken toward the LAST action with the max score - by
97
+ convention the action list is built low-to-high multiplier
98
+ (see rl_sizing_multiplier()), so a tie prefers the larger (less
99
+ aggressive shrink) multiplier, matching a zero-initialized/untrained
100
+ model's intended "prefer no-op" default."""
101
+ best_index = 0
102
+ best_score = scores[0]
103
+ for index, score in enumerate(scores):
104
+ if score >= best_score:
105
+ best_score = score
106
+ best_index = index
107
+ return best_index
108
+
109
+
110
+ def rl_sizing_multiplier(
111
+ model: dict | None,
112
+ state: dict | None,
113
+ rl_sizing_enabled: bool,
114
+ min_rl_multiplier: float = DEFAULT_MIN_RL_MULTIPLIER,
115
+ max_rl_multiplier: float = DEFAULT_MAX_RL_MULTIPLIER,
116
+ ) -> tuple[float, str]:
117
+ """Bounded, deterministic (argmax, never sampled), shrink-only size
118
+ adjustment - exact contract clone of
119
+ risk/position_sizing.py::rank_sizing_multiplier()/topology_sizing_multiplier():
120
+ returns (1.0, "<reason>") whenever the flag is off, the model is
121
+ missing/malformed, the state is incomplete, or the model's own
122
+ state_keys disagree with RL_SIZING_STATE_KEYS - every failure mode is a
123
+ strict no-op, never a crash and never an amplified size.
124
+
125
+ THREE safety properties, all enforced HERE rather than trusted from the
126
+ artifact (an untrusted/stale/hand-edited model file must never be able
127
+ to violate them):
128
+ 1. Argmax, never sampled - see _softmax_argmax_index()'s docstring.
129
+ 2. Clamped to [min_rl_multiplier, max_rl_multiplier] AFTER the argmax,
130
+ so a corrupted/adversarial action table can never emit a
131
+ multiplier outside the configured band, regardless of what the
132
+ model file claims its own actions are.
133
+ 3. max_rl_multiplier defaults to 1.0 - SHRINK-ONLY, matching
134
+ topology_sizing_multiplier()'s "never amplifies size beyond what
135
+ deterministic-only sizing already computed" rule. Amplification
136
+ (max_rl_multiplier > 1.0) is technically possible via config but
137
+ is NOT the shipped/recommended posture for v1 - see
138
+ development/Problems.md #71's abandon criteria."""
139
+ if not rl_sizing_enabled or model is None or state is None:
140
+ return 1.0, "rl_sizing_disabled_or_absent"
141
+
142
+ try:
143
+ state_keys = model.get("state_keys")
144
+ actions = model.get("actions")
145
+ weights = model.get("weights")
146
+ bias = model.get("bias")
147
+ mean = model.get("mean")
148
+ scale = model.get("scale")
149
+ if state_keys != RL_SIZING_STATE_KEYS:
150
+ return 1.0, "rl_sizing_state_key_mismatch"
151
+ if not actions or not weights or not bias or not mean or not scale:
152
+ return 1.0, "rl_sizing_malformed_model"
153
+ if len(weights) != len(actions) or len(bias) != len(actions):
154
+ return 1.0, "rl_sizing_malformed_model"
155
+
156
+ standardized = []
157
+ for key, mean_value, scale_value in zip(state_keys, mean, scale):
158
+ raw = float(state[key])
159
+ denominator = float(scale_value) if float(scale_value) != 0.0 else 1.0
160
+ standardized.append((raw - float(mean_value)) / denominator)
161
+
162
+ scores = []
163
+ for action_weights, action_bias in zip(weights, bias):
164
+ if len(action_weights) != len(standardized):
165
+ return 1.0, "rl_sizing_malformed_model"
166
+ score = float(action_bias) + sum(w * x for w, x in zip(action_weights, standardized))
167
+ scores.append(score)
168
+
169
+ best_index = _softmax_argmax_index(scores)
170
+ raw_multiplier = float(actions[best_index])
171
+ except (KeyError, TypeError, ValueError, ZeroDivisionError):
172
+ return 1.0, "rl_sizing_malformed_model"
173
+
174
+ clamped = max(float(min_rl_multiplier), min(raw_multiplier, float(max_rl_multiplier)))
175
+ return clamped, "rl_sizing_policy_scaled_sizing"
176
+
177
+
178
+ def load_rl_sizing_model(path: Path) -> dict | None:
179
+ """Missing file -> None (fresh checkout / never trained - the
180
+ documented default state). Malformed JSON -> None (never raises).
181
+ Mirrors main.py's own "missing/malformed model -> None, never a hard
182
+ failure" convention already established for every other optional
183
+ learned artifact (topology, gating, experts)."""
184
+ if not path.exists():
185
+ return None
186
+ try:
187
+ return json.loads(path.read_text(encoding="utf-8"))
188
+ except (OSError, ValueError):
189
+ return None