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,353 @@
1
+ """Bootstrap skill inference: simulate the null instead of assuming it.
2
+
3
+ The rest of the stack's skill-vs-luck machinery is parametric: PSR/DSR
4
+ (:mod:`quantred.analytics.metrics`) assume the Sharpe estimator's asymptotic
5
+ distribution (with skew/kurtosis corrections) and, for DSR, an *assumed* effective
6
+ trial count and trial-Sharpe variance. Those are good, cheap, always-on defaults —
7
+ this module is the heavier, definitive check behind ``--bootstrap-skill``, not a
8
+ replacement.
9
+
10
+ Two questions, two functions:
11
+
12
+ 1. **Is this one track record skill?** :func:`bootstrap_null` imposes the null
13
+ (demean by the estimated alpha), block-resamples the residual with the
14
+ Politis-Romano stationary bootstrap (never i.i.d. - active returns are
15
+ autocorrelated), and reports the realized IR's rank in the empirical null
16
+ distribution: an *own* p-value with no assumption about the return
17
+ distribution's shape.
18
+ 2. **Is the best of the K configs actually tried skill?** :func:`reality_check`
19
+ (White 2000) resamples the same block indices across every trial's column at
20
+ once, so the cross-trial correlation structure is preserved by construction
21
+ (DSR has to *assume* a trial-Sharpe variance to approximate this; this replays
22
+ the actual trials from the trial store) - the null distribution of "the best
23
+ IR among K correlated tries," compared against the actual observed best.
24
+
25
+ Both report a Monte-Carlo standard error on the p-value (``sqrt(p(1-p)/B)``) and a
26
+ block-length sensitivity check (p at L/2 and 2L) - a p-value that flips across that
27
+ range is not a result.
28
+ """
29
+
30
+ import math
31
+ from typing import Any, Dict, Optional, Sequence, Union
32
+
33
+ import numpy as np
34
+
35
+ from quantred.analytics.metrics import TRADING_DAYS_PER_YEAR
36
+
37
+ Numbers = Union[Sequence[float], np.ndarray]
38
+
39
+ __all__ = [
40
+ "politis_white_block_length",
41
+ "stationary_bootstrap_indices",
42
+ "bootstrap_null",
43
+ "reality_check",
44
+ ]
45
+
46
+
47
+ def _as_array(values: Numbers) -> np.ndarray:
48
+ return np.asarray(values, dtype=float)
49
+
50
+
51
+ def _annualized_ir(x: np.ndarray, periods_per_year: float, axis: int = -1) -> np.ndarray:
52
+ """``mean/std * sqrt(periods_per_year)`` along ``axis``; zero-std slices get IR
53
+ 0 (never inf/nan - a null distribution must stay finite to rank against)."""
54
+ mean = x.mean(axis=axis)
55
+ std = x.std(axis=axis, ddof=1)
56
+ with np.errstate(invalid="ignore", divide="ignore"):
57
+ ir = np.where(std > 0, mean / std * math.sqrt(periods_per_year), 0.0)
58
+ return np.asarray(ir, dtype=float)
59
+
60
+
61
+ # --------------------------------------------------------------------------- #
62
+ # Block-length selection (Politis & White 2004 / Patton, Politis & White 2009)
63
+ # --------------------------------------------------------------------------- #
64
+ def politis_white_block_length(returns: Numbers, *, max_lag: Optional[int] = None) -> float:
65
+ """The automatic expected block length for the stationary bootstrap.
66
+
67
+ Estimates the series' own decay time from its correlogram (a flat-top lag
68
+ window over the sample autocorrelations), then sets the MSE-optimal average
69
+ block length for the stationary bootstrap (Politis & White 2004 eq. 4.1-4.2,
70
+ the ``D_SB = 2*Ghat**2`` variant). Too short a block leaks autocorrelation into
71
+ the resample and makes the p-value anti-conservative - the dangerous direction
72
+ - so this is the *reported*, always-overridable
73
+ default, never a silent assumption.
74
+
75
+ This is an engineering-faithful implementation of the published rule (the same
76
+ flat-top kernel, the same "insignificant for K_n consecutive lags" bandwidth
77
+ rule) - not a byte-for-byte port of any particular reference implementation.
78
+ No test in this codebase depends on matching one exactly, only on the
79
+ calibration/power properties the block length is supposed to buy (see
80
+ ``tests/test_bootstrap_skill.py``).
81
+ """
82
+ r = _as_array(returns)
83
+ n = len(r)
84
+ if n < 20:
85
+ # Too short to estimate a correlogram meaningfully; fall back to a quarter
86
+ # of the sample, floored at 1 - a conservative (long) block for tiny n.
87
+ return max(float(n) / 4.0, 1.0)
88
+
89
+ r = r - r.mean()
90
+ var = float(np.dot(r, r) / n)
91
+ if var <= 0:
92
+ return 1.0
93
+
94
+ k_max = max_lag or min(n // 2, 100)
95
+ rho = np.ones(k_max + 1, dtype=float)
96
+ for k in range(1, k_max + 1):
97
+ rho[k] = float(np.dot(r[: n - k], r[k:]) / n) / var
98
+
99
+ # Bandwidth m: the first lag beyond which the correlogram is "insignificant"
100
+ # for K_n consecutive lags (Politis & White's own significance band, not a
101
+ # generic 95% CI).
102
+ k_n = max(5, int(math.ceil(math.sqrt(math.log10(n)))))
103
+ band = 2.0 * math.sqrt(math.log10(n) / n)
104
+ m = k_max
105
+ for k in range(1, max(k_max - k_n + 1, 2)):
106
+ if np.all(np.abs(rho[k : k + k_n]) < band):
107
+ m = k
108
+ break
109
+ bandwidth = int(np.clip(2 * m, 1, k_max))
110
+
111
+ def _flat_top(x: float) -> float:
112
+ ax = abs(x)
113
+ if ax <= 0.5:
114
+ return 1.0
115
+ if ax <= 1.0:
116
+ return 2.0 * (1.0 - ax)
117
+ return 0.0
118
+
119
+ g_hat = 0.0
120
+ cap_g_hat = rho[0]
121
+ for k in range(1, bandwidth + 1):
122
+ w = _flat_top(k / bandwidth)
123
+ g_hat += 2.0 * w * k * rho[k]
124
+ cap_g_hat += 2.0 * w * rho[k]
125
+
126
+ if cap_g_hat <= 0:
127
+ return float(max(bandwidth, 1))
128
+ d_sb = 2.0 * cap_g_hat**2
129
+ if d_sb <= 0:
130
+ return float(max(bandwidth, 1))
131
+ b = ((2.0 * g_hat**2) / d_sb) ** (1.0 / 3.0) * n ** (1.0 / 3.0)
132
+ return float(np.clip(b, 1.0, max(n // 4, 1)))
133
+
134
+
135
+ # --------------------------------------------------------------------------- #
136
+ # Stationary (Politis-Romano 1994) block bootstrap
137
+ # --------------------------------------------------------------------------- #
138
+ def stationary_bootstrap_indices(t: int, block_length: float, b: int, rng: np.random.Generator) -> np.ndarray:
139
+ """``B`` rows of ``T`` circular resample indices with geometric block lengths
140
+ (expected length ``block_length``), Politis & Romano's (1994) stationary
141
+ bootstrap.
142
+
143
+ Implemented as its equivalent recurrence: at each step, "restart" to a fresh
144
+ uniform index with probability ``p = 1/block_length``, else continue the
145
+ previous index + 1 (mod T) - a geometric run length with mean
146
+ ``block_length``. Vectorized over ``B`` (one restart draw per time step, not
147
+ per ``(round, step)`` pair): the only large array actually materialized is the
148
+ ``(B, T)`` index gather this feeds, plus a ``T``-length loop of ``B``-wide
149
+ numpy ops.
150
+ """
151
+ if t <= 0:
152
+ return np.empty((b, 0), dtype=np.int64)
153
+ p = 1.0 / max(float(block_length), 1.0)
154
+ idx = np.empty((t, b), dtype=np.int64)
155
+ idx[0] = rng.integers(0, t, size=b)
156
+ if t > 1:
157
+ restarts = rng.random((t - 1, b)) < p
158
+ fresh = rng.integers(0, t, size=(t - 1, b))
159
+ for step in range(1, t):
160
+ prev = idx[step - 1]
161
+ idx[step] = np.where(restarts[step - 1], fresh[step - 1], (prev + 1) % t)
162
+ return idx.T # (B, T)
163
+
164
+
165
+ def _p_value(null: np.ndarray, observed: float, b: int) -> "tuple[float, float]":
166
+ """One-sided p = P(null >= observed), with the standard +1 continuity
167
+ correction (never reports p=0 from a finite B) and its Monte-Carlo SE."""
168
+ p = float((1 + np.sum(null >= observed)) / (b + 1))
169
+ se = float(math.sqrt(max(p * (1.0 - p), 0.0) / b))
170
+ return p, se
171
+
172
+
173
+ def _flips_significance(p: float, p_half: float, p_double: float, level: float = 0.05) -> bool:
174
+ sig = p < level
175
+ return (sig != (p_half < level)) or (sig != (p_double < level))
176
+
177
+
178
+ # --------------------------------------------------------------------------- #
179
+ # 1. Single track record: zero-alpha bootstrap
180
+ # --------------------------------------------------------------------------- #
181
+ def bootstrap_null(
182
+ returns: Numbers,
183
+ *,
184
+ B: int = 2000,
185
+ block_length: Optional[float] = None,
186
+ seed: int = 0,
187
+ periods_per_year: float = TRADING_DAYS_PER_YEAR,
188
+ alpha: Optional[float] = None,
189
+ ) -> Dict[str, Any]:
190
+ """Zero-alpha stationary block bootstrap for one active-return track record.
191
+
192
+ Demeans by the full-sample estimated alpha (Fama-French: ``r_tilde = r_a -
193
+ alpha_hat``; ``alpha_hat`` defaults to the sample mean - pass ``alpha=`` for a
194
+ regression-estimated intercept instead) to impose H0: no skill, block-resamples
195
+ the residual ``B`` times (stationary bootstrap; never the equity curve, never
196
+ an i.i.d. shuffle - active returns are autocorrelated), recomputes the
197
+ annualized IR each round -> the empirical null distribution. The p-value is
198
+ one-sided (``P(null IR >= observed IR)``): the hypothesis under test is
199
+ "skill", so a negative track record correctly returns a high p (no evidence of
200
+ skill needed to explain a loss).
201
+
202
+ Reports the Monte-Carlo SE of p, the block length used (default:
203
+ :func:`politis_white_block_length`, always overridable and always reported),
204
+ and block-length sensitivity at L/2 and 2L (a p whose
205
+ significance flips across that range is not a result, per
206
+ ``block_sensitivity_flag``).
207
+ """
208
+ r = _as_array(returns)
209
+ t = len(r)
210
+ if t < 8:
211
+ return {
212
+ "ir_observed": 0.0,
213
+ "p_value": 1.0,
214
+ "p_se": 0.0,
215
+ "B": 0,
216
+ "block_length": 0.0,
217
+ "seed": seed,
218
+ "n_obs": t,
219
+ "block_sensitivity": {},
220
+ "block_sensitivity_flag": False,
221
+ "insufficient_data": True,
222
+ }
223
+
224
+ a_hat = float(r.mean()) if alpha is None else float(alpha)
225
+ ir_observed = float(_annualized_ir(r, periods_per_year))
226
+ l_default = float(block_length) if block_length is not None else politis_white_block_length(r)
227
+ resid = r - a_hat
228
+
229
+ def _run(length: float):
230
+ rng = np.random.default_rng(seed)
231
+ idx = stationary_bootstrap_indices(t, length, B, rng)
232
+ resampled = resid[idx] # (B, T)
233
+ null_ir = _annualized_ir(resampled, periods_per_year, axis=-1)
234
+ p, se = _p_value(null_ir, ir_observed, B)
235
+ return p, se, null_ir
236
+
237
+ p_value, p_se, null_ir = _run(l_default)
238
+ p_half, _, _ = _run(max(l_default / 2.0, 1.0))
239
+ p_double, _, _ = _run(l_default * 2.0)
240
+
241
+ return {
242
+ "ir_observed": ir_observed,
243
+ "p_value": p_value,
244
+ "p_se": p_se,
245
+ "B": B,
246
+ "block_length": l_default,
247
+ "seed": seed,
248
+ "n_obs": t,
249
+ "alpha_hat": a_hat,
250
+ "null_ir_mean": float(np.mean(null_ir)),
251
+ "null_ir_std": float(np.std(null_ir, ddof=1)) if B > 1 else 0.0,
252
+ "block_sensitivity": {"half": p_half, "double": p_double},
253
+ "block_sensitivity_flag": _flips_significance(p_value, p_half, p_double),
254
+ "insufficient_data": False,
255
+ }
256
+
257
+
258
+ # --------------------------------------------------------------------------- #
259
+ # 2. Best-of-K: White's Reality Check
260
+ # --------------------------------------------------------------------------- #
261
+ def reality_check(
262
+ trial_returns_matrix: Any,
263
+ *,
264
+ B: int = 2000,
265
+ block_length: Optional[float] = None,
266
+ seed: int = 0,
267
+ periods_per_year: float = TRADING_DAYS_PER_YEAR,
268
+ trial_ids: Optional[Sequence[Any]] = None,
269
+ ) -> Dict[str, Any]:
270
+ """White's (2000) Reality Check over the T x K joint panel of trials' OOS
271
+ return series.
272
+
273
+ Every trial column is demeaned by its OWN mean (impose H0 independently per
274
+ trial - each trial's own null is "no skill for that config"), then resampled
275
+ with ONE set of block indices per round applied to *every* column at once -
276
+ cross-trial correlation is preserved by construction, which is the entire
277
+ point: DSR has to *assume* a trial-Sharpe variance to approximate this: this
278
+ replays the actual trials. The per-round max IR across K columns is the null
279
+ distribution of "the best IR among K correlated tries"; the family p-value is
280
+ the rank of the actual observed max REAL (non-resampled) IR in it - the
281
+ question a researcher who tried K configs and kept the best actually faces.
282
+
283
+ Reports which trial achieved the observed max (``selected_trial``), every
284
+ trial's own real IR, the Monte-Carlo SE of the family p, and the same
285
+ block-length sensitivity check as :func:`bootstrap_null`.
286
+ """
287
+ m = np.asarray(trial_returns_matrix, dtype=float)
288
+ if m.ndim != 2 or m.shape[0] == 0 or m.shape[1] == 0:
289
+ return {
290
+ "family_p": 1.0,
291
+ "family_p_se": 0.0,
292
+ "B": 0,
293
+ "k_trials": 0 if m.ndim != 2 else int(m.shape[1]),
294
+ "block_sensitivity": {},
295
+ "block_sensitivity_flag": False,
296
+ "insufficient_data": True,
297
+ }
298
+ t, k = m.shape
299
+ if t < 8:
300
+ return {
301
+ "family_p": 1.0,
302
+ "family_p_se": 0.0,
303
+ "B": 0,
304
+ "k_trials": k,
305
+ "n_obs": t,
306
+ "block_sensitivity": {},
307
+ "block_sensitivity_flag": False,
308
+ "insufficient_data": True,
309
+ }
310
+
311
+ real_ir = _annualized_ir(m, periods_per_year, axis=0) # (K,)
312
+ best_idx = int(np.argmax(real_ir))
313
+ observed_max_ir = float(real_ir[best_idx])
314
+ ids = list(trial_ids) if trial_ids is not None else list(range(k))
315
+
316
+ resid = m - m.mean(axis=0, keepdims=True)
317
+ l_default = (
318
+ float(block_length) if block_length is not None else politis_white_block_length(m[:, best_idx])
319
+ )
320
+
321
+ def _run(length: float):
322
+ rng = np.random.default_rng(seed)
323
+ idx = stationary_bootstrap_indices(t, length, B, rng) # (B, T)
324
+ resampled = resid[
325
+ idx
326
+ ] # (B, T, K) - one gather, one set of block indices/round, shared by every column
327
+ ir = _annualized_ir(resampled, periods_per_year, axis=1) # (B, K)
328
+ null_max = ir.max(axis=1) # (B,)
329
+ p, se = _p_value(null_max, observed_max_ir, B)
330
+ return p, se, null_max
331
+
332
+ family_p, family_p_se, null_max = _run(l_default)
333
+ p_half, _, _ = _run(max(l_default / 2.0, 1.0))
334
+ p_double, _, _ = _run(l_default * 2.0)
335
+
336
+ return {
337
+ "family_p": family_p,
338
+ "family_p_se": family_p_se,
339
+ "B": B,
340
+ "block_length": l_default,
341
+ "seed": seed,
342
+ "k_trials": k,
343
+ "n_obs": t,
344
+ "observed_max_ir": observed_max_ir,
345
+ "selected_trial": ids[best_idx],
346
+ "selected_trial_index": best_idx,
347
+ "per_trial_real_ir": {ids[i]: float(real_ir[i]) for i in range(k)},
348
+ "null_max_ir_mean": float(np.mean(null_max)),
349
+ "null_max_ir_std": float(np.std(null_max, ddof=1)) if B > 1 else 0.0,
350
+ "block_sensitivity": {"half": p_half, "double": p_double},
351
+ "block_sensitivity_flag": _flips_significance(family_p, p_half, p_double),
352
+ "insufficient_data": False,
353
+ }