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,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
+ }