Signal to Noise Ratio
Estimate the fundamental signal-to-noise ratio embedded in trading strategy return streams to rigorously distinguish genuine predictive alpha from random statistical noise, employing advanced statistical tests and bootstrap resampling methods to quantify the true strategy edge with confidence bounds.
Signal-to-Noise Ratio — Research & Experimentation
Category: Research & Experimentation | Subcategory: Hypothesis
What This Notebook Does
The signal-to-noise ratio (SNR) quantifies how much of a time series' variation is predictable (signal) vs random (noise). In trading, high SNR means the return-generating process is structured enough to exploit; low SNR means most of the variation is random walk and any apparent pattern is likely to be overfitting.
Key methods for measuring SNR in financial returns:
- Variance ratio test (VR): compares return variance at different horizons. VR > 1 → positive autocorrelation (momentum); VR < 1 → mean reversion. VR = 1 → random walk (pure noise at that horizon).
- Autocorrelation function (ACF): directly measures return autocorrelation at lags 1, 2, ..., k. Significant ACF means structure (signal).
- Hurst exponent: H > 0.5 → trending (signal dominates); H < 0.5 → mean-reverting; H = 0.5 → random walk.
- IC time series: rolling IC over time shows whether signal strength is consistent or episodic.
This notebook:
- Loads price data for multiple assets
- Computes the variance ratio at multiple horizons
- Tests autocorrelation significance (Ljung-Box test)
- Estimates the Hurst exponent using R/S analysis
- Summarises SNR across all assets in a comparison table
- Exports the analysis
!pip install numpy pandas matplotlib seaborn scipy yfinance statsmodels --quietimport numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from scipy import stats
from statsmodels.stats.diagnostic import acorr_ljungbox
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.
Configuration
VR_HORIZONS specifies the horizons at which to compute the variance ratio. Horizon 2 tests 2-day vs 1-day variance; horizon 10 tests 10-day vs 1-day variance. HURST_MAX_LAG controls the maximum lag for R/S analysis — more lags give a more accurate Hurst estimate but require more data.
# ── Data Source Toggle ─────────────────────────────
USE_LIVE_DATA = False
TICKERS = ['BTC-USD','ETH-USD','SOL-USD','BNB-USD','AVAX-USD']
START_DATE = '2020-01-01'
END_DATE = '2024-12-31'
# ──────────────────────────────────────────────────
ASSETS = ['BTC','ETH','SOL','BNB','AVAX']
VR_HORIZONS = [2, 4, 8, 16]
ACF_LAGS = 20
HURST_MAX_LAG = 100
print('Config ready.')Config ready.
Data Acquisition
The synthetic data includes distinct assets with different SNR profiles: BTC uses a mild AR(1) process (some structure), while AVAX is close to a random walk. This contrast makes the difference in SNR metrics visible and demonstrates why the analysis is informative.
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, 1.10]) / np.sqrt(252)
# Different AR coefficients — BTC has most structure, AVAX least
ar = np.array([0.12, 0.09, 0.06, 0.08, 0.02])
data = np.zeros((n, 5))
for t in range(1, n):
data[t] = ar * data[t-1] + rng.standard_normal(5) * vols
idx = pd.date_range('2020-01-01', periods=n, freq='B')
returns = pd.DataFrame(data, columns=ASSETS, index=idx)
print(f'Synthetic data: {n} days, AR coefs = {ar}')Synthetic data: 1200 days, AR coefs = [0.12 0.09 0.06 0.08 0.02]
Variance Ratio & Ljung-Box Tests
The variance ratio at horizon k is Var(k-period return) / (k × Var(1-period return)). Under a random walk this equals 1. The Ljung-Box test checks whether the first ACF_LAGS autocorrelations are jointly zero — a significant p-value means there is detectable structure in the return series.
def variance_ratio(rets: np.ndarray, k: int) -> float:
"""
Compute the variance ratio at horizon k.
Parameters
----------
rets : np.ndarray Daily returns.
k : int Aggregation horizon.
Returns
-------
float Variance ratio (1 = random walk).
"""
if len(rets) < k * 2:
return np.nan
k_rets = np.array([rets[i:i+k].sum() for i in range(0, len(rets)-k+1)])
return np.var(k_rets, ddof=1) / (k * np.var(rets, ddof=1) + 1e-10)
def hurst_exponent(rets: np.ndarray, max_lag: int = 100) -> float:
"""
Estimate the Hurst exponent via R/S analysis.
Parameters
----------
rets : np.ndarray Return series.
max_lag : int Maximum lag for R/S computation.
Returns
-------
float Hurst exponent H. H=0.5 is random walk.
"""
lags = range(10, min(max_lag, len(rets) // 2))
rs_vals = []
for lag in lags:
subseries = rets[:lag] - np.mean(rets[:lag])
cum_dev = np.cumsum(subseries)
r = cum_dev.max() - cum_dev.min()
s = np.std(rets[:lag], ddof=1)
rs_vals.append(r / (s + 1e-10))
if len(rs_vals) < 2:
return 0.5
h, _ = np.polyfit(np.log(list(lags)), np.log(rs_vals), 1)
return h
results = []
for asset in ASSETS:
r = returns[asset].values
vrs = {f'VR_{k}': variance_ratio(r, k) for k in VR_HORIZONS}
lb = acorr_ljungbox(r, lags=[ACF_LAGS], return_df=True)
lb_pval = lb['lb_pvalue'].values[0]
h = hurst_exponent(r, HURST_MAX_LAG)
row = {'asset': asset, 'hurst': h, 'lb_pvalue': lb_pval, **vrs}
results.append(row)
snr_df = pd.DataFrame(results).set_index('asset')
print(snr_df.round(3).to_string())hurst lb_pvalue VR_2 VR_4 VR_8 VR_16 asset BTC 0.396 0.010 1.138 1.281 1.421 1.473 ETH 0.475 0.246 1.082 1.118 1.147 1.118 SOL 0.809 0.332 1.055 1.028 0.976 0.934 BNB 0.536 0.489 1.076 1.078 1.121 1.189 AVAX 0.485 0.189 1.036 1.039 0.972 1.003
Visualisation
The heatmap on the left shows variance ratios at all horizons for all assets — values above 1 (blue) indicate momentum; below 1 (red) indicate mean reversion. The bar chart on the right shows the Hurst exponent per asset — assets near 0.5 are close to a random walk, those above 0.5 have trending (predictable) dynamics.
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
fig.suptitle('Signal-to-Noise Ratio Analysis', fontsize=13, fontweight='bold')
vr_cols = [f'VR_{k}' for k in VR_HORIZONS]
ax1 = axes[0]
sns.heatmap(snr_df[vr_cols], ax=ax1, cmap='RdYlBu', center=1.0,
annot=True, fmt='.3f', linewidths=0.5,
cbar_kws={'label': 'Variance Ratio (1=RW)'})
ax1.set_title('Variance Ratio by Asset and Horizon')
ax1.set_xlabel('Horizon')
ax2 = axes[1]
colors = ['#1976d2' if h > 0.5 else '#e53935' for h in snr_df['hurst']]
ax2.bar(snr_df.index, snr_df['hurst'], color=colors, alpha=0.8)
ax2.axhline(0.5, color='black', ls='--', lw=1.2, label='H=0.5 (random walk)')
ax2.set_ylabel('Hurst Exponent'); ax2.legend(fontsize=9)
ax2.set_title('Hurst Exponent by Asset')
ax2.set_ylim(0, 1)
plt.tight_layout(); plt.show()Export
Save the full SNR comparison table. This is useful as a pre-analysis step before committing to a signal — assets with very low structure are poor candidates for momentum strategies.
snr_df.to_csv('signal_to_noise_ratio.csv')
print('Saved: signal_to_noise_ratio.csv')Saved: signal_to_noise_ratio.csv