alphafade 0.1.0__tar.gz
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.
- alphafade-0.1.0/.gitignore +16 -0
- alphafade-0.1.0/CHANGELOG.md +46 -0
- alphafade-0.1.0/LICENSE +21 -0
- alphafade-0.1.0/PKG-INFO +262 -0
- alphafade-0.1.0/README.md +228 -0
- alphafade-0.1.0/docs/methodology.md +539 -0
- alphafade-0.1.0/pyproject.toml +123 -0
- alphafade-0.1.0/src/alphafade/__init__.py +70 -0
- alphafade-0.1.0/src/alphafade/_errors.py +56 -0
- alphafade-0.1.0/src/alphafade/_stats.py +141 -0
- alphafade-0.1.0/src/alphafade/_supwald_table.py +17 -0
- alphafade-0.1.0/src/alphafade/_validate.py +324 -0
- alphafade-0.1.0/src/alphafade/breaks.py +325 -0
- alphafade-0.1.0/src/alphafade/compare.py +297 -0
- alphafade-0.1.0/src/alphafade/crowding.py +326 -0
- alphafade-0.1.0/src/alphafade/datasets.py +464 -0
- alphafade-0.1.0/src/alphafade/decay.py +496 -0
- alphafade-0.1.0/src/alphafade/horizon.py +284 -0
- alphafade-0.1.0/src/alphafade/lifetime.py +268 -0
- alphafade-0.1.0/src/alphafade/plotting.py +145 -0
- alphafade-0.1.0/src/alphafade/publication.py +283 -0
- alphafade-0.1.0/src/alphafade/py.typed +0 -0
- alphafade-0.1.0/src/alphafade/report.py +542 -0
- alphafade-0.1.0/src/alphafade/rolling.py +342 -0
- alphafade-0.1.0/src/alphafade/walkforward.py +275 -0
- alphafade-0.1.0/tests/__init__.py +0 -0
- alphafade-0.1.0/tests/conftest.py +35 -0
- alphafade-0.1.0/tests/data/ff3_daily.csv +12 -0
- alphafade-0.1.0/tests/data/ff3_monthly.zip +0 -0
- alphafade-0.1.0/tests/data/ff3_weekly.csv +12 -0
- alphafade-0.1.0/tests/data/mom_daily.zip +0 -0
- alphafade-0.1.0/tests/data/mom_monthly.csv +28 -0
- alphafade-0.1.0/tests/test_breaks.py +150 -0
- alphafade-0.1.0/tests/test_compare.py +162 -0
- alphafade-0.1.0/tests/test_crowding.py +188 -0
- alphafade-0.1.0/tests/test_datasets.py +548 -0
- alphafade-0.1.0/tests/test_decay.py +225 -0
- alphafade-0.1.0/tests/test_horizon.py +322 -0
- alphafade-0.1.0/tests/test_lifetime.py +218 -0
- alphafade-0.1.0/tests/test_publication.py +133 -0
- alphafade-0.1.0/tests/test_readme.py +51 -0
- alphafade-0.1.0/tests/test_regressions_oct.py +135 -0
- alphafade-0.1.0/tests/test_report.py +213 -0
- alphafade-0.1.0/tests/test_report_export.py +190 -0
- alphafade-0.1.0/tests/test_rolling.py +242 -0
- alphafade-0.1.0/tests/test_stats.py +73 -0
- alphafade-0.1.0/tests/test_stress.py +563 -0
- alphafade-0.1.0/tests/test_validate.py +159 -0
- alphafade-0.1.0/tests/test_walkforward.py +144 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to **alphafade** are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2026-10-06
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- `forward_returns`: the one look-ahead-safe way to line up signals with future returns.
|
|
14
|
+
- `ic_series`, `rolling_ic`: per-date and rolling information coefficient (Spearman or Pearson).
|
|
15
|
+
- `rolling_sharpe`: annualized rolling Sharpe with frequency inference.
|
|
16
|
+
- `fit_decay` / `DecayFit`: exponential decay of an edge over calendar time with half-life,
|
|
17
|
+
residual block-bootstrap confidence interval, linear comparison (AIC), linear fallback, and
|
|
18
|
+
an explicit "no detectable decay" result.
|
|
19
|
+
- `find_break`: unknown-date break search (Andrews sup-Wald with a simulated p-value table, or
|
|
20
|
+
CUSUM); `chow_test` for a known date; `BreakResult`.
|
|
21
|
+
- `publication_gap` / `GapResult`: McLean & Pontiff in-sample / post-sample / post-publication
|
|
22
|
+
comparison with Newey-West t-statistics.
|
|
23
|
+
- `crowding_score`: Lou & Polk comomentum (leave-one-out, or pairwise) per trade leg.
|
|
24
|
+
- `analyze` / `FadeReport` / `CrowdingLink`: everything in one call, with `.summary()`,
|
|
25
|
+
`.verdict()`, `.to_frame()`, `.to_dict()`, `.to_json()` and `.plot()` (matplotlib via the
|
|
26
|
+
`plot` extra). With short history the default rolling window shrinks to a third of the data.
|
|
27
|
+
- `signal_lifetime` / `LifetimeResult`: years (and calendar date) until a fitted edge falls to
|
|
28
|
+
a chosen level or share of its start, with a confidence interval.
|
|
29
|
+
- `compare_signals` / `SignalComparison`: fit and rank many signals by decay speed, with Holm
|
|
30
|
+
or Benjamini-Hochberg multiple-testing adjustment.
|
|
31
|
+
- `walk_forward_decay` / `WalkForwardResult`: expanding-window refits with no look-ahead and
|
|
32
|
+
stability measures for the half-life.
|
|
33
|
+
- `ic_by_horizon` / `HorizonResult`: mean IC per forecast horizon (Newey-West t) and a horizon
|
|
34
|
+
half-life.
|
|
35
|
+
- `datasets.load_ff3`, `datasets.load_momentum`: Ken French factors as decimals, downloaded
|
|
36
|
+
only when asked, cached in `~/.cache/alphafade/` (or `$ALPHAFADE_CACHE`), or offline via `path=`.
|
|
37
|
+
- Specific exceptions (`InputError`, `AlignmentError`, `FrequencyError`,
|
|
38
|
+
`InsufficientDataError`, `DownloadError`) and warnings (`DataDroppedWarning`, `FitWarning`).
|
|
39
|
+
- Safety: downloads are https-only (redirects included), time-limited, and size-capped; zip
|
|
40
|
+
files are decompressed with a cap on the real output size. Boolean data, overflow-scale
|
|
41
|
+
values, and Newey-West lags below the overlap floor are rejected with clear errors.
|
|
42
|
+
- PEP 561 `py.typed` marker; `mypy --strict` clean.
|
|
43
|
+
- Example: `examples/umd_momentum.py` (real momentum factor, 1963 to present).
|
|
44
|
+
|
|
45
|
+
[Unreleased]: https://github.com/JSand15/alphafade/compare/v0.1.0...HEAD
|
|
46
|
+
[0.1.0]: https://github.com/JSand15/alphafade/releases/tag/v0.1.0
|
alphafade-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jeevun Sandhu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
alphafade-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: alphafade
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Is my trading signal dying? Measure calendar-time alpha decay, half-lives, structural breaks, publication effects, and crowding.
|
|
5
|
+
Project-URL: Homepage, https://github.com/JSand15/alphafade
|
|
6
|
+
Project-URL: Repository, https://github.com/JSand15/alphafade
|
|
7
|
+
Project-URL: Issues, https://github.com/JSand15/alphafade/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/JSand15/alphafade/blob/main/CHANGELOG.md
|
|
9
|
+
Author: Jeevun Sandhu
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: alpha decay,anomalies,comomentum,crowding,finance,half-life,information coefficient,quant,structural break
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Education
|
|
15
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
16
|
+
Classifier: Intended Audience :: Science/Research
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
24
|
+
Classifier: Topic :: Office/Business :: Financial :: Investment
|
|
25
|
+
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
26
|
+
Classifier: Typing :: Typed
|
|
27
|
+
Requires-Python: >=3.11
|
|
28
|
+
Requires-Dist: numpy>=1.26
|
|
29
|
+
Requires-Dist: pandas>=2.2
|
|
30
|
+
Requires-Dist: scipy>=1.11
|
|
31
|
+
Provides-Extra: plot
|
|
32
|
+
Requires-Dist: matplotlib>=3.8; extra == 'plot'
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
35
|
+
# alphafade
|
|
36
|
+
|
|
37
|
+
[](https://github.com/JSand15/alphafade/actions/workflows/ci.yml)
|
|
38
|
+
[](https://pypi.org/project/alphafade/)
|
|
39
|
+
[](https://pypi.org/project/alphafade/)
|
|
40
|
+
[](https://github.com/JSand15/alphafade/blob/main/LICENSE)
|
|
41
|
+
|
|
42
|
+
**Is my trading signal dying, and if so, how fast and why?**
|
|
43
|
+
|
|
44
|
+
Most tools measure how a signal's predictive power fades over the days after each trade.
|
|
45
|
+
alphafade measures something different: how a signal's edge shrinks across *calendar time*
|
|
46
|
+
(months and years), and whether that shrinkage lines up with crowding, meaning more money
|
|
47
|
+
chasing the same pattern. It fits a decay curve and reports an honest half-life with a
|
|
48
|
+
bootstrap confidence interval, or says "no detectable decay" when the data can't tell. It
|
|
49
|
+
also tests for structural breaks, measures the post-publication drop McLean & Pontiff (2016)
|
|
50
|
+
made famous, and computes a Lou & Polk comomentum crowding score. Every t-statistic is
|
|
51
|
+
autocorrelation-robust (Newey-West), and nothing is dropped, resampled, or shifted behind your
|
|
52
|
+
back.
|
|
53
|
+
|
|
54
|
+
## Install
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install alphafade # core: numpy, pandas, scipy
|
|
58
|
+
pip install "alphafade[plot]" # + matplotlib for report.plot()
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Python 3.11 or newer. With [uv](https://docs.astral.sh/uv/): `uv add "alphafade[plot]"`.
|
|
62
|
+
|
|
63
|
+
## Quickstart
|
|
64
|
+
|
|
65
|
+
Is momentum dying? This downloads Ken French's momentum factor (a few KB, cached
|
|
66
|
+
afterwards) and runs every analysis:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
import alphafade as af
|
|
70
|
+
|
|
71
|
+
umd = af.datasets.load_momentum("M").loc["1963-07":] # monthly momentum returns
|
|
72
|
+
report = af.analyze(
|
|
73
|
+
umd,
|
|
74
|
+
sample_end="1989-12-31", # Jegadeesh & Titman's sample ended here
|
|
75
|
+
publication_date="1993-03-01", # ...and their paper came out here
|
|
76
|
+
rng=42, # reproducible bootstrap
|
|
77
|
+
)
|
|
78
|
+
print(report.summary())
|
|
79
|
+
report.plot() # needs alphafade[plot]
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Part of the output (data through August 2026):
|
|
83
|
+
|
|
84
|
+
```text
|
|
85
|
+
Verdict: No detectable decay in the strategy's average return so far.
|
|
86
|
+
...
|
|
87
|
+
After publication (1993-03 to 2026-08, 402 obs): average 0.003823 per period (4.59% a year,
|
|
88
|
+
Sharpe 0.28, t = 1.60), 53% lower than in-sample (t of the change = -1.40).
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Momentum earns about half as much since publication, but it's so volatile that 33 years of
|
|
92
|
+
data can't rule out luck. alphafade tells you that instead of printing a confident-looking
|
|
93
|
+
half-life.
|
|
94
|
+
|
|
95
|
+
### With a signal instead of returns
|
|
96
|
+
|
|
97
|
+
If you have the signal itself (dates × assets) and asset returns, the per-date
|
|
98
|
+
information coefficient (IC: the cross-sectional correlation between today's signal and
|
|
99
|
+
the returns that follow) is usually the sharper thing to track:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
import numpy as np, pandas as pd
|
|
103
|
+
import alphafade as af
|
|
104
|
+
|
|
105
|
+
rng = np.random.default_rng(0)
|
|
106
|
+
dates = pd.date_range("1980-01-31", periods=480, freq="ME") # 40 years, 500 stocks
|
|
107
|
+
signal = pd.DataFrame(rng.standard_normal((480, 500)), index=dates)
|
|
108
|
+
edge = 0.10 * np.exp(-np.arange(480) / 12 / 5) # true half-life: 3.5 years
|
|
109
|
+
realized = 0.05 * (edge[:, None] * signal + rng.standard_normal((480, 500))).shift(1)
|
|
110
|
+
|
|
111
|
+
fwd = af.forward_returns(realized) # row t = return earned AFTER t (no look-ahead)
|
|
112
|
+
ic = af.ic_series(signal, fwd) # Spearman IC per date
|
|
113
|
+
fit = af.fit_decay(ic, rng=0) # exponential decay + bootstrap CI
|
|
114
|
+
print(fit.summary())
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
```text
|
|
118
|
+
Exponential decay fit on 479 observations (1980-01 to 2019-11).
|
|
119
|
+
The edge started at 0.07159 and is shrinking about 14.8% per year: half-life 4.3 years (95% CI 3.0 to 5.9).
|
|
120
|
+
The exponential model fits better than the linear one (AIC 22.6 lower).
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
The true half-life (3.5 years) is inside the interval. Across 12 random seeds of this
|
|
124
|
+
setup the median estimate was 3.56 years and every interval contained the truth.
|
|
125
|
+
|
|
126
|
+
### Beyond the basics
|
|
127
|
+
|
|
128
|
+
Four more tools, each answering a question a half-life alone can't:
|
|
129
|
+
|
|
130
|
+
- `signal_lifetime` turns a fit into "when does the edge reach a level I care about?"
|
|
131
|
+
- `compare_signals` ranks many strategies at once and corrects the p-values for testing many
|
|
132
|
+
signals (otherwise some pure-noise signal will look like it is fading by luck).
|
|
133
|
+
- `walk_forward_decay` refits at each date using only the data available then, so you can see
|
|
134
|
+
whether the half-life is stable or just a quirk of the full sample.
|
|
135
|
+
- `ic_by_horizon` measures fading across the *forecast horizon* (how many periods ahead the
|
|
136
|
+
signal still predicts), which is a different question from fading across calendar time.
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
import numpy as np, pandas as pd
|
|
140
|
+
import alphafade as af
|
|
141
|
+
|
|
142
|
+
rng = np.random.default_rng(1)
|
|
143
|
+
dates = pd.date_range("1980-01-31", periods=480, freq="ME")
|
|
144
|
+
years = np.arange(480) / 12
|
|
145
|
+
fading = pd.Series(0.02 * np.exp(-years / 8) + rng.normal(0, 0.02, 480), index=dates)
|
|
146
|
+
noise = pd.Series(rng.normal(0.003, 0.02, 480), index=dates)
|
|
147
|
+
|
|
148
|
+
fit = af.fit_decay(fading, rng=0)
|
|
149
|
+
life = af.signal_lifetime(fit, fraction=0.5) # when is the edge down to half its start?
|
|
150
|
+
print(life.summary())
|
|
151
|
+
|
|
152
|
+
ranked = af.compare_signals(pd.DataFrame({"fading": fading, "noise": noise}), rng=0)
|
|
153
|
+
print(ranked.table[["half_life_years", "p_adjusted", "decay_detected_adjusted"]].round(3))
|
|
154
|
+
|
|
155
|
+
walk = af.walk_forward_decay(fading, min_obs=120, step=60, rng=0)
|
|
156
|
+
print(walk.table[["n_obs", "decay_detected"]].tail(3))
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
```text
|
|
160
|
+
The fitted exponential edge reaches 50% of its starting level about 3.6 years after the sample start (95% CI 2.3 to 5.7), around 1983-08. That has already happened. The interval holds the starting level fixed and only varies the decay rate.
|
|
161
|
+
half_life_years p_adjusted decay_detected_adjusted
|
|
162
|
+
fading 3.554 0.000 True
|
|
163
|
+
noise NaN 0.129 False
|
|
164
|
+
n_obs decay_detected
|
|
165
|
+
2009-12-31 360 True
|
|
166
|
+
2014-12-31 420 True
|
|
167
|
+
2019-12-31 480 True
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
The true half-life here is 5.5 years and the interval contains it. Multiple-testing correction
|
|
171
|
+
lowers, but cannot remove, the chance that pure noise is flagged: with other seeds this same
|
|
172
|
+
setup occasionally flags the noise series too. `FadeReport.to_dict()` and `.to_json()` export
|
|
173
|
+
a report's headline numbers as plain data.
|
|
174
|
+
|
|
175
|
+
## Public API
|
|
176
|
+
|
|
177
|
+
| Function / class | What it does |
|
|
178
|
+
|---|---|
|
|
179
|
+
| `forward_returns(returns, periods=1)` | Shift realized returns so row *t* holds the return earned after *t*. The one place alphafade moves data in time. |
|
|
180
|
+
| `ic_series(signal, fwd_returns, method="spearman")` | Per-date information coefficient. |
|
|
181
|
+
| `rolling_ic(signal, fwd_returns, window=36)` | Rolling mean IC. |
|
|
182
|
+
| `rolling_sharpe(returns, window=252)` | Annualized rolling Sharpe ratio (frequency inferred). |
|
|
183
|
+
| `fit_decay(perf, n_boot=1000, rng=None)` → `DecayFit` | Exponential decay *a*·e^(−λt) over calendar years: half-life, block-bootstrap CI, linear comparison (AIC), linear fallback. |
|
|
184
|
+
| `find_break(returns, method="sup_wald")` → `BreakResult` | Unknown-date break search (Andrews sup-Wald, or CUSUM). |
|
|
185
|
+
| `chow_test(returns, date)` → `BreakResult` | Did the average change at a date you chose in advance? |
|
|
186
|
+
| `publication_gap(returns, sample_end, publication_date)` → `GapResult` | McLean & Pontiff split: in-sample / post-sample / post-publication means, Sharpe, % declines, Newey-West t-stats. |
|
|
187
|
+
| `crowding_score(stock_returns, long_members, short_members, factors=...)` | Lou & Polk comomentum per leg (leave-one-out or pairwise residual correlation). |
|
|
188
|
+
| `signal_lifetime(fit, floor=None, fraction=None)` → `LifetimeResult` | Years until a fitted edge falls to a level (or share of its start), with a CI and a calendar date. |
|
|
189
|
+
| `compare_signals(perf)` → `SignalComparison` | Fit and rank many signals by decay speed, with Holm or Benjamini-Hochberg adjusted p-values. |
|
|
190
|
+
| `walk_forward_decay(perf, min_obs=60, step=12)` → `WalkForwardResult` | Expanding-window refits with no look-ahead; shows whether the half-life is stable. |
|
|
191
|
+
| `ic_by_horizon(signal, returns, horizons)` → `HorizonResult` | Mean IC (Newey-West t) per forecast horizon and the horizon half-life. |
|
|
192
|
+
| `analyze(returns, ...)` → `FadeReport` | Everything above, with `.summary()`, `.verdict()`, `.to_frame()`, `.to_dict()`, `.to_json()`, `.plot()`. |
|
|
193
|
+
| `datasets.load_ff3(freq)`, `datasets.load_momentum(freq)` | Ken French factors as decimals: explicit download, cached in `~/.cache/alphafade/`, or offline with `path=`. |
|
|
194
|
+
|
|
195
|
+
Errors are specific and say how to fix the input: `InputError` (a `ValueError`),
|
|
196
|
+
`AlignmentError`, `FrequencyError`, `InsufficientDataError`, `DownloadError`, all subclasses
|
|
197
|
+
of `AlphaFadeError`; warnings subclass `AlphaFadeWarning`. Anything lossy
|
|
198
|
+
(dropping NaNs, thin cross-sections) emits a `DataDroppedWarning` with counts; unreliable
|
|
199
|
+
fits emit `FitWarning`. Every function that uses randomness takes `rng=` (a seed or a numpy
|
|
200
|
+
`Generator`).
|
|
201
|
+
|
|
202
|
+
## How this differs from alphalens and quantstats
|
|
203
|
+
|
|
204
|
+
| | alphalens | quantstats / pyfolio | **alphafade** |
|
|
205
|
+
|---|---|---|---|
|
|
206
|
+
| Question | How good is this factor, and over what forecast horizon does its IC fade? | How did this portfolio perform (returns, drawdowns, risk)? | Is the edge shrinking across *years*, how fast, since when, and is crowding to blame? |
|
|
207
|
+
| Time axis | Days after the signal (forecast horizon) | Calendar time, descriptive | Calendar time, *inferential* |
|
|
208
|
+
| Half-life with confidence interval | No | No | Yes (block bootstrap; "no detectable decay" when appropriate) |
|
|
209
|
+
| Structural breaks, publication effect | No | No | sup-Wald, CUSUM, Chow, McLean & Pontiff regression |
|
|
210
|
+
| Crowding | No | No | Lou & Polk comomentum |
|
|
211
|
+
| Autocorrelation-robust t-stats | No | No | Newey-West everywhere |
|
|
212
|
+
|
|
213
|
+
They're complements: use alphalens to build and vet a factor, quantstats to report on a
|
|
214
|
+
portfolio, and alphafade to ask whether the edge is going away. alphafade isn't a
|
|
215
|
+
backtester and doesn't build portfolios; you bring returns or a signal.
|
|
216
|
+
|
|
217
|
+
## Limitations (read these)
|
|
218
|
+
|
|
219
|
+
- **Decay is hard to measure.** With a realistic signal (3,000 stocks, 60 years of monthly
|
|
220
|
+
data, starting IC 0.10), single half-life estimates scatter by about ±6% (one standard
|
|
221
|
+
deviation). For a volatile strategy's raw returns, decades of data often can't
|
|
222
|
+
distinguish decay from noise, as the UMD example shows. Wide intervals are the honest
|
|
223
|
+
answer, not a bug.
|
|
224
|
+
- **One shift, not many.** `find_break` looks for a single change in the average. Several
|
|
225
|
+
regime changes, or a slow slide, show up as one "most likely" date. Use `fit_decay` to
|
|
226
|
+
describe a gradual fade.
|
|
227
|
+
- **Asymptotic p-values.** sup-Wald p-values come from a simulated large-sample
|
|
228
|
+
distribution (reproducible: `scripts/make_supwald_table.py`) and are floored at 0.0005.
|
|
229
|
+
In simulations its false-alarm rate is close to 5% at a few hundred observations but can
|
|
230
|
+
rise under strong autocorrelation. It gives up a little power to stay honest.
|
|
231
|
+
- **Decay toward zero.** The exponential model assumes the edge fades to 0, not to some
|
|
232
|
+
permanent floor. A linear trend is always fitted alongside for comparison.
|
|
233
|
+
- **Survivorship bias.** If your stock universe contains only companies that exist today,
|
|
234
|
+
the IC and crowding scores are biased (the losers that got delisted are missing). Use a
|
|
235
|
+
point-in-time universe such as CRSP when you can.
|
|
236
|
+
- **Frequencies are never guessed.** Mixed daily/monthly data raises a `FrequencyError`;
|
|
237
|
+
resample it yourself. Inputs must be wide (dates × assets) with a sorted `DatetimeIndex`.
|
|
238
|
+
- **Weekly momentum.** Ken French doesn't publish a weekly momentum file; compound the daily
|
|
239
|
+
one.
|
|
240
|
+
|
|
241
|
+
## Learn more
|
|
242
|
+
|
|
243
|
+
- [`docs/methodology.md`](https://github.com/JSand15/alphafade/blob/main/docs/methodology.md): every formula and default, with references.
|
|
244
|
+
- [`examples/umd_momentum.py`](https://github.com/JSand15/alphafade/blob/main/examples/umd_momentum.py): the end-to-end momentum study above.
|
|
245
|
+
- [`CHANGELOG.md`](https://github.com/JSand15/alphafade/blob/main/CHANGELOG.md) · [`CONTRIBUTING.md`](https://github.com/JSand15/alphafade/blob/main/CONTRIBUTING.md)
|
|
246
|
+
|
|
247
|
+
## References
|
|
248
|
+
|
|
249
|
+
McLean, R. D., & Pontiff, J. (2016). Does academic research destroy stock return
|
|
250
|
+
predictability? *Journal of Finance*, 71(1), 5–32. ·
|
|
251
|
+
Lou, D., & Polk, C. (2022). Comomentum: Inferring arbitrage activity from return
|
|
252
|
+
correlations. *Review of Financial Studies*, 35(7), 3272–3302. ·
|
|
253
|
+
Andrews, D. W. K. (1993). Tests for parameter instability and structural change with
|
|
254
|
+
unknown change point. *Econometrica*, 61(4), 821–856. ·
|
|
255
|
+
Newey, W. K., & West, K. D. (1987). A simple, positive semi-definite, heteroskedasticity and
|
|
256
|
+
autocorrelation consistent covariance matrix. *Econometrica*, 55(3), 703–708. ·
|
|
257
|
+
Jegadeesh, N., & Titman, S. (1993). Returns to buying winners and selling losers.
|
|
258
|
+
*Journal of Finance*, 48(1), 65–91.
|
|
259
|
+
|
|
260
|
+
## License
|
|
261
|
+
|
|
262
|
+
MIT © 2026 Jeevun Sandhu
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# alphafade
|
|
2
|
+
|
|
3
|
+
[](https://github.com/JSand15/alphafade/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/alphafade/)
|
|
5
|
+
[](https://pypi.org/project/alphafade/)
|
|
6
|
+
[](https://github.com/JSand15/alphafade/blob/main/LICENSE)
|
|
7
|
+
|
|
8
|
+
**Is my trading signal dying, and if so, how fast and why?**
|
|
9
|
+
|
|
10
|
+
Most tools measure how a signal's predictive power fades over the days after each trade.
|
|
11
|
+
alphafade measures something different: how a signal's edge shrinks across *calendar time*
|
|
12
|
+
(months and years), and whether that shrinkage lines up with crowding, meaning more money
|
|
13
|
+
chasing the same pattern. It fits a decay curve and reports an honest half-life with a
|
|
14
|
+
bootstrap confidence interval, or says "no detectable decay" when the data can't tell. It
|
|
15
|
+
also tests for structural breaks, measures the post-publication drop McLean & Pontiff (2016)
|
|
16
|
+
made famous, and computes a Lou & Polk comomentum crowding score. Every t-statistic is
|
|
17
|
+
autocorrelation-robust (Newey-West), and nothing is dropped, resampled, or shifted behind your
|
|
18
|
+
back.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install alphafade # core: numpy, pandas, scipy
|
|
24
|
+
pip install "alphafade[plot]" # + matplotlib for report.plot()
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Python 3.11 or newer. With [uv](https://docs.astral.sh/uv/): `uv add "alphafade[plot]"`.
|
|
28
|
+
|
|
29
|
+
## Quickstart
|
|
30
|
+
|
|
31
|
+
Is momentum dying? This downloads Ken French's momentum factor (a few KB, cached
|
|
32
|
+
afterwards) and runs every analysis:
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
import alphafade as af
|
|
36
|
+
|
|
37
|
+
umd = af.datasets.load_momentum("M").loc["1963-07":] # monthly momentum returns
|
|
38
|
+
report = af.analyze(
|
|
39
|
+
umd,
|
|
40
|
+
sample_end="1989-12-31", # Jegadeesh & Titman's sample ended here
|
|
41
|
+
publication_date="1993-03-01", # ...and their paper came out here
|
|
42
|
+
rng=42, # reproducible bootstrap
|
|
43
|
+
)
|
|
44
|
+
print(report.summary())
|
|
45
|
+
report.plot() # needs alphafade[plot]
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Part of the output (data through August 2026):
|
|
49
|
+
|
|
50
|
+
```text
|
|
51
|
+
Verdict: No detectable decay in the strategy's average return so far.
|
|
52
|
+
...
|
|
53
|
+
After publication (1993-03 to 2026-08, 402 obs): average 0.003823 per period (4.59% a year,
|
|
54
|
+
Sharpe 0.28, t = 1.60), 53% lower than in-sample (t of the change = -1.40).
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Momentum earns about half as much since publication, but it's so volatile that 33 years of
|
|
58
|
+
data can't rule out luck. alphafade tells you that instead of printing a confident-looking
|
|
59
|
+
half-life.
|
|
60
|
+
|
|
61
|
+
### With a signal instead of returns
|
|
62
|
+
|
|
63
|
+
If you have the signal itself (dates × assets) and asset returns, the per-date
|
|
64
|
+
information coefficient (IC: the cross-sectional correlation between today's signal and
|
|
65
|
+
the returns that follow) is usually the sharper thing to track:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
import numpy as np, pandas as pd
|
|
69
|
+
import alphafade as af
|
|
70
|
+
|
|
71
|
+
rng = np.random.default_rng(0)
|
|
72
|
+
dates = pd.date_range("1980-01-31", periods=480, freq="ME") # 40 years, 500 stocks
|
|
73
|
+
signal = pd.DataFrame(rng.standard_normal((480, 500)), index=dates)
|
|
74
|
+
edge = 0.10 * np.exp(-np.arange(480) / 12 / 5) # true half-life: 3.5 years
|
|
75
|
+
realized = 0.05 * (edge[:, None] * signal + rng.standard_normal((480, 500))).shift(1)
|
|
76
|
+
|
|
77
|
+
fwd = af.forward_returns(realized) # row t = return earned AFTER t (no look-ahead)
|
|
78
|
+
ic = af.ic_series(signal, fwd) # Spearman IC per date
|
|
79
|
+
fit = af.fit_decay(ic, rng=0) # exponential decay + bootstrap CI
|
|
80
|
+
print(fit.summary())
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
```text
|
|
84
|
+
Exponential decay fit on 479 observations (1980-01 to 2019-11).
|
|
85
|
+
The edge started at 0.07159 and is shrinking about 14.8% per year: half-life 4.3 years (95% CI 3.0 to 5.9).
|
|
86
|
+
The exponential model fits better than the linear one (AIC 22.6 lower).
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The true half-life (3.5 years) is inside the interval. Across 12 random seeds of this
|
|
90
|
+
setup the median estimate was 3.56 years and every interval contained the truth.
|
|
91
|
+
|
|
92
|
+
### Beyond the basics
|
|
93
|
+
|
|
94
|
+
Four more tools, each answering a question a half-life alone can't:
|
|
95
|
+
|
|
96
|
+
- `signal_lifetime` turns a fit into "when does the edge reach a level I care about?"
|
|
97
|
+
- `compare_signals` ranks many strategies at once and corrects the p-values for testing many
|
|
98
|
+
signals (otherwise some pure-noise signal will look like it is fading by luck).
|
|
99
|
+
- `walk_forward_decay` refits at each date using only the data available then, so you can see
|
|
100
|
+
whether the half-life is stable or just a quirk of the full sample.
|
|
101
|
+
- `ic_by_horizon` measures fading across the *forecast horizon* (how many periods ahead the
|
|
102
|
+
signal still predicts), which is a different question from fading across calendar time.
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
import numpy as np, pandas as pd
|
|
106
|
+
import alphafade as af
|
|
107
|
+
|
|
108
|
+
rng = np.random.default_rng(1)
|
|
109
|
+
dates = pd.date_range("1980-01-31", periods=480, freq="ME")
|
|
110
|
+
years = np.arange(480) / 12
|
|
111
|
+
fading = pd.Series(0.02 * np.exp(-years / 8) + rng.normal(0, 0.02, 480), index=dates)
|
|
112
|
+
noise = pd.Series(rng.normal(0.003, 0.02, 480), index=dates)
|
|
113
|
+
|
|
114
|
+
fit = af.fit_decay(fading, rng=0)
|
|
115
|
+
life = af.signal_lifetime(fit, fraction=0.5) # when is the edge down to half its start?
|
|
116
|
+
print(life.summary())
|
|
117
|
+
|
|
118
|
+
ranked = af.compare_signals(pd.DataFrame({"fading": fading, "noise": noise}), rng=0)
|
|
119
|
+
print(ranked.table[["half_life_years", "p_adjusted", "decay_detected_adjusted"]].round(3))
|
|
120
|
+
|
|
121
|
+
walk = af.walk_forward_decay(fading, min_obs=120, step=60, rng=0)
|
|
122
|
+
print(walk.table[["n_obs", "decay_detected"]].tail(3))
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
```text
|
|
126
|
+
The fitted exponential edge reaches 50% of its starting level about 3.6 years after the sample start (95% CI 2.3 to 5.7), around 1983-08. That has already happened. The interval holds the starting level fixed and only varies the decay rate.
|
|
127
|
+
half_life_years p_adjusted decay_detected_adjusted
|
|
128
|
+
fading 3.554 0.000 True
|
|
129
|
+
noise NaN 0.129 False
|
|
130
|
+
n_obs decay_detected
|
|
131
|
+
2009-12-31 360 True
|
|
132
|
+
2014-12-31 420 True
|
|
133
|
+
2019-12-31 480 True
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
The true half-life here is 5.5 years and the interval contains it. Multiple-testing correction
|
|
137
|
+
lowers, but cannot remove, the chance that pure noise is flagged: with other seeds this same
|
|
138
|
+
setup occasionally flags the noise series too. `FadeReport.to_dict()` and `.to_json()` export
|
|
139
|
+
a report's headline numbers as plain data.
|
|
140
|
+
|
|
141
|
+
## Public API
|
|
142
|
+
|
|
143
|
+
| Function / class | What it does |
|
|
144
|
+
|---|---|
|
|
145
|
+
| `forward_returns(returns, periods=1)` | Shift realized returns so row *t* holds the return earned after *t*. The one place alphafade moves data in time. |
|
|
146
|
+
| `ic_series(signal, fwd_returns, method="spearman")` | Per-date information coefficient. |
|
|
147
|
+
| `rolling_ic(signal, fwd_returns, window=36)` | Rolling mean IC. |
|
|
148
|
+
| `rolling_sharpe(returns, window=252)` | Annualized rolling Sharpe ratio (frequency inferred). |
|
|
149
|
+
| `fit_decay(perf, n_boot=1000, rng=None)` → `DecayFit` | Exponential decay *a*·e^(−λt) over calendar years: half-life, block-bootstrap CI, linear comparison (AIC), linear fallback. |
|
|
150
|
+
| `find_break(returns, method="sup_wald")` → `BreakResult` | Unknown-date break search (Andrews sup-Wald, or CUSUM). |
|
|
151
|
+
| `chow_test(returns, date)` → `BreakResult` | Did the average change at a date you chose in advance? |
|
|
152
|
+
| `publication_gap(returns, sample_end, publication_date)` → `GapResult` | McLean & Pontiff split: in-sample / post-sample / post-publication means, Sharpe, % declines, Newey-West t-stats. |
|
|
153
|
+
| `crowding_score(stock_returns, long_members, short_members, factors=...)` | Lou & Polk comomentum per leg (leave-one-out or pairwise residual correlation). |
|
|
154
|
+
| `signal_lifetime(fit, floor=None, fraction=None)` → `LifetimeResult` | Years until a fitted edge falls to a level (or share of its start), with a CI and a calendar date. |
|
|
155
|
+
| `compare_signals(perf)` → `SignalComparison` | Fit and rank many signals by decay speed, with Holm or Benjamini-Hochberg adjusted p-values. |
|
|
156
|
+
| `walk_forward_decay(perf, min_obs=60, step=12)` → `WalkForwardResult` | Expanding-window refits with no look-ahead; shows whether the half-life is stable. |
|
|
157
|
+
| `ic_by_horizon(signal, returns, horizons)` → `HorizonResult` | Mean IC (Newey-West t) per forecast horizon and the horizon half-life. |
|
|
158
|
+
| `analyze(returns, ...)` → `FadeReport` | Everything above, with `.summary()`, `.verdict()`, `.to_frame()`, `.to_dict()`, `.to_json()`, `.plot()`. |
|
|
159
|
+
| `datasets.load_ff3(freq)`, `datasets.load_momentum(freq)` | Ken French factors as decimals: explicit download, cached in `~/.cache/alphafade/`, or offline with `path=`. |
|
|
160
|
+
|
|
161
|
+
Errors are specific and say how to fix the input: `InputError` (a `ValueError`),
|
|
162
|
+
`AlignmentError`, `FrequencyError`, `InsufficientDataError`, `DownloadError`, all subclasses
|
|
163
|
+
of `AlphaFadeError`; warnings subclass `AlphaFadeWarning`. Anything lossy
|
|
164
|
+
(dropping NaNs, thin cross-sections) emits a `DataDroppedWarning` with counts; unreliable
|
|
165
|
+
fits emit `FitWarning`. Every function that uses randomness takes `rng=` (a seed or a numpy
|
|
166
|
+
`Generator`).
|
|
167
|
+
|
|
168
|
+
## How this differs from alphalens and quantstats
|
|
169
|
+
|
|
170
|
+
| | alphalens | quantstats / pyfolio | **alphafade** |
|
|
171
|
+
|---|---|---|---|
|
|
172
|
+
| Question | How good is this factor, and over what forecast horizon does its IC fade? | How did this portfolio perform (returns, drawdowns, risk)? | Is the edge shrinking across *years*, how fast, since when, and is crowding to blame? |
|
|
173
|
+
| Time axis | Days after the signal (forecast horizon) | Calendar time, descriptive | Calendar time, *inferential* |
|
|
174
|
+
| Half-life with confidence interval | No | No | Yes (block bootstrap; "no detectable decay" when appropriate) |
|
|
175
|
+
| Structural breaks, publication effect | No | No | sup-Wald, CUSUM, Chow, McLean & Pontiff regression |
|
|
176
|
+
| Crowding | No | No | Lou & Polk comomentum |
|
|
177
|
+
| Autocorrelation-robust t-stats | No | No | Newey-West everywhere |
|
|
178
|
+
|
|
179
|
+
They're complements: use alphalens to build and vet a factor, quantstats to report on a
|
|
180
|
+
portfolio, and alphafade to ask whether the edge is going away. alphafade isn't a
|
|
181
|
+
backtester and doesn't build portfolios; you bring returns or a signal.
|
|
182
|
+
|
|
183
|
+
## Limitations (read these)
|
|
184
|
+
|
|
185
|
+
- **Decay is hard to measure.** With a realistic signal (3,000 stocks, 60 years of monthly
|
|
186
|
+
data, starting IC 0.10), single half-life estimates scatter by about ±6% (one standard
|
|
187
|
+
deviation). For a volatile strategy's raw returns, decades of data often can't
|
|
188
|
+
distinguish decay from noise, as the UMD example shows. Wide intervals are the honest
|
|
189
|
+
answer, not a bug.
|
|
190
|
+
- **One shift, not many.** `find_break` looks for a single change in the average. Several
|
|
191
|
+
regime changes, or a slow slide, show up as one "most likely" date. Use `fit_decay` to
|
|
192
|
+
describe a gradual fade.
|
|
193
|
+
- **Asymptotic p-values.** sup-Wald p-values come from a simulated large-sample
|
|
194
|
+
distribution (reproducible: `scripts/make_supwald_table.py`) and are floored at 0.0005.
|
|
195
|
+
In simulations its false-alarm rate is close to 5% at a few hundred observations but can
|
|
196
|
+
rise under strong autocorrelation. It gives up a little power to stay honest.
|
|
197
|
+
- **Decay toward zero.** The exponential model assumes the edge fades to 0, not to some
|
|
198
|
+
permanent floor. A linear trend is always fitted alongside for comparison.
|
|
199
|
+
- **Survivorship bias.** If your stock universe contains only companies that exist today,
|
|
200
|
+
the IC and crowding scores are biased (the losers that got delisted are missing). Use a
|
|
201
|
+
point-in-time universe such as CRSP when you can.
|
|
202
|
+
- **Frequencies are never guessed.** Mixed daily/monthly data raises a `FrequencyError`;
|
|
203
|
+
resample it yourself. Inputs must be wide (dates × assets) with a sorted `DatetimeIndex`.
|
|
204
|
+
- **Weekly momentum.** Ken French doesn't publish a weekly momentum file; compound the daily
|
|
205
|
+
one.
|
|
206
|
+
|
|
207
|
+
## Learn more
|
|
208
|
+
|
|
209
|
+
- [`docs/methodology.md`](https://github.com/JSand15/alphafade/blob/main/docs/methodology.md): every formula and default, with references.
|
|
210
|
+
- [`examples/umd_momentum.py`](https://github.com/JSand15/alphafade/blob/main/examples/umd_momentum.py): the end-to-end momentum study above.
|
|
211
|
+
- [`CHANGELOG.md`](https://github.com/JSand15/alphafade/blob/main/CHANGELOG.md) · [`CONTRIBUTING.md`](https://github.com/JSand15/alphafade/blob/main/CONTRIBUTING.md)
|
|
212
|
+
|
|
213
|
+
## References
|
|
214
|
+
|
|
215
|
+
McLean, R. D., & Pontiff, J. (2016). Does academic research destroy stock return
|
|
216
|
+
predictability? *Journal of Finance*, 71(1), 5–32. ·
|
|
217
|
+
Lou, D., & Polk, C. (2022). Comomentum: Inferring arbitrage activity from return
|
|
218
|
+
correlations. *Review of Financial Studies*, 35(7), 3272–3302. ·
|
|
219
|
+
Andrews, D. W. K. (1993). Tests for parameter instability and structural change with
|
|
220
|
+
unknown change point. *Econometrica*, 61(4), 821–856. ·
|
|
221
|
+
Newey, W. K., & West, K. D. (1987). A simple, positive semi-definite, heteroskedasticity and
|
|
222
|
+
autocorrelation consistent covariance matrix. *Econometrica*, 55(3), 703–708. ·
|
|
223
|
+
Jegadeesh, N., & Titman, S. (1993). Returns to buying winners and selling losers.
|
|
224
|
+
*Journal of Finance*, 48(1), 65–91.
|
|
225
|
+
|
|
226
|
+
## License
|
|
227
|
+
|
|
228
|
+
MIT © 2026 Jeevun Sandhu
|