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.
Files changed (49) hide show
  1. alphafade-0.1.0/.gitignore +16 -0
  2. alphafade-0.1.0/CHANGELOG.md +46 -0
  3. alphafade-0.1.0/LICENSE +21 -0
  4. alphafade-0.1.0/PKG-INFO +262 -0
  5. alphafade-0.1.0/README.md +228 -0
  6. alphafade-0.1.0/docs/methodology.md +539 -0
  7. alphafade-0.1.0/pyproject.toml +123 -0
  8. alphafade-0.1.0/src/alphafade/__init__.py +70 -0
  9. alphafade-0.1.0/src/alphafade/_errors.py +56 -0
  10. alphafade-0.1.0/src/alphafade/_stats.py +141 -0
  11. alphafade-0.1.0/src/alphafade/_supwald_table.py +17 -0
  12. alphafade-0.1.0/src/alphafade/_validate.py +324 -0
  13. alphafade-0.1.0/src/alphafade/breaks.py +325 -0
  14. alphafade-0.1.0/src/alphafade/compare.py +297 -0
  15. alphafade-0.1.0/src/alphafade/crowding.py +326 -0
  16. alphafade-0.1.0/src/alphafade/datasets.py +464 -0
  17. alphafade-0.1.0/src/alphafade/decay.py +496 -0
  18. alphafade-0.1.0/src/alphafade/horizon.py +284 -0
  19. alphafade-0.1.0/src/alphafade/lifetime.py +268 -0
  20. alphafade-0.1.0/src/alphafade/plotting.py +145 -0
  21. alphafade-0.1.0/src/alphafade/publication.py +283 -0
  22. alphafade-0.1.0/src/alphafade/py.typed +0 -0
  23. alphafade-0.1.0/src/alphafade/report.py +542 -0
  24. alphafade-0.1.0/src/alphafade/rolling.py +342 -0
  25. alphafade-0.1.0/src/alphafade/walkforward.py +275 -0
  26. alphafade-0.1.0/tests/__init__.py +0 -0
  27. alphafade-0.1.0/tests/conftest.py +35 -0
  28. alphafade-0.1.0/tests/data/ff3_daily.csv +12 -0
  29. alphafade-0.1.0/tests/data/ff3_monthly.zip +0 -0
  30. alphafade-0.1.0/tests/data/ff3_weekly.csv +12 -0
  31. alphafade-0.1.0/tests/data/mom_daily.zip +0 -0
  32. alphafade-0.1.0/tests/data/mom_monthly.csv +28 -0
  33. alphafade-0.1.0/tests/test_breaks.py +150 -0
  34. alphafade-0.1.0/tests/test_compare.py +162 -0
  35. alphafade-0.1.0/tests/test_crowding.py +188 -0
  36. alphafade-0.1.0/tests/test_datasets.py +548 -0
  37. alphafade-0.1.0/tests/test_decay.py +225 -0
  38. alphafade-0.1.0/tests/test_horizon.py +322 -0
  39. alphafade-0.1.0/tests/test_lifetime.py +218 -0
  40. alphafade-0.1.0/tests/test_publication.py +133 -0
  41. alphafade-0.1.0/tests/test_readme.py +51 -0
  42. alphafade-0.1.0/tests/test_regressions_oct.py +135 -0
  43. alphafade-0.1.0/tests/test_report.py +213 -0
  44. alphafade-0.1.0/tests/test_report_export.py +190 -0
  45. alphafade-0.1.0/tests/test_rolling.py +242 -0
  46. alphafade-0.1.0/tests/test_stats.py +73 -0
  47. alphafade-0.1.0/tests/test_stress.py +563 -0
  48. alphafade-0.1.0/tests/test_validate.py +159 -0
  49. alphafade-0.1.0/tests/test_walkforward.py +144 -0
@@ -0,0 +1,16 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ .mypy_cache/
9
+ .ruff_cache/
10
+ .coverage
11
+ coverage.xml
12
+ htmlcov/
13
+ .hypothesis/
14
+ .DS_Store
15
+ examples/output/
16
+ .venvs/
@@ -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
@@ -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.
@@ -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
+ [![CI](https://github.com/JSand15/alphafade/actions/workflows/ci.yml/badge.svg)](https://github.com/JSand15/alphafade/actions/workflows/ci.yml)
38
+ [![PyPI](https://img.shields.io/pypi/v/alphafade.svg)](https://pypi.org/project/alphafade/)
39
+ [![Python](https://img.shields.io/pypi/pyversions/alphafade.svg)](https://pypi.org/project/alphafade/)
40
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](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
+ [![CI](https://github.com/JSand15/alphafade/actions/workflows/ci.yml/badge.svg)](https://github.com/JSand15/alphafade/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/alphafade.svg)](https://pypi.org/project/alphafade/)
5
+ [![Python](https://img.shields.io/pypi/pyversions/alphafade.svg)](https://pypi.org/project/alphafade/)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](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