Factor Decay Curve
Plot and parametrically model the empirical decay curve of risk factor signal predictive power over increasing holding periods, understanding the rate at which factor alpha erodes after portfolio construction and optimizing factor signal refresh frequency to minimize alpha decay costs.
Factor Decay Curve — Research & Experimentation
Category: Research & Experimentation | Subcategory: Factor Research
What This Notebook Does
The factor decay curve extends the concept of signal decay (how fast a single signal's IC fades) to the level of a fully constructed factor portfolio. While signal decay measures raw predictive power, factor decay measures the realised P&L persistence after portfolio construction — accounting for the impact of position sizing, rebalancing mechanics, and cross-sectional interactions.
A factor decay curve answers the question: if I trade on the factor signal computed today, how much of that edge will still be there at holding periods of 1, 5, 10, 20 days? This directly determines:
- Optimal holding period: where marginal factor return per day peaks
- Breakeven transaction cost: if 10-day total return = 0.5% and trading cost = 0.3%, it's barely worth trading
- Rebalancing frequency: should this factor be traded daily, weekly, or monthly?
This notebook:
- Constructs factor signals (momentum and low-vol) at a base date
- Measures the average forward factor return at horizons 1–MAX_HORIZON days
- Fits exponential decay models to each factor
- Calculates the optimal hold time net of transaction costs
- Compares factor decay across multiple factors
- Exports the decay curves
!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
MAX_HORIZON is the longest forward window to test. TRANSACTION_COST_BPS is the round-trip cost in basis points (e.g., 20bps = 0.2% total for buy+sell). The optimal hold time is where the cumulative gross factor return exceeds the transaction cost by the largest margin.
# ── Data Source Toggle ─────────────────────────────
USE_LIVE_DATA = False
TICKERS = ['BTC-USD','ETH-USD','SOL-USD','BNB-USD','AVAX-USD',
'MATIC-USD','LINK-USD','DOT-USD']
START_DATE = '2020-01-01'
END_DATE = '2024-12-31'
# ──────────────────────────────────────────────────
ASSETS = ['BTC','ETH','SOL','BNB','AVAX','MATIC','LINK','DOT']
MAX_HORIZON = 30 # test up to 30-day holding periods
TRANSACTION_COST_BPS = 20 # round-trip cost in basis points
TOP_N = 3 # assets per leg
FACTORS = {
'MOM_7d': {'lookback': 7, 'type': 'momentum'},
'MOM_21d': {'lookback': 21, 'type': 'momentum'},
'LOW_VOL': {'lookback': 21, 'type': 'low_vol'},
}
print('Config ready.')Config ready.
Section 2 — Data Acquisition
The synthetic data uses AR(1) processes with different autocorrelation strengths per asset to ensure that some assets have genuine momentum (predictable decay) while others are closer to random walks. The variance in AR coefficients creates cross-sectional dispersion that the factor portfolios can exploit.
rng = np.random.default_rng(42)
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:
n = 1200
vols = np.array([0.65,0.75,1.20,0.70,1.10,1.40,1.15,0.90]) / np.sqrt(252)
mu = np.array([0.30,0.25,0.50,0.20,0.45,0.60,0.40,0.35]) / 252
ar = np.array([0.12,0.09,0.07,0.10,0.06,0.08,0.11,0.05])
data = np.zeros((n, 8))
for t in range(1, n):
data[t] = ar * data[t-1] + rng.standard_normal(8) * vols + mu
idx = pd.date_range('2020-01-01', periods=n, freq='B')
returns = pd.DataFrame(data, columns=ASSETS, index=idx)
print(f'Synthetic AR data: {n} days')Synthetic AR data: 1200 days
Section 3 — Factor Decay Computation
For each factor and each horizon h, we compute the Spearman IC between the factor signal (constructed at time t) and the asset return over the next h days. Averaging this IC across all dates and converting to an implied portfolio return gives the factor's mean forward return at that horizon. The cumulative sum of per-day returns is the gross value of holding the position for h days.
def build_signal(returns, lookback, factor_type):
"""
Build factor signal from returns.
Parameters
----------
returns : pd.DataFrame Daily returns.
lookback : int Signal lookback window.
factor_type : str 'momentum' or 'low_vol'.
Returns
-------
pd.DataFrame Signal values (higher = prefer to go long).
"""
if factor_type == 'momentum':
return returns.rolling(lookback).mean()
elif factor_type == 'low_vol':
return -returns.rolling(lookback).std() # negative vol → low vol → long
return returns.rolling(lookback).mean()
def compute_factor_decay(returns, lookback, factor_type, max_h, top_n):
"""
Compute the mean IC of a factor at each forward horizon from 1 to max_h.
Parameters
----------
returns : pd.DataFrame Daily returns.
lookback : int Signal lookback window.
factor_type : str Factor type ('momentum' or 'low_vol').
max_h : int Maximum forward horizon.
top_n : int Assets per leg (not used directly but stored for metadata).
Returns
-------
list Mean IC at each horizon 1..max_h.
"""
signal = build_signal(returns, lookback, factor_type)
ic_by_h = []
for h in range(1, max_h + 1):
fwd = returns.rolling(h).sum().shift(-h)
daily_ics = []
for t_idx in range(lookback, len(returns) - h):
s = signal.iloc[t_idx]
f = fwd.iloc[t_idx]
valid = s.notna() & f.notna()
if valid.sum() >= 4:
ic, _ = stats.spearmanr(s[valid], f[valid])
daily_ics.append(ic)
ic_by_h.append(np.nanmean(daily_ics) if daily_ics else 0.0)
return ic_by_h
decay_results = {}
for fname, fparams in FACTORS.items():
print(f'Computing decay for {fname}...')
decay_results[fname] = compute_factor_decay(
returns, fparams['lookback'], fparams['type'], MAX_HORIZON, TOP_N)
decay_df = pd.DataFrame(decay_results, index=range(1, MAX_HORIZON + 1))
decay_df.index.name = 'horizon'
print('\n', decay_df.round(4).to_string())Computing decay for MOM_7d...
Computing decay for MOM_21d...
Computing decay for LOW_VOL...
MOM_7d MOM_21d LOW_VOL
horizon
1 0.0472 0.0225 -0.0144
2 0.0394 0.0242 -0.0228
3 0.0398 0.0310 -0.0162
4 0.0260 0.0186 -0.0141
5 0.0229 0.0163 -0.0191
6 0.0214 0.0202 -0.0216
7 0.0125 0.0206 -0.0258
8 0.0184 0.0284 -0.0319
9 0.0054 0.0180 -0.0317
10 0.0036 0.0129 -0.0270
11 0.0000 0.0104 -0.0257
12 0.0091 0.0094 -0.0312
13 0.0179 0.0086 -0.0317
14 0.0190 0.0050 -0.0320
15 0.0211 0.0017 -0.0321
16 0.0230 0.0011 -0.0301
17 0.0244 0.0046 -0.0343
18 0.0221 -0.0024 -0.0378
19 0.0239 -0.0046 -0.0372
20 0.0261 -0.0060 -0.0361
21 0.0180 -0.0165 -0.0317
22 0.0124 -0.0239 -0.0335
23 0.0088 -0.0278 -0.0316
24 0.0046 -0.0305 -0.0308
25 0.0011 -0.0304 -0.0329
26 -0.0022 -0.0319 -0.0340
27 -0.0089 -0.0351 -0.0362
28 -0.0131 -0.0355 -0.0386
29 -0.0172 -0.0357 -0.0461
30 -0.0160 -0.0351 -0.0484
Section 4 — Net-of-Cost Optimal Hold Time
The net value at horizon h is the cumulative IC (proxy for cumulative return) minus the fixed transaction cost divided by the number of holding periods per year. The optimal hold time maximises net value — holding too short means you pay transaction costs too often relative to the gross return earned.
tc = TRANSACTION_COST_BPS / 10000 # convert bps to decimal
horizons = np.arange(1, MAX_HORIZON + 1)
print(f'Transaction cost: {TRANSACTION_COST_BPS} bps = {tc:.4f}')
for fname in FACTORS:
cumIC = np.cumsum(decay_results[fname])
net = cumIC - tc # subtract fixed one-way trading cost
opt_h = horizons[np.argmax(net)]
print(f'{fname}: optimal hold = {opt_h} days (net IC at opt_h = {max(net):.4f})')Transaction cost: 20 bps = 0.0020 MOM_7d: optimal hold = 25 days (net IC at opt_h = 0.4662) MOM_21d: optimal hold = 17 days (net IC at opt_h = 0.2517) LOW_VOL: optimal hold = 1 days (net IC at opt_h = -0.0164)
Section 5 — Visualisation
The left panel shows the decay curves for all factors as IC vs horizon. Faster-decaying factors (like short-term momentum) require more frequent rebalancing. The right panel shows cumulative IC net of transaction costs — the peak of each curve is the optimal holding period.
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
fig.suptitle('Factor Decay Curves', fontsize=13, fontweight='bold')
colors = ['#e53935','#1976d2','#43a047']
ax1 = axes[0]
for (fname, ics), color in zip(decay_results.items(), colors):
ax1.plot(horizons, ics, lw=2, color=color, label=fname)
ax1.axhline(0, color='black', lw=0.8, ls='--')
ax1.set_xlabel('Forward Horizon (days)'); ax1.set_ylabel('IC (Spearman)')
ax1.legend(fontsize=9); ax1.set_title('Factor IC Decay by Horizon')
ax2 = axes[1]
for (fname, ics), color in zip(decay_results.items(), colors):
cumIC = np.cumsum(ics)
net = cumIC - tc
ax2.plot(horizons, net, lw=2, color=color, label=fname)
opt_h = horizons[np.argmax(net)]
ax2.axvline(opt_h, color=color, lw=0.8, ls=':', alpha=0.7)
ax2.axhline(0, color='black', lw=0.8, ls='--', label=f'Zero net (TC={TRANSACTION_COST_BPS}bps)')
ax2.set_xlabel('Forward Horizon (days)'); ax2.set_ylabel('Cumulative IC Net of TC')
ax2.legend(fontsize=8); ax2.set_title('Net Factor Return vs Holding Period')
plt.tight_layout(); plt.show()Section 6 — Export
Save the decay curve table with IC at each horizon for all factors. This CSV is the input for the carry cost / turnover analysis and informs the rebalancing frequency choice for each factor.
decay_df.to_csv('factor_decay_curve.csv')
print('Saved: factor_decay_curve.csv')Saved: factor_decay_curve.csv