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
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Information horizon - how fast an alpha decays, and what to do about it.
|
|
2
|
+
|
|
3
|
+
Every alpha is perishable: its forecasting power (IC) decays over time at a rate
|
|
4
|
+
that is an intrinsic property of the signal. Measuring that decay yields two free
|
|
5
|
+
wins a research engine should surface:
|
|
6
|
+
|
|
7
|
+
1. **The rebalance cadence is derivable** - trading much faster than the signal
|
|
8
|
+
regenerates pays cost for noise; much slower throws away breadth. The half-life
|
|
9
|
+
pins the sweet spot.
|
|
10
|
+
2. **The freshest signal is rarely optimal** - blending the current signal with a
|
|
11
|
+
lagged copy systematically raises the information ratio (a second partly-
|
|
12
|
+
independent read, or a hedge that cancels noise).
|
|
13
|
+
|
|
14
|
+
This module is the pure math: fit the decay from an IC-vs-lag profile (measured by
|
|
15
|
+
the information-analysis layer), and compute the optimal current/lagged blend.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import math
|
|
19
|
+
from typing import Dict, Mapping, Tuple
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
|
|
23
|
+
#: horizon/half-life ratio at which signal-return correlation peaks (≈ 0.638).
|
|
24
|
+
PEAK_HORIZON_RATIO = 1.2566
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def fit_decay(ic_by_lag: Mapping[int, float], ci_z: float = 1.96) -> Dict[str, float]:
|
|
28
|
+
"""Fit the per-period decay ``δ`` from an IC-vs-lag profile by log-linear regression.
|
|
29
|
+
|
|
30
|
+
Models ``IC(n) = IC₀ · δ^n`` ⇒ ``ln IC(n) = ln IC₀ + n·ln δ``, fit on the lags with
|
|
31
|
+
a positive IC (the reliable, same-sign region). Returns ``δ``, the half-life
|
|
32
|
+
``HL = −ln2 / ln δ``, the fitted ``IC₀``, and the fit ``R²`` (low R² ⇒ the decay
|
|
33
|
+
isn't a clean exponential - don't over-trust the half-life).
|
|
34
|
+
|
|
35
|
+
Also fits the OLS standard error of the decay slope and reports the
|
|
36
|
+
``ci_z``-sigma confidence band on the half-life (``half_life_lower``,
|
|
37
|
+
``half_life_upper``) - short histories give wide CIs on the half-life, and
|
|
38
|
+
the multi-period trading policy's aim discount uses this band rather than
|
|
39
|
+
trusting the point estimate. ``half_life_upper`` (a less-negative slope, i.e. slower
|
|
40
|
+
decay) is the direction that avoids prematurely discounting a genuinely
|
|
41
|
+
persistent signal on a noisy short history; ``half_life_lower`` is the
|
|
42
|
+
faster-decay end. Both collapse to the point estimate when the slope's SE isn't
|
|
43
|
+
defined (fewer than 3 usable lags).
|
|
44
|
+
"""
|
|
45
|
+
lags = sorted(n for n, ic in ic_by_lag.items() if ic > 0 and n >= 0)
|
|
46
|
+
if len(lags) < 2:
|
|
47
|
+
return {
|
|
48
|
+
"delta": float("nan"),
|
|
49
|
+
"half_life": float("nan"),
|
|
50
|
+
"ic0": float("nan"),
|
|
51
|
+
"r_squared": 0.0,
|
|
52
|
+
"half_life_lower": float("nan"),
|
|
53
|
+
"half_life_upper": float("nan"),
|
|
54
|
+
"decay_slope_se": float("nan"),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
x = np.array(lags, dtype=float)
|
|
58
|
+
y = np.log(np.array([ic_by_lag[n] for n in lags], dtype=float))
|
|
59
|
+
slope, intercept = np.polyfit(x, y, 1)
|
|
60
|
+
delta = float(math.exp(slope))
|
|
61
|
+
resid = y - (slope * x + intercept)
|
|
62
|
+
ss_res = float(np.sum(resid**2))
|
|
63
|
+
ss_tot = float(np.sum((y - y.mean()) ** 2))
|
|
64
|
+
r_squared = 1.0 - ss_res / ss_tot if ss_tot > 0 else 0.0
|
|
65
|
+
|
|
66
|
+
half_life = -math.log(2) / math.log(delta) if 0 < delta < 1 else float("inf")
|
|
67
|
+
|
|
68
|
+
n = len(lags)
|
|
69
|
+
sxx = float(np.sum((x - x.mean()) ** 2))
|
|
70
|
+
slope_se = math.sqrt(ss_res / (n - 2) / sxx) if n > 2 and sxx > 0 else float("nan")
|
|
71
|
+
|
|
72
|
+
def _half_life_at(s: float) -> float:
|
|
73
|
+
return -math.log(2) / s if s < 0 else float("inf")
|
|
74
|
+
|
|
75
|
+
if slope_se == slope_se: # not NaN
|
|
76
|
+
half_life_upper = _half_life_at(slope + ci_z * slope_se) # less negative -> slower decay
|
|
77
|
+
half_life_lower = _half_life_at(slope - ci_z * slope_se) # more negative -> faster decay
|
|
78
|
+
else:
|
|
79
|
+
half_life_upper = half_life
|
|
80
|
+
half_life_lower = half_life
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
"delta": delta,
|
|
84
|
+
"half_life": half_life,
|
|
85
|
+
"ic0": float(math.exp(intercept)),
|
|
86
|
+
"r_squared": r_squared,
|
|
87
|
+
"half_life_lower": half_life_lower,
|
|
88
|
+
"half_life_upper": half_life_upper,
|
|
89
|
+
"decay_slope_se": slope_se,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def peak_return_horizon(half_life: float) -> float:
|
|
94
|
+
"""The forward-return horizon a signal of this half-life predicts best (≈ 1.26·HL).
|
|
95
|
+
|
|
96
|
+
Scoring a short-horizon signal against a long-horizon return (or vice versa) is a
|
|
97
|
+
common way to understate real skill; this is the horizon to match.
|
|
98
|
+
"""
|
|
99
|
+
return PEAK_HORIZON_RATIO * half_life if math.isfinite(half_life) else float("inf")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def blend_weights(gamma: float, rho: float) -> Tuple[float, float]:
|
|
103
|
+
"""IR-maximizing weights on the current vs lagged signal: ``(w_now, w_lag)``.
|
|
104
|
+
|
|
105
|
+
``w_now = (1 − γ·ρ) / (1 + γ² − 2·γ·ρ)`` (decay ``γ``, current/lagged
|
|
106
|
+
autocorrelation ``ρ``); ``w_lag = 1 − w_now``. Three regimes:
|
|
107
|
+
|
|
108
|
+
- ``γ > ρ`` → **diversify**: ``w_lag > 0`` (the lag carries independent info).
|
|
109
|
+
- ``γ < ρ`` → **hedge**: ``w_lag < 0`` (it mostly cancels current noise).
|
|
110
|
+
- ``γ = ρ`` → the latest signal alone is sufficient (``w_lag = 0``).
|
|
111
|
+
"""
|
|
112
|
+
denom = 1.0 + gamma * gamma - 2.0 * gamma * rho
|
|
113
|
+
if denom == 0:
|
|
114
|
+
return 1.0, 0.0
|
|
115
|
+
w_now = (1.0 - gamma * rho) / denom
|
|
116
|
+
return w_now, 1.0 - w_now
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def frequency_ir_curve(ic_by_lag: Mapping[int, float]) -> Dict[int, float]:
|
|
120
|
+
"""The accuracy-vs-frequency curve ``IR(Δt) = IC(Δt)·√(1/Δt)`` over candidate cadences.
|
|
121
|
+
|
|
122
|
+
Faster rebalancing raises ``√BR`` but acts on less-confirmed information (lower
|
|
123
|
+
``IC``); the optimum is interior. Returns ``{cadence: IR_proxy}`` so the cadence is
|
|
124
|
+
*chosen*, not assumed.
|
|
125
|
+
"""
|
|
126
|
+
curve = {}
|
|
127
|
+
for dt, ic in ic_by_lag.items():
|
|
128
|
+
if dt >= 1:
|
|
129
|
+
curve[dt] = float(ic * math.sqrt(1.0 / dt))
|
|
130
|
+
return curve
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def recommended_cadence(ic_by_lag: Mapping[int, float]) -> int:
|
|
134
|
+
"""The cadence (in periods) that maximizes the IR proxy ``IC(Δt)·√(1/Δt)``."""
|
|
135
|
+
curve = frequency_ir_curve(ic_by_lag)
|
|
136
|
+
return max(curve, key=curve.get) if curve else 1
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def effective_sample_size(n_obs: float, horizon: int, spacing: float = 1.0) -> float:
|
|
140
|
+
"""Effective *independent* observations when each spans ``horizon`` periods.
|
|
141
|
+
|
|
142
|
+
An IC measured from returns over a ``horizon``-period holding, sampled every
|
|
143
|
+
``spacing`` periods, overlaps: consecutive observations share ``horizon − spacing``
|
|
144
|
+
periods of return and so are not independent. The independent count is
|
|
145
|
+
``n_obs / overlap`` with ``overlap = max(1, horizon/spacing)`` — daily rows with a
|
|
146
|
+
21-day horizon (``spacing=1``) are ~21× overlapped, so ``T_eff ≈ n/21``; rebalances
|
|
147
|
+
already spaced ``≥ horizon`` apart (``spacing ≥ horizon``) are independent and
|
|
148
|
+
``T_eff = n``. This is the honest ``T`` the IC-uncertainty shrink
|
|
149
|
+
(:func:`quantred.alphas.refine.level_shrink_factor`) needs: using the raw row count
|
|
150
|
+
under-shrinks by exactly the overlap factor.
|
|
151
|
+
"""
|
|
152
|
+
overlap = max(1.0, horizon / max(spacing, 1e-9))
|
|
153
|
+
return float(n_obs / overlap) if overlap > 0 else float(n_obs)
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""Alpha refinement primitives - the pure-math steps that turn a raw signal into a
|
|
2
|
+
scaled, comparable residual-return forecast.
|
|
3
|
+
|
|
4
|
+
Every function here is a pure cross-sectional transform on a pandas Series indexed
|
|
5
|
+
by symbol: same names in, same names out, inputs untouched. They are deliberately
|
|
6
|
+
standalone (no state, no I/O) so each step of the pipeline in
|
|
7
|
+
:mod:`quantred.alphas.base` is unit-testable in isolation and composable.
|
|
8
|
+
|
|
9
|
+
The pipeline order:
|
|
10
|
+
|
|
11
|
+
raw scores -> winsorize -> zscore -> [neutralize] -> scale -> cap
|
|
12
|
+
|
|
13
|
+
All standardization here is **cross-sectional** (across names at one rebalance),
|
|
14
|
+
never across time for one name - standardizing over time would reintroduce the
|
|
15
|
+
look-ahead this module exists to avoid.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import math
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
import pandas as pd
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def winsorize(scores: pd.Series, lower: float = 0.025, upper: float = 0.975) -> pd.Series:
|
|
25
|
+
"""Clip scores to their ``[lower, upper]`` cross-sectional quantiles.
|
|
26
|
+
|
|
27
|
+
Kills single-name outliers so one extreme score can't dominate the z-score's
|
|
28
|
+
mean/std. With too few names the quantiles are meaningless; callers skip this
|
|
29
|
+
step on thin universes (see :mod:`quantred.alphas.base`).
|
|
30
|
+
"""
|
|
31
|
+
if scores.empty:
|
|
32
|
+
return scores
|
|
33
|
+
lo = scores.quantile(lower)
|
|
34
|
+
hi = scores.quantile(upper)
|
|
35
|
+
return scores.clip(lower=lo, upper=hi)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def demean(scores: pd.Series) -> pd.Series:
|
|
39
|
+
"""Subtract the cross-sectional mean (the thin-universe fallback for zscore).
|
|
40
|
+
|
|
41
|
+
Centers the scores at 0 without dividing by the cross-sectional std, which is
|
|
42
|
+
unstable on a handful of names. Preserves relative spacing; sets no scale.
|
|
43
|
+
"""
|
|
44
|
+
if scores.empty:
|
|
45
|
+
return scores
|
|
46
|
+
return scores - scores.mean()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def zscore(scores: pd.Series) -> pd.Series:
|
|
50
|
+
"""Cross-sectional standardization: ``z = (s - mean) / std``.
|
|
51
|
+
|
|
52
|
+
Uses the population std (``ddof=0``) so the output has mean 0 and unit
|
|
53
|
+
dispersion exactly. If every score is identical (zero std) the result is all
|
|
54
|
+
zeros rather than NaN/inf - a degenerate universe expresses no view.
|
|
55
|
+
"""
|
|
56
|
+
if scores.empty:
|
|
57
|
+
return scores
|
|
58
|
+
centered = scores - scores.mean()
|
|
59
|
+
std = scores.std(ddof=0)
|
|
60
|
+
if std == 0 or not np.isfinite(std):
|
|
61
|
+
return centered * 0.0
|
|
62
|
+
return centered / std
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def neutralize(z: pd.Series, exposures: pd.DataFrame) -> pd.Series:
|
|
66
|
+
"""Regress ``z`` on ``exposures`` and return the residual (orthogonal to them).
|
|
67
|
+
|
|
68
|
+
Makes the alpha neutral to the supplied exposures: an intercept is always
|
|
69
|
+
included (so the residual is mean-zero, i.e. benchmark/equal-weight neutral),
|
|
70
|
+
and the residual is orthogonal to every exposure column by construction
|
|
71
|
+
(the defining property of an OLS residual). Pass a single beta column for
|
|
72
|
+
benchmark-beta neutralization, or a factor-exposure matrix for factor
|
|
73
|
+
neutralization.
|
|
74
|
+
|
|
75
|
+
``exposures`` is a DataFrame indexed by symbol; it is aligned to ``z`` and any
|
|
76
|
+
name missing an exposure is dropped from the regression and returned unchanged.
|
|
77
|
+
"""
|
|
78
|
+
if z.empty or exposures is None or exposures.empty:
|
|
79
|
+
return z
|
|
80
|
+
|
|
81
|
+
frame = exposures.reindex(z.index)
|
|
82
|
+
valid = frame.dropna().index
|
|
83
|
+
if len(valid) < 2:
|
|
84
|
+
return z
|
|
85
|
+
|
|
86
|
+
y = z.loc[valid].to_numpy(dtype=float)
|
|
87
|
+
# Design matrix: an intercept column plus the exposures.
|
|
88
|
+
x = np.column_stack([np.ones(len(valid)), frame.loc[valid].to_numpy(dtype=float)])
|
|
89
|
+
coeffs, *_ = np.linalg.lstsq(x, y, rcond=None)
|
|
90
|
+
residual = y - x @ coeffs
|
|
91
|
+
|
|
92
|
+
out = z.copy()
|
|
93
|
+
out.loc[valid] = residual
|
|
94
|
+
return out
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def scale_to_alpha(z: pd.Series, residual_vol: pd.Series, ic: float) -> pd.Series:
|
|
98
|
+
"""Apply the refinement identity ``alpha_i = sigma_i * IC * z_i``.
|
|
99
|
+
|
|
100
|
+
Turns a standardized score into a forecast of annualized residual return. The
|
|
101
|
+
cross-sectional std of the result is ``~ IC * sigma`` (for unit-std ``z``), so
|
|
102
|
+
the alphas carry the right information ratio for a mean-variance optimizer to
|
|
103
|
+
size positions by genuine conviction rather than by an arbitrary signal scale.
|
|
104
|
+
|
|
105
|
+
``residual_vol`` is aligned to ``z`` by symbol. This is the **Case 1** scaling
|
|
106
|
+
(see :func:`case_test`): correct when the signal's per-name time-series vol is
|
|
107
|
+
roughly constant across names, so the cross-sectional z equals the time-series z
|
|
108
|
+
the standard rule is stated in. For a **Case 2** signal pass a constant
|
|
109
|
+
:func:`case_scale_factor` as ``residual_vol`` instead of the per-name vector.
|
|
110
|
+
"""
|
|
111
|
+
vol = residual_vol.reindex(z.index)
|
|
112
|
+
# Evaluated as (sigma * IC) * z to match the written identity exactly.
|
|
113
|
+
return vol * ic * z
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def case_scale_factor(
|
|
117
|
+
raw_scores: pd.Series,
|
|
118
|
+
residual_vol: pd.Series,
|
|
119
|
+
winsorize_limits: tuple = (0.025, 0.975),
|
|
120
|
+
) -> float:
|
|
121
|
+
"""The **Case 2** constant scale ``c_g = Std_CS{g} / Std_CS{g/ω}``.
|
|
122
|
+
|
|
123
|
+
A Case-2 signal (per-name signal vol ∝ the name's residual vol ``ω_n`` —
|
|
124
|
+
empirically most price/estimate signals) already carries the volatility inside
|
|
125
|
+
the raw score; multiplying by ``ω_n`` in :func:`scale_to_alpha` double-counts it
|
|
126
|
+
and tilts the book into high-vol names. The fix replaces the per-name ``ω_n`` with
|
|
127
|
+
one cross-sectional **constant** ``c_g`` (a vol-dimensioned, dispersion-matched
|
|
128
|
+
representative vol), so ``alpha = IC · c_g · z`` sizes by conviction without the
|
|
129
|
+
spurious vol tilt.
|
|
130
|
+
|
|
131
|
+
Computed on the **raw** signal (winsorized the same way the pipeline winsorizes),
|
|
132
|
+
because after standardization the raw dispersion that defines ``c_g`` is gone.
|
|
133
|
+
Returns NaN when the ratio can't be formed (too few names, all-zero vol) so the
|
|
134
|
+
caller can fall back.
|
|
135
|
+
"""
|
|
136
|
+
g = raw_scores.dropna()
|
|
137
|
+
if len(g) < 2:
|
|
138
|
+
return float("nan")
|
|
139
|
+
g = winsorize(g, *winsorize_limits)
|
|
140
|
+
vol = residual_vol.reindex(g.index)
|
|
141
|
+
ratio = (g / vol.where(vol > 0)).replace([np.inf, -np.inf], np.nan).dropna()
|
|
142
|
+
num = g.std(ddof=0)
|
|
143
|
+
den = ratio.std(ddof=0)
|
|
144
|
+
if not np.isfinite(den) or den == 0:
|
|
145
|
+
return float("nan")
|
|
146
|
+
return float(num / den)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def case_test(
|
|
150
|
+
signal_history: pd.DataFrame,
|
|
151
|
+
residual_vol: pd.Series,
|
|
152
|
+
*,
|
|
153
|
+
price_derived: bool = True,
|
|
154
|
+
min_obs: int = 36,
|
|
155
|
+
r2_case2: float = 0.25,
|
|
156
|
+
r2_case1: float = 0.05,
|
|
157
|
+
t_threshold: float = 2.0,
|
|
158
|
+
) -> dict:
|
|
159
|
+
"""Decide a signal's scaling: **Case 1** (``ω·IC·z``) vs **Case 2** (``IC·c_g·z``).
|
|
160
|
+
|
|
161
|
+
The standard refinement rule ``α = ω·IC·z`` is stated in *time-series* z-scores,
|
|
162
|
+
but the pipeline computes *cross-sectional* z. Whether the two coincide is an
|
|
163
|
+
empirical property of each signal:
|
|
164
|
+
|
|
165
|
+
- **Case 1** — per-name time-series signal vol ``Std_TS{g_n}`` is ~constant across
|
|
166
|
+
names ⇒ ``z_TS ≈ z_CS`` and the ``ω_n`` multiply is right (the classic
|
|
167
|
+
sector-momentum example).
|
|
168
|
+
- **Case 2** — ``Std_TS{g_n}`` is ~proportional to the name's residual vol ``ω_n``
|
|
169
|
+
(most price/estimate signals) ⇒ the vol is already inside the raw signal and the
|
|
170
|
+
``ω_n`` multiply double-counts it.
|
|
171
|
+
|
|
172
|
+
The test regresses ``Std_TS{g_n} = a + b·ω_n`` across names: a strong, significant
|
|
173
|
+
positive slope ⇒ Case 2. ``signal_history`` is a time×name frame of the **raw**
|
|
174
|
+
signal; ``residual_vol`` the per-name ``ω_n``. Needs ``≥ min_obs`` time observations
|
|
175
|
+
to engage; below that (or in the ambiguous band ``r2_case1 < R² < r2_case2``) it
|
|
176
|
+
returns the empirical base rate — **Case 2 for price-derived signals, Case 1
|
|
177
|
+
otherwise** — flagged ``ambiguous`` so a wrong call is visible, not silent.
|
|
178
|
+
"""
|
|
179
|
+
default_case = 2 if price_derived else 1
|
|
180
|
+
|
|
181
|
+
def _result(case, r2, t, slope, n_obs, n_names, engaged, ambiguous, reason):
|
|
182
|
+
return {
|
|
183
|
+
"case": case,
|
|
184
|
+
"r_squared": float(r2),
|
|
185
|
+
"t_stat": float(t),
|
|
186
|
+
"slope": float(slope),
|
|
187
|
+
"n_obs": int(n_obs),
|
|
188
|
+
"n_names": int(n_names),
|
|
189
|
+
"engaged": bool(engaged),
|
|
190
|
+
"ambiguous": bool(ambiguous),
|
|
191
|
+
"reason": reason,
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if signal_history is None or signal_history.empty:
|
|
195
|
+
return _result(default_case, 0.0, 0.0, 0.0, 0, 0, False, True, "no signal history")
|
|
196
|
+
|
|
197
|
+
std_ts = signal_history.std(ddof=1)
|
|
198
|
+
counts = signal_history.count()
|
|
199
|
+
n_obs = int(counts.median()) if len(counts) else 0
|
|
200
|
+
|
|
201
|
+
aligned = pd.concat([std_ts.rename("y"), residual_vol.rename("x")], axis=1).dropna()
|
|
202
|
+
# A name needs enough time observations for its Std_TS to mean anything.
|
|
203
|
+
aligned = aligned[counts.reindex(aligned.index).fillna(0) >= min(min_obs, max(n_obs, 2))]
|
|
204
|
+
n_names = len(aligned)
|
|
205
|
+
|
|
206
|
+
if n_obs < min_obs or n_names < 3:
|
|
207
|
+
return _result(
|
|
208
|
+
default_case,
|
|
209
|
+
0.0,
|
|
210
|
+
0.0,
|
|
211
|
+
0.0,
|
|
212
|
+
n_obs,
|
|
213
|
+
n_names,
|
|
214
|
+
False,
|
|
215
|
+
True,
|
|
216
|
+
f"insufficient history (n_obs={n_obs} < {min_obs} or n_names={n_names} < 3); "
|
|
217
|
+
f"defaulting to Case {default_case} ({'price-derived' if price_derived else 'other'})",
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
x = aligned["x"].to_numpy()
|
|
221
|
+
y = aligned["y"].to_numpy()
|
|
222
|
+
xm, ym = x.mean(), y.mean()
|
|
223
|
+
sxx = float(((x - xm) ** 2).sum())
|
|
224
|
+
if sxx == 0:
|
|
225
|
+
return _result(default_case, 0.0, 0.0, 0.0, n_obs, n_names, False, True, "no ω dispersion")
|
|
226
|
+
slope = float(((x - xm) * (y - ym)).sum() / sxx)
|
|
227
|
+
resid = y - (ym + slope * (x - xm))
|
|
228
|
+
ss_res = float((resid**2).sum())
|
|
229
|
+
ss_tot = float(((y - ym) ** 2).sum())
|
|
230
|
+
r2 = 1.0 - ss_res / ss_tot if ss_tot > 0 else 0.0
|
|
231
|
+
dof = n_names - 2
|
|
232
|
+
se_b = math.sqrt((ss_res / dof) / sxx) if dof > 0 and sxx > 0 else float("inf")
|
|
233
|
+
t_stat = slope / se_b if np.isfinite(se_b) and se_b > 0 else 0.0
|
|
234
|
+
|
|
235
|
+
if r2 >= r2_case2 and t_stat >= t_threshold and slope > 0:
|
|
236
|
+
return _result(
|
|
237
|
+
2,
|
|
238
|
+
r2,
|
|
239
|
+
t_stat,
|
|
240
|
+
slope,
|
|
241
|
+
n_obs,
|
|
242
|
+
n_names,
|
|
243
|
+
True,
|
|
244
|
+
False,
|
|
245
|
+
f"Std_TS ∝ ω (R²={r2:.2f}, t={t_stat:.1f}): vol already in the signal",
|
|
246
|
+
)
|
|
247
|
+
if r2 <= r2_case1:
|
|
248
|
+
return _result(
|
|
249
|
+
1,
|
|
250
|
+
r2,
|
|
251
|
+
t_stat,
|
|
252
|
+
slope,
|
|
253
|
+
n_obs,
|
|
254
|
+
n_names,
|
|
255
|
+
True,
|
|
256
|
+
False,
|
|
257
|
+
f"Std_TS ⊥ ω (R²={r2:.2f}): per-name signal vol ~constant",
|
|
258
|
+
)
|
|
259
|
+
return _result(
|
|
260
|
+
default_case,
|
|
261
|
+
r2,
|
|
262
|
+
t_stat,
|
|
263
|
+
slope,
|
|
264
|
+
n_obs,
|
|
265
|
+
n_names,
|
|
266
|
+
True,
|
|
267
|
+
True,
|
|
268
|
+
f"ambiguous (R²={r2:.2f}, t={t_stat:.1f}); defaulting to Case {default_case} "
|
|
269
|
+
f"({'price-derived' if price_derived else 'other'})",
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def level_shrink_factor(ic: float, t_eff: float) -> float:
|
|
274
|
+
"""IC-uncertainty level shrink: ``1/(1 + 1/(T_eff·IC²)) = g/(g+1)``, ``g=T_eff·IC²``.
|
|
275
|
+
|
|
276
|
+
The IC that scales alphas is itself estimated, and its sampling error dominates the
|
|
277
|
+
mapping (``Var{Δβ/β} ≈ 1/(IC²·T)``). This Bayes-with-zero-prior factor
|
|
278
|
+
shrinks the alpha **level** toward zero for that estimation error: ``→ 1`` as
|
|
279
|
+
``T_eff·IC² → ∞`` (skill well established), ``→ 0`` as ``IC → 0`` or ``T_eff → 0``.
|
|
280
|
+
``T_eff`` must be *effective independent* observations (overlapping horizons inflate
|
|
281
|
+
a raw row count — see :func:`quantred.alphas.horizon.effective_sample_size`). Anchors:
|
|
282
|
+
``IC=0.05, T=60 → 0.13``; ``IC=0.10, T=120 → 0.55``.
|
|
283
|
+
"""
|
|
284
|
+
g = t_eff * ic * ic
|
|
285
|
+
return float(g / (g + 1.0)) if np.isfinite(g) and g > 0 else 0.0
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def cap(alpha: pd.Series, n_std: float = 3.0) -> pd.Series:
|
|
289
|
+
"""Final sanity bound: clip alphas to ``+/- n_std * std(alpha)``.
|
|
290
|
+
|
|
291
|
+
A guard against a single name carrying an implausible forecast after scaling.
|
|
292
|
+
With a degenerate (zero-std) alpha vector this is a no-op.
|
|
293
|
+
"""
|
|
294
|
+
if alpha.empty:
|
|
295
|
+
return alpha
|
|
296
|
+
std = alpha.std(ddof=0)
|
|
297
|
+
if std == 0 or not np.isfinite(std):
|
|
298
|
+
return alpha
|
|
299
|
+
bound = n_std * std
|
|
300
|
+
return alpha.clip(lower=-bound, upper=bound)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Scorers - the atomic "score one name's bars" functions that feed the panel.
|
|
2
|
+
|
|
3
|
+
A scorer is just ``Callable[[pd.DataFrame], float]``: given one symbol's as-of-sliced
|
|
4
|
+
bars, return a continuous conviction. The panel's score producer applies a scorer
|
|
5
|
+
across the universe to fill the ``score`` column, which the refinement then turns
|
|
6
|
+
into an alpha. Keeping the source of the score behind this one-line contract is
|
|
7
|
+
what lets a strategy, a scanner, or (later) a combination of signals all flow
|
|
8
|
+
through the same alpha pipeline.
|
|
9
|
+
|
|
10
|
+
- :func:`strategy_scorer` - a strategy's own continuous conviction (its score).
|
|
11
|
+
The natural, richest source: it's the same number the trade clock's signal is
|
|
12
|
+
derived from.
|
|
13
|
+
- :func:`signal_scorer` - a strategy's *discrete* direction as +1/-1/0. A lossy
|
|
14
|
+
bucketing of the score; useful when only the direction is trusted.
|
|
15
|
+
- :func:`scanner_scorer` - a scanner's signed ``signal_strength``.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from typing import Callable
|
|
19
|
+
|
|
20
|
+
import pandas as pd
|
|
21
|
+
|
|
22
|
+
from quantred.scanners.base import SCANNER_BUY, SCANNER_SELL, ScannerStrategy
|
|
23
|
+
from quantred.strategies import signals
|
|
24
|
+
from quantred.strategies.base import Strategy
|
|
25
|
+
|
|
26
|
+
Scorer = Callable[[pd.DataFrame], float]
|
|
27
|
+
|
|
28
|
+
#: Map a discrete trade signal to a directional score. Exit signals carry no
|
|
29
|
+
#: cross-sectional view (they're about an existing position), so they score 0.
|
|
30
|
+
_SIGNAL_SCORE = {
|
|
31
|
+
signals.BUY: 1.0,
|
|
32
|
+
signals.SELL: -1.0,
|
|
33
|
+
signals.HOLD: 0.0,
|
|
34
|
+
signals.CLOSE_BUY: 0.0,
|
|
35
|
+
signals.CLOSE_SELL: 0.0,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def strategy_scorer(strategy: Strategy) -> Scorer:
|
|
40
|
+
"""Score a name by the strategy's continuous conviction on its last bar."""
|
|
41
|
+
|
|
42
|
+
def score(frame: pd.DataFrame) -> float:
|
|
43
|
+
processed = strategy.process_data(frame)
|
|
44
|
+
if processed.empty:
|
|
45
|
+
return 0.0
|
|
46
|
+
scores = strategy.calculate_scores(processed)
|
|
47
|
+
if scores.empty:
|
|
48
|
+
return 0.0
|
|
49
|
+
value = scores.iloc[-1]
|
|
50
|
+
return 0.0 if pd.isna(value) else float(value)
|
|
51
|
+
|
|
52
|
+
return score
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def signal_scorer(strategy: Strategy) -> Scorer:
|
|
56
|
+
"""Score a name by the strategy's discrete direction (+1 BUY / -1 SELL / 0)."""
|
|
57
|
+
|
|
58
|
+
def score(frame: pd.DataFrame) -> float:
|
|
59
|
+
processed = strategy.process_data(frame)
|
|
60
|
+
if processed.empty:
|
|
61
|
+
return 0.0
|
|
62
|
+
signal = strategy._latest_signal(strategy.generate_signals(processed))
|
|
63
|
+
return _SIGNAL_SCORE.get(signal, 0.0)
|
|
64
|
+
|
|
65
|
+
return score
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def scanner_scorer(scanner: ScannerStrategy) -> Scorer:
|
|
69
|
+
"""Score a name by the scanner's ``signal_strength``, signed by its direction."""
|
|
70
|
+
|
|
71
|
+
def score(frame: pd.DataFrame) -> float:
|
|
72
|
+
processed = scanner.process_data(frame)
|
|
73
|
+
if processed.empty:
|
|
74
|
+
return 0.0
|
|
75
|
+
signals_df = scanner.generate_signals_df(processed)
|
|
76
|
+
if signals_df.empty or "signal_strength" not in signals_df:
|
|
77
|
+
return 0.0
|
|
78
|
+
last = signals_df.iloc[-1]
|
|
79
|
+
strength = float(last.get("signal_strength", 0.0) or 0.0)
|
|
80
|
+
signal = last.get("signal")
|
|
81
|
+
if signal == SCANNER_BUY:
|
|
82
|
+
return strength
|
|
83
|
+
if signal == SCANNER_SELL:
|
|
84
|
+
return -strength
|
|
85
|
+
return 0.0
|
|
86
|
+
|
|
87
|
+
return score
|