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
@@ -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