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,354 @@
1
+ """Performance attribution - where the realized active return came from, and
2
+ whether the track record means anything.
3
+
4
+ The information-analysis module measures skill **ex ante** (IC, breadth,
5
+ predicted IR) and splits realized return **once**, pooled, into factor vs
6
+ specific. This module generalizes that split **per period, per factor**, adds a
7
+ systematic benchmark-timing split (expected/surprise/timing), and applies the
8
+ same honesty gates (t-stats, Bayesian-blended risk, multiple-testing inflation)
9
+ to the attributed series themselves - so a ranked table of ~8 return buckets
10
+ can't be read the way a single lucky backtest can't.
11
+
12
+ Everything here is pure math on already-computed exposures/weights/returns; the
13
+ data wiring (sampling rebalances, building leakage-safe cross-sections) lives in
14
+ the service layer (:func:`quantred.services.analysis.compute_attribution`), mirroring
15
+ how :mod:`quantred.analytics.information` stays pure math while
16
+ ``compute_information`` does the wiring. Research-clock only.
17
+ """
18
+
19
+ import math
20
+ from dataclasses import dataclass
21
+ from typing import Dict, Optional, Sequence
22
+
23
+ import numpy as np
24
+ import pandas as pd
25
+
26
+ from quantred.analytics.metrics import norm_cdf
27
+
28
+
29
+ # --------------------------------------------------------------------------- #
30
+ # Per-period attribution (the regression identity, exact by construction)
31
+ # --------------------------------------------------------------------------- #
32
+ def cross_sectional_regression(x: np.ndarray, r: np.ndarray):
33
+ """OLS ``b = (xᵀx)⁻¹xᵀr``; returns ``(b, residual)`` with ``r = x·b + residual``
34
+ exactly (up to floating point). ``x`` is ``N×K``, ``r`` is length-``N``.
35
+
36
+ Degenerate inputs (fewer names than columns, or no columns) return an
37
+ all-zero ``b`` and pass ``r`` through unchanged as the residual - the
38
+ honest "this stage explained nothing" answer, not a crash.
39
+ """
40
+ if x.size == 0 or x.shape[1] == 0 or x.shape[0] < x.shape[1] + 1:
41
+ k = x.shape[1] if x.ndim == 2 else 0
42
+ return np.zeros(k), r.copy()
43
+ b = np.linalg.pinv(x.T @ x) @ x.T @ r
44
+ return b, r - x @ b
45
+
46
+
47
+ @dataclass
48
+ class PeriodAttribution:
49
+ """One rebalance's attributed active return - every field sums exactly to
50
+ ``r_active`` (the regression-identity adding-up this module tests for)."""
51
+
52
+ r_active: float
53
+ beta_a: float
54
+ r_bench: float
55
+ systematic: float # beta_a * r_bench - the WHOLE benchmark-timing bucket
56
+ factor_returns: Dict[str, float] # b_j(t): the cross-sectional factor RETURN
57
+ factor_contributions: Dict[str, float] # w_a . (x_j * b_j): the portfolio's share
58
+ signal_returns: Dict[str, float]
59
+ signal_contributions: Dict[str, float]
60
+ specific: float
61
+ residual: float # numerical adding-up slack only; ~0 by construction
62
+
63
+
64
+ def attribute_period(
65
+ w_active: pd.Series,
66
+ risk_x: pd.DataFrame,
67
+ r_raw: pd.Series,
68
+ beta_per_name: pd.Series,
69
+ r_bench: float,
70
+ signal_x: Optional[pd.DataFrame] = None,
71
+ ) -> Optional[PeriodAttribution]:
72
+ """Attribute one period's realized active return by exact regression identity.
73
+
74
+ ``w_active`` = active weights ``w_a`` known at the *start* of the period;
75
+ ``risk_x`` = start-of-period risk-factor exposures (market/momentum/
76
+ volatility/size, :func:`quantred.risk.exposures.build_factor_exposures`);
77
+ ``beta_per_name`` = the canonical Σ-implied per-name beta
78
+ (:meth:`~quantred.risk.base.RiskMatrix.implied_beta`, the one canonical β
79
+ used everywhere), known at the start of the period; ``r_raw`` = realized
80
+ per-name raw returns over the period; ``r_bench`` = realized benchmark
81
+ return; ``signal_x`` = optional per-name combined-signal z-scores.
82
+
83
+ Two stages, each an exact regression identity so nothing is double-counted
84
+ or lost:
85
+
86
+ 1. **Systematic (known, predetermined) benchmark-timing**: strip
87
+ ``β_i(t)·r_B(t)`` per name using the *same* canonical β, so its portfolio
88
+ aggregate is exactly ``β_a(t)·r_B(t)`` - not fit to this period's data,
89
+ just the already-known tilt times the realized benchmark move.
90
+ 2. **Risk factors + signals, JOINTLY**: one cross-sectional regression of
91
+ the beta-adjusted return on ``[risk_x, signal_x]`` together. Signals as a
92
+ *second pass* on the risk-factor residual ("cleaner ownership") was tried
93
+ and reverted - a second pass leaves an
94
+ **omitted-variable bias** that does not vanish with more names: when
95
+ active weights are built from a signal that is not yet among the
96
+ regressors doing the fitting (exactly how a paper book is built
97
+ upstream - its own alpha z-score), whichever risk factor happens to
98
+ correlate with that signal *in this period's finite sample* picks up a
99
+ persistent, same-sign share of the signal's true return - a genuine
100
+ integrity bug, not sampling noise around zero (measured at |t| > 60 in
101
+ a 2000-trial check during development). Fitting both blocks in one
102
+ regression removes the omission and the bias disappears (measured
103
+ |t| < 1). The remaining tradeoff (signals correlated with risk
104
+ factors destabilize a joint fit) is real but smaller and disclosable
105
+ (report the condition number); the omitted-variable bias was not
106
+ disclosable - it looks exactly like real factor timing.
107
+
108
+ Returns ``None`` when too few names are common to the inputs to regress
109
+ (fewer than ``len(risk_x.columns) + (len(signal_x.columns) if any) + 2``).
110
+ """
111
+ common = (
112
+ w_active.index.intersection(risk_x.index)
113
+ .intersection(r_raw.dropna().index)
114
+ .intersection(beta_per_name.dropna().index)
115
+ )
116
+ has_signals = signal_x is not None and not signal_x.empty
117
+ if has_signals:
118
+ common = common.intersection(signal_x.dropna(how="any").index)
119
+ n_cols = len(risk_x.columns) + (len(signal_x.columns) if has_signals else 0)
120
+ if len(common) < n_cols + 2:
121
+ return None
122
+
123
+ w = w_active.loc[common].to_numpy(dtype=float)
124
+ x = risk_x.loc[common].to_numpy(dtype=float)
125
+ r = r_raw.loc[common].to_numpy(dtype=float)
126
+ beta = beta_per_name.loc[common].to_numpy(dtype=float)
127
+
128
+ r_active = float(w @ r)
129
+ beta_a = float(w @ beta)
130
+ systematic = beta_a * r_bench
131
+
132
+ # Stage 1: per-name systematic strip (same canonical beta, so it sums to
133
+ # beta_a*r_bench exactly: w @ (beta*r_bench) == (w@beta)*r_bench).
134
+ u1 = r - beta * r_bench
135
+
136
+ # Stage 2: risk factors and signals, jointly (see docstring for why).
137
+ joint_x = np.column_stack([x, signal_x.loc[common].to_numpy(dtype=float)]) if has_signals else x
138
+ b_joint, specific_series = cross_sectional_regression(joint_x, u1)
139
+ n_risk = len(risk_x.columns)
140
+ b_factors, b_signals = b_joint[:n_risk], b_joint[n_risk:]
141
+
142
+ factor_returns = {col: float(b_factors[j]) for j, col in enumerate(risk_x.columns)}
143
+ factor_contributions = {col: float(w @ (x[:, j] * b_factors[j])) for j, col in enumerate(risk_x.columns)}
144
+ signal_returns: Dict[str, float] = {}
145
+ signal_contributions: Dict[str, float] = {}
146
+ if has_signals:
147
+ sx = signal_x.loc[common].to_numpy(dtype=float)
148
+ signal_returns = {col: float(b_signals[k]) for k, col in enumerate(signal_x.columns)}
149
+ signal_contributions = {
150
+ col: float(w @ (sx[:, k] * b_signals[k])) for k, col in enumerate(signal_x.columns)
151
+ }
152
+
153
+ specific = float(w @ specific_series)
154
+ reconstructed = (
155
+ systematic + sum(factor_contributions.values()) + sum(signal_contributions.values()) + specific
156
+ )
157
+ residual = r_active - reconstructed
158
+
159
+ return PeriodAttribution(
160
+ r_active=r_active,
161
+ beta_a=beta_a,
162
+ r_bench=r_bench,
163
+ systematic=systematic,
164
+ factor_returns=factor_returns,
165
+ factor_contributions=factor_contributions,
166
+ signal_returns=signal_returns,
167
+ signal_contributions=signal_contributions,
168
+ specific=specific,
169
+ residual=residual,
170
+ )
171
+
172
+
173
+ # --------------------------------------------------------------------------- #
174
+ # The systematic split: expected / surprise / timing
175
+ # --------------------------------------------------------------------------- #
176
+ def systematic_split(
177
+ beta_a_series: Sequence[float], r_bench_series: Sequence[float], mu_b_period: float
178
+ ) -> Dict[str, float]:
179
+ """Split ``Σ_t β_a(t)·r_B(t)`` into expected-beta / benchmark-surprise / timing.
180
+
181
+ ``mu_b_period`` is the ASSUMED (ex-ante) benchmark return *per rebalance
182
+ period* - not the realized mean, which would make "expected" indistinguishable
183
+ from "surprise" after the fact. The identity (exact, by construction):
184
+ ``expected + surprise + timing == Σ_t β_a(t)·r_B(t)``, because
185
+ ``Σ(β_bar+δβ)(rb_bar+δr) = β_bar·rb_bar·T + β_bar·Σδr + rb_bar·Σδβ + Σδβδr``
186
+ and the middle two terms vanish (deviations from the mean sum to zero).
187
+
188
+ Only ``timing`` is a genuine per-period series with its own sampling
189
+ variation (the ``timing_series`` of ``δβ_a(t)·δr_B(t)``); expected/surprise
190
+ are aggregate-only numbers (they use ``β_bar``, only known after the fact) -
191
+ the report shows them without a t-stat, marked "not skill".
192
+ """
193
+ beta = np.asarray(beta_a_series, dtype=float)
194
+ rb = np.asarray(r_bench_series, dtype=float)
195
+ t = len(beta)
196
+ if t == 0:
197
+ return {
198
+ "expected": 0.0,
199
+ "surprise": 0.0,
200
+ "timing": 0.0,
201
+ "timing_series": [],
202
+ "beta_bar": 0.0,
203
+ "r_bench_bar": 0.0,
204
+ }
205
+ beta_bar = float(beta.mean())
206
+ rb_bar = float(rb.mean())
207
+ expected = beta_bar * mu_b_period * t
208
+ surprise = beta_bar * (rb_bar - mu_b_period) * t
209
+ timing_series = (beta - beta_bar) * (rb - rb_bar)
210
+ return {
211
+ "expected": float(expected),
212
+ "surprise": float(surprise),
213
+ "timing": float(timing_series.sum()),
214
+ "timing_series": timing_series.tolist(),
215
+ "beta_bar": beta_bar,
216
+ "r_bench_bar": rb_bar,
217
+ }
218
+
219
+
220
+ # --------------------------------------------------------------------------- #
221
+ # Bayesian blend - the honest risk for a short attributed series
222
+ # --------------------------------------------------------------------------- #
223
+ def prior_weight_t0(min_obs: int, bars_per_period: float) -> float:
224
+ """``T₀`` in REBALANCE-PERIOD units, derived from the risk model's own
225
+ estimation window.
226
+
227
+ The risk model needs ``min_obs`` *bars* of history before it trusts Σ
228
+ (:func:`quantred.risk.base.build_risk_matrix`, default 60); at this attribution's
229
+ rebalance cadence of ``bars_per_period`` bars per period, that's
230
+ ``min_obs / bars_per_period`` PERIODS worth of prior confidence - i.e. "the
231
+ risk model itself wouldn't trust an estimate from fewer periods than this,
232
+ so neither should the attribution". This mapping isn't specified anywhere
233
+ else in the codebase; it is a documented judgment call.
234
+ """
235
+ return float(min_obs) / max(bars_per_period, 1e-9)
236
+
237
+
238
+ def bayesian_blend_variance(sigma2_prior: float, sigma2_realized: float, t: float, t0: float) -> float:
239
+ """``σ² = σ²_prior·T₀/(T+T₀) + σ²_real·T/(T+T₀)`` (17A.12).
240
+
241
+ ``T → 0`` recovers the prior exactly; ``T → ∞`` recovers the realized
242
+ variance exactly; the two weights always sum to 1.
243
+ """
244
+ denom = t + t0
245
+ if denom <= 0:
246
+ return float(sigma2_prior)
247
+ return float(sigma2_prior * (t0 / denom) + sigma2_realized * (t / denom))
248
+
249
+
250
+ def series_stats(
251
+ values: Sequence[float],
252
+ periods_per_year_series: float,
253
+ sigma2_prior: float,
254
+ t0: float,
255
+ ) -> Dict[str, float]:
256
+ """Mean / Bayesian-blended-risk IR / t-stat for one attributed series.
257
+
258
+ The per-period realized variance is blended with ``sigma2_prior`` (17A.12)
259
+ so a short sample leans on the risk model's structural prior instead of a
260
+ wild few-point sample SD; ``t → ∞`` (long samples) recovers the plain
261
+ realized-variance t-stat.
262
+ """
263
+ arr = np.array([v for v in values if v == v], dtype=float) # drop NaNs
264
+ t = len(arr)
265
+ mean = float(arr.mean()) if t else 0.0
266
+ sigma2_real = float(arr.var(ddof=1)) if t > 1 else sigma2_prior
267
+ sigma2 = bayesian_blend_variance(sigma2_prior, sigma2_real, t, t0)
268
+ sd = math.sqrt(max(sigma2, 0.0))
269
+ ann_mean = mean * periods_per_year_series
270
+ ann_sd = sd * math.sqrt(periods_per_year_series)
271
+ ir = float(ann_mean / ann_sd) if ann_sd > 0 else 0.0
272
+ tstat = float((mean / sd) * math.sqrt(t)) if sd > 0 and t > 0 else 0.0
273
+ return {
274
+ "mean": mean,
275
+ "annualized_mean": ann_mean,
276
+ "vol_blended": sd,
277
+ "annualized_vol_blended": ann_sd,
278
+ "ir": ir,
279
+ "t_stat": tstat,
280
+ "periods": t,
281
+ }
282
+
283
+
284
+ # --------------------------------------------------------------------------- #
285
+ # Track-record calculus
286
+ # --------------------------------------------------------------------------- #
287
+ def years_to_significance(ir: float) -> float:
288
+ """``Y* = (2/IR)²`` - years until a real IR's t-stat would reach 2, under the
289
+ simplified ``SE{IR} ≈ 1/√Y`` this formula itself is built from (the more
290
+ precise :func:`quantred.analytics.information.ir_standard_error` includes an
291
+ ``IR²/2`` term the closed-form ``Y*`` ignores - both are reported so the
292
+ approximation is visible, not hidden).
293
+ """
294
+ if ir == 0:
295
+ return float("inf")
296
+ return float((2.0 / ir) ** 2)
297
+
298
+
299
+ def prob_positive_over_years(ir: float, years: float) -> float:
300
+ """``P(positive cumulative alpha over` years) = Φ(IR·√years)``."""
301
+ return float(norm_cdf(ir * math.sqrt(max(years, 0.0))))
302
+
303
+
304
+ # --------------------------------------------------------------------------- #
305
+ # Honest cumulation (the "cumulation trap")
306
+ # --------------------------------------------------------------------------- #
307
+ def cumulate_top_down(
308
+ component_series: Dict[str, Sequence[float]],
309
+ r_active_series: Sequence[float],
310
+ r_portfolio_series: Sequence[float],
311
+ r_bench_series: Sequence[float],
312
+ ) -> Dict[str, object]:
313
+ """Cumulative active return is ``ΠR_P − ΠR_B``, *never* ``Π(1+r_a)`` - and
314
+ per-component cumulation is top-down (17A.9), with the compounding residual
315
+ ``δ_CP`` reported, not silently absorbed.
316
+
317
+ ``component_series`` values must sum to ``r_active_series`` **at each
318
+ period** (the per-period regression identity from :func:`attribute_period`).
319
+ Each component is chain-linked by the compounding path up to (not including)
320
+ its own period - ``Σ_t component(t)·Π_{s<t}(1+r_a(s))`` - which telescopes so
321
+ ``Σ_component linked_component == Π_t(1+r_a(t)) − 1`` (``naive_cumulative``)
322
+ exactly. The gap between that naive linked total and the honest
323
+ ``ΠR_P − ΠR_B`` (``honest_car``) is ``delta_cp``; by construction
324
+ ``Σ linked_components + delta_cp == honest_car`` exactly - the identity this
325
+ module tests for.
326
+ """
327
+ r_a = np.asarray(r_active_series, dtype=float)
328
+ r_p = np.asarray(r_portfolio_series, dtype=float)
329
+ r_b = np.asarray(r_bench_series, dtype=float)
330
+
331
+ if len(r_a) == 0:
332
+ return {
333
+ "linked_components": {k: 0.0 for k in component_series},
334
+ "naive_cumulative": 0.0,
335
+ "honest_car": 0.0,
336
+ "delta_cp": 0.0,
337
+ }
338
+
339
+ growth = np.concatenate([[1.0], np.cumprod(1.0 + r_a)])[:-1] # Π_{s<t}(1+r_a(s))
340
+ naive_cumulative = float(np.prod(1.0 + r_a) - 1.0)
341
+ honest_car = float(np.prod(1.0 + r_p) - np.prod(1.0 + r_b)) if len(r_p) and len(r_b) else naive_cumulative
342
+
343
+ linked_components = {}
344
+ for name, series in component_series.items():
345
+ arr = np.asarray(series, dtype=float)
346
+ linked_components[name] = float(np.sum(arr * growth))
347
+
348
+ delta_cp = honest_car - naive_cumulative
349
+ return {
350
+ "linked_components": linked_components,
351
+ "naive_cumulative": naive_cumulative,
352
+ "honest_car": honest_car,
353
+ "delta_cp": delta_cp,
354
+ }