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,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
+ ]