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/risk/sample.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Sample-covariance estimators: raw, and Ledoit–Wolf shrinkage.
|
|
2
|
+
|
|
3
|
+
The raw sample covariance ``S`` over ``N`` names from ``T`` observations needs
|
|
4
|
+
``N(N+1)/2`` parameters; when ``T`` is not far greater than ``N`` it is noisy and
|
|
5
|
+
often **non-invertible** - fatal, because the optimizer needs ``Σ⁻¹``.
|
|
6
|
+
Ledoit–Wolf shrinks ``S`` toward a structured target ``F`` by the analytically
|
|
7
|
+
optimal intensity ``δ``:
|
|
8
|
+
|
|
9
|
+
Σ̂ = δ·F + (1 − δ)·S
|
|
10
|
+
|
|
11
|
+
Here ``F`` is the **constant-correlation** target (every pairwise correlation = the
|
|
12
|
+
average sample correlation, variances kept), which is the right structure for
|
|
13
|
+
equity returns. The closed form is vendored in pure numpy (Ledoit & Wolf, 2004,
|
|
14
|
+
"Honey, I Shrunk the Sample Covariance Matrix") so the risk module needs no
|
|
15
|
+
compiled or sklearn dependency.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from typing import Optional, Tuple
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
import pandas as pd
|
|
22
|
+
|
|
23
|
+
from quantred.risk.base import RiskModel
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SampleCovariance(RiskModel):
|
|
27
|
+
"""The raw sample covariance. Honest, but ill-conditioned when ``T ≲ N``."""
|
|
28
|
+
|
|
29
|
+
def estimate(self, returns: pd.DataFrame) -> Tuple[np.ndarray, Optional[float]]:
|
|
30
|
+
x = returns.to_numpy(dtype=float)
|
|
31
|
+
x = x - x.mean(axis=0)
|
|
32
|
+
t = x.shape[0]
|
|
33
|
+
if t < 2:
|
|
34
|
+
n = x.shape[1]
|
|
35
|
+
return np.zeros((n, n)), None
|
|
36
|
+
return (x.T @ x) / t, None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class LedoitWolfCovariance(RiskModel):
|
|
40
|
+
"""Ledoit–Wolf shrinkage toward a constant-correlation target (well-conditioned)."""
|
|
41
|
+
|
|
42
|
+
def estimate(self, returns: pd.DataFrame) -> Tuple[np.ndarray, float]:
|
|
43
|
+
x = returns.to_numpy(dtype=float)
|
|
44
|
+
x = x - x.mean(axis=0)
|
|
45
|
+
t, n = x.shape
|
|
46
|
+
if t < 2 or n == 0:
|
|
47
|
+
return np.zeros((n, n)), 0.0
|
|
48
|
+
|
|
49
|
+
sample = (x.T @ x) / t # MLE sample covariance
|
|
50
|
+
var = np.diag(sample)
|
|
51
|
+
std = np.sqrt(var)
|
|
52
|
+
if n == 1 or np.any(std == 0):
|
|
53
|
+
# No correlation structure to shrink (or a dead name); S is the answer.
|
|
54
|
+
return sample, 0.0
|
|
55
|
+
|
|
56
|
+
# Constant-correlation target F: average off-diagonal correlation, variances kept.
|
|
57
|
+
outer_std = np.outer(std, std)
|
|
58
|
+
corr = sample / outer_std
|
|
59
|
+
r_bar = (corr.sum() - n) / (n * (n - 1))
|
|
60
|
+
target = r_bar * outer_std
|
|
61
|
+
np.fill_diagonal(target, var)
|
|
62
|
+
|
|
63
|
+
# π̂ — sum of asymptotic variances of the sample-covariance entries.
|
|
64
|
+
x2 = x**2
|
|
65
|
+
pi_mat = (x2.T @ x2) / t - sample**2
|
|
66
|
+
pi_hat = pi_mat.sum()
|
|
67
|
+
|
|
68
|
+
# ρ̂ — asymptotic covariances; diagonal terms plus the constant-correlation
|
|
69
|
+
# off-diagonal adjustment (the θ terms).
|
|
70
|
+
rho_diag = np.trace(pi_mat)
|
|
71
|
+
cubed = (x**3).T @ x / t # A[i,j] = E[x_i^3 x_j]
|
|
72
|
+
theta_ii = cubed - var[:, None] * sample # θ_{ii,ij}
|
|
73
|
+
theta_jj = cubed.T - var[None, :] * sample # θ_{jj,ij}
|
|
74
|
+
ratio = np.outer(std, 1.0 / std) # ratio[i,j] = std_i / std_j
|
|
75
|
+
off = (r_bar / 2.0) * (ratio.T * theta_ii + ratio * theta_jj)
|
|
76
|
+
np.fill_diagonal(off, 0.0)
|
|
77
|
+
rho_hat = rho_diag + off.sum()
|
|
78
|
+
|
|
79
|
+
# γ̂ — misfit of the target to the sample covariance.
|
|
80
|
+
gamma_hat = float(((target - sample) ** 2).sum())
|
|
81
|
+
|
|
82
|
+
if gamma_hat == 0:
|
|
83
|
+
delta = 0.0
|
|
84
|
+
else:
|
|
85
|
+
kappa = (pi_hat - rho_hat) / gamma_hat
|
|
86
|
+
delta = max(0.0, min(1.0, kappa / t))
|
|
87
|
+
|
|
88
|
+
sigma = delta * target + (1.0 - delta) * sample
|
|
89
|
+
return sigma, float(delta)
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Out-of-core risk estimation — stream the return panel instead of materializing it.
|
|
2
|
+
|
|
3
|
+
:func:`quantred.risk.base.build_risk_matrix` (and the factor model that shares its
|
|
4
|
+
:func:`build_return_panel`) is the only genuinely **panel-wide** (``T×N``) pandas
|
|
5
|
+
materialization in the stack: it builds an aligned returns DataFrame for the whole
|
|
6
|
+
history and hands it to an estimator. For a broad universe over many years that ``T×N``
|
|
7
|
+
panel is the thing that does not fit in RAM.
|
|
8
|
+
|
|
9
|
+
This module is the out-of-core counterpart: it estimates the *same*
|
|
10
|
+
quantities by **streaming** the return panel out of a
|
|
11
|
+
:class:`~quantred.data.store.ParquetBarStore` in date-chunks and accumulating sufficient
|
|
12
|
+
statistics. It never holds the ``T×N`` panel: peak working memory is one chunk
|
|
13
|
+
(``chunk_obs × N``) plus the small accumulators (``N×N`` / ``K×K`` / ``N``) plus a 1-D
|
|
14
|
+
``O(T)`` index of complete-case timestamps — so on a long, wide history it peaks well
|
|
15
|
+
below the eager path (the dominant ``T×N`` matrix is never materialized), growing in
|
|
16
|
+
``T`` only as a thin timestamp vector.
|
|
17
|
+
|
|
18
|
+
Two estimators, both reproducing their eager oracle to machine epsilon for the
|
|
19
|
+
well-sampled names:
|
|
20
|
+
|
|
21
|
+
- :func:`streaming_sample_covariance` ↔ :func:`build_risk_matrix` with
|
|
22
|
+
:class:`~quantred.risk.sample.SampleCovariance`.
|
|
23
|
+
- :func:`streaming_factor_risk_matrix` ↔ :func:`quantred.risk.factor.estimate_factor_model`
|
|
24
|
+
(the structural ``Σ = X F Xᵀ + Δ``). Exposures stay a small ``N×K`` leaf computed by
|
|
25
|
+
:func:`quantred.risk.exposures.build_factor_exposures`.
|
|
26
|
+
|
|
27
|
+
Ledoit–Wolf shrinkage needs higher moments and stays on the eager path for now — noted,
|
|
28
|
+
not faked. Requires the ``store`` extra (``polars``).
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from datetime import datetime
|
|
32
|
+
from typing import TYPE_CHECKING, Callable, Iterable, List, Optional, Tuple
|
|
33
|
+
|
|
34
|
+
import numpy as np
|
|
35
|
+
import pandas as pd
|
|
36
|
+
|
|
37
|
+
from quantred.marketdata.client import TimeframeLike
|
|
38
|
+
from quantred.risk.base import RiskMatrix
|
|
39
|
+
from quantred.risk.factor import FactorRiskMatrix
|
|
40
|
+
|
|
41
|
+
if TYPE_CHECKING: # pragma: no cover - typing only
|
|
42
|
+
from quantred.data.store import ParquetBarStore
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _kept_and_blocks(
|
|
46
|
+
store: "ParquetBarStore",
|
|
47
|
+
universe: List[str],
|
|
48
|
+
timeframe: TimeframeLike,
|
|
49
|
+
as_of: datetime,
|
|
50
|
+
lookback_days: int,
|
|
51
|
+
min_obs: int,
|
|
52
|
+
chunk_obs: int,
|
|
53
|
+
) -> Optional[Tuple[List[str], Callable[[], Iterable[np.ndarray]]]]:
|
|
54
|
+
"""The shared streaming spine: the complete-case return panel, never materialized.
|
|
55
|
+
|
|
56
|
+
Reproduces :func:`build_return_panel` exactly: keep names with ≥ ``min_obs`` finite
|
|
57
|
+
returns (in ``universe`` order), take the timestamps where **every** kept name has a
|
|
58
|
+
finite return (the lazy analogue of ``returns[kept].dropna()``), and return a factory
|
|
59
|
+
that streams that complete-case panel in ``chunk_obs``-row blocks (columns in ``kept``
|
|
60
|
+
order). Returns ``(kept, blocks)`` or ``None`` if fewer than two complete-case rows.
|
|
61
|
+
"""
|
|
62
|
+
import polars as pl
|
|
63
|
+
|
|
64
|
+
from quantred.data import compute
|
|
65
|
+
|
|
66
|
+
lf = compute.with_returns(store.scan_lazy(universe, timeframe, as_of, lookback_days), src="close")
|
|
67
|
+
# Present == finite return: mirror pandas' notna/dropna (null and NaN are missing).
|
|
68
|
+
nn = lf.filter(pl.col("ret").is_not_null() & pl.col("ret").is_not_nan())
|
|
69
|
+
|
|
70
|
+
counts = nn.group_by("symbol").agg(pl.len().alias("n")).collect(engine="streaming")
|
|
71
|
+
count_map = dict(zip(counts["symbol"].to_list(), counts["n"].to_list()))
|
|
72
|
+
kept: List[str] = []
|
|
73
|
+
seen = set()
|
|
74
|
+
for sym in universe:
|
|
75
|
+
if sym in seen:
|
|
76
|
+
continue
|
|
77
|
+
seen.add(sym)
|
|
78
|
+
if count_map.get(sym, 0) >= min_obs:
|
|
79
|
+
kept.append(sym)
|
|
80
|
+
if not kept:
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
k = len(kept)
|
|
84
|
+
complete = (
|
|
85
|
+
nn.filter(pl.col("symbol").is_in(kept))
|
|
86
|
+
.group_by("ts")
|
|
87
|
+
.agg(pl.len().alias("c"))
|
|
88
|
+
.filter(pl.col("c") == k)
|
|
89
|
+
.select("ts")
|
|
90
|
+
.collect(engine="streaming")
|
|
91
|
+
.sort("ts")["ts"]
|
|
92
|
+
.to_list()
|
|
93
|
+
)
|
|
94
|
+
if len(complete) < 2:
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
def blocks() -> Iterable[np.ndarray]:
|
|
98
|
+
for i in range(0, len(complete), chunk_obs):
|
|
99
|
+
ts_chunk = complete[i : i + chunk_obs]
|
|
100
|
+
block = (
|
|
101
|
+
nn.filter(pl.col("symbol").is_in(kept) & pl.col("ts").is_in(ts_chunk))
|
|
102
|
+
.select(["ts", "symbol", "ret"])
|
|
103
|
+
.collect(engine="streaming")
|
|
104
|
+
.pivot(values="ret", index="ts", on="symbol")
|
|
105
|
+
.sort("ts")
|
|
106
|
+
)
|
|
107
|
+
yield block.select(kept).to_numpy() # rows × N, columns in kept order
|
|
108
|
+
|
|
109
|
+
return kept, blocks
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def streaming_sample_covariance(
|
|
113
|
+
store: "ParquetBarStore",
|
|
114
|
+
universe: List[str],
|
|
115
|
+
timeframe: TimeframeLike,
|
|
116
|
+
as_of: datetime,
|
|
117
|
+
*,
|
|
118
|
+
lookback_days: int = 365,
|
|
119
|
+
periods_per_year: float = 252.0,
|
|
120
|
+
min_obs: int = 60,
|
|
121
|
+
chunk_obs: int = 1024,
|
|
122
|
+
) -> Optional[RiskMatrix]:
|
|
123
|
+
"""Annualized sample :class:`RiskMatrix` by streaming the return panel.
|
|
124
|
+
|
|
125
|
+
Mirrors :func:`build_risk_matrix` with :class:`~quantred.risk.sample.SampleCovariance`
|
|
126
|
+
(population covariance), accumulating cross-products chunk-by-chunk via
|
|
127
|
+
:func:`quantred.data.compute.streaming_covariance`. Returns ``None`` when no name clears
|
|
128
|
+
``min_obs`` or fewer than two complete-case rows exist; ``shrinkage`` is ``None``.
|
|
129
|
+
Kept names are returned in ``universe`` order.
|
|
130
|
+
"""
|
|
131
|
+
from quantred.data import compute
|
|
132
|
+
|
|
133
|
+
prepared = _kept_and_blocks(store, universe, timeframe, as_of, lookback_days, min_obs, chunk_obs)
|
|
134
|
+
if prepared is None:
|
|
135
|
+
return None
|
|
136
|
+
kept, blocks = prepared
|
|
137
|
+
|
|
138
|
+
sigma_bar, n_obs = compute.streaming_covariance(blocks())
|
|
139
|
+
if n_obs < 2:
|
|
140
|
+
return None
|
|
141
|
+
return RiskMatrix(symbols=kept, sigma=sigma_bar * periods_per_year, shrinkage=None)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def streaming_factor_risk_matrix(
|
|
145
|
+
store: "ParquetBarStore",
|
|
146
|
+
universe: List[str],
|
|
147
|
+
timeframe: TimeframeLike,
|
|
148
|
+
as_of: datetime,
|
|
149
|
+
exposures: pd.DataFrame,
|
|
150
|
+
*,
|
|
151
|
+
lookback_days: int = 365,
|
|
152
|
+
periods_per_year: float = 252.0,
|
|
153
|
+
min_obs: int = 60,
|
|
154
|
+
chunk_obs: int = 1024,
|
|
155
|
+
) -> Optional[FactorRiskMatrix]:
|
|
156
|
+
"""Structural factor :class:`FactorRiskMatrix` ``Σ = X F Xᵀ + Δ`` by streaming returns.
|
|
157
|
+
|
|
158
|
+
Mirrors :func:`quantred.risk.factor.estimate_factor_model`: factor returns are recovered
|
|
159
|
+
per period by the cross-sectional OLS ``f_t = (XᵀX)⁻¹Xᵀ r_t``, ``F`` is their
|
|
160
|
+
covariance (``ddof=1``, as ``np.cov``) and ``Δ`` the per-name residual variance
|
|
161
|
+
(``ddof=1``) — both annualized. The ``N×K`` ``exposures`` (a small cross-sectional
|
|
162
|
+
leaf from :func:`quantred.risk.build_factor_exposures`) stay pandas; only the panel-wide
|
|
163
|
+
``T×N`` returns are streamed. Each chunk is projected to factor returns and residuals
|
|
164
|
+
and folded into ``K×K`` / ``N`` accumulators, so the ``T×N`` panel is never held.
|
|
165
|
+
|
|
166
|
+
Names are the well-sampled set ∩ ``exposures.index`` (the eager path's selection),
|
|
167
|
+
in ``universe`` order. Returns ``None`` if fewer than two names/periods survive.
|
|
168
|
+
"""
|
|
169
|
+
prepared = _kept_and_blocks(store, universe, timeframe, as_of, lookback_days, min_obs, chunk_obs)
|
|
170
|
+
if prepared is None:
|
|
171
|
+
return None
|
|
172
|
+
kept, blocks = prepared
|
|
173
|
+
|
|
174
|
+
names = [s for s in kept if s in exposures.index]
|
|
175
|
+
if len(names) < 2:
|
|
176
|
+
return None
|
|
177
|
+
col = [kept.index(s) for s in names] # pick the `names` columns out of each kept-ordered block
|
|
178
|
+
x = exposures.loc[names].to_numpy(dtype=float) # N×K
|
|
179
|
+
proj = x @ np.linalg.pinv(x.T @ x) # N×K; factor returns of a chunk R are R @ proj
|
|
180
|
+
|
|
181
|
+
n_obs = 0
|
|
182
|
+
k = x.shape[1]
|
|
183
|
+
sf = np.zeros(k) # Σ f_t
|
|
184
|
+
sff = np.zeros((k, k)) # Σ f_t f_tᵀ
|
|
185
|
+
sr = np.zeros(len(names)) # Σ residual_t (per name)
|
|
186
|
+
srr = np.zeros(len(names)) # Σ residual_t² (per name)
|
|
187
|
+
for block in blocks():
|
|
188
|
+
r = block[:, col] # rows × N (names columns)
|
|
189
|
+
f = r @ proj # rows × K factor returns
|
|
190
|
+
resid = r - f @ x.T # rows × N residuals
|
|
191
|
+
sf += f.sum(axis=0)
|
|
192
|
+
sff += f.T @ f
|
|
193
|
+
sr += resid.sum(axis=0)
|
|
194
|
+
srr += (resid * resid).sum(axis=0)
|
|
195
|
+
n_obs += r.shape[0]
|
|
196
|
+
|
|
197
|
+
if n_obs < 2:
|
|
198
|
+
return None
|
|
199
|
+
# ddof=1 sample stats from the streamed sums, to match np.cov / Series.var(ddof=1).
|
|
200
|
+
mean_f = sf / n_obs
|
|
201
|
+
factor_cov = (sff / n_obs - np.outer(mean_f, mean_f)) * (n_obs / (n_obs - 1)) * periods_per_year
|
|
202
|
+
factor_cov = np.atleast_2d(factor_cov)
|
|
203
|
+
specific_var = (srr - sr * sr / n_obs) / (n_obs - 1) * periods_per_year
|
|
204
|
+
sigma = x @ factor_cov @ x.T + np.diag(specific_var)
|
|
205
|
+
|
|
206
|
+
return FactorRiskMatrix(
|
|
207
|
+
symbols=names,
|
|
208
|
+
sigma=sigma,
|
|
209
|
+
shrinkage=None,
|
|
210
|
+
exposures=exposures.loc[names],
|
|
211
|
+
factor_cov=factor_cov,
|
|
212
|
+
specific_var=specific_var,
|
|
213
|
+
factor_names=list(exposures.columns),
|
|
214
|
+
)
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Base class for universe scanners - the bouncers deciding which symbols are even
|
|
2
|
+
worth a strategy's attention today.
|
|
3
|
+
|
|
4
|
+
A scanner answers a different question than a strategy: *which symbols are worth
|
|
5
|
+
trading right now?* It scores each symbol's recent bars and emits a per-symbol
|
|
6
|
+
scan signal (``SCANNER_BUY`` / ``SCANNER_SELL`` / ``SCANNER_HOLD``).
|
|
7
|
+
|
|
8
|
+
Scanners operate on a single symbol's OHLCV frame at a time (the
|
|
9
|
+
:class:`~quantred.scanners.symbol_scanner.SymbolScanner` iterates the universe), which
|
|
10
|
+
keeps the interface simple and avoids MultiIndex bookkeeping.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from abc import ABC, abstractmethod
|
|
14
|
+
from typing import Any, Dict, List
|
|
15
|
+
|
|
16
|
+
import pandas as pd
|
|
17
|
+
|
|
18
|
+
from quantred.analytics import metrics
|
|
19
|
+
|
|
20
|
+
# Scan-signal vocabulary (distinct from trade signals so the two never mix).
|
|
21
|
+
SCANNER_BUY = "SCANNER_BUY"
|
|
22
|
+
SCANNER_SELL = "SCANNER_SELL"
|
|
23
|
+
SCANNER_HOLD = "SCANNER_HOLD"
|
|
24
|
+
|
|
25
|
+
_ACTIONABLE = frozenset({SCANNER_BUY, SCANNER_SELL})
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ScannerStrategy(ABC):
|
|
29
|
+
"""Abstract base for scanner strategies."""
|
|
30
|
+
|
|
31
|
+
SCANNER_BUY = SCANNER_BUY
|
|
32
|
+
SCANNER_SELL = SCANNER_SELL
|
|
33
|
+
|
|
34
|
+
#: Tunable parameters (with min/max/step) for the optimizer.
|
|
35
|
+
PARAM_RANGES: Dict[str, Dict[str, Any]] = {}
|
|
36
|
+
|
|
37
|
+
def __init__(self, config: Dict[str, Any]):
|
|
38
|
+
self.config = self._validate_config(config)
|
|
39
|
+
|
|
40
|
+
# ------------------------------------------------------------------ #
|
|
41
|
+
# Abstract hooks
|
|
42
|
+
# ------------------------------------------------------------------ #
|
|
43
|
+
@abstractmethod
|
|
44
|
+
def initialize(self) -> None:
|
|
45
|
+
"""Optional setup before scanning."""
|
|
46
|
+
|
|
47
|
+
@abstractmethod
|
|
48
|
+
def process_data(self, data: pd.DataFrame) -> pd.DataFrame:
|
|
49
|
+
"""Return one symbol's OHLCV frame enriched with scan indicators."""
|
|
50
|
+
|
|
51
|
+
@abstractmethod
|
|
52
|
+
def generate_signals_df(self, data: pd.DataFrame) -> pd.DataFrame:
|
|
53
|
+
"""Return a frame aligned to ``data.index`` with a ``signal`` column.
|
|
54
|
+
|
|
55
|
+
Should also include a numeric ``signal_strength`` column for ranking.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
# ------------------------------------------------------------------ #
|
|
59
|
+
# Shared behavior
|
|
60
|
+
# ------------------------------------------------------------------ #
|
|
61
|
+
def latest_signal(self, signals_df: pd.DataFrame) -> str:
|
|
62
|
+
"""The most recent scan signal, or ``SCANNER_HOLD`` if none/empty."""
|
|
63
|
+
if signals_df.empty or "signal" not in signals_df:
|
|
64
|
+
return SCANNER_HOLD
|
|
65
|
+
signal = signals_df["signal"].iloc[-1]
|
|
66
|
+
return signal if signal in _ACTIONABLE else SCANNER_HOLD
|
|
67
|
+
|
|
68
|
+
def required_data_points(self) -> int:
|
|
69
|
+
"""Bars needed for a valid scan (lookback + current bar)."""
|
|
70
|
+
return self.config.get("lookback_periods", 20) + 1
|
|
71
|
+
|
|
72
|
+
def is_hit(self, returns: float, price_change: float) -> bool:
|
|
73
|
+
"""Whether a forward outcome counts as a successful signal (override per scanner)."""
|
|
74
|
+
return returns > 0
|
|
75
|
+
|
|
76
|
+
def evaluate_forward(
|
|
77
|
+
self, signals_df: pd.DataFrame, forward: pd.DataFrame, hold_bars: int
|
|
78
|
+
) -> Dict[str, float]:
|
|
79
|
+
"""Score a symbol's signals against subsequent price action.
|
|
80
|
+
|
|
81
|
+
Used by the optimizer to tune scanner parameters. Returns hit rate,
|
|
82
|
+
average return, Sharpe and profit factor over the realized signals.
|
|
83
|
+
"""
|
|
84
|
+
empty = {
|
|
85
|
+
"hit_rate": 0.0,
|
|
86
|
+
"avg_return": 0.0,
|
|
87
|
+
"total_signals": 0,
|
|
88
|
+
"sharpe_ratio": 0.0,
|
|
89
|
+
"profit_factor": 0.0,
|
|
90
|
+
}
|
|
91
|
+
if signals_df.empty or forward.empty:
|
|
92
|
+
return empty
|
|
93
|
+
|
|
94
|
+
closes = forward["close"]
|
|
95
|
+
trade_returns: List[float] = []
|
|
96
|
+
hits = 0
|
|
97
|
+
|
|
98
|
+
for timestamp, row in signals_df.iterrows():
|
|
99
|
+
if row["signal"] not in _ACTIONABLE:
|
|
100
|
+
continue
|
|
101
|
+
future = closes[closes.index > timestamp].head(hold_bars)
|
|
102
|
+
if future.empty or timestamp not in closes.index:
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
entry = closes.loc[timestamp]
|
|
106
|
+
exit_price = future.iloc[-1]
|
|
107
|
+
ret = (exit_price / entry - 1) * 100
|
|
108
|
+
if row["signal"] == SCANNER_SELL:
|
|
109
|
+
ret = -ret
|
|
110
|
+
trade_returns.append(ret)
|
|
111
|
+
if self.is_hit(ret, abs(ret)):
|
|
112
|
+
hits += 1
|
|
113
|
+
|
|
114
|
+
if not trade_returns:
|
|
115
|
+
return empty
|
|
116
|
+
|
|
117
|
+
series = pd.Series(trade_returns)
|
|
118
|
+
return {
|
|
119
|
+
"hit_rate": hits / len(trade_returns) * 100,
|
|
120
|
+
"avg_return": float(series.mean()),
|
|
121
|
+
"total_signals": len(trade_returns),
|
|
122
|
+
"sharpe_ratio": metrics.sharpe_ratio(series),
|
|
123
|
+
"profit_factor": metrics.profit_factor(series),
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
def _validate_config(self, config: Dict[str, Any]) -> Dict[str, Any]:
|
|
127
|
+
"""Fill defaults and range-check values against ``PARAM_RANGES``."""
|
|
128
|
+
validated = dict(config)
|
|
129
|
+
for param, spec in self.PARAM_RANGES.items():
|
|
130
|
+
if param not in validated:
|
|
131
|
+
validated[param] = spec["default"]
|
|
132
|
+
continue
|
|
133
|
+
value = validated[param]
|
|
134
|
+
if spec["type"] in ("int", "float"):
|
|
135
|
+
value = int(value) if spec["type"] == "int" else float(value)
|
|
136
|
+
if not (spec["min"] <= value <= spec["max"]):
|
|
137
|
+
raise ValueError(
|
|
138
|
+
f"Scanner parameter '{param}' value {value} outside [{spec['min']}, {spec['max']}]"
|
|
139
|
+
)
|
|
140
|
+
validated[param] = value
|
|
141
|
+
return validated
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Universe scanner.
|
|
2
|
+
|
|
3
|
+
Runs a chosen :class:`ScannerStrategy` across a candidate symbol list and returns
|
|
4
|
+
those flagged at a specific research clock - the universe the trading
|
|
5
|
+
strategy/engine then operates on. Only TA-Lib-free scanners are registered, in
|
|
6
|
+
keeping with the no-compiled-deps goal.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from typing import Dict, List, Optional, Tuple, Type
|
|
12
|
+
|
|
13
|
+
from quantred.data.scan import slice_to_as_of
|
|
14
|
+
from quantred.marketdata.client import MarketDataClient
|
|
15
|
+
from quantred.marketdata.timeframe import Timeframe
|
|
16
|
+
from quantred.scanners.base import ScannerStrategy
|
|
17
|
+
from quantred.utils.timeutils import NEW_YORK, shift_days
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
# Extra calendar days fetched per required bar, to absorb weekends/holidays.
|
|
22
|
+
_LOOKBACK_DAY_BUFFER = 3
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
#: Scanner names this package reserves for classes it defines *in this module*.
|
|
26
|
+
#: Empty, and deliberately so: the example scanner moved to ``quantred.demo`` and
|
|
27
|
+
#: arrives by entry point like any other pack's, so the engine discovers its own
|
|
28
|
+
#: demonstration the way it discovers yours. Adding a public scanner means shipping
|
|
29
|
+
#: it from ``quantred.demo`` with an entry point in ``pyproject.toml``, not
|
|
30
|
+
#: extending this literal - see ``docs/content/engineering/extending.md``.
|
|
31
|
+
#:
|
|
32
|
+
#: Kept as a distinct name from :attr:`SymbolScanner.SCANNERS`, which discovery
|
|
33
|
+
#: overwrites with the reserved set *plus* whatever installed packages contribute:
|
|
34
|
+
#: deriving the reserved set from that live attribute meant a module reload absorbed
|
|
35
|
+
#: third-party scanners into it, and the names an extension may not override then
|
|
36
|
+
#: silently included names that came from an extension.
|
|
37
|
+
BUILTIN_SCANNERS: Dict[str, Type[ScannerStrategy]] = {}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def resolve_scan_clock(as_of: Optional[datetime] = None) -> datetime:
|
|
41
|
+
"""The exchange-zone instant a scan is resolved at, from an optional request.
|
|
42
|
+
|
|
43
|
+
One rule, so the clock a scan *runs* at and the clock it *reports* cannot differ.
|
|
44
|
+
The payload used to echo the caller's own argument, so a naive ``2024-06-01`` was
|
|
45
|
+
reported as a bare date while the scan actually ran at ``2024-06-01`` New York,
|
|
46
|
+
and an omitted ``as_of`` was reported as nothing at all rather than as the
|
|
47
|
+
wall-clock now it resolved to. A selection clock reported differently from the one
|
|
48
|
+
applied is worse than no clock, because it reads as provenance.
|
|
49
|
+
"""
|
|
50
|
+
end = as_of or datetime.now(NEW_YORK)
|
|
51
|
+
if end.tzinfo is None:
|
|
52
|
+
return NEW_YORK.localize(end)
|
|
53
|
+
return end.astimezone(NEW_YORK)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class SymbolScanner:
|
|
57
|
+
"""Filters a candidate universe down to scanner-signaled symbols."""
|
|
58
|
+
|
|
59
|
+
#: Scanners resolvable by name, kept in step with
|
|
60
|
+
#: :data:`quantred.services.registry.SCANNERS` by ``refresh_registries()``.
|
|
61
|
+
#:
|
|
62
|
+
#: Empty until discovery has run, which is why every read goes through
|
|
63
|
+
#: :meth:`_registry` rather than touching this directly. Nothing is defined in
|
|
64
|
+
#: this module any more, so the seed that used to make a bare
|
|
65
|
+
#: ``import symbol_scanner`` usable is gone: without the lazy resolve, importing
|
|
66
|
+
#: this module alone gave ``available() == []`` and every name raised.
|
|
67
|
+
SCANNERS: Dict[str, Type[ScannerStrategy]] = dict(BUILTIN_SCANNERS)
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def _registry(cls) -> Dict[str, Type[ScannerStrategy]]:
|
|
71
|
+
"""The resolvable scanners, running discovery first if nothing has yet.
|
|
72
|
+
|
|
73
|
+
Delegates to the service registry rather than keeping a second answer. The
|
|
74
|
+
import is deferred because ``registry`` imports *this* module; during that
|
|
75
|
+
import ``registry.SCANNERS`` is already seeded with the reserved names, so a
|
|
76
|
+
pack that scans while being discovered sees those rather than nothing.
|
|
77
|
+
|
|
78
|
+
This is also the fallback the guard around import-time discovery exists for:
|
|
79
|
+
if ``refresh_registries()`` raises before its final line, ``registry.SCANNERS``
|
|
80
|
+
still holds its seed and every scan path keeps working, where a stale empty
|
|
81
|
+
class attribute would have failed every name the CLI still advertised.
|
|
82
|
+
"""
|
|
83
|
+
if not cls.SCANNERS:
|
|
84
|
+
from quantred.services import registry
|
|
85
|
+
|
|
86
|
+
cls.SCANNERS = dict(registry.SCANNERS)
|
|
87
|
+
return cls.SCANNERS
|
|
88
|
+
|
|
89
|
+
def __init__(
|
|
90
|
+
self,
|
|
91
|
+
data_client: MarketDataClient,
|
|
92
|
+
# Required. There is no built-in to fall back to - every scanner, the demo
|
|
93
|
+
# one included, arrives by entry point, so a default here could only name
|
|
94
|
+
# something that may not be installed and raise one frame further in.
|
|
95
|
+
strategy_name: str,
|
|
96
|
+
config: Optional[dict] = None,
|
|
97
|
+
):
|
|
98
|
+
registry = self._registry()
|
|
99
|
+
if strategy_name not in registry:
|
|
100
|
+
raise ValueError(f"Unknown scanner '{strategy_name}'. Available: {self.available()}")
|
|
101
|
+
scanner_cls = registry[strategy_name]
|
|
102
|
+
defaults = {p: spec["default"] for p, spec in scanner_cls.PARAM_RANGES.items()}
|
|
103
|
+
defaults.update(config or {})
|
|
104
|
+
|
|
105
|
+
self.data_client = data_client
|
|
106
|
+
self.strategy = scanner_cls(defaults)
|
|
107
|
+
self.strategy.initialize()
|
|
108
|
+
self.timeframe: str = getattr(scanner_cls, "TIMEFRAME", "1Day")
|
|
109
|
+
|
|
110
|
+
@classmethod
|
|
111
|
+
def available(cls) -> List[str]:
|
|
112
|
+
return list(cls._registry())
|
|
113
|
+
|
|
114
|
+
def scan(
|
|
115
|
+
self, symbols: List[str], timeframe: Optional[str] = None, as_of: Optional[datetime] = None
|
|
116
|
+
) -> List[Tuple[str, str]]:
|
|
117
|
+
"""Return ``(symbol, scan_signal)`` for every flagged symbol in ``symbols``."""
|
|
118
|
+
timeframe = timeframe or self.timeframe
|
|
119
|
+
start, end = self._scan_window(timeframe, as_of=as_of)
|
|
120
|
+
bars = self.data_client.get_bars(symbols, timeframe, start, end)
|
|
121
|
+
|
|
122
|
+
flagged: List[Tuple[str, str]] = []
|
|
123
|
+
for symbol, frame in bars.items():
|
|
124
|
+
if frame.empty:
|
|
125
|
+
continue
|
|
126
|
+
frame = slice_to_as_of(frame, end)
|
|
127
|
+
if frame.empty:
|
|
128
|
+
continue
|
|
129
|
+
processed = self.strategy.process_data(frame)
|
|
130
|
+
signal = self.strategy.latest_signal(self.strategy.generate_signals_df(processed))
|
|
131
|
+
if signal in (self.strategy.SCANNER_BUY, self.strategy.SCANNER_SELL):
|
|
132
|
+
flagged.append((symbol, signal))
|
|
133
|
+
|
|
134
|
+
logger.info("Scanner flagged %d/%d symbols", len(flagged), len(symbols))
|
|
135
|
+
return flagged
|
|
136
|
+
|
|
137
|
+
def scan_window(self, as_of: Optional[datetime] = None) -> Tuple[str, datetime, datetime]:
|
|
138
|
+
"""The ``(timeframe, start, end)`` a default :meth:`scan` at ``as_of`` reads."""
|
|
139
|
+
start, end = self._scan_window(self.timeframe, as_of=as_of)
|
|
140
|
+
return self.timeframe, start, end
|
|
141
|
+
|
|
142
|
+
def _scan_window(self, timeframe: str, as_of: Optional[datetime] = None) -> Tuple[datetime, datetime]:
|
|
143
|
+
"""A lookback window large enough for the scanner's indicators."""
|
|
144
|
+
tf = Timeframe.parse(timeframe)
|
|
145
|
+
required = self.strategy.required_data_points()
|
|
146
|
+
days = max(required * tf.amount * _LOOKBACK_DAY_BUFFER, 30)
|
|
147
|
+
end = resolve_scan_clock(as_of)
|
|
148
|
+
return shift_days(end, -days), end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Shared service core.
|
|
2
|
+
|
|
3
|
+
Plain, side-effect-light functions that wrap the existing engine / optimizer /
|
|
4
|
+
walk-forward / analytics layers and return JSON-serializable dicts. This is the
|
|
5
|
+
*one* orchestration code path: the CLI (``main.py``), the MCP server
|
|
6
|
+
(:mod:`quantred.mcp.server`), and the research agent (:mod:`quantred.research`) all call
|
|
7
|
+
these - no business logic lives in any of those adapters.
|
|
8
|
+
|
|
9
|
+
Nothing here can place an order: service functions take a
|
|
10
|
+
:class:`~quantred.marketdata.client.MarketDataClient` (data only) and never a broker.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from quantred.services.analysis import (
|
|
14
|
+
run_backtest,
|
|
15
|
+
run_draft_walk_forward,
|
|
16
|
+
run_optimization,
|
|
17
|
+
run_scan,
|
|
18
|
+
run_walk_forward,
|
|
19
|
+
summarize_bars,
|
|
20
|
+
validate_draft_scanner_code,
|
|
21
|
+
validate_draft_strategy_code,
|
|
22
|
+
)
|
|
23
|
+
from quantred.services.configs import list_configs, load_config, save_config
|
|
24
|
+
from quantred.services.glossary import metrics_glossary
|
|
25
|
+
from quantred.services.registry import (
|
|
26
|
+
SCANNERS,
|
|
27
|
+
STRATEGIES,
|
|
28
|
+
get_param_ranges,
|
|
29
|
+
list_scanners,
|
|
30
|
+
list_strategies,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"STRATEGIES",
|
|
35
|
+
"SCANNERS",
|
|
36
|
+
"list_strategies",
|
|
37
|
+
"list_scanners",
|
|
38
|
+
"get_param_ranges",
|
|
39
|
+
"run_scan",
|
|
40
|
+
"run_backtest",
|
|
41
|
+
"run_optimization",
|
|
42
|
+
"run_walk_forward",
|
|
43
|
+
"validate_draft_strategy_code",
|
|
44
|
+
"validate_draft_scanner_code",
|
|
45
|
+
"run_draft_walk_forward",
|
|
46
|
+
"summarize_bars",
|
|
47
|
+
"metrics_glossary",
|
|
48
|
+
"save_config",
|
|
49
|
+
"load_config",
|
|
50
|
+
"list_configs",
|
|
51
|
+
]
|