quantred 2.4.0__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 (117) hide show
  1. quantred/__init__.py +24 -0
  2. quantred/alphas/__init__.py +53 -0
  3. quantred/alphas/base.py +221 -0
  4. quantred/alphas/combine.py +260 -0
  5. quantred/alphas/horizon.py +153 -0
  6. quantred/alphas/refine.py +300 -0
  7. quantred/alphas/scorers.py +87 -0
  8. quantred/analytics/attribution.py +354 -0
  9. quantred/analytics/bootstrap.py +353 -0
  10. quantred/analytics/charts.py +393 -0
  11. quantred/analytics/excursion.py +185 -0
  12. quantred/analytics/execution_quality.py +180 -0
  13. quantred/analytics/exposure.py +167 -0
  14. quantred/analytics/htmlreport.py +1208 -0
  15. quantred/analytics/information.py +197 -0
  16. quantred/analytics/metrics.py +502 -0
  17. quantred/analytics/performance.py +639 -0
  18. quantred/analytics/reporting.py +1672 -0
  19. quantred/analytics/screening.py +190 -0
  20. quantred/analytics/series_comparison.py +303 -0
  21. quantred/analytics/trade_analytics.py +353 -0
  22. quantred/brokers/alpaca/broker.py +415 -0
  23. quantred/brokers/alpaca/factory.py +33 -0
  24. quantred/brokers/alpaca/market_data.py +166 -0
  25. quantred/brokers/base.py +220 -0
  26. quantred/brokers/dryrun.py +164 -0
  27. quantred/brokers/errors.py +70 -0
  28. quantred/cli.py +8619 -0
  29. quantred/costs/__init__.py +11 -0
  30. quantred/costs/base.py +126 -0
  31. quantred/costs/parametric.py +131 -0
  32. quantred/data/__init__.py +29 -0
  33. quantred/data/compute.py +332 -0
  34. quantred/data/edges.py +81 -0
  35. quantred/data/features.py +120 -0
  36. quantred/data/panel.py +61 -0
  37. quantred/data/scan.py +85 -0
  38. quantred/data/store.py +269 -0
  39. quantred/demo/__init__.py +22 -0
  40. quantred/demo/scanners.py +104 -0
  41. quantred/demo/strategies.py +125 -0
  42. quantred/engine/backtest.py +1206 -0
  43. quantred/engine/barcheck.py +228 -0
  44. quantred/engine/live.py +539 -0
  45. quantred/execution/decision.py +192 -0
  46. quantred/execution/flatten.py +302 -0
  47. quantred/execution/halt.py +165 -0
  48. quantred/execution/ledger.py +758 -0
  49. quantred/execution/live_trader.py +587 -0
  50. quantred/execution/order_id.py +83 -0
  51. quantred/execution/sizing.py +114 -0
  52. quantred/indicators/indicators.py +158 -0
  53. quantred/marketdata/base.py +62 -0
  54. quantred/marketdata/client.py +59 -0
  55. quantred/marketdata/session.py +112 -0
  56. quantred/marketdata/synthetic.py +74 -0
  57. quantred/marketdata/timeframe.py +125 -0
  58. quantred/mcp/__init__.py +6 -0
  59. quantred/mcp/selftest.py +139 -0
  60. quantred/mcp/server.py +2419 -0
  61. quantred/optimization/causality.py +607 -0
  62. quantred/optimization/config_store.py +300 -0
  63. quantred/optimization/optimizer.py +431 -0
  64. quantred/optimization/parallel.py +345 -0
  65. quantred/optimization/param_space.py +309 -0
  66. quantred/optimization/walk_forward.py +1254 -0
  67. quantred/portfolio/allocator.py +135 -0
  68. quantred/portfolio/benchmark.py +109 -0
  69. quantred/portfolio/optimizer.py +786 -0
  70. quantred/portfolio/policy.py +335 -0
  71. quantred/portfolio/posterior.py +208 -0
  72. quantred/research/__init__.py +30 -0
  73. quantred/research/agent.py +868 -0
  74. quantred/research/demo_proposals.py +222 -0
  75. quantred/research/llm.py +311 -0
  76. quantred/research/proposer.py +234 -0
  77. quantred/research/provider_errors.py +242 -0
  78. quantred/research/sandbox.py +340 -0
  79. quantred/risk/__init__.py +60 -0
  80. quantred/risk/base.py +218 -0
  81. quantred/risk/conditional.py +474 -0
  82. quantred/risk/exposures.py +88 -0
  83. quantred/risk/factor.py +119 -0
  84. quantred/risk/sample.py +89 -0
  85. quantred/risk/streaming.py +214 -0
  86. quantred/scanners/base.py +141 -0
  87. quantred/scanners/symbol_scanner.py +148 -0
  88. quantred/services/__init__.py +51 -0
  89. quantred/services/analysis.py +6082 -0
  90. quantred/services/audit.py +574 -0
  91. quantred/services/campaign.py +381 -0
  92. quantred/services/configs.py +282 -0
  93. quantred/services/data.py +172 -0
  94. quantred/services/dryrun.py +206 -0
  95. quantred/services/glossary.py +130 -0
  96. quantred/services/maintenance.py +314 -0
  97. quantred/services/registry.py +351 -0
  98. quantred/services/setup.py +1023 -0
  99. quantred/services/sizing.py +150 -0
  100. quantred/services/smallreal.py +450 -0
  101. quantred/settings.py +571 -0
  102. quantred/store/__init__.py +0 -0
  103. quantred/store/bars.py +660 -0
  104. quantred/store/trials.py +1804 -0
  105. quantred/strategies/base.py +643 -0
  106. quantred/strategies/signals.py +28 -0
  107. quantred/utils/errors.py +48 -0
  108. quantred/utils/extras.py +330 -0
  109. quantred/utils/logging_config.py +33 -0
  110. quantred/utils/numeric.py +46 -0
  111. quantred/utils/streaming.py +474 -0
  112. quantred/utils/timeutils.py +148 -0
  113. quantred-2.4.0.dist-info/METADATA +632 -0
  114. quantred-2.4.0.dist-info/RECORD +117 -0
  115. quantred-2.4.0.dist-info/WHEEL +4 -0
  116. quantred-2.4.0.dist-info/entry_points.txt +8 -0
  117. quantred-2.4.0.dist-info/licenses/LICENSE +21 -0
