Research·Strategy Hypothesis Testing·Advanced

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.

quant-researchsignal-generation

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:

  1. Loads price data for multiple assets
  2. Computes the variance ratio at multiple horizons
  3. Tests autocorrelation significance (Ljung-Box test)
  4. Estimates the Hurst exponent using R/S analysis
  5. Summarises SNR across all assets in a comparison table
  6. Exports the analysis
[1]
!pip install numpy pandas matplotlib seaborn scipy yfinance statsmodels --quiet
[2]
import 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.

[3]
# ── 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.

[4]
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.

[5]
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.

[6]
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()
cell output

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.

[7]
snr_df.to_csv('signal_to_noise_ratio.csv')
print('Saved: signal_to_noise_ratio.csv')
Saved: signal_to_noise_ratio.csv