quantred 2.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- quantred/__init__.py +24 -0
- quantred/alphas/__init__.py +53 -0
- quantred/alphas/base.py +221 -0
- quantred/alphas/combine.py +260 -0
- quantred/alphas/horizon.py +153 -0
- quantred/alphas/refine.py +300 -0
- quantred/alphas/scorers.py +87 -0
- quantred/analytics/attribution.py +354 -0
- quantred/analytics/bootstrap.py +353 -0
- quantred/analytics/charts.py +393 -0
- quantred/analytics/excursion.py +185 -0
- quantred/analytics/execution_quality.py +180 -0
- quantred/analytics/exposure.py +167 -0
- quantred/analytics/htmlreport.py +1208 -0
- quantred/analytics/information.py +197 -0
- quantred/analytics/metrics.py +502 -0
- quantred/analytics/performance.py +639 -0
- quantred/analytics/reporting.py +1672 -0
- quantred/analytics/screening.py +190 -0
- quantred/analytics/series_comparison.py +303 -0
- quantred/analytics/trade_analytics.py +353 -0
- quantred/brokers/alpaca/broker.py +415 -0
- quantred/brokers/alpaca/factory.py +33 -0
- quantred/brokers/alpaca/market_data.py +166 -0
- quantred/brokers/base.py +220 -0
- quantred/brokers/dryrun.py +164 -0
- quantred/brokers/errors.py +70 -0
- quantred/cli.py +8619 -0
- quantred/costs/__init__.py +11 -0
- quantred/costs/base.py +126 -0
- quantred/costs/parametric.py +131 -0
- quantred/data/__init__.py +29 -0
- quantred/data/compute.py +332 -0
- quantred/data/edges.py +81 -0
- quantred/data/features.py +120 -0
- quantred/data/panel.py +61 -0
- quantred/data/scan.py +85 -0
- quantred/data/store.py +269 -0
- quantred/demo/__init__.py +22 -0
- quantred/demo/scanners.py +104 -0
- quantred/demo/strategies.py +125 -0
- quantred/engine/backtest.py +1206 -0
- quantred/engine/barcheck.py +228 -0
- quantred/engine/live.py +539 -0
- quantred/execution/decision.py +192 -0
- quantred/execution/flatten.py +302 -0
- quantred/execution/halt.py +165 -0
- quantred/execution/ledger.py +758 -0
- quantred/execution/live_trader.py +587 -0
- quantred/execution/order_id.py +83 -0
- quantred/execution/sizing.py +114 -0
- quantred/indicators/indicators.py +158 -0
- quantred/marketdata/base.py +62 -0
- quantred/marketdata/client.py +59 -0
- quantred/marketdata/session.py +112 -0
- quantred/marketdata/synthetic.py +74 -0
- quantred/marketdata/timeframe.py +125 -0
- quantred/mcp/__init__.py +6 -0
- quantred/mcp/selftest.py +139 -0
- quantred/mcp/server.py +2419 -0
- quantred/optimization/causality.py +607 -0
- quantred/optimization/config_store.py +300 -0
- quantred/optimization/optimizer.py +431 -0
- quantred/optimization/parallel.py +345 -0
- quantred/optimization/param_space.py +309 -0
- quantred/optimization/walk_forward.py +1254 -0
- quantred/portfolio/allocator.py +135 -0
- quantred/portfolio/benchmark.py +109 -0
- quantred/portfolio/optimizer.py +786 -0
- quantred/portfolio/policy.py +335 -0
- quantred/portfolio/posterior.py +208 -0
- quantred/research/__init__.py +30 -0
- quantred/research/agent.py +868 -0
- quantred/research/demo_proposals.py +222 -0
- quantred/research/llm.py +311 -0
- quantred/research/proposer.py +234 -0
- quantred/research/provider_errors.py +242 -0
- quantred/research/sandbox.py +340 -0
- quantred/risk/__init__.py +60 -0
- quantred/risk/base.py +218 -0
- quantred/risk/conditional.py +474 -0
- quantred/risk/exposures.py +88 -0
- quantred/risk/factor.py +119 -0
- quantred/risk/sample.py +89 -0
- quantred/risk/streaming.py +214 -0
- quantred/scanners/base.py +141 -0
- quantred/scanners/symbol_scanner.py +148 -0
- quantred/services/__init__.py +51 -0
- quantred/services/analysis.py +6082 -0
- quantred/services/audit.py +574 -0
- quantred/services/campaign.py +381 -0
- quantred/services/configs.py +282 -0
- quantred/services/data.py +172 -0
- quantred/services/dryrun.py +206 -0
- quantred/services/glossary.py +130 -0
- quantred/services/maintenance.py +314 -0
- quantred/services/registry.py +351 -0
- quantred/services/setup.py +1023 -0
- quantred/services/sizing.py +150 -0
- quantred/services/smallreal.py +450 -0
- quantred/settings.py +571 -0
- quantred/store/__init__.py +0 -0
- quantred/store/bars.py +660 -0
- quantred/store/trials.py +1804 -0
- quantred/strategies/base.py +643 -0
- quantred/strategies/signals.py +28 -0
- quantred/utils/errors.py +48 -0
- quantred/utils/extras.py +330 -0
- quantred/utils/logging_config.py +33 -0
- quantred/utils/numeric.py +46 -0
- quantred/utils/streaming.py +474 -0
- quantred/utils/timeutils.py +148 -0
- quantred-2.4.0.dist-info/METADATA +632 -0
- quantred-2.4.0.dist-info/RECORD +117 -0
- quantred-2.4.0.dist-info/WHEEL +4 -0
- quantred-2.4.0.dist-info/entry_points.txt +8 -0
- quantred-2.4.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
}
|