quantred/__init__.py ADDED
@@ -0,0 +1,24 @@
1
+ """QuantRed — a layered, broker-agnostic algorithmic-trading research engine.
2
+
3
+ The package root deliberately imports nothing. Every subpackage pulls in something
4
+ heavy or optional (pandas, the Alpaca SDK, matplotlib, the MCP SDK), and a root that
5
+ eagerly imported them would make ``quantred --help`` pay for capabilities the
6
+ command is not using — and would make an absent optional extra an import error at
7
+ startup rather than an actionable message at the point of use.
8
+
9
+ The layers, in dependency order (each may import from below, never above):
10
+
11
+ brokers -> marketdata -> indicators -> strategies/scanners -> engine
12
+ -> alphas/risk/portfolio/analytics
13
+ -> services -> cli / mcp
14
+
15
+ The one invariant that outranks all of them: **two clocks**. The research clock
16
+ (backtest, optimize, walk-forward, the whole analytics stack) may be slow,
17
+ exploratory, and LLM-assisted; it only ever *proposes*. The trade clock
18
+ (``engine.live``) is deterministic and imports none of it. Promotion between them is
19
+ a manual human step.
20
+ """
21
+
22
+ #: The single source of truth for the version — packaging reads it from here, so a
23
+ #: published artifact and what `quantred --version` reports cannot drift apart.
24
+ __version__ = "2.4.0"
@@ -0,0 +1,53 @@
1
+ """Continuous alphas: turn a score column into a comparable residual-return forecast.
2
+
3
+ The central active-management move: convert a raw, arbitrary-scale signal into an
4
+ *alpha* - a forecast of residual return in annualized-return units, the same for
5
+ every name - so views can be pooled, ranked, and sized by a mean-variance
6
+ optimizer. Research-clock only: alphas forecast, they never trade.
7
+
8
+ The refinement runs over a :class:`~quantred.data.panel.FeaturePanel`: a scorer
9
+ (:mod:`quantred.alphas.scorers`) fills the ``score`` column, :func:`refine_alpha` adds
10
+ ``z`` and ``alpha`` via the identity ``alpha = sigma * IC * z``, and
11
+ :func:`panel_to_alphas` exports the ranked :class:`Alpha` rows.
12
+ """
13
+
14
+ from quantred.alphas.base import (
15
+ DEFAULT_IC,
16
+ DEFAULT_MIN_UNIVERSE,
17
+ Alpha,
18
+ AlphaContext,
19
+ panel_to_alphas,
20
+ refine_alpha,
21
+ )
22
+ from quantred.alphas.combine import (
23
+ SignalMeasurement,
24
+ combination_weights,
25
+ combine_scores,
26
+ combined_ic,
27
+ combined_score,
28
+ effective_ic,
29
+ measure_signals,
30
+ shrink_ic,
31
+ )
32
+ from quantred.alphas.scorers import Scorer, scanner_scorer, signal_scorer, strategy_scorer
33
+
34
+ __all__ = [
35
+ "Alpha",
36
+ "AlphaContext",
37
+ "refine_alpha",
38
+ "panel_to_alphas",
39
+ "Scorer",
40
+ "strategy_scorer",
41
+ "signal_scorer",
42
+ "scanner_scorer",
43
+ "DEFAULT_IC",
44
+ "DEFAULT_MIN_UNIVERSE",
45
+ "SignalMeasurement",
46
+ "measure_signals",
47
+ "combined_score",
48
+ "combination_weights",
49
+ "combined_ic",
50
+ "effective_ic",
51
+ "shrink_ic",
52
+ "combine_scores",
53
+ ]
@@ -0,0 +1,221 @@
1
+ """Alpha types and the refinement that turns a score column into a forecast.
2
+
3
+ An *alpha* is a forecast of **residual return** (return in excess of what beta to
4
+ the benchmark explains), annualized and on the same scale for every name, so it is
5
+ directly comparable across symbols and directly consumable by a mean-variance
6
+ optimizer.
7
+
8
+ The refinement runs over a :class:`~quantred.data.panel.FeaturePanel`: it reads the
9
+ cross-section's ``score`` and ``residual_vol`` columns (and ``beta`` when
10
+ neutralizing) and writes ``z`` and ``alpha`` columns back. One implementation, one
11
+ place - whatever produced the score (a strategy, a scanner, a combination of
12
+ signals) flows through the same pipeline.
13
+
14
+ Research clock only: it forecasts, it never reads a realized forward return, it
15
+ places no orders.
16
+ """
17
+
18
+ from dataclasses import dataclass
19
+ from datetime import datetime
20
+ from typing import List, Optional, Tuple
21
+
22
+ import pandas as pd
23
+
24
+ from quantred.alphas import refine
25
+ from quantred.data.features import EXPOSURE_PREFIX
26
+ from quantred.data.panel import FeaturePanel
27
+
28
+ #: Conservative default information coefficient. The absolute scale of alphas is
29
+ #: only as good as this prior until IC is measured from realized outcomes; the
30
+ #: *relative* sizing across names is correct regardless (IC is a common scalar).
31
+ DEFAULT_IC = 0.03
32
+
33
+ #: Below this many names the cross-sectional z-score and winsorize quantiles are
34
+ #: unstable, so the pipeline falls back to demean-only and flags low confidence.
35
+ DEFAULT_MIN_UNIVERSE = 10
36
+
37
+
38
+ def thin_universe_text(n_names: int, minimum: int = DEFAULT_MIN_UNIVERSE) -> str:
39
+ """Why a thin universe lowers confidence, for every surface that reports it."""
40
+ return (
41
+ f"thin universe ({n_names} names, fewer than {minimum}) - demean-only: no winsorizing or "
42
+ "unit-std scaling, so alpha magnitudes are not comparable to a full universe's"
43
+ )
44
+
45
+
46
+ @dataclass
47
+ class Alpha:
48
+ """A refined residual-return forecast: annualized, benchmark-relative."""
49
+
50
+ symbol: str
51
+ alpha: float # expected annualized residual return, e.g. 0.04 = +4%/yr
52
+ as_of: datetime
53
+ residual_vol: float # sigma used in scaling (annualized), for audit
54
+ ic: float # information coefficient used in scaling
55
+ raw_z: float # standardized score that produced it
56
+
57
+
58
+ @dataclass
59
+ class AlphaContext:
60
+ """Knobs for the refinement (everything that isn't a panel column)."""
61
+
62
+ ic: float = DEFAULT_IC
63
+ default_residual_vol: float = 0.20 # fallback when a name has no risk estimate
64
+ neutralize: bool = False # regress out the benchmark-beta exposure
65
+ #: Risk-model factors to regress out (``exp_<name>`` panel columns, written by
66
+ #: :func:`quantred.data.features.add_factor_exposure_features`). ``"market"`` here
67
+ #: supersedes the plain-beta ``neutralize`` (same exposure, standardized).
68
+ #: Momentum is deliberately absent from the usual set — a momentum tilt is a
69
+ #: return bet the alphas may intend; market/volatility/size are risk-control.
70
+ neutralize_factors: tuple = ()
71
+ winsorize_limits: tuple = (0.025, 0.975)
72
+ alpha_cap_std: float = 3.0
73
+ min_universe: int = DEFAULT_MIN_UNIVERSE
74
+ #: Per-name scaling. ``"case1"`` = the standard rule ``ω·IC·z`` (the default,
75
+ #: correct when per-name signal vol is constant across names); ``"case2"`` =
76
+ #: ``IC·c_g·z`` with a constant :func:`~quantred.alphas.refine.case_scale_factor`, for
77
+ #: signals whose vol is ∝ the name's residual vol (multiplying by ``ω_n`` would
78
+ #: double-count it). :func:`~quantred.alphas.refine.case_test` picks the case empirically.
79
+ #: See the Continuous-alphas page in the engineering docs for the Case test.
80
+ scaling: str = "case1"
81
+ #: IC-uncertainty **level** shrink. When set to ``(measured_ic, t_eff)`` the refined
82
+ #: alpha is multiplied once by :func:`~quantred.alphas.refine.level_shrink_factor` — the
83
+ #: estimation-error haircut on the *level*. ``None`` = off (a measured IC hasn't been
84
+ #: supplied, e.g. an assumed prior, or the level shrink is owned upstream by the
85
+ #: multi-signal combination's per-signal Bayesian shrink).
86
+ level_shrink: Optional[Tuple[float, float]] = None
87
+
88
+
89
+ def refine_alpha(
90
+ panel: FeaturePanel,
91
+ context: AlphaContext,
92
+ score_col: str = "score",
93
+ ) -> FeaturePanel:
94
+ """Refine a panel's ``score`` column into ``z`` and ``alpha`` columns.
95
+
96
+ Cross-sectional at this one rebalance: winsorize, z-score, optionally neutralize
97
+ against the ``beta`` exposure and/or the risk-model factor block
98
+ (``context.neutralize_factors`` reading ``exp_<factor>`` columns), scale by
99
+ ``sigma * IC``, then cap. On a thin
100
+ universe (``< context.min_universe`` scored names) winsorize and the unit-std
101
+ scaling are skipped in favor of demean-only, and ``panel.meta['low_confidence']``
102
+ is set. Reads ``residual_vol`` for ``sigma`` (falling back to the context default).
103
+ """
104
+ if not panel.has(score_col):
105
+ panel.meta["low_confidence"] = False
106
+ return panel
107
+
108
+ s = panel.get(score_col).dropna()
109
+ if s.empty:
110
+ panel.meta["low_confidence"] = False
111
+ return panel
112
+
113
+ thin = len(s) < context.min_universe
114
+ panel.meta["low_confidence"] = thin
115
+
116
+ # 1-2. Winsorize then standardize. On thin universes the quantiles and the
117
+ # cross-sectional std are unreliable, so demean-only (no scaling).
118
+ if thin:
119
+ z = refine.demean(s)
120
+ else:
121
+ z = refine.zscore(refine.winsorize(s, *context.winsorize_limits))
122
+
123
+ # 3. Optional neutralization: benchmark beta and/or risk-model factor exposures
124
+ # (one regression on the union, so the residual is orthogonal to all of it).
125
+ # A factor column is used only if it actually varies across covered names —
126
+ # an absent, all-NaN, or constant column must degrade to beta neutralization,
127
+ # never silently to *no* neutralization. Names missing a factor value get the
128
+ # cross-sectional mean (0 — exposures are standardized), which keeps them in
129
+ # the regression instead of stripping their beta neutralization too.
130
+ if not thin:
131
+ columns = {}
132
+ imputed = 0
133
+ for factor in context.neutralize_factors:
134
+ col = f"{EXPOSURE_PREFIX}{factor}"
135
+ if not panel.has(col):
136
+ continue
137
+ series = panel.get(col).reindex(z.index)
138
+ if series.notna().sum() >= 2 and series.std(ddof=0) > 0:
139
+ imputed += int(series.isna().sum())
140
+ columns[col] = series.fillna(0.0)
141
+ market_col = f"{EXPOSURE_PREFIX}market"
142
+ if context.neutralize and panel.has("beta") and market_col not in columns:
143
+ columns["beta"] = panel.get("beta")
144
+ if columns:
145
+ z = refine.neutralize(z, pd.DataFrame(columns).reindex(z.index))
146
+ panel.meta["neutralized_against"] = [
147
+ c[len(EXPOSURE_PREFIX) :] if c.startswith(EXPOSURE_PREFIX) else c for c in columns
148
+ ]
149
+ panel.meta["neutralize_imputed"] = imputed
150
+
151
+ # 4. Scale to a residual-return forecast. Case 1: alpha_i = sigma_i * IC * z_i
152
+ # (per-name vol). Case 2: alpha_i = IC * c_g * z_i (one constant c_g), which
153
+ # avoids double-counting vol for signals whose per-name vol is ∝ sigma_i.
154
+ if panel.has("residual_vol"):
155
+ vol = panel.get("residual_vol").reindex(z.index).fillna(context.default_residual_vol)
156
+ else:
157
+ vol = pd.Series(context.default_residual_vol, index=z.index)
158
+
159
+ chain: List[dict] = []
160
+ if context.scaling == "case2" and not thin:
161
+ c_g = refine.case_scale_factor(s, vol, context.winsorize_limits)
162
+ if not (c_g == c_g): # NaN fallback → a representative vol, so it never crashes
163
+ c_g = float(vol.mean())
164
+ alpha = refine.scale_to_alpha(z, pd.Series(c_g, index=z.index), context.ic)
165
+ chain.append({"step": "scale", "case": 2, "c_g": float(c_g), "multiplier": float(context.ic * c_g)})
166
+ else:
167
+ alpha = refine.scale_to_alpha(z, vol, context.ic)
168
+ chain.append({"step": "scale", "case": 1, "multiplier": float(context.ic)})
169
+
170
+ # 4b. IC-uncertainty level shrink, applied exactly once here — the single
171
+ # application point for both the single-signal and combined paths.
172
+ if context.level_shrink is not None:
173
+ ic_measured, t_eff = context.level_shrink
174
+ factor = refine.level_shrink_factor(ic_measured, t_eff)
175
+ alpha = alpha * factor
176
+ chain.append(
177
+ {
178
+ "step": "ic_uncertainty",
179
+ "owner": "level_shrink",
180
+ "ic": float(ic_measured),
181
+ "t_eff": float(t_eff),
182
+ "multiplier": float(factor),
183
+ }
184
+ )
185
+ panel.meta["shrink_chain"] = chain
186
+
187
+ # 5. Final sanity cap (meaningless on a thin, unscaled vector).
188
+ if not thin:
189
+ alpha = refine.cap(alpha, context.alpha_cap_std)
190
+
191
+ panel.set("z", z)
192
+ panel.set("alpha", alpha)
193
+ return panel
194
+
195
+
196
+ def panel_to_alphas(panel: FeaturePanel, context: AlphaContext) -> List[Alpha]:
197
+ """Export the refined panel rows as :class:`Alpha` records (alpha-ranked)."""
198
+ if not panel.has("alpha"):
199
+ return []
200
+ rows = []
201
+ for symbol in panel.symbols:
202
+ alpha = panel.get("alpha").get(symbol)
203
+ if alpha is None or pd.isna(alpha):
204
+ continue
205
+ z = panel.get("z").get(symbol) if panel.has("z") else float("nan")
206
+ vol = (
207
+ panel.get("residual_vol").get(symbol)
208
+ if panel.has("residual_vol")
209
+ else context.default_residual_vol
210
+ )
211
+ rows.append(
212
+ Alpha(
213
+ symbol=symbol,
214
+ alpha=float(alpha),
215
+ as_of=panel.as_of,
216
+ residual_vol=float(vol) if vol == vol else context.default_residual_vol,
217
+ ic=context.ic,
218
+ raw_z=float(z) if z == z else 0.0,
219
+ )
220
+ )
221
+ return sorted(rows, key=lambda a: a.alpha, reverse=True)
@@ -0,0 +1,260 @@
1
+ """Multi-signal alpha combination & shrinkage.
2
+
3
+ One signal becomes one alpha (:mod:`quantred.alphas`). Real research has several - a
4
+ trend read, a volume read, a mean-reversion read - and they are **correlated**.
5
+ Two mistakes follow if you ignore that:
6
+
7
+ 1. **Naive weighting double-counts.** Weighting by raw IC over-weights redundant
8
+ signals (three flavors of trend look like three bets but are one) and
9
+ under-weights a weak-but-independent signal that adds the most.
10
+ 2. **Estimated ICs are themselves uncertain**, and that uncertainty should shrink
11
+ the contribution toward zero - more for short histories and low ICs.
12
+
13
+ This module adds both correctives: optimal combination via the signal
14
+ **correlation matrix**, and **Bayesian IC-uncertainty shrinkage**. The output is a
15
+ single combined score (with its combined IC), which flows through the same
16
+ :func:`~quantred.alphas.base.refine_alpha` pipeline - so the per-signal assumed IC is
17
+ replaced by one measured, shrunk, redundancy-aware number, never applied twice.
18
+
19
+ The ICs and the correlation matrix must be **measured** (here, over a trailing
20
+ window of realized residual returns), not assumed - in-sample/assumed ICs would let
21
+ the combination over-fit its own weights.
22
+ """
23
+
24
+ from dataclasses import dataclass
25
+ from datetime import datetime
26
+ from typing import Callable, Dict, List
27
+
28
+ import numpy as np
29
+ import pandas as pd
30
+
31
+ from quantred.alphas import refine
32
+ from quantred.indicators import indicators
33
+
34
+ Scorer = Callable[[pd.DataFrame], float]
35
+
36
+ #: Tiny ridge added to the signal correlation matrix before inverting it. Keeps the
37
+ #: GLS combination finite when two signals are near-duplicates (ρ → 1 ⇒ singular).
38
+ DEFAULT_RIDGE = 1e-4
39
+
40
+
41
+ # --------------------------------------------------------------------------- #
42
+ # The combination math (pure, closed-form)
43
+ # --------------------------------------------------------------------------- #
44
+ def shrink_ic(ic: float, n_obs: int) -> float:
45
+ """Bayesian IC-uncertainty shrinkage: ``ic' = ic · g/(g+1)``, ``g = n·ic²``.
46
+
47
+ Confidence grows with the number of observations ``n`` and the signal's own
48
+ explanatory power ``ic²``. So ``ic' → 0`` as ``n`` is small or ``ic → 0``, and
49
+ ``ic' → ic`` as ``n·ic² → ∞``. This is what stops a noisy, short-history IC from
50
+ being trusted at face value.
51
+ """
52
+ g = n_obs * ic * ic
53
+ return float(ic * g / (g + 1.0)) if g > 0 else 0.0
54
+
55
+
56
+ def effective_ic(ic1: float, ic2: float, rho: float) -> float:
57
+ """Redundancy-corrected effective IC of signal 1 given signal 2 (two-signal).
58
+
59
+ ``IC₁' = (IC₁ − ρ·IC₂) / (1 − ρ²)`` - the part of signal 1's skill that is not
60
+ already explained by signal 2.
61
+ """
62
+ denom = 1.0 - rho * rho
63
+ if denom <= 0:
64
+ return 0.0
65
+ return (ic1 - rho * ic2) / denom
66
+
67
+
68
+ def _regularized(corr: np.ndarray, ridge: float) -> np.ndarray:
69
+ c = np.asarray(corr, dtype=float)
70
+ return c + ridge * np.eye(c.shape[0])
71
+
72
+
73
+ def combination_weights(ics: np.ndarray, corr: np.ndarray, ridge: float = DEFAULT_RIDGE) -> np.ndarray:
74
+ """Optimal signal weights ``w = Ω⁻¹ · IC`` (GLS on the signal correlation matrix).
75
+
76
+ These maximize the combined IC. Because ``Ω⁻¹`` accounts for correlation, two
77
+ redundant signals **split** a weight rather than each getting full credit - the
78
+ built-in defense against double-counting. A ridge regularizes the inverse so a
79
+ near-duplicate pair (ρ → 1) stays finite.
80
+ """
81
+ ic = np.asarray(ics, dtype=float)
82
+ return np.linalg.solve(_regularized(corr, ridge), ic)
83
+
84
+
85
+ def combined_ic(ics: np.ndarray, corr: np.ndarray, ridge: float = DEFAULT_RIDGE) -> float:
86
+ """The combined information coefficient ``√(ICᵀ Ω⁻¹ IC)``.
87
+
88
+ For two signals this is exactly ``√((IC₁² + IC₂² − 2ρ·IC₁·IC₂)/(1 − ρ²))``.
89
+ Adding a weak *independent* signal raises it; adding a strong *redundant* one
90
+ barely does.
91
+ """
92
+ ic = np.asarray(ics, dtype=float)
93
+ val = float(ic @ np.linalg.solve(_regularized(corr, ridge), ic))
94
+ return float(np.sqrt(max(val, 0.0)))
95
+
96
+
97
+ def combine_scores(score_frame: pd.DataFrame, weights: np.ndarray) -> pd.Series:
98
+ """Weighted sum of per-signal cross-sectional z-scores → one combined score.
99
+
100
+ Each signal column is standardized cross-sectionally first (so the weights act
101
+ on comparable units), then combined by ``weights``. The result's absolute scale
102
+ is irrelevant - :func:`refine_alpha` re-standardizes it - so only the relative
103
+ weights matter.
104
+ """
105
+ if score_frame.empty:
106
+ return pd.Series(dtype=float)
107
+ z = score_frame.apply(refine.zscore, axis=0)
108
+ combined = z.to_numpy() @ np.asarray(weights, dtype=float)
109
+ return pd.Series(combined, index=score_frame.index)
110
+
111
+
112
+ # --------------------------------------------------------------------------- #
113
+ # IC measurement (over a trailing window of realized residual returns)
114
+ # --------------------------------------------------------------------------- #
115
+ @dataclass
116
+ class SignalMeasurement:
117
+ """Measured inputs for the combination, plus their provenance."""
118
+
119
+ signals: List[str]
120
+ ics: Dict[str, float] # mean cross-sectional IC per signal
121
+ shrunk_ics: Dict[str, float] # after Bayesian shrinkage
122
+ correlation: pd.DataFrame # K×K signal correlation matrix
123
+ weights: Dict[str, float] # Ω⁻¹ · shrunk_IC, per signal
124
+ combined_ic: float
125
+ n_periods: int # rebalances measured (the shrinkage sample size)
126
+
127
+
128
+ def measure_signals(
129
+ bars: Dict[str, pd.DataFrame],
130
+ scorers: Dict[str, Scorer],
131
+ benchmark_bars: pd.DataFrame,
132
+ as_of: datetime,
133
+ *,
134
+ horizon: int = 5,
135
+ n_points: int = 12,
136
+ warmup: int = 30,
137
+ min_names: int = 5,
138
+ ridge: float = DEFAULT_RIDGE,
139
+ ) -> SignalMeasurement:
140
+ """Measure each signal's IC and the signal correlation matrix over history.
141
+
142
+ At each of ``n_points`` rebalance dates within ``(warmup, as_of − horizon]``, score
143
+ every signal on the cross-section (using only bars ``≤ t``) and correlate it with
144
+ the subsequent ``horizon``-bar realized **residual** return (return minus
145
+ ``β·benchmark`` - rewarding skill, not beta). The mean over rebalances is the IC;
146
+ the mean cross-sectional correlation between signals is ``Ω``. ICs are then shrunk
147
+ and combined into GLS weights.
148
+ """
149
+ names = [s for s in bars if not bars[s].empty]
150
+ signal_names = list(scorers)
151
+ ref_index = benchmark_bars.index
152
+ usable = ref_index[ref_index <= _to_ts(as_of, ref_index)]
153
+ # Rebalance points: evenly spaced, leaving room for the forward horizon.
154
+ last = len(usable) - horizon - 1
155
+ if last <= warmup:
156
+ return _empty_measurement(signal_names)
157
+ points = np.linspace(warmup, last, num=min(n_points, last - warmup), dtype=int)
158
+
159
+ per_signal_ic: Dict[str, List[float]] = {s: [] for s in signal_names}
160
+ corr_accum: List[pd.DataFrame] = []
161
+
162
+ for j in points:
163
+ t = usable[j]
164
+ t_fwd = usable[j + horizon]
165
+ scores, fwd = _cross_section(bars, names, scorers, benchmark_bars, t, t_fwd, min_names)
166
+ if scores is None:
167
+ continue
168
+ for s in signal_names:
169
+ ic = scores[s].corr(fwd)
170
+ if pd.notna(ic):
171
+ per_signal_ic[s].append(float(ic))
172
+ corr_accum.append(scores.corr())
173
+
174
+ n_periods = len(corr_accum)
175
+ ics = {s: float(np.mean(v)) if v else 0.0 for s, v in per_signal_ic.items()}
176
+ if corr_accum:
177
+ correlation = sum(corr_accum) / n_periods
178
+ else:
179
+ correlation = pd.DataFrame(np.eye(len(signal_names)), index=signal_names, columns=signal_names)
180
+
181
+ shrunk = {s: shrink_ic(ics[s], n_periods) for s in signal_names}
182
+ ic_vec = np.array([shrunk[s] for s in signal_names])
183
+ corr_mat = correlation.loc[signal_names, signal_names].to_numpy()
184
+ w = combination_weights(ic_vec, corr_mat, ridge) if n_periods else np.zeros(len(signal_names))
185
+ ic_comb = combined_ic(ic_vec, corr_mat, ridge) if n_periods else 0.0
186
+
187
+ return SignalMeasurement(
188
+ signals=signal_names,
189
+ ics=ics,
190
+ shrunk_ics=shrunk,
191
+ correlation=correlation,
192
+ weights={s: float(w[i]) for i, s in enumerate(signal_names)},
193
+ combined_ic=ic_comb,
194
+ n_periods=n_periods,
195
+ )
196
+
197
+
198
+ def _cross_section(bars, names, scorers, benchmark_bars, t, t_fwd, min_names):
199
+ """Per-signal score cross-section at ``t`` and the realized residual return to ``t_fwd``."""
200
+ bench_close = benchmark_bars["close"]
201
+ if t not in bench_close.index or t_fwd not in bench_close.index:
202
+ return None, None
203
+ bench_ret = bench_close.loc[t_fwd] / bench_close.loc[t] - 1.0
204
+
205
+ score_rows: Dict[str, Dict[str, float]] = {s: {} for s in scorers}
206
+ resid: Dict[str, float] = {}
207
+ for sym in names:
208
+ frame = bars[sym]
209
+ hist = frame.loc[frame.index <= t]
210
+ if len(hist) < 2 or t not in frame.index or t_fwd not in frame.index:
211
+ continue
212
+ for s, scorer in scorers.items():
213
+ val = scorer(hist)
214
+ if val is not None and not pd.isna(val):
215
+ score_rows[s][sym] = float(val)
216
+ r = frame["close"].loc[t_fwd] / frame["close"].loc[t] - 1.0
217
+ beta = indicators.calculate_beta(hist["close"], bench_close.loc[bench_close.index <= t])
218
+ resid[sym] = r - beta * bench_ret
219
+
220
+ scores = pd.DataFrame(score_rows)
221
+ fwd = pd.Series(resid)
222
+ common = scores.dropna().index.intersection(fwd.dropna().index)
223
+ if len(common) < min_names:
224
+ return None, None
225
+ return scores.loc[common], fwd.loc[common]
226
+
227
+
228
+ def _to_ts(as_of: datetime, index: pd.Index) -> pd.Timestamp:
229
+ """Localize a possibly-naive ``as_of`` to a (possibly tz-aware) index's timezone."""
230
+ ts = pd.Timestamp(as_of)
231
+ if isinstance(index, pd.DatetimeIndex) and index.tz is not None:
232
+ ts = ts.tz_localize(index.tz) if ts.tzinfo is None else ts.tz_convert(index.tz)
233
+ return ts
234
+
235
+
236
+ def _empty_measurement(signal_names: List[str]) -> SignalMeasurement:
237
+ return SignalMeasurement(
238
+ signals=signal_names,
239
+ ics={s: 0.0 for s in signal_names},
240
+ shrunk_ics={s: 0.0 for s in signal_names},
241
+ correlation=pd.DataFrame(np.eye(len(signal_names)), index=signal_names, columns=signal_names),
242
+ weights={s: 0.0 for s in signal_names},
243
+ combined_ic=0.0,
244
+ n_periods=0,
245
+ )
246
+
247
+
248
+ def combined_score(bars, scorers, measurement: SignalMeasurement, as_of: datetime) -> pd.Series:
249
+ """Score each name with every signal at ``as_of`` and combine by the GLS weights."""
250
+ names = [s for s in bars if not bars[s].empty]
251
+ rows: Dict[str, Dict[str, float]] = {s: {} for s in scorers}
252
+ for sym in names:
253
+ frame = bars[sym]
254
+ for s, scorer in scorers.items():
255
+ val = scorer(frame)
256
+ if val is not None and not pd.isna(val):
257
+ rows[s][sym] = float(val)
258
+ score_frame = pd.DataFrame(rows).reindex(columns=measurement.signals)
259
+ weights = np.array([measurement.weights[s] for s in measurement.signals])
260
+ return combine_scores(score_frame.dropna(), weights)