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
portfolio/rank_signal.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Rank signal resolution and cross-sectional normalization (V5.1 Phase 1,
|
|
2
|
+
development/Problems.md #73 - the sigmoid-at-inference/raw-at-training
|
|
3
|
+
mismatch). Two responsibilities, both pure:
|
|
4
|
+
|
|
5
|
+
1. WHICH head(s) drive live trading - previously hardcoded in main.py
|
|
6
|
+
(always prefer the sequence model's rank_20d, fall back to multitask's),
|
|
7
|
+
now a config-driven, promotion-gate-aware blend resolved once per run.
|
|
8
|
+
2. HOW that raw score becomes a [0,1] rank - previously a raw model output
|
|
9
|
+
passed straight through (which, combined with a since-fixed sigmoid/raw
|
|
10
|
+
training mismatch, meant live values compressed into roughly
|
|
11
|
+
[0.5, 0.7] instead of spanning [0,1] - IC is invariant to any monotone
|
|
12
|
+
transform, which is exactly why no rank-IC-based gate ever caught it).
|
|
13
|
+
Cross-sectional percentile normalization, computed fresh each bar over
|
|
14
|
+
whichever symbols have a raw score, is the fix that survives regardless
|
|
15
|
+
of what scale the underlying model head happens to output at - the
|
|
16
|
+
same reasoning train.py::_rank_ic_from_arrays() already documents for
|
|
17
|
+
why it ranks predictions before comparing them.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def resolve_rank_signal_policy(training_metrics_by_model: dict[str, dict | None], config: dict) -> dict:
|
|
24
|
+
"""Resolved ONCE in main.py::Initialize() (never per-bar - the
|
|
25
|
+
promotion-gate verdicts this reads only change when a new model is
|
|
26
|
+
installed). Reads phase_v2.rank_signal:
|
|
27
|
+
|
|
28
|
+
heads: {"rank_20d": 1.0, "rank_5d": 0.0} - the blend weights.
|
|
29
|
+
model_priority: ["sequence", "multitask"] - which model's value for
|
|
30
|
+
a given head wins when both are available.
|
|
31
|
+
demote_not_promotable_heads: when true, a head whose backtest
|
|
32
|
+
promotion-gate verdict (backtest.{head}_ranking_quality.quality_status,
|
|
33
|
+
checked against the FIRST model in model_priority that has a
|
|
34
|
+
verdict for it - the same precedence select_raw_rank_score() uses
|
|
35
|
+
at runtime) is not in min_promotion_status has its weight zeroed
|
|
36
|
+
and redistributed proportionally across the surviving heads.
|
|
37
|
+
A head configured at weight 0.0 is still checked and can still
|
|
38
|
+
become a survivor - "listed with zero weight" means "tracked,
|
|
39
|
+
currently inactive," not "not configured" (development/Problems.md);
|
|
40
|
+
it only actually receives weight if every positively-weighted head
|
|
41
|
+
gets demoted, in which case survivors split the freed weight
|
|
42
|
+
equally rather than dividing by the now-zero surviving total.
|
|
43
|
+
|
|
44
|
+
Returns {"heads": {...}, "model_priority": [...], "normalization": ...,
|
|
45
|
+
"demoted": [...], "reason": "..."}.
|
|
46
|
+
|
|
47
|
+
FAIL-OPEN, always: never raises. Missing/unreadable metrics for a head
|
|
48
|
+
leave that head's configured weight untouched (a promotion verdict
|
|
49
|
+
that cannot be checked is not evidence of a problem). If demotion
|
|
50
|
+
would zero out EVERY head's weight, the entire operation is skipped
|
|
51
|
+
and the originally configured policy is returned unchanged, with
|
|
52
|
+
reason recording why - demoting every head would silently stop the
|
|
53
|
+
book from ever engaging, a far bigger behavior change than "prefer a
|
|
54
|
+
different head" implies.
|
|
55
|
+
"""
|
|
56
|
+
rank_signal_config = config.get("phase_v2", {}).get("rank_signal", {})
|
|
57
|
+
heads = dict(rank_signal_config.get("heads", {"rank_20d": 1.0}))
|
|
58
|
+
model_priority = list(rank_signal_config.get("model_priority", ["sequence", "multitask"]))
|
|
59
|
+
normalization = str(rank_signal_config.get("normalization", "cross_sectional"))
|
|
60
|
+
demote_enabled = bool(rank_signal_config.get("demote_not_promotable_heads", True))
|
|
61
|
+
min_promotion_status = set(rank_signal_config.get("min_promotion_status", ["promotable", "watchlist"]))
|
|
62
|
+
|
|
63
|
+
if not demote_enabled or not heads:
|
|
64
|
+
return {
|
|
65
|
+
"heads": heads, "model_priority": model_priority, "normalization": normalization,
|
|
66
|
+
"demoted": [], "reason": "demotion_disabled_or_no_heads_configured",
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
demoted: list[str] = []
|
|
70
|
+
surviving_heads: dict[str, float] = {}
|
|
71
|
+
for head_name, weight in heads.items():
|
|
72
|
+
status = _resolve_head_quality_status(head_name, model_priority, training_metrics_by_model)
|
|
73
|
+
if status is None or status in min_promotion_status:
|
|
74
|
+
surviving_heads[head_name] = weight
|
|
75
|
+
elif weight > 0.0:
|
|
76
|
+
# A head already at 0.0 weight can't be meaningfully "demoted"
|
|
77
|
+
# (there's nothing to redistribute away from it) - only a
|
|
78
|
+
# positively-weighted head's demotion is worth recording.
|
|
79
|
+
demoted.append(head_name)
|
|
80
|
+
|
|
81
|
+
if not surviving_heads:
|
|
82
|
+
# Demoting every head would silently stop the book from ever
|
|
83
|
+
# engaging - a far bigger behavior change than "prefer a
|
|
84
|
+
# different head" implies. Leave the configured policy untouched.
|
|
85
|
+
return {
|
|
86
|
+
"heads": heads, "model_priority": model_priority, "normalization": normalization,
|
|
87
|
+
"demoted": [], "reason": "all_heads_would_be_demoted_policy_unchanged",
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
total_weight = sum(surviving_heads.values())
|
|
91
|
+
if total_weight > 0.0:
|
|
92
|
+
redistributed = {name: weight / total_weight for name, weight in surviving_heads.items()}
|
|
93
|
+
else:
|
|
94
|
+
# Every positively-weighted head got demoted, leaving only
|
|
95
|
+
# zero-weight survivors (heads tracked but not currently primary) -
|
|
96
|
+
# split the freed weight equally among them rather than dividing
|
|
97
|
+
# by a zero total.
|
|
98
|
+
redistributed = {name: 1.0 / len(surviving_heads) for name in surviving_heads}
|
|
99
|
+
# Zero-weighted entries for demoted heads are kept (not dropped) so
|
|
100
|
+
# select_raw_rank_score()'s blend loop can still see - and skip - them
|
|
101
|
+
# without a KeyError, and so state.json's rank_head_weights stays a
|
|
102
|
+
# complete, auditable record of every configured head.
|
|
103
|
+
for head_name in heads:
|
|
104
|
+
redistributed.setdefault(head_name, 0.0)
|
|
105
|
+
|
|
106
|
+
reason = "no_demotion_needed" if not demoted else f"demoted:{','.join(demoted)}"
|
|
107
|
+
return {
|
|
108
|
+
"heads": redistributed, "model_priority": model_priority, "normalization": normalization,
|
|
109
|
+
"demoted": demoted, "reason": reason,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _resolve_head_quality_status(
|
|
114
|
+
head_name: str, model_priority: list[str], training_metrics_by_model: dict[str, dict | None]
|
|
115
|
+
) -> str | None:
|
|
116
|
+
"""First model in model_priority that has a backtest ranking_quality
|
|
117
|
+
verdict for `head_name` wins - the same precedence
|
|
118
|
+
select_raw_rank_score() uses to pick which model's VALUE feeds the
|
|
119
|
+
blend, so the demotion decision and the runtime value come from the
|
|
120
|
+
same model whenever possible. Returns None (never raises) when no
|
|
121
|
+
configured model has a verdict for this head - e.g. an older metrics
|
|
122
|
+
file predating this head, or the metrics file missing entirely."""
|
|
123
|
+
for model_name in model_priority:
|
|
124
|
+
metrics = training_metrics_by_model.get(model_name)
|
|
125
|
+
if not metrics:
|
|
126
|
+
continue
|
|
127
|
+
quality = (metrics.get("backtest", {}) or {}).get(f"{head_name}_ranking_quality")
|
|
128
|
+
if quality:
|
|
129
|
+
return quality.get("quality_status")
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def select_raw_rank_score(
|
|
134
|
+
sequence_prediction: dict | None, multitask_payload: dict | None, policy: dict
|
|
135
|
+
) -> tuple[float | None, str]:
|
|
136
|
+
"""Replaces main.py's previous hardcode (always prefer the sequence
|
|
137
|
+
model's rank_20d, fall back to multitask's). Now a weighted blend
|
|
138
|
+
across policy["heads"], each head's own value resolved via
|
|
139
|
+
policy["model_priority"] (sequence before multitask, by default).
|
|
140
|
+
|
|
141
|
+
Returns (score, source) where source records exactly which heads/
|
|
142
|
+
models contributed, e.g. "sequence:0.6*rank_5d+0.4*rank_20d" or
|
|
143
|
+
"sequence:1.0*rank_20d,multitask:1.0*rank_5d" when different heads
|
|
144
|
+
come from different models. Returns (None, "no_rank_available") when
|
|
145
|
+
no configured head has a value from any model this bar (e.g. every
|
|
146
|
+
model still warming up)."""
|
|
147
|
+
payload_by_model = {"sequence": sequence_prediction, "multitask": multitask_payload}
|
|
148
|
+
model_priority = policy.get("model_priority", ["sequence", "multitask"])
|
|
149
|
+
heads = policy.get("heads", {})
|
|
150
|
+
|
|
151
|
+
weighted_sum = 0.0
|
|
152
|
+
total_weight_used = 0.0
|
|
153
|
+
contributions: list[str] = []
|
|
154
|
+
|
|
155
|
+
for head_name, weight in heads.items():
|
|
156
|
+
if weight <= 0.0:
|
|
157
|
+
continue
|
|
158
|
+
value, model_used = _resolve_head_value(head_name, model_priority, payload_by_model)
|
|
159
|
+
if value is None:
|
|
160
|
+
continue
|
|
161
|
+
weighted_sum += weight * float(value)
|
|
162
|
+
total_weight_used += weight
|
|
163
|
+
contributions.append(f"{model_used}:{weight:g}*{head_name}")
|
|
164
|
+
|
|
165
|
+
if total_weight_used <= 0.0:
|
|
166
|
+
return None, "no_rank_available"
|
|
167
|
+
|
|
168
|
+
score = weighted_sum / total_weight_used
|
|
169
|
+
source = ",".join(contributions)
|
|
170
|
+
return score, source
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _resolve_head_value(
|
|
174
|
+
head_name: str, model_priority: list[str], payload_by_model: dict[str, dict | None]
|
|
175
|
+
) -> tuple[float | None, str | None]:
|
|
176
|
+
for model_name in model_priority:
|
|
177
|
+
payload = payload_by_model.get(model_name)
|
|
178
|
+
if payload and payload.get(head_name) is not None:
|
|
179
|
+
return float(payload[head_name]), model_name
|
|
180
|
+
return None, None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def cross_sectional_rank_scores(scores_by_symbol: dict[str, float]) -> dict[str, float]:
|
|
184
|
+
"""Per-bar percentile rank across whatever symbols have a raw score
|
|
185
|
+
this bar, ties averaged (matching pandas' `.rank(pct=True)` semantics -
|
|
186
|
+
the same transform train.py::_rank_ic_from_arrays() applies to
|
|
187
|
+
predictions before computing rank-IC, so a raw score's absolute scale
|
|
188
|
+
can never matter, only its ordering within this bar's cross-section).
|
|
189
|
+
Pure Python - no pandas import on the runtime hot path.
|
|
190
|
+
|
|
191
|
+
<=1 eligible symbol returns {} (no meaningful cross-section to rank
|
|
192
|
+
within, so the book has no view this bar) - NEVER a spurious 0.5 for
|
|
193
|
+
a lone symbol, which would silently look like "predicted median"
|
|
194
|
+
rather than "no ranking possible."."""
|
|
195
|
+
if len(scores_by_symbol) <= 1:
|
|
196
|
+
return {}
|
|
197
|
+
|
|
198
|
+
ordered = sorted(scores_by_symbol.items(), key=lambda item: item[1])
|
|
199
|
+
n = len(ordered)
|
|
200
|
+
|
|
201
|
+
# Average-rank tie handling: every symbol sharing a raw score value
|
|
202
|
+
# gets the mean of the positions that tied group would have spanned.
|
|
203
|
+
ranks: dict[str, float] = {}
|
|
204
|
+
index = 0
|
|
205
|
+
while index < n:
|
|
206
|
+
group_end = index
|
|
207
|
+
while group_end + 1 < n and ordered[group_end + 1][1] == ordered[index][1]:
|
|
208
|
+
group_end += 1
|
|
209
|
+
# Positions are 1-indexed for the standard rank formula; percentile
|
|
210
|
+
# rank then divides by n so ties land on the correct SHARED
|
|
211
|
+
# percentile rather than each tied member getting a distinct one.
|
|
212
|
+
average_position = (index + 1 + group_end + 1) / 2.0
|
|
213
|
+
percentile = average_position / n
|
|
214
|
+
for tied_index in range(index, group_end + 1):
|
|
215
|
+
symbol = ordered[tied_index][0]
|
|
216
|
+
ranks[symbol] = percentile
|
|
217
|
+
index = group_end + 1
|
|
218
|
+
|
|
219
|
+
return ranks
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""V5.3.5 (development/Problems.md #102): a rolling TRAILING realized-IC
|
|
2
|
+
gate for book engagement - the direct sibling of
|
|
3
|
+
portfolio/book_construction.py::compute_confidence_spread()'s consumer in
|
|
4
|
+
_select_book_group(), added because dispersion and skill turned out to be
|
|
5
|
+
different things a real backtest could tell apart even when the existing
|
|
6
|
+
min_rank_confidence_spread gate couldn't.
|
|
7
|
+
|
|
8
|
+
Background: #102 found that lowering min_rank_confidence_spread (which
|
|
9
|
+
only measures whether the model's raw scores are DISPERSED today) let the
|
|
10
|
+
live book trade through real, historically bad stretches where the
|
|
11
|
+
model's own per-era IC diagnostic (#71) is near-zero or negative - a
|
|
12
|
+
day's scores can look perfectly "spread out" while carrying zero genuine
|
|
13
|
+
predictive validity. This module adds an independent, second veto based
|
|
14
|
+
on TRAILING REALIZED IC (was the model actually right, recently) rather
|
|
15
|
+
than same-day dispersion.
|
|
16
|
+
|
|
17
|
+
Deliberately its own module, not folded into book_construction.py or
|
|
18
|
+
risk/kill_switch.py:
|
|
19
|
+
- book_construction.py is same-bar cross-sectional selection math with
|
|
20
|
+
no temporal/resolution state today (compute_confidence_spread() reads
|
|
21
|
+
only this bar's own scores). This gate needs a multi-bar buffer and a
|
|
22
|
+
"wait for the future, then resolve" shape - closer in spirit to
|
|
23
|
+
evaluation/kill_switch_replay.py's day-by-day state-carry pattern -
|
|
24
|
+
and deserves its own independently-testable home, the same
|
|
25
|
+
relationship portfolio/book_neutrality.py already has to
|
|
26
|
+
book_construction.py (a sibling module, imported by main.py's call
|
|
27
|
+
site, not inlined).
|
|
28
|
+
- risk/kill_switch.py already has a dormant `min_live_rank_ic`
|
|
29
|
+
condition, but it reads `live_rank_ic` from a locally-cached file
|
|
30
|
+
only a separate Postgres-connected worker ever writes
|
|
31
|
+
(visualization/grafana/retraining_status.json) - which doesn't exist
|
|
32
|
+
in a standalone `aq backtest` run, so that condition is a live no-op
|
|
33
|
+
during backtests today. It is also a portfolio-wide sticky trade-lock
|
|
34
|
+
trip (blast radius: everything), a different kind of decision from
|
|
35
|
+
this module's book-construction-level, per-rebalance engagement veto
|
|
36
|
+
(blast radius: this one rebalance). Wiring this module's rolling
|
|
37
|
+
buffer into that kill-switch condition too would be a real, small,
|
|
38
|
+
separately-verifiable follow-up - explicitly not done here, to keep
|
|
39
|
+
this round's verification story to one mechanism.
|
|
40
|
+
|
|
41
|
+
Pure, deterministic, cheap - both functions below do only pandas/numpy
|
|
42
|
+
work over an already-assembled event list (no I/O), respecting main.py's
|
|
43
|
+
per-bar latency budget the same way every other pure risk/analyzer module
|
|
44
|
+
in this codebase does (risk/kill_switch.py, analyzer/market_analyzer.py).
|
|
45
|
+
|
|
46
|
+
Design choice: a hard, boolean gate - not a continuous position-sizing
|
|
47
|
+
multiplier. Every existing book-level check (min_rank_confidence_spread)
|
|
48
|
+
is a hard veto with a single, audit-log-able reason, and
|
|
49
|
+
book_construction.py's own docstring already defers continuous
|
|
50
|
+
rank-weighted sizing as a documented future extension rather than
|
|
51
|
+
building it alongside a same-round change to the selection logic itself.
|
|
52
|
+
A scaling multiplier for this signal specifically is the same kind of
|
|
53
|
+
future extension, deferred for the same reason: shipping a new signal and
|
|
54
|
+
a new sizing curve in the same round makes it impossible to attribute a
|
|
55
|
+
verification result to either one cleanly.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
from __future__ import annotations
|
|
59
|
+
|
|
60
|
+
from evaluation.rank_ic_core import aggregate_rank_ic_observations, resolve_realized_rank_ic_observations
|
|
61
|
+
|
|
62
|
+
# Same "this condition can never fire" sentinel convention as
|
|
63
|
+
# risk/kill_switch.py::_NEVER_BELOW - enabling this gate must never, by
|
|
64
|
+
# itself, activate an uncalibrated threshold. The real floor comes from
|
|
65
|
+
# evaluation/rolling_ic_gate_calibration.py, never guessed.
|
|
66
|
+
_NEVER_BELOW = -1e12
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def compute_rolling_ic_state(
|
|
70
|
+
event_buffer: list[dict],
|
|
71
|
+
*,
|
|
72
|
+
horizon_days: int,
|
|
73
|
+
rolling_window_days: int,
|
|
74
|
+
min_names_per_date: int = 2,
|
|
75
|
+
) -> dict:
|
|
76
|
+
"""Resolves `event_buffer` (main.py's own flat, in-memory
|
|
77
|
+
{"ticker", "created_at", "resolved_predicted_rank_20d", "close_price"}
|
|
78
|
+
deque, or the offline replay/calibration tools' equivalent) via
|
|
79
|
+
evaluation.rank_ic_core.resolve_realized_rank_ic_observations(), then
|
|
80
|
+
restricts to the trailing `rolling_window_days` UNIQUE resolved
|
|
81
|
+
origin dates (not calendar days, and not raw observation count - a
|
|
82
|
+
date with more symbols resolved must not count for more "days" of
|
|
83
|
+
trailing history than a thin one) before aggregating.
|
|
84
|
+
|
|
85
|
+
`min_names_per_date` (V5.3.5, development/Problems.md #102) - default
|
|
86
|
+
2 preserves this function's original behavior exactly (see
|
|
87
|
+
evaluation.rank_ic_core.rank_ic_from_arrays()'s own docstring for why
|
|
88
|
+
this exists: a 2-name origin date is mathematically forced to a
|
|
89
|
+
+-1 IC reading regardless of real skill, and real historical data
|
|
90
|
+
confirmed this dominates the low tail of a walk-forward calibration's
|
|
91
|
+
distribution). Callers evaluating a live or replayed rolling-IC
|
|
92
|
+
reading - where thin-universe noise masquerading as "the model is
|
|
93
|
+
badly wrong" would cause a spurious veto - should pass a value well
|
|
94
|
+
above 2 (evaluation/rolling_ic_gate_calibration.py and evaluation/
|
|
95
|
+
rolling_ic_gate_replay.py both default to 10, sourced from
|
|
96
|
+
phase_v2.rolling_ic_gate.min_names_per_date).
|
|
97
|
+
|
|
98
|
+
Returns {"rolling_mean_ic": float | None, "num_resolved_dates": int,
|
|
99
|
+
"num_resolved_observations": int}. `rolling_mean_ic` is None (never
|
|
100
|
+
0.0) when zero observations have resolved yet - "unknown" must never
|
|
101
|
+
be silently treated as "IC is exactly zero" by evaluate_rolling_ic_gate()
|
|
102
|
+
below, the same None-vs-neutral-default distinction every other pure
|
|
103
|
+
signal function in this codebase already draws (e.g.
|
|
104
|
+
risk/kill_switch.py::_rolling_sharpe())."""
|
|
105
|
+
resolved = resolve_realized_rank_ic_observations(event_buffer, horizon_days=horizon_days)
|
|
106
|
+
if not resolved:
|
|
107
|
+
return {"rolling_mean_ic": None, "num_resolved_dates": 0, "num_resolved_observations": 0}
|
|
108
|
+
|
|
109
|
+
unique_dates = sorted({observation["origin_date"] for observation in resolved})
|
|
110
|
+
trailing_dates = set(unique_dates[-rolling_window_days:]) if rolling_window_days > 0 else set()
|
|
111
|
+
trailing_observations = [observation for observation in resolved if observation["origin_date"] in trailing_dates]
|
|
112
|
+
|
|
113
|
+
if not trailing_observations:
|
|
114
|
+
return {"rolling_mean_ic": None, "num_resolved_dates": 0, "num_resolved_observations": 0}
|
|
115
|
+
|
|
116
|
+
aggregate = aggregate_rank_ic_observations(trailing_observations, min_names_per_date=min_names_per_date)
|
|
117
|
+
return {
|
|
118
|
+
"rolling_mean_ic": aggregate["mean_ic"] if aggregate["num_dates"] > 0 else None,
|
|
119
|
+
"num_resolved_dates": aggregate["num_dates"],
|
|
120
|
+
"num_resolved_observations": len(trailing_observations),
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def evaluate_rolling_ic_gate(rolling_ic_state: dict, config: dict) -> dict:
|
|
125
|
+
"""Pure decision function - the direct sibling of
|
|
126
|
+
portfolio.book_construction.compute_confidence_spread()'s consumer at
|
|
127
|
+
_select_book_group(). `rolling_ic_state` is compute_rolling_ic_state()'s
|
|
128
|
+
own output; `config` is phase_v2.rolling_ic_gate's dict.
|
|
129
|
+
|
|
130
|
+
GUARDRAIL - fail-open at two levels, matching
|
|
131
|
+
risk/kill_switch.py::evaluate_kill_switch()'s own documented contract:
|
|
132
|
+
1. config["enabled"] is False -> always engaged, full no-op,
|
|
133
|
+
regardless of any other input.
|
|
134
|
+
2. num_resolved_dates < min_resolved_dates_required (default:
|
|
135
|
+
config's own rolling_window_days, matching evaluate_kill_switch()'s
|
|
136
|
+
min_bars_for_sharpe precedent - "the trailing window size" is the
|
|
137
|
+
value's own documented intent) -> also engaged. A cold-start
|
|
138
|
+
buffer (the first ~40-60 live bars of any run, before enough
|
|
139
|
+
predictions have had time to resolve against a realized 20-day
|
|
140
|
+
forward return) must never masquerade as "IC is bad" - it must
|
|
141
|
+
masquerade as "gate not evaluated yet".
|
|
142
|
+
|
|
143
|
+
Returns {"engaged": bool, "reason": str, "observed_rolling_mean_ic":
|
|
144
|
+
float | None, "min_rolling_mean_ic": float, "num_resolved_dates": int}."""
|
|
145
|
+
min_rolling_mean_ic = float(config.get("min_rolling_mean_ic", _NEVER_BELOW))
|
|
146
|
+
num_resolved_dates = int(rolling_ic_state.get("num_resolved_dates", 0))
|
|
147
|
+
observed_rolling_mean_ic = rolling_ic_state.get("rolling_mean_ic")
|
|
148
|
+
|
|
149
|
+
if not bool(config.get("enabled", False)):
|
|
150
|
+
return {
|
|
151
|
+
"engaged": True,
|
|
152
|
+
"reason": "rolling_ic_gate_disabled",
|
|
153
|
+
"observed_rolling_mean_ic": observed_rolling_mean_ic,
|
|
154
|
+
"min_rolling_mean_ic": min_rolling_mean_ic,
|
|
155
|
+
"num_resolved_dates": num_resolved_dates,
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
min_resolved_dates_required = int(config.get("min_resolved_dates_required", config.get("rolling_window_days", 40)))
|
|
159
|
+
if num_resolved_dates < min_resolved_dates_required or observed_rolling_mean_ic is None:
|
|
160
|
+
return {
|
|
161
|
+
"engaged": True,
|
|
162
|
+
"reason": "rolling_ic_gate_insufficient_history",
|
|
163
|
+
"observed_rolling_mean_ic": observed_rolling_mean_ic,
|
|
164
|
+
"min_rolling_mean_ic": min_rolling_mean_ic,
|
|
165
|
+
"num_resolved_dates": num_resolved_dates,
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if observed_rolling_mean_ic < min_rolling_mean_ic:
|
|
169
|
+
return {
|
|
170
|
+
"engaged": False,
|
|
171
|
+
"reason": "rolling_ic_below_floor",
|
|
172
|
+
"observed_rolling_mean_ic": observed_rolling_mean_ic,
|
|
173
|
+
"min_rolling_mean_ic": min_rolling_mean_ic,
|
|
174
|
+
"num_resolved_dates": num_resolved_dates,
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
"engaged": True,
|
|
179
|
+
"reason": "rolling_ic_above_floor",
|
|
180
|
+
"observed_rolling_mean_ic": observed_rolling_mean_ic,
|
|
181
|
+
"min_rolling_mean_ic": min_rolling_mean_ic,
|
|
182
|
+
"num_resolved_dates": num_resolved_dates,
|
|
183
|
+
}
|