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/manual_override.py
ADDED
|
@@ -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")
|
risk/position_sizing.py
ADDED
|
@@ -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
|