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,197 @@
1
+ """Information analysis - measure skill (IC), scope (breadth), and reconcile the IR.
2
+
3
+ The rest of the stack *assumes* an information coefficient and *predicts* an information
4
+ ratio. This module **measures** them and confronts the prediction with reality - the
5
+ most honest diagnostic in the whole system. Classical information analysis is
6
+ cross-sectional and decompositional: how much skill (IC), across how many
7
+ *independent* bets (BR), and does the realized IR match ``IC·√BR``?
8
+
9
+ Everything here is pure math on already-computed forecasts and realized returns; the
10
+ data wiring (sampling rebalances, aligning forward returns) lives in the service
11
+ layer. Research-clock only.
12
+ """
13
+
14
+ import math
15
+ from typing import Dict, List, Sequence
16
+
17
+ import numpy as np
18
+ import pandas as pd
19
+
20
+ #: Below this many rebalances the IC volatility - and so the t-stat - is meaningless.
21
+ MIN_PERIODS = 12
22
+
23
+
24
+ def pearson_ic(forecast: pd.Series, realized: pd.Series) -> float:
25
+ """Cross-sectional Pearson correlation between a forecast and the realized return."""
26
+ aligned = pd.concat([forecast, realized], axis=1).dropna()
27
+ if len(aligned) < 2 or aligned.iloc[:, 0].std() == 0 or aligned.iloc[:, 1].std() == 0:
28
+ return float("nan")
29
+ return float(aligned.iloc[:, 0].corr(aligned.iloc[:, 1]))
30
+
31
+
32
+ def rank_ic(forecast: pd.Series, realized: pd.Series) -> float:
33
+ """Spearman (rank) IC - robust to alpha scale and outliers."""
34
+ aligned = pd.concat([forecast, realized], axis=1).dropna()
35
+ if len(aligned) < 2:
36
+ return float("nan")
37
+ ranked = aligned.rank()
38
+ if ranked.iloc[:, 0].std() == 0 or ranked.iloc[:, 1].std() == 0:
39
+ return float("nan")
40
+ return float(ranked.iloc[:, 0].corr(ranked.iloc[:, 1]))
41
+
42
+
43
+ def ic_stats(ics: Sequence[float]) -> Dict[str, float]:
44
+ """Summarize an IC time series: mean, volatility, and the skill t-stat.
45
+
46
+ ``IC_tstat = mean_IC / (IC_vol / √P)`` is the honesty gate on skill - a fine mean
47
+ IC with a t-stat below ~2 is a few lucky periods, not evidence of skill.
48
+ """
49
+ arr = np.array([x for x in ics if x == x], dtype=float) # drop NaNs
50
+ p = len(arr)
51
+ mean = float(arr.mean()) if p else 0.0
52
+ vol = float(arr.std(ddof=1)) if p > 1 else 0.0
53
+ tstat = float(mean / (vol / math.sqrt(p))) if vol > 0 and p > 1 else 0.0
54
+ return {"mean_ic": mean, "ic_vol": vol, "ic_tstat": tstat, "periods": p}
55
+
56
+
57
+ def effective_breadth(n_names: int, rebalances_per_year: float, rho_bar: float) -> Dict[str, float]:
58
+ """Effective vs naive breadth - the most common self-deception in quant.
59
+
60
+ ``BR_naive = (rebalances/yr)·N`` counts every name as an independent bet.
61
+ ``BR_eff = (rebalances/yr)·N / (1 + (N−1)·ρ̄)`` deflates it by the average pairwise
62
+ correlation ρ̄: perfectly correlated bets (ρ̄→1) collapse to ``rebalances/yr``
63
+ (effectively one bet), independent bets (ρ̄=0) keep the full count.
64
+ """
65
+ br_naive = rebalances_per_year * n_names
66
+ denom = 1.0 + (n_names - 1) * rho_bar
67
+ br_eff = br_naive / denom if denom > 0 else br_naive
68
+ return {"br_eff": float(br_eff), "br_naive": float(br_naive), "rho_bar": float(rho_bar)}
69
+
70
+
71
+ def predicted_ir(mean_ic: float, br_eff: float) -> float:
72
+ """The Fundamental Law: ``predicted_IR = mean_IC · √BR_eff``."""
73
+ return float(mean_ic * math.sqrt(max(br_eff, 0.0)))
74
+
75
+
76
+ def ir_standard_error(ir: float, years: float) -> float:
77
+ """Standard error of a realized IR over ``years``: ``√((1 + IR²/2) / T)``.
78
+
79
+ A 3-year backtest has ``SE(IR) ≈ 0.58`` - so an IR of 0.5 is statistically
80
+ indistinguishable from 0. Report IR with this band, not as a point estimate.
81
+ """
82
+ if years <= 0:
83
+ return float("inf")
84
+ return float(math.sqrt((1.0 + ir * ir / 2.0) / years))
85
+
86
+
87
+ def multiple_testing_inflation(n_trials: int) -> float:
88
+ """``P(any |t| > 2 in m trials) = 1 − 0.95^m`` - why one lucky backtest means little.
89
+
90
+ With 20 informationless strategies this is ~0.64, not 0.05.
91
+ """
92
+ return float(1.0 - 0.95 ** max(n_trials, 0))
93
+
94
+
95
+ def risk_bucket_diagnostic(
96
+ active_weights: pd.Series,
97
+ sigma: np.ndarray,
98
+ symbols: Sequence[str],
99
+ residual_vol: pd.Series,
100
+ *,
101
+ min_per_bucket: int = 8,
102
+ ) -> Dict:
103
+ """Equal-risk-contribution check by residual-vol bucket.
104
+
105
+ Under **correct** alpha scaling every name contributes ~equally to active variance
106
+ (``E{z²}=1``), so a residual-vol bucket's share of ``w_aᵀΣw_a`` should track its
107
+ share of *names*, not its vol. A monotone gradient across vol buckets is the
108
+ fingerprint of mis-scaled alphas — most often a Case mis-choice (see
109
+ :func:`quantred.alphas.refine.case_test`): scaling a Case-2 signal as Case 1 multiplies
110
+ in ``ω_n`` a second time and tilts variance into the high-vol bucket.
111
+
112
+ Buckets names by residual-vol quantile and reports each bucket's share of active
113
+ variance and of ``Σ|w_a|``, the expected (name-fraction) share, and the per-bucket
114
+ sampling error ``√(2/n)`` of mean ``z²``. Degrades quintiles → terciles → suppressed
115
+ as the universe thins (tiny buckets are noise, not signal). ``active_weights`` are
116
+ the book's active weights ``w_a = w − w_B``; ``sigma`` the annualized Σ over
117
+ ``symbols`` in that order.
118
+ """
119
+ aligned = (
120
+ pd.concat([active_weights.rename("w"), residual_vol.rename("vol")], axis=1)
121
+ .reindex(list(symbols))
122
+ .dropna()
123
+ )
124
+ n = len(aligned)
125
+
126
+ # Bucket count: a bucket mean of z² has sampling error ≈ √(2/n_bucket); below
127
+ # ~min_per_bucket names per bucket the gradient is noise, so widen or suppress.
128
+ if n >= 5 * min_per_bucket:
129
+ n_buckets = 5
130
+ elif n >= 3 * max(min_per_bucket - 3, 4):
131
+ n_buckets = 3
132
+ else:
133
+ return {
134
+ "engaged": False,
135
+ "n_names": int(n),
136
+ "reason": f"universe too thin for reliable buckets (n={n}); "
137
+ f"need ≥ {3 * max(min_per_bucket - 3, 4)} names for terciles",
138
+ }
139
+
140
+ idx = list(aligned.index)
141
+ w = aligned["w"].to_numpy()
142
+ vols = aligned["vol"].to_numpy()
143
+ pos = {s: i for i, s in enumerate(symbols)}
144
+ order = [pos[s] for s in idx]
145
+ sub = np.asarray(sigma, dtype=float)[np.ix_(order, order)]
146
+
147
+ # Per-name contribution to active variance: w_i·(Σw)_i sums to w_aᵀΣw_a exactly.
148
+ contrib = w * (sub @ w)
149
+ total_var = float(contrib.sum())
150
+ total_abs_w = float(np.abs(w).sum())
151
+
152
+ order_by_vol = np.argsort(vols, kind="stable")
153
+ buckets = np.array_split(order_by_vol, n_buckets)
154
+ rows: List[Dict] = []
155
+ var_shares: List[float] = []
156
+ for b, members in enumerate(buckets):
157
+ nb = len(members)
158
+ var_share = float(contrib[members].sum() / total_var) if total_var != 0 else 0.0
159
+ rows.append(
160
+ {
161
+ "bucket": b + 1, # 1 = lowest residual vol
162
+ "n_names": int(nb),
163
+ "vol_low": float(vols[members].min()),
164
+ "vol_high": float(vols[members].max()),
165
+ "variance_share": var_share,
166
+ "name_fraction": float(nb / n),
167
+ "abs_weight_share": float(np.abs(w[members]).sum() / total_abs_w) if total_abs_w > 0 else 0.0,
168
+ "sampling_error": float(math.sqrt(2.0 / nb)) if nb > 0 else float("inf"),
169
+ }
170
+ )
171
+ var_shares.append(var_share)
172
+
173
+ # Monotone gradient beyond the sampling band ⇒ a vol tilt (mis-scaling).
174
+ diffs = np.diff(var_shares)
175
+ monotone = bool(np.all(diffs > 0) or np.all(diffs < 0))
176
+ gradient = float(var_shares[-1] - var_shares[0])
177
+ band = float(math.sqrt(2.0 / (n / n_buckets)) / n_buckets)
178
+ tilt = bool(monotone and abs(gradient) > band)
179
+ direction = "high-vol" if gradient > 0 else "low-vol"
180
+
181
+ return {
182
+ "engaged": True,
183
+ "n_names": int(n),
184
+ "n_buckets": n_buckets,
185
+ "buckets": rows,
186
+ "expected_share": float(1.0 / n_buckets),
187
+ "variance_share_gradient": gradient,
188
+ "monotone": monotone,
189
+ "sampling_band": band,
190
+ "tilt_detected": tilt,
191
+ "verdict": (
192
+ f"vol-bucket tilt toward {direction} names (gradient {gradient:+.2f} vs band "
193
+ f"±{band:.2f}) — likely a Case mis-choice; check the per-signal case_test"
194
+ if tilt
195
+ else "no material vol-bucket tilt (contributions ~equal across buckets)"
196
+ ),
197
+ }
@@ -0,0 +1,502 @@
1
+ """Pure performance-metric primitives - the numbers that tell you, honestly,
2
+ whether you have an edge or just a flattering backtest.
3
+
4
+ One definition each for the ratios and risk measures used in the project, shared
5
+ by the backtest analytics (:mod:`quantred.analytics.performance`) and the scanner base
6
+ class - so "Sharpe" or "max drawdown" mean the same thing everywhere (a Sharpe
7
+ that changes depending on who's asking is how people end up trading their savings).
8
+
9
+ Every function is pure and defensive: empty/degenerate inputs yield 0.0 (or inf
10
+ for ratios with a zero denominator) rather than raising - a backtest with zero
11
+ trades shouldn't crash; it should just quietly tell you it found nothing.
12
+
13
+ A note on annualization: the headline ratios take a
14
+ ``periods_per_year`` so they annualize to the *sampling frequency of the series
15
+ passed in*. The backtest equity curve is resampled to daily P&L
16
+ (:func:`quantred.analytics.performance.build_equity_curve`), so its returns are daily
17
+ and ``TRADING_DAYS_PER_YEAR`` is the correct factor; intraday or weekly series
18
+ pass their own factor via :meth:`quantred.marketdata.timeframe.Timeframe.periods_per_year`.
19
+ """
20
+
21
+ import math
22
+ from typing import Sequence, Tuple, Union
23
+
24
+ import numpy as np
25
+ import pandas as pd
26
+
27
+ #: US trading days per year, used to annualize daily-return ratios.
28
+ TRADING_DAYS_PER_YEAR = 252
29
+
30
+ #: Euler-Mascheroni constant, used by the Deflated Sharpe expected-maximum.
31
+ _EULER_MASCHERONI = 0.5772156649015329
32
+
33
+ Numbers = Union[Sequence[float], pd.Series, np.ndarray]
34
+
35
+
36
+ def _as_series(values: Numbers) -> pd.Series:
37
+ return values if isinstance(values, pd.Series) else pd.Series(list(values), dtype="float64")
38
+
39
+
40
+ # --------------------------------------------------------------------------- #
41
+ # Gaussian helpers (hand-rolled so the base install needs no scipy)
42
+ # --------------------------------------------------------------------------- #
43
+ def norm_cdf(x: float) -> float:
44
+ """Standard-normal CDF via the error function (exact, stdlib only)."""
45
+ return 0.5 * (1.0 + math.erf(x / math.sqrt(2.0)))
46
+
47
+
48
+ def norm_ppf(p: float) -> float:
49
+ """Standard-normal inverse CDF (quantile) via Acklam's rational approximation.
50
+
51
+ Accurate to ~1e-9 over the open interval (0, 1); clamps the endpoints.
52
+ """
53
+ if p <= 0.0:
54
+ return float("-inf")
55
+ if p >= 1.0:
56
+ return float("inf")
57
+
58
+ # Coefficients for Acklam's algorithm.
59
+ a = [
60
+ -3.969683028665376e01,
61
+ 2.209460984245205e02,
62
+ -2.759285104469687e02,
63
+ 1.383577518672690e02,
64
+ -3.066479806614716e01,
65
+ 2.506628277459239e00,
66
+ ]
67
+ b = [
68
+ -5.447609879822406e01,
69
+ 1.615858368580409e02,
70
+ -1.556989798598866e02,
71
+ 6.680131188771972e01,
72
+ -1.328068155288572e01,
73
+ ]
74
+ c = [
75
+ -7.784894002430293e-03,
76
+ -3.223964580411365e-01,
77
+ -2.400758277161838e00,
78
+ -2.549732539343734e00,
79
+ 4.374664141464968e00,
80
+ 2.938163982698783e00,
81
+ ]
82
+ d = [7.784695709041462e-03, 3.224671290700398e-01, 2.445134137142996e00, 3.754408661907416e00]
83
+
84
+ plow, phigh = 0.02425, 1.0 - 0.02425
85
+ if p < plow:
86
+ q = math.sqrt(-2.0 * math.log(p))
87
+ return (((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) / (
88
+ (((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1.0
89
+ )
90
+ if p > phigh:
91
+ q = math.sqrt(-2.0 * math.log(1.0 - p))
92
+ return -(((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) / (
93
+ (((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1.0
94
+ )
95
+ q = p - 0.5
96
+ r = q * q
97
+ return (
98
+ (((((a[0] * r + a[1]) * r + a[2]) * r + a[3]) * r + a[4]) * r + a[5])
99
+ * q
100
+ / (((((b[0] * r + b[1]) * r + b[2]) * r + b[3]) * r + b[4]) * r + 1.0)
101
+ )
102
+
103
+
104
+ # --------------------------------------------------------------------------- #
105
+ # Returns / growth
106
+ # --------------------------------------------------------------------------- #
107
+ def returns_from_equity(equity_curve: Numbers) -> pd.Series:
108
+ """Period-over-period returns from an equity curve."""
109
+ return _as_series(equity_curve).pct_change().dropna()
110
+
111
+
112
+ def cagr(equity_curve: Numbers, years: float) -> float:
113
+ """Compound annual growth rate from first->last equity over ``years`` elapsed.
114
+
115
+ Returns a fraction (0.20 == 20%/yr). ``0.0`` for non-positive elapsed time or
116
+ a degenerate curve.
117
+ """
118
+ equity = _as_series(equity_curve)
119
+ if len(equity) < 2 or years <= 0:
120
+ return 0.0
121
+ start, end = float(equity.iloc[0]), float(equity.iloc[-1])
122
+ if start <= 0 or end <= 0:
123
+ return 0.0
124
+ return float((end / start) ** (1.0 / years) - 1.0)
125
+
126
+
127
+ def annualized_volatility(returns: Numbers, periods_per_year: int = TRADING_DAYS_PER_YEAR) -> float:
128
+ """Standard deviation of returns scaled to a year."""
129
+ r = _as_series(returns)
130
+ if len(r) < 2:
131
+ return 0.0
132
+ return float(r.std() * np.sqrt(periods_per_year))
133
+
134
+
135
+ def downside_deviation(returns: Numbers, periods_per_year: int = TRADING_DAYS_PER_YEAR) -> float:
136
+ """Annualized deviation of negative returns only (the Sortino denominator)."""
137
+ r = _as_series(returns)
138
+ downside = r[r < 0]
139
+ if len(downside) < 2:
140
+ return 0.0
141
+ return float(downside.std() * np.sqrt(periods_per_year))
142
+
143
+
144
+ # --------------------------------------------------------------------------- #
145
+ # Risk-adjusted ratios
146
+ # --------------------------------------------------------------------------- #
147
+ def sharpe_ratio(returns: Numbers, periods_per_year: int = TRADING_DAYS_PER_YEAR) -> float:
148
+ """Annualized Sharpe ratio (risk-free rate assumed 0)."""
149
+ r = _as_series(returns)
150
+ if len(r) < 2 or r.std() == 0:
151
+ return 0.0
152
+ return float(np.sqrt(periods_per_year) * r.mean() / r.std())
153
+
154
+
155
+ def sortino_ratio(returns: Numbers, periods_per_year: int = TRADING_DAYS_PER_YEAR) -> float:
156
+ """Annualized Sortino ratio (downside-deviation denominator)."""
157
+ r = _as_series(returns)
158
+ if len(r) < 2:
159
+ return 0.0
160
+ downside = r[r < 0]
161
+ if downside.empty or downside.std() == 0:
162
+ return 0.0
163
+ return float(np.sqrt(periods_per_year) * r.mean() / downside.std())
164
+
165
+
166
+ def calmar_ratio(cagr_value: float, max_drawdown_value: float) -> float:
167
+ """CAGR / max drawdown.
168
+
169
+ Both arguments are fractions. ``inf`` when drawdown is zero. The drawdown's sign
170
+ convention varies by caller, so only its magnitude is used; the CAGR keeps its sign.
171
+ Taking the magnitude of the whole ratio reported a losing run's Calmar as positive.
172
+ """
173
+ if max_drawdown_value == 0:
174
+ return float("inf")
175
+ return float(cagr_value / abs(max_drawdown_value))
176
+
177
+
178
+ #: Below this, a book has no market exposure worth dividing by and Treynor stops
179
+ #: meaning anything: the ratio's error grows as 1/beta², and a beta this small is not
180
+ #: resolvable from zero by the regression that produced it. Guarding only the exact
181
+ #: ``beta == 0`` let a beta of -0.0001 through and produced a Treynor of -262 beside a
182
+ #: beta the report rendered as "-0.00" - an unbounded number whose cause was invisible
183
+ #: on the same screen. :class:`~quantred.execution.sizing.BetaSizer` already clamps
184
+ #: for this when it *sizes* on beta; the same blow-up applies when reporting on it, and
185
+ #: `--beta-sizing` puts books in exactly this regime by design, so it is not exotic.
186
+ MIN_ABS_BETA_FOR_TREYNOR = 0.05
187
+
188
+
189
+ def treynor_ratio(returns: Numbers, beta: float, periods_per_year: int = TRADING_DAYS_PER_YEAR) -> float:
190
+ """Annualized excess return per unit of market beta.
191
+
192
+ ``0.0`` when ``|beta|`` is below :data:`MIN_ABS_BETA_FOR_TREYNOR` - the established
193
+ "unavailable" value for the benchmark-relative metrics, flagged alongside them so a
194
+ suppressed ratio is distinguishable from a genuine zero.
195
+ """
196
+ r = _as_series(returns)
197
+ if len(r) < 2 or abs(beta) < MIN_ABS_BETA_FOR_TREYNOR:
198
+ return 0.0
199
+ return float(r.mean() * periods_per_year / beta)
200
+
201
+
202
+ # --------------------------------------------------------------------------- #
203
+ # Drawdown shape
204
+ # --------------------------------------------------------------------------- #
205
+ def drawdown_series(equity_curve: Numbers) -> pd.Series:
206
+ """Per-point drawdown as a (negative) fraction of the running peak."""
207
+ equity = _as_series(equity_curve)
208
+ if equity.empty:
209
+ return pd.Series(dtype="float64")
210
+ running_max = equity.expanding().max()
211
+ return (equity - running_max) / running_max
212
+
213
+
214
+ def max_drawdown(equity_curve: Numbers) -> float:
215
+ """Maximum peak-to-trough decline of an equity curve, as a positive fraction."""
216
+ dd = drawdown_series(equity_curve)
217
+ return float(abs(dd.min())) if not dd.empty else 0.0
218
+
219
+
220
+ def max_drawdown_duration(equity_curve: Numbers) -> int:
221
+ """Longest run of consecutive periods spent below a prior peak (underwater)."""
222
+ equity = _as_series(equity_curve)
223
+ if len(equity) < 2:
224
+ return 0
225
+ underwater = equity < equity.cummax()
226
+ longest = current = 0
227
+ for uw in underwater.to_numpy():
228
+ current = current + 1 if uw else 0
229
+ longest = max(longest, current)
230
+ return int(longest)
231
+
232
+
233
+ def ulcer_index(equity_curve: Numbers) -> float:
234
+ """Root-mean-square drawdown depth (%), penalizing deep *and* long drawdowns."""
235
+ dd = drawdown_series(equity_curve)
236
+ if dd.empty:
237
+ return 0.0
238
+ return float(np.sqrt(np.mean((dd.to_numpy() * 100.0) ** 2)))
239
+
240
+
241
+ def recovery_factor(net_profit: float, max_drawdown_value: float) -> float:
242
+ """Net profit / max drawdown (absolute-dollar cousin of Calmar)."""
243
+ if max_drawdown_value == 0:
244
+ return float("inf")
245
+ return float(net_profit / max_drawdown_value)
246
+
247
+
248
+ def martin_ratio(cagr_value: float, ulcer_index_value: float) -> float:
249
+ """Ulcer Performance Index: CAGR / ulcer index. ``0.0`` if ulcer is zero."""
250
+ if ulcer_index_value == 0:
251
+ return 0.0
252
+ return float(cagr_value * 100.0 / ulcer_index_value)
253
+
254
+
255
+ def sterling_ratio(cagr_value: float, average_drawdown: float) -> float:
256
+ """CAGR / average drawdown. ``inf`` when average drawdown is zero; negative for a
257
+ losing run, as :func:`calmar_ratio` is."""
258
+ if average_drawdown == 0:
259
+ return float("inf")
260
+ return float(cagr_value / abs(average_drawdown))
261
+
262
+
263
+ # --------------------------------------------------------------------------- #
264
+ # Tail / distribution risk
265
+ # --------------------------------------------------------------------------- #
266
+ def value_at_risk(returns: Numbers, level: float = 0.95) -> float:
267
+ """Historical Value-at-Risk: the loss not exceeded with ``level`` confidence.
268
+
269
+ Returned as a positive fraction (0.03 == a 3% loss), and 0.0 when even that tail is
270
+ a gain: taking the magnitude of the percentile reported a 0.2% *gain* at the 5th
271
+ percentile as a 0.2% loss.
272
+ """
273
+ r = _as_series(returns)
274
+ if r.empty:
275
+ return 0.0
276
+ return float(max(0.0, -np.percentile(r.to_numpy(), (1.0 - level) * 100.0)))
277
+
278
+
279
+ def conditional_var(returns: Numbers, level: float = 0.95) -> float:
280
+ """Expected shortfall: mean loss in the worst ``1 - level`` tail (positive), 0.0 when
281
+ that tail is on average a gain - the same sign rule as :func:`value_at_risk`."""
282
+ r = _as_series(returns)
283
+ if r.empty:
284
+ return 0.0
285
+ cutoff = np.percentile(r.to_numpy(), (1.0 - level) * 100.0)
286
+ tail = r[r <= cutoff]
287
+ if tail.empty:
288
+ return float(max(0.0, -cutoff))
289
+ return float(max(0.0, -tail.mean()))
290
+
291
+
292
+ def skewness(returns: Numbers) -> float:
293
+ """Sample skewness of returns (0.0 for < 3 points)."""
294
+ r = _as_series(returns)
295
+ if len(r) < 3:
296
+ return 0.0
297
+ return float(r.skew())
298
+
299
+
300
+ def kurtosis(returns: Numbers) -> float:
301
+ """Excess kurtosis of returns (Fisher; 0.0 for a normal distribution)."""
302
+ r = _as_series(returns)
303
+ if len(r) < 4:
304
+ return 0.0
305
+ return float(r.kurtosis())
306
+
307
+
308
+ def tail_ratio(returns: Numbers) -> float:
309
+ """Ratio of the right tail (95th pct) to the left tail (5th pct), in magnitude."""
310
+ r = _as_series(returns)
311
+ if r.empty:
312
+ return 0.0
313
+ left = abs(np.percentile(r.to_numpy(), 5))
314
+ if left == 0:
315
+ return float("inf")
316
+ return float(abs(np.percentile(r.to_numpy(), 95)) / left)
317
+
318
+
319
+ def omega_ratio(returns: Numbers, threshold: float = 0.0) -> float:
320
+ """Probability-weighted gains over losses relative to ``threshold``."""
321
+ r = _as_series(returns)
322
+ if r.empty:
323
+ return 0.0
324
+ excess = r - threshold
325
+ gains = excess[excess > 0].sum()
326
+ losses = -excess[excess < 0].sum()
327
+ if losses == 0:
328
+ return float("inf")
329
+ return float(gains / losses)
330
+
331
+
332
+ # --------------------------------------------------------------------------- #
333
+ # Statistical robustness (Bailey & Lopez de Prado) - the honest-evaluation core
334
+ # --------------------------------------------------------------------------- #
335
+ def probabilistic_sharpe_ratio(returns: Numbers, benchmark_sr: float = 0.0) -> float:
336
+ """P(true Sharpe > ``benchmark_sr``) given the observed per-period Sharpe.
337
+
338
+ Corrects the Sharpe estimate for sample length, skew and (excess) kurtosis.
339
+ ``benchmark_sr`` is a *per-period* (non-annualized) Sharpe, matching the
340
+ series passed in. Returns a probability in [0, 1].
341
+ """
342
+ r = _as_series(returns)
343
+ n = len(r)
344
+ if n < 3 or r.std() == 0:
345
+ return 0.0
346
+ sr = float(r.mean() / r.std()) # per-period Sharpe estimate
347
+ skew = skewness(r)
348
+ kurt = kurtosis(r) + 3.0 # Pearson kurtosis (normal == 3)
349
+ denom = 1.0 - skew * sr + (kurt - 1.0) / 4.0 * sr**2
350
+ if denom <= 0:
351
+ return 0.0
352
+ z = (sr - benchmark_sr) * math.sqrt(n - 1) / math.sqrt(denom)
353
+ return float(norm_cdf(z))
354
+
355
+
356
+ def expected_max_sharpe(var_of_trial_sr: float, n_trials: int) -> float:
357
+ """Expected maximum per-period Sharpe across ``n_trials`` independent trials.
358
+
359
+ The multiple-testing benchmark used by the Deflated Sharpe Ratio.
360
+ """
361
+ if n_trials <= 1 or var_of_trial_sr <= 0:
362
+ return 0.0
363
+ z1 = norm_ppf(1.0 - 1.0 / n_trials)
364
+ z2 = norm_ppf(1.0 - 1.0 / (n_trials * math.e))
365
+ return float(math.sqrt(var_of_trial_sr) * ((1.0 - _EULER_MASCHERONI) * z1 + _EULER_MASCHERONI * z2))
366
+
367
+
368
+ def deflated_sharpe_ratio(returns: Numbers, n_trials: int, var_of_trial_sr: float = None) -> float:
369
+ """PSR against the expected best of ``n_trials`` configs (anti-overfitting).
370
+
371
+ ``var_of_trial_sr`` is the variance of the *per-period* Sharpe estimates
372
+ across the configs that were tried. When unknown (a standalone backtest),
373
+ it falls back to the null-hypothesis estimator variance ``1/(n-1)`` so the
374
+ deflation is still conservative. Returns a probability in [0, 1].
375
+ """
376
+ r = _as_series(returns)
377
+ n = len(r)
378
+ if n < 3 or r.std() == 0 or n_trials < 1:
379
+ return 0.0
380
+ if var_of_trial_sr is None:
381
+ var_of_trial_sr = 1.0 / (n - 1)
382
+ sr_star = expected_max_sharpe(var_of_trial_sr, n_trials)
383
+ return probabilistic_sharpe_ratio(r, benchmark_sr=sr_star)
384
+
385
+
386
+ # --------------------------------------------------------------------------- #
387
+ # Trade-level statistics
388
+ # --------------------------------------------------------------------------- #
389
+ def win_rate(pnl: Numbers) -> float:
390
+ """Fraction of trades with positive P&L."""
391
+ p = _as_series(pnl)
392
+ return float((p > 0).sum() / len(p)) if len(p) else 0.0
393
+
394
+
395
+ def profit_factor(pnl: Numbers) -> float:
396
+ """Gross profit / gross loss. ``inf`` when there are no losses."""
397
+ p = _as_series(pnl)
398
+ gross_loss = abs(p[p < 0].sum())
399
+ if gross_loss == 0:
400
+ return float("inf")
401
+ return float(p[p > 0].sum() / gross_loss)
402
+
403
+
404
+ def expectancy(pnl: Numbers) -> float:
405
+ """Mean P&L per trade (in account currency)."""
406
+ p = _as_series(pnl)
407
+ return float(p.mean()) if len(p) else 0.0
408
+
409
+
410
+ def payoff_ratio(pnl: Numbers) -> float:
411
+ """Average win / average loss (magnitudes). ``inf`` with no losing trades."""
412
+ p = _as_series(pnl)
413
+ wins, losses = p[p > 0], p[p < 0]
414
+ if losses.empty or losses.mean() == 0:
415
+ return float("inf") if not wins.empty else 0.0
416
+ if wins.empty:
417
+ return 0.0
418
+ return float(wins.mean() / abs(losses.mean()))
419
+
420
+
421
+ def gain_to_pain_ratio(pnl: Numbers) -> float:
422
+ """Sum of P&L over the sum of absolute losses. ``inf`` with no losses."""
423
+ p = _as_series(pnl)
424
+ pain = abs(p[p < 0].sum())
425
+ if pain == 0:
426
+ return float("inf")
427
+ return float(p.sum() / pain)
428
+
429
+
430
+ def kelly_criterion(pnl: Numbers) -> float:
431
+ """Kelly fraction from the win rate and payoff ratio: ``W - (1 - W)/R``.
432
+
433
+ The theoretical optimal capital fraction; values are typically scaled down
434
+ in practice. ``0.0`` for degenerate input.
435
+ """
436
+ p = _as_series(pnl)
437
+ if p.empty:
438
+ return 0.0
439
+ w = win_rate(p)
440
+ r = payoff_ratio(p)
441
+ if r == 0 or math.isinf(r):
442
+ return 0.0
443
+ return float(w - (1.0 - w) / r)
444
+
445
+
446
+ def system_quality_number(pnl: Numbers) -> float:
447
+ """Van Tharp's SQN: ``sqrt(n) * mean(pnl) / std(pnl)`` (expectancy quality)."""
448
+ p = _as_series(pnl)
449
+ if len(p) < 2 or p.std() == 0:
450
+ return 0.0
451
+ return float(np.sqrt(len(p)) * p.mean() / p.std())
452
+
453
+
454
+ def consecutive(pnl: Numbers, *, winning: bool) -> int:
455
+ """Longest run of consecutive winning (or losing) trades, in trade order."""
456
+ p = _as_series(pnl)
457
+ longest = current = 0
458
+ for value in p.to_numpy():
459
+ hit = value > 0 if winning else value < 0
460
+ current = current + 1 if hit else 0
461
+ longest = max(longest, current)
462
+ return int(longest)
463
+
464
+
465
+ # --------------------------------------------------------------------------- #
466
+ # Benchmark-relative
467
+ # --------------------------------------------------------------------------- #
468
+ def _align(returns: Numbers, benchmark_returns: Numbers) -> pd.DataFrame:
469
+ paired = pd.concat([_as_series(returns), _as_series(benchmark_returns)], axis=1, keys=["r", "b"]).dropna()
470
+ return paired
471
+
472
+
473
+ def alpha_beta(returns: Numbers, benchmark_returns: Numbers) -> Tuple[float, float, float]:
474
+ """OLS of strategy returns on benchmark returns -> ``(alpha, beta, r_squared)``.
475
+
476
+ ``alpha`` is the per-period intercept; ``beta`` the slope; ``r_squared`` the
477
+ fraction of variance explained. ``(0.0, 0.0, 0.0)`` for degenerate input.
478
+ """
479
+ paired = _align(returns, benchmark_returns)
480
+ if len(paired) < 3:
481
+ return 0.0, 0.0, 0.0
482
+ benchmark_var = paired["b"].var()
483
+ if benchmark_var == 0:
484
+ return 0.0, 0.0, 0.0
485
+ beta = float(paired["r"].cov(paired["b"]) / benchmark_var)
486
+ alpha = float(paired["r"].mean() - beta * paired["b"].mean())
487
+ corr = paired["r"].corr(paired["b"])
488
+ r_squared = float(corr**2) if pd.notna(corr) else 0.0
489
+ return alpha, beta, r_squared
490
+
491
+
492
+ def information_ratio(
493
+ returns: Numbers, benchmark_returns: Numbers, periods_per_year: int = TRADING_DAYS_PER_YEAR
494
+ ) -> float:
495
+ """Annualized mean active return over its tracking error vs a benchmark."""
496
+ paired = _align(returns, benchmark_returns)
497
+ if len(paired) < 2:
498
+ return 0.0
499
+ active = paired["r"] - paired["b"]
500
+ if active.std() == 0:
501
+ return 0.0
502
+ return float(np.sqrt(periods_per_year) * active.mean() / active.std())