Signal Decay Analysis
Quantitatively measure how the predictive information content and alpha of trading signals decays over time after initial signal generation, determining the optimal signal validity time window and the empirical alpha half-life for different categories of trading signals in different market regimes.
Signal Decay Analysis — Research & Experimentation
Category: Research & Experimentation | Subcategory: Hypothesis
What This Notebook Does
Signal decay measures how quickly a trading signal's predictive power fades after it is generated. A signal that is strong at day 1 but dead by day 5 suggests a very short holding period; one that stays predictive for 20 days allows more relaxed rebalancing and lower transaction costs.
Understanding decay is critical for:
- Determining optimal holding period: hold too long and you're trading noise; hold too short and trading costs eat the alpha
- Distinguishing signal types: fundamental signals (slow decay) vs technical signals (fast decay)
- Capacity management: fast-decaying signals require high turnover, limiting AUM capacity
The decay curve is estimated by computing the information coefficient (IC) — the rank correlation between the signal and the N-day forward return — at horizons N = 1, 2, 3, ..., MAX_HORIZON. A signal with high IC at horizon 1 but IC ≈ 0 by horizon 7 has a half-life of around 3–4 days.
This notebook:
- Generates a signal (momentum) and tests multiple assets
- Computes IC at each forward horizon from 1 to MAX_HORIZON
- Fits an exponential decay model to estimate the half-life
- Visualises the decay curve with confidence bands
- Exports the IC-by-horizon table
!pip install numpy pandas matplotlib seaborn scipy yfinance --quietimport numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from scipy import stats, optimize
import warnings
warnings.filterwarnings('ignore')
%matplotlib inline
plt.rcParams['figure.figsize'] = (14, 5)
plt.rcParams['axes.spines.top'] = False
plt.rcParams['axes.spines.right'] = False
print('Imports ready.')Imports ready.
Section 1 — Configuration
SIGNAL_LOOKBACK is the past-return window used to construct the signal. MAX_HORIZON is the furthest forward horizon to test — we compute IC at each integer day from 1 to this value. A larger MAX_HORIZON gives more points on the decay curve but requires more data to compute reliably.
# ── Data Source Toggle ─────────────────────────────
USE_LIVE_DATA = False
TICKERS = ['BTC-USD','ETH-USD','SOL-USD','BNB-USD']
START_DATE = '2020-01-01'
END_DATE = '2024-12-31'
# ──────────────────────────────────────────────────
ASSETS = ['BTC','ETH','SOL','BNB']
SIGNAL_LOOKBACK = 7 # days of past return used as signal
MAX_HORIZON = 30 # maximum forward horizon to test
MIN_OBS = 50 # minimum observations to compute IC
print('Config ready.')Config ready.
Section 2 — Data Acquisition
The synthetic path includes a meaningful short-term autocorrelation (momentum effect) that decays exponentially. This makes the decay curve clearly visible — the signal will be strongest at horizon 1 and roughly halved by horizon 5–7, matching typical short-term crypto momentum.
if USE_LIVE_DATA:
import yfinance as yf
prices = yf.download(TICKERS, start=START_DATE, end=END_DATE)['Close']
prices.columns = ASSETS
prices.dropna(inplace=True)
returns = prices.pct_change().dropna()
print(f'Live data: {len(returns)} days')
else:
rng = np.random.default_rng(42)
n = 1200
vols = np.array([0.65, 0.75, 1.20, 0.70]) / np.sqrt(252)
# AR(1) process per asset to create decaying autocorrelation
ar_coef = np.array([0.12, 0.10, 0.08, 0.11])
data = np.zeros((n, 4))
for t in range(1, n):
data[t] = ar_coef * data[t-1] + rng.standard_normal(4) * vols
idx = pd.date_range('2020-01-01', periods=n, freq='B')
returns = pd.DataFrame(data, columns=ASSETS, index=idx)
print(f'Synthetic AR(1) data: {n} days, AR coef = {ar_coef}')Synthetic AR(1) data: 1200 days, AR coef = [0.12 0.1 0.08 0.11]
Section 3 — IC Decay Curve
At each horizon h, we compute the rank IC (Spearman correlation) between the signal (7-day past return) and the h-day forward return. Spearman correlation is preferred over Pearson because returns are fat-tailed — rank correlation is more robust to outliers. We aggregate IC across all assets using a cross-sectional mean.
def compute_ic_at_horizon(returns: pd.DataFrame, signal_lookback: int, horizon: int) -> float:
"""
Compute the mean cross-sectional Spearman IC between signal and h-day forward return.
Parameters
----------
returns : pd.DataFrame Daily returns for each asset.
signal_lookback: int Lookback window for the signal.
horizon : int Forward horizon in days.
Returns
-------
float Mean rank IC across assets.
"""
signal = returns.rolling(signal_lookback).mean() # avg past return as signal
forward = returns.rolling(horizon).mean().shift(-horizon)
daily_ics = []
for date in returns.index[signal_lookback + horizon:-horizon]:
s = signal.loc[date]
f = forward.loc[date]
valid = s.notna() & f.notna()
if valid.sum() >= 2:
ic, _ = stats.spearmanr(s[valid], f[valid])
daily_ics.append(ic)
return np.nanmean(daily_ics) if daily_ics else 0.0
horizons = list(range(1, MAX_HORIZON + 1))
ic_values = [compute_ic_at_horizon(returns, SIGNAL_LOOKBACK, h) for h in horizons]
decay_df = pd.DataFrame({'horizon': horizons, 'ic': ic_values})
print(decay_df.to_string(index=False)) horizon ic
1 0.053736
2 0.070648
3 0.073294
4 0.060084
5 0.065427
6 0.056224
7 0.062087
8 0.059133
9 0.046468
10 0.036829
11 0.019129
12 0.013174
13 0.015424
14 0.015622
15 0.013242
16 0.010508
17 0.017429
18 0.016940
19 0.012814
20 0.019428
21 0.007819
22 0.001393
23 0.003487
24 0.000699
25 -0.012598
26 -0.010692
27 -0.011765
28 -0.015479
29 -0.019912
30 -0.014475
Section 4 — Exponential Decay Fit
We fit an exponential function IC(h) = IC_0 × exp(-λ × h) to the decay curve using scipy.optimize.curve_fit. The half-life is ln(2) / λ — the number of days until predictive power halves. This gives a single interpretable number summarising how quickly the signal becomes uninformative.
def exp_decay(h, ic0, lam):
"""Exponential decay: IC(h) = IC_0 * exp(-lambda * h)."""
return ic0 * np.exp(-lam * h)
try:
popt, pcov = optimize.curve_fit(exp_decay, horizons, ic_values,
p0=[ic_values[0], 0.1], maxfev=5000)
ic0_fit, lam_fit = popt
half_life = np.log(2) / lam_fit
ic_fitted = [exp_decay(h, ic0_fit, lam_fit) for h in horizons]
print(f'Exponential fit: IC_0 = {ic0_fit:.4f}, λ = {lam_fit:.4f}')
print(f'Signal half-life: {half_life:.1f} days')
print(f'Optimal holding period (IC > 0.02): ~{next((h for h,ic in zip(horizons,ic_fitted) if abs(ic)<0.02), MAX_HORIZON)} days')
except Exception as e:
ic_fitted = [0] * len(horizons)
print(f'Fit failed: {e}')Exponential fit: IC_0 = 0.0924, λ = 0.1114 Signal half-life: 6.2 days Optimal holding period (IC > 0.02): ~14 days
Section 5 — Visualisation
The left panel shows the observed IC values (bars) and the fitted exponential curve (line). A well-fitting curve validates the exponential decay assumption. The right panel shows the cumulative IC — the area under the curve — which represents total predictive power accumulated over the holding period. The optimal holding period is near the knee of the cumulative curve where incremental gains fall below the cost of rebalancing.
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
fig.suptitle('Signal Decay Analysis', fontsize=13, fontweight='bold')
ax1 = axes[0]
ax1.bar(horizons, ic_values, color='#1976d2', alpha=0.6, label='Observed IC')
ax1.plot(horizons, ic_fitted, color='#e53935', lw=2,
label=f'Exp fit (half-life={half_life:.1f}d)' if max(ic_fitted) > 0 else 'Fit')
ax1.axhline(0, color='black', lw=0.8, ls='--')
ax1.axhline(0.02, color='green', lw=0.8, ls=':', label='IC = 0.02 threshold')
ax1.set_xlabel('Forward Horizon (days)')
ax1.set_ylabel('Rank IC (Spearman)')
ax1.legend(fontsize=8)
ax1.set_title(f'Signal Decay Curve (lookback={SIGNAL_LOOKBACK}d)')
ax2 = axes[1]
cumulative_ic = np.cumsum(ic_values)
ax2.plot(horizons, cumulative_ic, color='#43a047', lw=2)
ax2.fill_between(horizons, cumulative_ic, alpha=0.15, color='#43a047')
ax2.set_xlabel('Forward Horizon (days)')
ax2.set_ylabel('Cumulative IC')
ax2.set_title('Cumulative Predictive Power vs Holding Period')
plt.tight_layout(); plt.show()Section 6 — Export
Save the full decay table with observed and fitted IC values. This can be used to compare decay curves across different signals and inform portfolio rebalancing frequency decisions.
decay_df['ic_fitted'] = ic_fitted
decay_df.to_csv('signal_decay_analysis.csv', index=False)
print('Saved: signal_decay_analysis.csv')Saved: signal_decay_analysis.csv
Section 7 — Conclusion
This notebook has demonstrated a methodology for analysing signal decay, which is crucial for determining optimal holding periods and understanding the nature of different trading signals. By fitting an exponential decay model to the Information Coefficient (IC) values across various forward horizons, we can estimate a signal's half-life and visualize its predictive power over time. This analysis helps in making informed decisions about rebalancing frequency and capacity management.