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.
- quantred/__init__.py +24 -0
- quantred/alphas/__init__.py +53 -0
- quantred/alphas/base.py +221 -0
- quantred/alphas/combine.py +260 -0
- quantred/alphas/horizon.py +153 -0
- quantred/alphas/refine.py +300 -0
- quantred/alphas/scorers.py +87 -0
- quantred/analytics/attribution.py +354 -0
- quantred/analytics/bootstrap.py +353 -0
- quantred/analytics/charts.py +393 -0
- quantred/analytics/excursion.py +185 -0
- quantred/analytics/execution_quality.py +180 -0
- quantred/analytics/exposure.py +167 -0
- quantred/analytics/htmlreport.py +1208 -0
- quantred/analytics/information.py +197 -0
- quantred/analytics/metrics.py +502 -0
- quantred/analytics/performance.py +639 -0
- quantred/analytics/reporting.py +1672 -0
- quantred/analytics/screening.py +190 -0
- quantred/analytics/series_comparison.py +303 -0
- quantred/analytics/trade_analytics.py +353 -0
- quantred/brokers/alpaca/broker.py +415 -0
- quantred/brokers/alpaca/factory.py +33 -0
- quantred/brokers/alpaca/market_data.py +166 -0
- quantred/brokers/base.py +220 -0
- quantred/brokers/dryrun.py +164 -0
- quantred/brokers/errors.py +70 -0
- quantred/cli.py +8619 -0
- quantred/costs/__init__.py +11 -0
- quantred/costs/base.py +126 -0
- quantred/costs/parametric.py +131 -0
- quantred/data/__init__.py +29 -0
- quantred/data/compute.py +332 -0
- quantred/data/edges.py +81 -0
- quantred/data/features.py +120 -0
- quantred/data/panel.py +61 -0
- quantred/data/scan.py +85 -0
- quantred/data/store.py +269 -0
- quantred/demo/__init__.py +22 -0
- quantred/demo/scanners.py +104 -0
- quantred/demo/strategies.py +125 -0
- quantred/engine/backtest.py +1206 -0
- quantred/engine/barcheck.py +228 -0
- quantred/engine/live.py +539 -0
- quantred/execution/decision.py +192 -0
- quantred/execution/flatten.py +302 -0
- quantred/execution/halt.py +165 -0
- quantred/execution/ledger.py +758 -0
- quantred/execution/live_trader.py +587 -0
- quantred/execution/order_id.py +83 -0
- quantred/execution/sizing.py +114 -0
- quantred/indicators/indicators.py +158 -0
- quantred/marketdata/base.py +62 -0
- quantred/marketdata/client.py +59 -0
- quantred/marketdata/session.py +112 -0
- quantred/marketdata/synthetic.py +74 -0
- quantred/marketdata/timeframe.py +125 -0
- quantred/mcp/__init__.py +6 -0
- quantred/mcp/selftest.py +139 -0
- quantred/mcp/server.py +2419 -0
- quantred/optimization/causality.py +607 -0
- quantred/optimization/config_store.py +300 -0
- quantred/optimization/optimizer.py +431 -0
- quantred/optimization/parallel.py +345 -0
- quantred/optimization/param_space.py +309 -0
- quantred/optimization/walk_forward.py +1254 -0
- quantred/portfolio/allocator.py +135 -0
- quantred/portfolio/benchmark.py +109 -0
- quantred/portfolio/optimizer.py +786 -0
- quantred/portfolio/policy.py +335 -0
- quantred/portfolio/posterior.py +208 -0
- quantred/research/__init__.py +30 -0
- quantred/research/agent.py +868 -0
- quantred/research/demo_proposals.py +222 -0
- quantred/research/llm.py +311 -0
- quantred/research/proposer.py +234 -0
- quantred/research/provider_errors.py +242 -0
- quantred/research/sandbox.py +340 -0
- quantred/risk/__init__.py +60 -0
- quantred/risk/base.py +218 -0
- quantred/risk/conditional.py +474 -0
- quantred/risk/exposures.py +88 -0
- quantred/risk/factor.py +119 -0
- quantred/risk/sample.py +89 -0
- quantred/risk/streaming.py +214 -0
- quantred/scanners/base.py +141 -0
- quantred/scanners/symbol_scanner.py +148 -0
- quantred/services/__init__.py +51 -0
- quantred/services/analysis.py +6082 -0
- quantred/services/audit.py +574 -0
- quantred/services/campaign.py +381 -0
- quantred/services/configs.py +282 -0
- quantred/services/data.py +172 -0
- quantred/services/dryrun.py +206 -0
- quantred/services/glossary.py +130 -0
- quantred/services/maintenance.py +314 -0
- quantred/services/registry.py +351 -0
- quantred/services/setup.py +1023 -0
- quantred/services/sizing.py +150 -0
- quantred/services/smallreal.py +450 -0
- quantred/settings.py +571 -0
- quantred/store/__init__.py +0 -0
- quantred/store/bars.py +660 -0
- quantred/store/trials.py +1804 -0
- quantred/strategies/base.py +643 -0
- quantred/strategies/signals.py +28 -0
- quantred/utils/errors.py +48 -0
- quantred/utils/extras.py +330 -0
- quantred/utils/logging_config.py +33 -0
- quantred/utils/numeric.py +46 -0
- quantred/utils/streaming.py +474 -0
- quantred/utils/timeutils.py +148 -0
- quantred-2.4.0.dist-info/METADATA +632 -0
- quantred-2.4.0.dist-info/RECORD +117 -0
- quantred-2.4.0.dist-info/WHEEL +4 -0
- quantred-2.4.0.dist-info/entry_points.txt +8 -0
- 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
|
+
]
|
quantred/alphas/base.py
ADDED
|
@@ -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)
|