
Mean Reversion
- 232 installs
- 257 repo stars
- Updated June 24, 2026
- agiprolabs/claude-trading-skills
mean-reversion is a Claude Code skill that tests and trades mean reversion using Hurst exponent, ADF, variance-ratio, half-life, and z-score signals.
About
mean-reversion provides tools to statistically confirm and trade mean reversion, including Hurst exponent, ADF testing, variance ratio, half-life estimation, and z-score signals. A developer uses it to test whether a price or spread series is mean-reverting before trading it, and to design pairs and range strategies. It documents when mean reversion works and fails.
- Tests and trades mean reversion via Hurst exponent, ADF, variance ratio, and half-life estimation
- Ships mean_reversion_test.py and pairs_scanner.py plus statistical-tests and strategy-design references
- Includes Ornstein-Uhlenbeck modeling and z-score signals
Mean Reversion by the numbers
- 232 all-time installs (skills.sh)
- Ranked #405 of 1,106 Finance & Trading skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
mean-reversion capabilities & compatibility
- Capabilities
- mean reversion · regime detection · cointegration analysis
- Use cases
- data analysis · research · trading
What mean-reversion says it does
Mean reversion is the statistical tendency for prices, spreads, or other financial variables to return toward a long-run average after deviating from it.
Before trading mean reversion, you must statistically confirm the series is mean-reverting.
npx skills add https://github.com/agiprolabs/claude-trading-skills --skill mean-reversionAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 232 |
|---|---|
| repo stars | ★ 257 |
| Last updated | June 24, 2026 |
| Repository | agiprolabs/claude-trading-skills ↗ |
What it does
Test whether a series is mean-reverting and design z-score and pairs strategies.
Who is it for?
Statistically confirming mean reversion and sizing entry/exit z-score signals.
Skip if: Trending markets or strategies with structural breaks and regime changes.
When should I use this skill?
You need to confirm and design a mean-reversion or pairs trading strategy.
By the numbers
- 3 stationarity tests (ADF, Hurst, variance ratio)
- Hurst interpretation table
- half-life via AR(1) regression
Files
Mean Reversion
Mean reversion is the statistical tendency for prices, spreads, or other financial variables to return toward a long-run average after deviating from it. A mean-reverting series overshoots its mean, then corrects back -- creating predictable oscillations that can be traded.
When Mean Reversion Works
- Ranging markets: Sideways price action with clear support/resistance
- Pairs spreads: Spread between cointegrated assets reverts to equilibrium
- Oversold/overbought extremes: RSI, Bollinger Band, or z-score extremes in stationary series
- Funding rate arbitrage: Perpetual funding rates revert to baseline
- Stablecoin depegs: Classic mean-reversion opportunity (peg = known mean)
- Post-dump recovery: Brief mean-reversion windows after initial PumpFun dumps
When Mean Reversion Fails
- Strong trending markets (most crypto most of the time)
- Regime changes: what was stationary becomes non-stationary
- Structural breaks: token migration, protocol upgrade, delistings
- Low liquidity: wide spreads consume mean-reversion profits
---
Testing for Mean Reversion
Before trading mean reversion, you must statistically confirm the series is mean-reverting. Three complementary tests:
1. Augmented Dickey-Fuller (ADF) Test
Tests the null hypothesis that a series has a unit root (non-stationary).
from scipy import stats
import numpy as np
def adf_test(series: np.ndarray, max_lag: int = 0) -> dict:
"""Run ADF test. Reject null (p < 0.05) → stationary → mean-reverting."""
# See references/statistical_tests.md for full implementation
# Use statsmodels.tsa.stattools.adfuller for production
pass- p < 0.01: Strong evidence of stationarity
- p < 0.05: Evidence of stationarity
- p > 0.10: Cannot reject unit root -- likely non-stationary
2. Hurst Exponent
Measures the long-range dependence of a time series.
| Hurst Value | Interpretation | Trading Implication |
|---|---|---|
| H < 0.5 | Mean-reverting | Trade mean reversion |
| H = 0.5 | Random walk | No edge |
| H > 0.5 | Trending | Trade momentum |
def hurst_exponent(series: np.ndarray) -> float:
"""Compute Hurst exponent via R/S method. H < 0.5 → mean-reverting."""
# See references/statistical_tests.md for full R/S algorithm
pass3. Variance Ratio Test
Compares variance of multi-period returns to single-period variance.
- VR < 1: Negative autocorrelation (mean-reverting)
- VR = 1: Random walk
- VR > 1: Positive autocorrelation (trending)
def variance_ratio(series: np.ndarray, q: int = 5) -> float:
"""Compute variance ratio at horizon q. VR < 1 → mean-reverting."""
returns = np.diff(np.log(series))
var_1 = np.var(returns)
returns_q = np.diff(np.log(series[::q]))
var_q = np.var(returns_q)
return var_q / (q * var_1)See references/statistical_tests.md for complete implementations and interpretation guides.
---
Half-Life Estimation
The half-life tells you how many periods it takes for a deviation to decay to half its size. This is the single most important parameter for mean-reversion trading.
AR(1) Regression Method
Fit the autoregressive model: delta_X_t = alpha + beta * X_{t-1} + epsilon
def half_life(series: np.ndarray) -> float:
"""Estimate mean-reversion half-life from AR(1) regression.
Returns:
Half-life in periods. Negative means non-mean-reverting.
"""
y = np.diff(series)
x = series[:-1]
x = np.column_stack([np.ones(len(x)), x])
beta = np.linalg.lstsq(x, y, rcond=None)[0][1]
if beta >= 0:
return -1.0 # Not mean-reverting
return -np.log(2) / np.log(1 + beta)Using Half-Life
| Parameter | Rule of Thumb |
|---|---|
| Lookback window | 2x half-life |
| Holding period | 1x half-life |
| Maximum hold | 3x half-life (stop) |
| Signal recalc | 0.5x half-life |
---
Z-Score Signal Framework
The z-score normalizes the deviation from the mean, providing standardized entry/exit signals.
z = (price - rolling_mean) / rolling_stdSignal Rules
| Condition | Signal | Action |
|---|---|---|
| z < -2.0 | Buy | Enter long (price below mean) |
| z > +2.0 | Sell | Enter short (price above mean) |
| z crosses 0 | Exit | Close position (returned to mean) |
| abs(z) > 3.0 | Stop | Close position (reversion failed) |
Lookback Window
Set the rolling window to approximately 2x the half-life:
def z_score_signals(
prices: np.ndarray,
lookback: int,
entry_z: float = 2.0,
exit_z: float = 0.0,
stop_z: float = 3.0,
) -> np.ndarray:
"""Generate z-score-based mean-reversion signals.
Returns:
Array of signals: 1 (long), -1 (short), 0 (flat).
"""
rolling_mean = pd.Series(prices).rolling(lookback).mean().values
rolling_std = pd.Series(prices).rolling(lookback).std().values
z = (prices - rolling_mean) / rolling_std
# See scripts/mean_reversion_test.py for full signal generation
...Position Sizing with Z-Score
Scale position size with z-score magnitude for better risk-adjusted returns:
size = base_size * min(abs(z) / entry_threshold, max_scale)See references/strategy_design.md for complete entry/exit framework and sizing.
---
Ornstein-Uhlenbeck (OU) Process
The OU process is the continuous-time model of mean reversion:
dX = theta * (mu - X) * dt + sigma * dW| Parameter | Meaning | Estimation |
|---|---|---|
| theta | Speed of mean reversion | From AR(1) beta: theta = -ln(1+beta)/dt |
| mu | Long-run mean | From AR(1) intercept: mu = -alpha/beta |
| sigma | Volatility of innovations | Residual std from AR(1) |
Parameter Estimation
def estimate_ou_params(series: np.ndarray, dt: float = 1.0) -> dict:
"""Estimate OU process parameters from observed series.
Returns:
Dict with keys: theta, mu, sigma, half_life.
"""
y = np.diff(series)
x = series[:-1]
x_with_const = np.column_stack([np.ones(len(x)), x])
params = np.linalg.lstsq(x_with_const, y, rcond=None)[0]
alpha, beta = params[0], params[1]
theta = -np.log(1 + beta) / dt
mu = -alpha / beta if beta != 0 else np.mean(series)
residuals = y - (alpha + beta * x)
sigma = np.std(residuals) * np.sqrt(2 * theta / (1 - np.exp(-2 * theta * dt)))
return {
"theta": theta,
"mu": mu,
"sigma": sigma,
"half_life": np.log(2) / theta if theta > 0 else -1,
}---
Strategy Types
Single-Asset Mean Reversion
Apply z-score framework directly to a token's price series. Works best on:
- Stablecoins (USDC/USDT spread)
- Tokens in established ranges
- After confirming stationarity with ADF test
Pairs Trading
Trade the spread between two cointegrated assets:
1. Confirm cointegration (see cointegration-analysis skill) 2. Compute spread: S = Y - beta * X 3. Apply z-score framework to the spread 4. Go long spread (buy Y, sell X) when z < -2 5. Go short spread (sell Y, buy X) when z > +2
Statistical Arbitrage
Multi-asset extension of pairs trading:
- Eigenportfolios from PCA of correlated assets
- Trade the smallest eigenvalue portfolios (most mean-reverting)
- Requires larger universe (10+ assets)
---
Crypto-Specific Considerations
1. Most crypto trends: Hurst exponent for BTC, ETH, SOL is typically 0.55-0.70. Raw price mean reversion is rare. 2. Where to find mean reversion:
- Pairs spreads (SOL/ETH ratio, BTC dominance)
- Funding rates on perpetuals
- Basis between spot and futures
- Stablecoin depegs
- Fee tier spreads across DEXs
3. Short lookbacks: Crypto mean reversion has short half-lives (hours to days, not weeks) 4. Transaction costs: DEX swap fees (0.25-1%) can eat mean-reversion profits. Factor in slippage. 5. Regime awareness: Use regime-detection skill to only trade mean reversion in ranging regimes.
---
Integration with Other Skills
| Skill | Integration |
|---|---|
cointegration-analysis | Find cointegrated pairs for pairs trading |
pandas-ta | RSI, Bollinger Bands as mean-reversion indicators |
regime-detection | Filter: only trade MR in ranging regimes |
vectorbt | Backtest mean-reversion strategies |
volatility-modeling | Estimate sigma for OU model |
slippage-modeling | Factor execution costs into P&L estimates |
position-sizing | Size positions using Kelly + z-score scaling |
---
Files
References
references/statistical_tests.md-- ADF, Hurst exponent, variance ratio, and half-life estimation with full implementations and interpretationreferences/strategy_design.md-- Z-score framework, position sizing, pairs trading setup, risk management, and backtest considerations
Scripts
scripts/mean_reversion_test.py-- Comprehensive mean-reversion analysis: ADF, Hurst, variance ratio, half-life, OU estimation, z-score signalsscripts/pairs_scanner.py-- Scan multiple assets for mean-reverting pairs: correlation, cointegration, spread analysis, ranking
---
Quick Start
# Run mean-reversion analysis on synthetic data
python scripts/mean_reversion_test.py --demo
# Scan for mean-reverting pairs
python scripts/pairs_scanner.py --demo
# Analyze a specific token (requires BIRDEYE_API_KEY)
BIRDEYE_API_KEY=your_key TOKEN_MINT=So11...1 python scripts/mean_reversion_test.pyThis skill provides analytical tools and information only. It does not constitute financial advice or trading recommendations.
Statistical Tests for Mean Reversion
This reference covers four key tests for detecting mean-reverting behavior in financial time series. Run all four to build conviction before trading a mean-reversion strategy.
---
Augmented Dickey-Fuller (ADF) Test
Theory
The ADF test checks whether a time series has a unit root (is non-stationary). The regression model:
delta_y_t = alpha + beta * y_{t-1} + sum(gamma_i * delta_y_{t-i}) + epsilon_t- Null hypothesis (H0): beta = 0 (unit root exists, non-stationary)
- Alternative (H1): beta < 0 (no unit root, stationary)
If we reject H0 (p < 0.05), the series is stationary and potentially mean-reverting.
Lag Selection
Choose the number of augmenting lags using one of:
- AIC/BIC: Fit models with 1..max_lag lags, pick lowest information criterion
- Rule of thumb:
max_lag = int(np.sqrt(len(series))) - Default: Start with 1 lag, increase if residuals show autocorrelation
Implementation
import numpy as np
def adf_test_manual(series: np.ndarray, max_lag: int = 1) -> dict:
"""Manual ADF test. Returns dict with test_statistic, p_value_approx, conclusion."""
n = len(series)
y = np.diff(series)
x_lag = series[:-1]
start = max_lag
y_trimmed = y[start:]
regressors = [np.ones(len(y_trimmed)), x_lag[start:]]
for lag in range(1, max_lag + 1):
regressors.append(y[start - lag : n - 1 - lag])
X = np.column_stack(regressors)
coeffs = np.linalg.lstsq(X, y_trimmed, rcond=None)[0]
beta = coeffs[1]
fitted = X @ coeffs
sigma2 = np.sum((y_trimmed - fitted) ** 2) / (len(y_trimmed) - len(coeffs))
cov_matrix = sigma2 * np.linalg.inv(X.T @ X)
t_stat = beta / np.sqrt(cov_matrix[1, 1])
# MacKinnon critical values (constant, no trend, n > 250)
crit = {0.01: -3.43, 0.05: -2.86, 0.10: -2.57}
p = 0.005 if t_stat < crit[0.01] else 0.03 if t_stat < crit[0.05] else 0.07 if t_stat < crit[0.10] else 0.20
return {"test_statistic": t_stat, "p_value_approx": p, "critical_values": crit}Interpretation Guide
| ADF Statistic | p-value | Conclusion | Action |
|---|---|---|---|
| < -3.43 | < 0.01 | Strongly stationary | High confidence mean reversion |
| -3.43 to -2.86 | 0.01-0.05 | Stationary | Good for mean reversion |
| -2.86 to -2.57 | 0.05-0.10 | Weakly stationary | Proceed with caution |
| > -2.57 | > 0.10 | Non-stationary | Do not trade mean reversion |
Common Pitfalls
- ADF has low power on short samples (< 100 data points)
- Structural breaks can fool the test (split test around known breaks)
- A stationary series can still trend locally -- use regime detection
---
Hurst Exponent (R/S Method)
Theory
The Hurst exponent H measures long-range dependence. For a time series:
- H < 0.5: Anti-persistent (mean-reverting). Past up moves predict future down moves.
- H = 0.5: Random walk. No predictable pattern.
- H > 0.5: Persistent (trending). Past up moves predict future up moves.
R/S Algorithm
1. For each subseries length n in a range of scales: a. Divide the series into m = N/n non-overlapping blocks b. For each block, compute the mean-adjusted cumulative deviation c. R = max(cumulative) - min(cumulative) (range) d. S = standard deviation of the block e. Compute R/S for each block, take the average 2. Plot log(average R/S) vs log(n) 3. Slope of the line = Hurst exponent
Implementation
import numpy as np
def hurst_rs(series: np.ndarray, min_window: int = 10) -> float:
"""Compute Hurst exponent using rescaled range (R/S) method."""
n = len(series)
max_window = n // 2
window_sizes = []
w = min_window
while w <= max_window:
window_sizes.append(w)
w = max(w + 1, int(w * 1.5))
log_n, log_rs = [], []
for w in window_sizes:
rs_block = []
for i in range(n // w):
block = series[i * w : (i + 1) * w]
cumulative = np.cumsum(block - np.mean(block))
R = np.max(cumulative) - np.min(cumulative)
S = np.std(block, ddof=1)
if S > 1e-10:
rs_block.append(R / S)
if rs_block:
log_n.append(np.log(w))
log_rs.append(np.log(np.mean(rs_block)))
if len(log_n) < 2:
return 0.5
return np.polyfit(log_n, log_rs, 1)[0]Interpretation
| Hurst Range | Behavior | Strength | Crypto Typical |
|---|---|---|---|
| 0.0 - 0.3 | Strongly mean-reverting | High | Rare (spreads only) |
| 0.3 - 0.45 | Mean-reverting | Moderate | Pairs spreads, funding |
| 0.45 - 0.55 | Random walk | None | Some altcoins |
| 0.55 - 0.70 | Trending | Moderate | BTC, ETH, SOL |
| 0.70 - 1.0 | Strongly trending | High | Meme coins, breakouts |
Data Requirements
- Minimum 100 data points for a rough estimate
- 500+ points for a reliable estimate
- Use returns (not prices) for raw assets; use price levels for spreads
- Recalculate periodically -- Hurst changes with regime
---
Variance Ratio Test
Theory
If a series is a random walk, the variance of q-period returns should be q times the variance of 1-period returns. The variance ratio:
VR(q) = Var(r_t(q)) / (q * Var(r_t(1)))- VR < 1: Negative autocorrelation (mean-reverting)
- VR = 1: Random walk
- VR > 1: Positive autocorrelation (trending)
Implementation
import numpy as np
from scipy.stats import norm
def variance_ratio(series: np.ndarray, q: int = 5) -> dict:
"""Compute variance ratio and Lo-MacKinlay z-statistic."""
log_prices = np.log(series)
returns = np.diff(log_prices)
n = len(returns)
var_1 = np.var(returns, ddof=1)
returns_q = log_prices[q:] - log_prices[:-q]
var_q = np.var(returns_q, ddof=1)
vr = var_q / (q * var_1)
z_stat = (vr - 1) / np.sqrt(2 * (q - 1) / (3 * n))
p_value = 2 * (1 - norm.cdf(abs(z_stat)))
return {"vr": vr, "z_stat": z_stat, "p_value": p_value}Multi-Horizon Analysis
Test at multiple horizons to see where mean reversion is strongest:
for q in [2, 5, 10, 20, 50]:
result = variance_ratio(prices, q=q)
print(f" q={q:3d}: VR={result['vr']:.3f} z={result['z_stat']:+.2f} p={result['p_value']:.4f}")---
Half-Life from AR(1) Regression
Theory
For a mean-reverting process, the half-life is the expected time for a deviation to decay to half its original size. From the AR(1) model:
delta_X_t = alpha + beta * X_{t-1} + epsilon- beta must be negative for mean reversion
- Half-life =
-ln(2) / ln(1 + beta) - For small beta: half-life is approximately
-ln(2) / beta
Implementation
import numpy as np
def estimate_half_life(series: np.ndarray) -> dict:
"""Estimate half-life from AR(1): delta_X = alpha + beta*X_{t-1} + eps."""
y = np.diff(series)
x = series[:-1]
X = np.column_stack([np.ones(len(x)), x])
coeffs = np.linalg.lstsq(X, y, rcond=None)[0]
alpha, beta = coeffs[0], coeffs[1]
if beta >= 0:
return {"half_life": -1, "beta": beta, "mu": np.mean(series)}
half_life = -np.log(2) / np.log(1 + beta)
mu = -alpha / beta
return {"half_life": half_life, "beta": beta, "mu": mu}Practical Guidelines
| Half-Life | Timeframe | Suitability |
|---|---|---|
| < 1 period | Very fast | Likely noise; high transaction costs |
| 1-5 periods | Fast | Good for HFT with low fees |
| 5-20 periods | Moderate | Sweet spot for most strategies |
| 20-50 periods | Slow | Viable but ties up capital |
| > 50 periods | Very slow | Impractical for most traders |
- Half-life should be shorter than your patience and longer than your transaction costs can tolerate
- Use the half-life to set your lookback window (2x), holding period (1x), and stop duration (3x)
Mean-Reversion Strategy Design
This reference covers the practical aspects of building, sizing, and risk-managing a mean-reversion strategy. It assumes you have already confirmed mean-reversion behavior using the tests in statistical_tests.md.
---
Z-Score Entry/Exit Framework
Computing the Z-Score
import numpy as np, pandas as pd
def compute_z_score(series: np.ndarray, lookback: int) -> np.ndarray:
"""Compute rolling z-score. NaN for first lookback-1 values."""
s = pd.Series(series)
return ((s - s.rolling(lookback).mean()) / s.rolling(lookback).std()).valuesLookback Window Selection
The lookback window controls how "normal" is defined. Too short and you chase noise; too long and you miss regime changes.
| Method | Formula | When to Use |
|---|---|---|
| Half-life based | lookback = 2 * half_life | Default choice |
| Optimize | Walk-forward test 10..100 | When half-life is unreliable |
| Fixed | 20 (daily), 48 (hourly) | Quick-and-dirty baseline |
Entry and Exit Thresholds
| Parameter | Conservative | Moderate | Aggressive |
|---|---|---|---|
| Entry z | +/- 2.5 | +/- 2.0 | +/- 1.5 |
| Exit z | +/- 0.25 | 0 | -/+ 0.5 (overshoot) |
| Stop z | +/- 3.5 | +/- 3.0 | +/- 4.0 |
| Time stop | 3x half-life | 2x half-life | 4x half-life |
Conservative: Fewer trades, higher win rate, lower total return. Aggressive: More trades, lower win rate, potentially higher return but more risk.
Signal Generation Logic
def generate_signals(z_scores: np.ndarray, entry_z: float = 2.0,
exit_z: float = 0.0, stop_z: float = 3.0) -> np.ndarray:
"""Generate signals: 1 (long), -1 (short), 0 (flat)."""
positions = np.zeros(len(z_scores))
pos = 0
for i, z in enumerate(z_scores):
if np.isnan(z): continue
if pos == 0:
if z < -entry_z: pos = 1
elif z > entry_z: pos = -1
elif pos == 1:
if z > -exit_z or z < -stop_z: pos = 0
elif pos == -1:
if z < exit_z or z > stop_z: pos = 0
positions[i] = pos
return positions---
Position Sizing for Mean Reversion
Linear Z-Score Scaling
Scale position size proportionally to z-score magnitude. More extreme deviations get larger positions because the expected reversion is larger.
def mean_reversion_size(z: float, base_size: float,
entry_z: float = 2.0, max_scale: float = 2.0) -> float:
"""Position size scaled by z-score magnitude."""
return base_size * min(abs(z) / entry_z, max_scale)Layered Entry
Instead of entering the full position at one z-score level, layer in:
| Layer | Z-Score | Size | Cumulative |
|---|---|---|---|
| 1 | +/- 1.5 | 25% | 25% |
| 2 | +/- 2.0 | 25% | 50% |
| 3 | +/- 2.5 | 25% | 75% |
| 4 | +/- 3.0 | 25% | 100% |
This approach lowers average entry price but requires more capital allocation.
Kelly Criterion for Mean Reversion
For a mean-reversion strategy with known win rate and payoff ratio:
kelly_fraction = win_rate - (1 - win_rate) / payoff_ratioTypical mean-reversion strategies: win rate 60-70%, payoff ratio 0.8-1.2, Kelly fraction 0.15-0.35. Use half-Kelly in practice. See the kelly-criterion skill for detailed implementation.
---
Pairs Trading Setup
Step 1: Find Cointegrated Pairs
Use the cointegration-analysis skill or this quick test:
def engle_granger_test(y: np.ndarray, x: np.ndarray) -> dict:
"""Engle-Granger cointegration: regress Y on X, ADF test residuals."""
X = np.column_stack([np.ones(len(x)), x])
coeffs = np.linalg.lstsq(X, y, rcond=None)[0]
spread = y - (coeffs[0] + coeffs[1] * x)
adf_result = adf_test_manual(spread) # from statistical_tests
return {"hedge_ratio": coeffs[1], "spread": spread, "adf": adf_result}Step 2: Compute the Spread
spread_t = price_Y_t - beta * price_X_tWhere beta is the hedge ratio from cointegration regression. The spread should be stationary.
Step 3: Trade the Spread
Apply the z-score framework to the spread:
- Long spread (buy Y, sell X) when spread z-score < -2
- Short spread (sell Y, buy X) when spread z-score > +2
- Exit when z-score returns to 0
Step 4: Size Each Leg
For dollar-neutral pairs:
notional_y = position_size
notional_x = position_size * beta # Hedge ratio scalingHedge Ratio Recalibration
- Recalculate the hedge ratio periodically (every half-life or 20-50 bars)
- Use rolling OLS or Kalman filter for dynamic hedge ratios
- A changing hedge ratio signals potential cointegration breakdown
---
Risk Management
Stop Loss Rules
| Stop Type | Trigger | Rationale |
|---|---|---|
| Z-score stop | abs(z) > 3.0 | Mean reversion has failed |
| Time stop | Holding > 3x half-life | Taking too long to revert |
| Drawdown stop | Position loss > 2% of portfolio | Capital preservation |
| Regime stop | Regime changes to trending | Structural change |
Maximum Holding Period
If a trade has not reverted within 3x the estimated half-life, the mean-reversion hypothesis may be wrong. Close the position regardless of P&L.
Strategy-Level Circuit Breakers
| Metric | Threshold | Action |
|---|---|---|
| Consecutive losses | 5 in a row | Pause trading, retest stationarity |
| Daily drawdown | > 3% | Stop trading for the day |
| Weekly drawdown | > 5% | Reduce position sizes by 50% |
| Monthly drawdown | > 10% | Pause strategy, full review |
Regime Filtering
Mean reversion only works in ranging markets. Use the regime-detection skill to: 1. Classify current regime (trending, ranging, volatile) 2. Only open new mean-reversion positions in ranging regime 3. Tighten stops when regime shifts toward trending 4. Close all positions immediately on regime change to volatile/crash
---
Backtest Considerations
Transaction Costs
Mean-reversion strategies trade frequently with small expected gains. Transaction costs can destroy profitability.
# Example: factor in round-trip costs
cost_per_trade = 0.003 # 30 bps (DEX swap fee + slippage)
round_trip_cost = 2 * cost_per_trade # 60 bps per round trip
min_expected_move = round_trip_cost * 2 # Need at least 120 bps expected reversionRequired Trade Count
- Minimum 50 trades for any statistical significance
- 100+ trades for reliable Sharpe ratio estimation
- 200+ trades for confident drawdown estimation
Walk-Forward Optimization
Never optimize parameters on the full sample. Use walk-forward:
1. In-sample (60%): Optimize lookback, entry z, exit z 2. Out-of-sample (40%): Test with fixed parameters 3. Rolling window: Slide the window forward and repeat 4. Anchored: Keep start fixed, extend end
Common Backtest Mistakes
- Lookahead bias: Using future data to compute signals (e.g., full-sample mean)
- Survivorship bias: Only testing tokens that still exist
- Parameter overfitting: Optimizing too many parameters on too little data
- Ignoring transaction costs: Mean-reversion P&L is small; costs matter enormously
- Not testing for regime changes: A strategy that worked in 2024 ranging markets may fail in a 2025 bull run
---
Crypto-Specific Adjustments
Fee-Aware Thresholds
For DEX trading with 0.25-1% swap fees, adjust entry thresholds:
# Only enter if expected reversion exceeds round-trip costs
min_entry_z = round_trip_cost_bps / rolling_std_bps
effective_entry_z = max(2.0, min_entry_z)24/7 Markets
- No market close: overnight gaps do not exist, but weekend liquidity drops
- Use hourly or 4-hour bars for crypto mean reversion (not daily)
- Half-lives are typically measured in hours, not days
Liquidity Constraints
- Check that sufficient liquidity exists for your position size
- Use the
liquidity-analysisskill before entering - Illiquid tokens have wider spreads that destroy mean-reversion profits
- Prefer tokens with > $100K daily volume for mean-reversion strategies
#!/usr/bin/env python3
"""Comprehensive mean-reversion analysis for a single asset.
Runs ADF test, Hurst exponent, variance ratio, half-life estimation,
Ornstein-Uhlenbeck parameter estimation, and z-score signal generation.
Usage:
python scripts/mean_reversion_test.py --demo
python scripts/mean_reversion_test.py
Dependencies:
uv pip install pandas numpy scipy httpx
Environment Variables:
BIRDEYE_API_KEY: Your Birdeye API key (optional; required for live data)
TOKEN_MINT: Solana token mint address (optional; defaults to SOL)
"""
import argparse
import os
import sys
from typing import Optional
import numpy as np
import pandas as pd
from scipy import stats as scipy_stats
# ── Configuration ───────────────────────────────────────────────────
BIRDEYE_API_KEY: str = os.getenv("BIRDEYE_API_KEY", "")
TOKEN_MINT: str = os.getenv(
"TOKEN_MINT", "So11111111111111111111111111111111111111112"
)
DEFAULT_LOOKBACK_BARS: int = 200
# ── ADF Test ────────────────────────────────────────────────────────
def adf_test(series: np.ndarray, max_lag: int = 1) -> dict:
"""Run Augmented Dickey-Fuller test for stationarity.
Tests the null hypothesis that the series has a unit root (non-stationary).
Rejecting the null (p < 0.05) indicates stationarity, a prerequisite for
mean reversion.
Args:
series: Price or spread series (levels, not returns).
max_lag: Number of augmenting lags in the ADF regression.
Returns:
Dict with test_statistic, p_value_approx, beta, critical_values,
and conclusion string.
"""
y = np.diff(series)
x_lag = series[:-1]
start = max_lag
y_trimmed = y[start:]
regressors = [np.ones(len(y_trimmed)), x_lag[start:]]
for lag in range(1, max_lag + 1):
regressors.append(y[start - lag : len(y) - lag])
X = np.column_stack(regressors)
coeffs = np.linalg.lstsq(X, y_trimmed, rcond=None)[0]
beta = coeffs[1]
fitted = X @ coeffs
residuals = y_trimmed - fitted
sigma2 = np.sum(residuals**2) / (len(y_trimmed) - len(coeffs))
cov_matrix = sigma2 * np.linalg.inv(X.T @ X)
se_beta = np.sqrt(cov_matrix[1, 1])
t_stat = beta / se_beta
# MacKinnon approximate critical values (constant, no trend, n > 250)
critical_values = {0.01: -3.43, 0.05: -2.86, 0.10: -2.57}
if t_stat < critical_values[0.01]:
p_approx = 0.005
conclusion = "Strongly stationary (p < 0.01) -- mean-reverting"
elif t_stat < critical_values[0.05]:
p_approx = 0.03
conclusion = "Stationary (p < 0.05) -- likely mean-reverting"
elif t_stat < critical_values[0.10]:
p_approx = 0.07
conclusion = "Weakly stationary (p < 0.10) -- possibly mean-reverting"
else:
p_approx = 0.20
conclusion = "Non-stationary (p > 0.10) -- NOT mean-reverting"
return {
"test_statistic": float(t_stat),
"p_value_approx": float(p_approx),
"beta": float(beta),
"critical_values": critical_values,
"conclusion": conclusion,
}
# ── Hurst Exponent ──────────────────────────────────────────────────
def hurst_exponent(series: np.ndarray, min_window: int = 10) -> float:
"""Compute Hurst exponent using the rescaled range (R/S) method.
H < 0.5: mean-reverting, H = 0.5: random walk, H > 0.5: trending.
Args:
series: Price or log-price series.
min_window: Minimum subseries length for R/S calculation.
Returns:
Hurst exponent estimate (float between 0 and 1).
Raises:
ValueError: If series is too short for reliable estimation.
"""
n = len(series)
if n < 2 * min_window:
raise ValueError(f"Series too short ({n}). Need >= {2 * min_window} points.")
max_window = n // 2
window_sizes: list[int] = []
w = min_window
while w <= max_window:
window_sizes.append(w)
w = max(w + 1, int(w * 1.5))
log_n: list[float] = []
log_rs: list[float] = []
for w in window_sizes:
num_blocks = n // w
rs_block: list[float] = []
for i in range(num_blocks):
block = series[i * w : (i + 1) * w]
mean_block = np.mean(block)
deviations = block - mean_block
cumulative = np.cumsum(deviations)
R = float(np.max(cumulative) - np.min(cumulative))
S = float(np.std(block, ddof=1))
if S > 1e-10:
rs_block.append(R / S)
if rs_block:
log_n.append(np.log(w))
log_rs.append(np.log(np.mean(rs_block)))
if len(log_n) < 2:
return 0.5
coeffs = np.polyfit(log_n, log_rs, 1)
return float(coeffs[0])
# ── Variance Ratio ──────────────────────────────────────────────────
def variance_ratio(series: np.ndarray, q: int = 5) -> dict:
"""Compute variance ratio at horizon q.
VR < 1: mean-reverting, VR = 1: random walk, VR > 1: trending.
Args:
series: Price series (levels).
q: Multi-period return horizon.
Returns:
Dict with vr, z_stat, p_value, and conclusion.
"""
log_prices = np.log(series)
returns_1 = np.diff(log_prices)
n = len(returns_1)
var_1 = np.var(returns_1, ddof=1)
if var_1 < 1e-15:
return {"vr": 1.0, "z_stat": 0.0, "p_value": 1.0, "conclusion": "Zero variance"}
returns_q = log_prices[q:] - log_prices[:-q]
var_q = np.var(returns_q, ddof=1)
vr = var_q / (q * var_1)
# Lo-MacKinlay z-statistic (homoskedastic)
z_stat = (vr - 1) / np.sqrt(2 * (q - 1) / (3 * n))
p_value = float(2 * (1 - scipy_stats.norm.cdf(abs(z_stat))))
if vr < 1 and p_value < 0.05:
conclusion = f"Mean-reverting at horizon {q} (VR={vr:.3f}, p={p_value:.4f})"
elif vr > 1 and p_value < 0.05:
conclusion = f"Trending at horizon {q} (VR={vr:.3f}, p={p_value:.4f})"
else:
conclusion = f"Random walk at horizon {q} (VR={vr:.3f}, p={p_value:.4f})"
return {
"vr": float(vr),
"z_stat": float(z_stat),
"p_value": p_value,
"conclusion": conclusion,
}
# ── Half-Life Estimation ───────────────────────────────────────────
def estimate_half_life(series: np.ndarray) -> dict:
"""Estimate mean-reversion half-life from AR(1) regression.
Fits: delta_X_t = alpha + beta * X_{t-1} + epsilon
Half-life = -ln(2) / ln(1 + beta) when beta < 0.
Args:
series: Price or spread series.
Returns:
Dict with half_life, beta, alpha, mu, r_squared, and conclusion.
"""
y = np.diff(series)
x = series[:-1]
X = np.column_stack([np.ones(len(x)), x])
coeffs = np.linalg.lstsq(X, y, rcond=None)[0]
alpha, beta = float(coeffs[0]), float(coeffs[1])
y_hat = X @ coeffs
ss_res = float(np.sum((y - y_hat) ** 2))
ss_tot = float(np.sum((y - np.mean(y)) ** 2))
r_squared = 1 - ss_res / ss_tot if ss_tot > 0 else 0.0
if beta >= 0:
return {
"half_life": -1.0,
"beta": beta,
"alpha": alpha,
"mu": float(np.mean(series)),
"r_squared": r_squared,
"conclusion": "Not mean-reverting (beta >= 0)",
}
hl = -np.log(2) / np.log(1 + beta)
mu = -alpha / beta
return {
"half_life": float(hl),
"beta": beta,
"alpha": alpha,
"mu": float(mu),
"r_squared": r_squared,
"conclusion": f"Mean-reverting with half-life of {hl:.1f} periods",
}
# ── Ornstein-Uhlenbeck Parameter Estimation ────────────────────────
def estimate_ou_params(series: np.ndarray, dt: float = 1.0) -> dict:
"""Estimate Ornstein-Uhlenbeck process parameters.
The OU process: dX = theta * (mu - X) * dt + sigma * dW
Parameters are estimated from AR(1) regression on the series.
Args:
series: Price or spread series.
dt: Time step between observations (1.0 for unit time).
Returns:
Dict with theta, mu, sigma, half_life, and interpretation.
"""
y = np.diff(series)
x = series[:-1]
X = np.column_stack([np.ones(len(x)), x])
coeffs = np.linalg.lstsq(X, y, rcond=None)[0]
alpha, beta = float(coeffs[0]), float(coeffs[1])
if beta >= 0:
return {
"theta": 0.0,
"mu": float(np.mean(series)),
"sigma": float(np.std(np.diff(series))),
"half_life": -1.0,
"interpretation": "Not mean-reverting (beta >= 0); OU model not applicable",
}
# OU parameter mapping from discrete AR(1)
theta = -np.log(1 + beta) / dt
mu = -alpha / beta
residuals = y - (alpha + beta * x)
resid_std = float(np.std(residuals))
# Convert residual std to OU sigma
exp_term = 1 - np.exp(-2 * theta * dt)
if exp_term > 0:
sigma = resid_std * np.sqrt(2 * theta / exp_term)
else:
sigma = resid_std
hl = np.log(2) / theta if theta > 0 else -1.0
return {
"theta": float(theta),
"mu": float(mu),
"sigma": float(sigma),
"half_life": float(hl),
"interpretation": (
f"Speed of reversion (theta): {theta:.4f}\n"
f" Long-run mean (mu): {mu:.4f}\n"
f" Volatility (sigma): {sigma:.4f}\n"
f" Half-life: {hl:.1f} periods"
),
}
# ── Z-Score Signal Generation ──────────────────────────────────────
def compute_z_scores(
prices: np.ndarray,
lookback: int,
) -> np.ndarray:
"""Compute rolling z-scores for mean-reversion signals.
z = (price - rolling_mean) / rolling_std
Args:
prices: Price series.
lookback: Rolling window length (recommend 2x half-life).
Returns:
Array of z-scores (NaN for first lookback-1 values).
"""
s = pd.Series(prices)
rolling_mean = s.rolling(lookback).mean()
rolling_std = s.rolling(lookback).std()
z = (s - rolling_mean) / rolling_std
return z.values
def generate_signals(
z_scores: np.ndarray,
entry_z: float = 2.0,
exit_z: float = 0.0,
stop_z: float = 3.0,
) -> np.ndarray:
"""Generate mean-reversion trading signals from z-scores.
Args:
z_scores: Array of z-score values.
entry_z: Absolute z-score threshold for entry.
exit_z: Absolute z-score threshold for exit (return to mean).
stop_z: Absolute z-score threshold for stop loss.
Returns:
Array of positions: 1 (long), -1 (short), 0 (flat).
"""
n = len(z_scores)
positions = np.zeros(n)
current_pos = 0
for i in range(n):
z = z_scores[i]
if np.isnan(z):
continue
if current_pos == 0:
if z < -entry_z:
current_pos = 1
elif z > entry_z:
current_pos = -1
elif current_pos == 1:
if z > -exit_z:
current_pos = 0
elif z < -stop_z:
current_pos = 0
elif current_pos == -1:
if z < exit_z:
current_pos = 0
elif z > stop_z:
current_pos = 0
positions[i] = current_pos
return positions
# ── Demo Data Generation ───────────────────────────────────────────
def generate_demo_data(
n: int = 500,
theta: float = 0.1,
mu: float = 100.0,
sigma: float = 2.0,
seed: int = 42,
) -> np.ndarray:
"""Generate synthetic mean-reverting data using OU process.
dX = theta * (mu - X) * dt + sigma * dW
Args:
n: Number of data points.
theta: Speed of mean reversion.
mu: Long-run mean level.
sigma: Volatility of innovations.
seed: Random seed for reproducibility.
Returns:
Array of simulated prices.
"""
rng = np.random.default_rng(seed)
prices = np.zeros(n)
prices[0] = mu + rng.normal(0, sigma)
dt = 1.0
for i in range(1, n):
dW = rng.normal(0, np.sqrt(dt))
prices[i] = prices[i - 1] + theta * (mu - prices[i - 1]) * dt + sigma * dW
return prices
# ── Birdeye Data Fetching ──────────────────────────────────────────
def fetch_birdeye_ohlcv(
mint: str,
api_key: str,
interval: str = "1H",
limit: int = 200,
) -> Optional[np.ndarray]:
"""Fetch OHLCV data from Birdeye API and return close prices.
Args:
mint: Solana token mint address.
api_key: Birdeye API key.
interval: Candle interval (1m, 5m, 15m, 1H, 4H, 1D).
limit: Number of candles to fetch.
Returns:
Array of close prices, or None on failure.
"""
try:
import httpx
except ImportError:
print("Error: httpx required for live data. Install with: uv pip install httpx")
return None
import time
url = "https://public-api.birdeye.so/defi/ohlcv"
now = int(time.time())
# Map interval to seconds for time_from calculation
interval_seconds = {
"1m": 60, "5m": 300, "15m": 900,
"1H": 3600, "4H": 14400, "1D": 86400,
}
secs = interval_seconds.get(interval, 3600)
time_from = now - (limit * secs)
params = {
"address": mint,
"type": interval,
"time_from": time_from,
"time_to": now,
}
headers = {"X-API-KEY": api_key, "accept": "application/json"}
try:
with httpx.Client(timeout=30) as client:
resp = client.get(url, params=params, headers=headers)
resp.raise_for_status()
data = resp.json()
except httpx.HTTPStatusError as e:
print(f"Birdeye API error: {e.response.status_code} - {e.response.text[:200]}")
return None
except httpx.RequestError as e:
print(f"Network error: {e}")
return None
items = data.get("data", {}).get("items", [])
if not items:
print("No OHLCV data returned from Birdeye.")
return None
closes = [float(item["c"]) for item in items if "c" in item]
if len(closes) < 50:
print(f"Insufficient data: {len(closes)} bars (need >= 50).")
return None
return np.array(closes)
# ── Report Generation ──────────────────────────────────────────────
def print_report(
prices: np.ndarray,
source: str,
entry_z: float = 2.0,
exit_z: float = 0.0,
stop_z: float = 3.0,
) -> None:
"""Run all mean-reversion tests and print a comprehensive report.
Args:
prices: Price series to analyze.
source: Description of data source (for the report header).
entry_z: Z-score entry threshold.
exit_z: Z-score exit threshold.
stop_z: Z-score stop threshold.
"""
print("=" * 70)
print(" MEAN-REVERSION ANALYSIS REPORT")
print(f" Data source: {source}")
print(f" Series length: {len(prices)} bars")
print(f" Price range: {np.min(prices):.4f} - {np.max(prices):.4f}")
print(f" Current price: {prices[-1]:.4f}")
print("=" * 70)
# ── ADF Test ────────────────────────────────────────────────────
print("\n1. AUGMENTED DICKEY-FULLER TEST")
print("-" * 40)
adf = adf_test(prices, max_lag=1)
print(f" Test statistic: {adf['test_statistic']:.4f}")
print(f" Approx p-value: {adf['p_value_approx']:.4f}")
print(f" Critical values: 1%={adf['critical_values'][0.01]:.2f}, "
f"5%={adf['critical_values'][0.05]:.2f}, "
f"10%={adf['critical_values'][0.10]:.2f}")
print(f" >> {adf['conclusion']}")
# ── Hurst Exponent ──────────────────────────────────────────────
print("\n2. HURST EXPONENT (R/S Method)")
print("-" * 40)
try:
H = hurst_exponent(prices)
if H < 0.4:
h_label = "Strongly mean-reverting"
elif H < 0.5:
h_label = "Mean-reverting"
elif H < 0.55:
h_label = "Near random walk"
elif H < 0.7:
h_label = "Trending"
else:
h_label = "Strongly trending"
print(f" Hurst exponent: {H:.4f}")
print(f" >> {h_label}")
except ValueError as e:
print(f" Error: {e}")
H = 0.5
# ── Variance Ratio ──────────────────────────────────────────────
print("\n3. VARIANCE RATIO TEST")
print("-" * 40)
for q in [2, 5, 10, 20]:
if q >= len(prices) // 2:
continue
vr = variance_ratio(prices, q=q)
print(f" q={q:3d}: VR={vr['vr']:.3f} z={vr['z_stat']:+.2f} "
f"p={vr['p_value']:.4f} ({vr['conclusion'].split('(')[0].strip()})")
# ── Half-Life ───────────────────────────────────────────────────
print("\n4. HALF-LIFE ESTIMATION")
print("-" * 40)
hl = estimate_half_life(prices)
print(f" Beta (AR1): {hl['beta']:.6f}")
print(f" Alpha: {hl['alpha']:.6f}")
print(f" Long-run mean (mu): {hl['mu']:.4f}")
print(f" R-squared: {hl['r_squared']:.4f}")
print(f" Half-life: {hl['half_life']:.1f} periods")
print(f" >> {hl['conclusion']}")
# ── OU Parameters ───────────────────────────────────────────────
print("\n5. ORNSTEIN-UHLENBECK PARAMETERS")
print("-" * 40)
ou = estimate_ou_params(prices)
print(f" Theta (speed): {ou['theta']:.6f}")
print(f" Mu (mean): {ou['mu']:.4f}")
print(f" Sigma (vol): {ou['sigma']:.4f}")
print(f" Half-life: {ou['half_life']:.1f} periods")
# ── Z-Score Signals ─────────────────────────────────────────────
print("\n6. Z-SCORE SIGNAL STATUS")
print("-" * 40)
effective_hl = hl["half_life"] if hl["half_life"] > 0 else 20
lookback = max(10, int(2 * effective_hl))
lookback = min(lookback, len(prices) // 2)
z_scores = compute_z_scores(prices, lookback)
signals = generate_signals(z_scores, entry_z, exit_z, stop_z)
current_z = z_scores[-1] if not np.isnan(z_scores[-1]) else 0.0
current_signal = int(signals[-1])
signal_map = {1: "LONG (buy -- price below mean)", -1: "SHORT (sell -- price above mean)", 0: "FLAT (no signal)"}
print(f" Lookback window: {lookback} bars (2x half-life)")
print(f" Entry z: +/-{entry_z:.1f} Exit z: {exit_z:.1f} Stop z: +/-{stop_z:.1f}")
print(f" Current z-score: {current_z:+.4f}")
print(f" Current signal: {signal_map.get(current_signal, 'UNKNOWN')}")
# Recent z-score history
recent_z = z_scores[-10:]
valid_z = [z for z in recent_z if not np.isnan(z)]
if valid_z:
print(f" Recent z range: [{min(valid_z):+.2f}, {max(valid_z):+.2f}]")
# Count signals in the series
long_count = int(np.sum(signals == 1))
short_count = int(np.sum(signals == -1))
flat_count = int(np.sum(signals == 0))
print(f" Signal distribution: Long={long_count}, Short={short_count}, Flat={flat_count}")
# ── Overall Assessment ──────────────────────────────────────────
print("\n" + "=" * 70)
print(" OVERALL ASSESSMENT")
print("=" * 70)
score = 0
reasons: list[str] = []
if adf["p_value_approx"] < 0.05:
score += 1
reasons.append("ADF: stationary (supports mean reversion)")
else:
reasons.append("ADF: non-stationary (against mean reversion)")
if H < 0.5:
score += 1
reasons.append(f"Hurst: {H:.3f} < 0.5 (supports mean reversion)")
else:
reasons.append(f"Hurst: {H:.3f} >= 0.5 (against mean reversion)")
vr_result = variance_ratio(prices, q=5)
if vr_result["vr"] < 1 and vr_result["p_value"] < 0.10:
score += 1
reasons.append(f"VR(5): {vr_result['vr']:.3f} < 1 (supports mean reversion)")
else:
reasons.append(f"VR(5): {vr_result['vr']:.3f} (neutral or trending)")
if hl["half_life"] > 0:
score += 1
reasons.append(f"Half-life: {hl['half_life']:.1f} periods (mean-reverting)")
else:
reasons.append("Half-life: negative (not mean-reverting)")
for r in reasons:
print(f" {r}")
print(f"\n Mean-reversion score: {score}/4")
if score >= 3:
print(" >> STRONG mean-reversion evidence. Suitable for MR strategies.")
elif score >= 2:
print(" >> MODERATE mean-reversion evidence. Proceed with caution.")
elif score == 1:
print(" >> WEAK mean-reversion evidence. Consider other strategies.")
else:
print(" >> NO mean-reversion evidence. Do NOT trade mean reversion on this series.")
if hl["half_life"] > 0:
print(f"\n Suggested parameters:")
print(f" Lookback window: {lookback} bars")
print(f" Holding period: ~{effective_hl:.0f} bars")
print(f" Max hold (stop): ~{3 * effective_hl:.0f} bars")
print("\n Note: This analysis is for informational purposes only.")
print(" It does not constitute financial advice or a trading recommendation.")
print("=" * 70)
# ── Main ────────────────────────────────────────────────────────────
def main() -> None:
"""Entry point: parse arguments and run mean-reversion analysis."""
parser = argparse.ArgumentParser(
description="Mean-reversion analysis for a single asset."
)
parser.add_argument(
"--demo",
action="store_true",
help="Use synthetic mean-reverting data instead of live data.",
)
parser.add_argument(
"--bars",
type=int,
default=DEFAULT_LOOKBACK_BARS,
help=f"Number of bars to analyze (default: {DEFAULT_LOOKBACK_BARS}).",
)
parser.add_argument(
"--entry-z",
type=float,
default=2.0,
help="Z-score entry threshold (default: 2.0).",
)
parser.add_argument(
"--interval",
type=str,
default="1H",
help="Candle interval for Birdeye data (default: 1H).",
)
args = parser.parse_args()
if args.demo:
print("Running in DEMO mode with synthetic OU process data.")
print("Parameters: theta=0.1, mu=100.0, sigma=2.0, n=500\n")
prices = generate_demo_data(n=500, theta=0.1, mu=100.0, sigma=2.0)
print_report(prices, source="Synthetic OU process (demo)", entry_z=args.entry_z)
else:
if not BIRDEYE_API_KEY:
print("BIRDEYE_API_KEY not set. Use --demo for synthetic data.")
print(" export BIRDEYE_API_KEY=your_key_here")
sys.exit(1)
print(f"Fetching {args.bars} bars of {args.interval} data for {TOKEN_MINT[:8]}...")
prices = fetch_birdeye_ohlcv(
mint=TOKEN_MINT,
api_key=BIRDEYE_API_KEY,
interval=args.interval,
limit=args.bars,
)
if prices is None:
print("Failed to fetch data. Use --demo for synthetic data.")
sys.exit(1)
print_report(
prices,
source=f"Birdeye API ({TOKEN_MINT[:8]}..., {args.interval})",
entry_z=args.entry_z,
)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Scan multiple assets for mean-reverting pairs.
Tests all pairwise combinations for correlation, cointegration,
Hurst exponent of the spread, and half-life. Ranks pairs by
mean-reversion quality and shows current trading signals.
Usage:
python scripts/pairs_scanner.py --demo
Dependencies:
uv pip install pandas numpy scipy
Environment Variables:
None required (demo mode uses synthetic data).
"""
import argparse
import itertools
import sys
from typing import Optional
import numpy as np
import pandas as pd
from scipy import stats as scipy_stats
# ── Configuration ───────────────────────────────────────────────────
NUM_DEMO_ASSETS: int = 5
DEMO_BARS: int = 500
SEED: int = 42
# ── Statistical Tests (self-contained) ─────────────────────────────
def adf_test(series: np.ndarray, max_lag: int = 1) -> dict:
"""Run Augmented Dickey-Fuller test for stationarity.
Args:
series: Price or spread series.
max_lag: Number of augmenting lags.
Returns:
Dict with test_statistic, p_value_approx, and is_stationary.
"""
y = np.diff(series)
x_lag = series[:-1]
start = max_lag
y_trimmed = y[start:]
regressors = [np.ones(len(y_trimmed)), x_lag[start:]]
for lag in range(1, max_lag + 1):
regressors.append(y[start - lag : len(y) - lag])
X = np.column_stack(regressors)
coeffs = np.linalg.lstsq(X, y_trimmed, rcond=None)[0]
beta = coeffs[1]
fitted = X @ coeffs
residuals = y_trimmed - fitted
n_obs = len(y_trimmed)
n_params = len(coeffs)
sigma2 = np.sum(residuals**2) / (n_obs - n_params)
try:
cov_matrix = sigma2 * np.linalg.inv(X.T @ X)
se_beta = np.sqrt(cov_matrix[1, 1])
t_stat = beta / se_beta
except np.linalg.LinAlgError:
return {"test_statistic": 0.0, "p_value_approx": 1.0, "is_stationary": False}
# MacKinnon critical values (constant, no trend)
# For cointegration residuals, use stricter values
critical_values = {0.01: -3.43, 0.05: -2.86, 0.10: -2.57}
if t_stat < critical_values[0.01]:
p_approx = 0.005
elif t_stat < critical_values[0.05]:
p_approx = 0.03
elif t_stat < critical_values[0.10]:
p_approx = 0.07
else:
p_approx = 0.20
return {
"test_statistic": float(t_stat),
"p_value_approx": float(p_approx),
"is_stationary": p_approx < 0.05,
}
def hurst_exponent(series: np.ndarray, min_window: int = 10) -> float:
"""Compute Hurst exponent using R/S method.
Args:
series: Time series data.
min_window: Minimum window size for R/S calculation.
Returns:
Hurst exponent (H < 0.5 = mean-reverting).
"""
n = len(series)
if n < 2 * min_window:
return 0.5
max_window = n // 2
window_sizes: list[int] = []
w = min_window
while w <= max_window:
window_sizes.append(w)
w = max(w + 1, int(w * 1.5))
log_n: list[float] = []
log_rs: list[float] = []
for w in window_sizes:
num_blocks = n // w
rs_block: list[float] = []
for i in range(num_blocks):
block = series[i * w : (i + 1) * w]
mean_b = np.mean(block)
cumulative = np.cumsum(block - mean_b)
R = float(np.max(cumulative) - np.min(cumulative))
S = float(np.std(block, ddof=1))
if S > 1e-10:
rs_block.append(R / S)
if rs_block:
log_n.append(np.log(w))
log_rs.append(np.log(np.mean(rs_block)))
if len(log_n) < 2:
return 0.5
coeffs = np.polyfit(log_n, log_rs, 1)
return float(coeffs[0])
def estimate_half_life(series: np.ndarray) -> float:
"""Estimate mean-reversion half-life from AR(1) regression.
Args:
series: Price or spread series.
Returns:
Half-life in periods. Negative means not mean-reverting.
"""
y = np.diff(series)
x = series[:-1]
X = np.column_stack([np.ones(len(x)), x])
coeffs = np.linalg.lstsq(X, y, rcond=None)[0]
beta = float(coeffs[1])
if beta >= 0:
return -1.0
return float(-np.log(2) / np.log(1 + beta))
# ── Correlation Tests ───────────────────────────────────────────────
def compute_correlations(
series_a: np.ndarray,
series_b: np.ndarray,
) -> dict:
"""Compute Pearson and Spearman correlation between two series.
Args:
series_a: First price series.
series_b: Second price series.
Returns:
Dict with pearson_r, pearson_p, spearman_r, spearman_p.
"""
pearson_r, pearson_p = scipy_stats.pearsonr(series_a, series_b)
spearman_r, spearman_p = scipy_stats.spearmanr(series_a, series_b)
return {
"pearson_r": float(pearson_r),
"pearson_p": float(pearson_p),
"spearman_r": float(spearman_r),
"spearman_p": float(spearman_p),
}
# ── Cointegration Test (Engle-Granger) ─────────────────────────────
def engle_granger_test(
y: np.ndarray,
x: np.ndarray,
) -> dict:
"""Test for cointegration using Engle-Granger two-step method.
Step 1: Regress Y on X to get hedge ratio (beta).
Step 2: Test residuals (spread) for stationarity with ADF.
Args:
y: Dependent price series.
x: Independent price series.
Returns:
Dict with hedge_ratio, intercept, spread, adf_result,
and is_cointegrated flag.
"""
X = np.column_stack([np.ones(len(x)), x])
coeffs = np.linalg.lstsq(X, y, rcond=None)[0]
intercept, hedge_ratio = float(coeffs[0]), float(coeffs[1])
spread = y - (intercept + hedge_ratio * x)
adf_result = adf_test(spread)
return {
"hedge_ratio": hedge_ratio,
"intercept": intercept,
"spread": spread,
"adf_result": adf_result,
"is_cointegrated": adf_result["is_stationary"],
}
# ── Pair Analysis ──────────────────────────────────────────────────
def analyze_pair(
name_a: str,
name_b: str,
prices_a: np.ndarray,
prices_b: np.ndarray,
) -> Optional[dict]:
"""Run full mean-reversion analysis on a pair of assets.
Computes correlation, cointegration, spread Hurst exponent,
spread half-life, and current z-score of the spread.
Args:
name_a: Name/label of first asset.
name_b: Name/label of second asset.
prices_a: Price series of first asset.
prices_b: Price series of second asset.
Returns:
Dict with pair analysis results, or None on error.
"""
try:
# Correlation
corr = compute_correlations(prices_a, prices_b)
# Cointegration (try both directions, pick lower p-value)
coint_ab = engle_granger_test(prices_a, prices_b)
coint_ba = engle_granger_test(prices_b, prices_a)
if coint_ab["adf_result"]["p_value_approx"] <= coint_ba["adf_result"]["p_value_approx"]:
coint = coint_ab
dependent, independent = name_a, name_b
else:
coint = coint_ba
dependent, independent = name_b, name_a
spread = coint["spread"]
# Hurst of spread
H = hurst_exponent(spread)
# Half-life of spread
hl = estimate_half_life(spread)
# Current z-score
lookback = max(10, int(2 * hl)) if hl > 0 else 20
lookback = min(lookback, len(spread) // 2)
s = pd.Series(spread)
rolling_mean = s.rolling(lookback).mean()
rolling_std = s.rolling(lookback).std()
z = (s - rolling_mean) / rolling_std
current_z = float(z.iloc[-1]) if not np.isnan(z.iloc[-1]) else 0.0
# Signal
if abs(current_z) >= 2.0:
if current_z < -2.0:
signal = "BUY SPREAD"
else:
signal = "SELL SPREAD"
elif abs(current_z) < 0.5:
signal = "AT MEAN"
else:
signal = "NO SIGNAL"
# Quality score (0-100)
quality = 0.0
if coint["is_cointegrated"]:
quality += 40
elif coint["adf_result"]["p_value_approx"] < 0.10:
quality += 20
if H < 0.5:
quality += 20 * (0.5 - H) / 0.5 # More MR = more points
if 1 < hl < 50:
quality += 20 # Practical half-life
if abs(corr["pearson_r"]) > 0.5:
quality += 10
if abs(corr["spearman_r"]) > 0.5:
quality += 10
return {
"pair": f"{name_a}/{name_b}",
"dependent": dependent,
"independent": independent,
"pearson_r": corr["pearson_r"],
"spearman_r": corr["spearman_r"],
"is_cointegrated": coint["is_cointegrated"],
"coint_p": coint["adf_result"]["p_value_approx"],
"coint_t": coint["adf_result"]["test_statistic"],
"hedge_ratio": coint["hedge_ratio"],
"hurst": H,
"half_life": hl,
"current_z": current_z,
"signal": signal,
"quality_score": quality,
"lookback": lookback,
}
except Exception as e:
print(f" Warning: Error analyzing {name_a}/{name_b}: {e}")
return None
# ── Demo Data Generation ───────────────────────────────────────────
def generate_demo_assets(
n_assets: int = 5,
n_bars: int = 500,
seed: int = 42,
) -> dict[str, np.ndarray]:
"""Generate synthetic asset prices with known cointegration structure.
Creates:
- Asset A, B: cointegrated pair (strong mean-reverting spread)
- Asset C: partially correlated with A (weaker cointegration)
- Asset D: trending (random walk with drift)
- Asset E: independent random walk
Args:
n_assets: Number of assets (uses first 5 names).
n_bars: Number of data points per asset.
seed: Random seed for reproducibility.
Returns:
Dict mapping asset name to price array.
"""
rng = np.random.default_rng(seed)
names = ["SOL", "ETH", "BTC", "BONK", "JUP"][:n_assets]
assets: dict[str, np.ndarray] = {}
# Common factor (market)
market = np.cumsum(rng.normal(0.001, 0.02, n_bars))
# Asset A (SOL): market + own noise
sol_noise = np.cumsum(rng.normal(0, 0.01, n_bars))
assets["SOL"] = 100 * np.exp(0.5 * market + 0.5 * sol_noise)
# Asset B (ETH): cointegrated with SOL (similar market exposure + mean-reverting spread)
spread_noise = np.zeros(n_bars)
spread_noise[0] = rng.normal(0, 0.01)
for i in range(1, n_bars):
spread_noise[i] = 0.9 * spread_noise[i - 1] + rng.normal(0, 0.01)
assets["ETH"] = 2000 * np.exp(0.5 * market + 0.5 * sol_noise + spread_noise)
# Asset C (BTC): partially correlated with market
btc_noise = np.cumsum(rng.normal(0, 0.015, n_bars))
assets["BTC"] = 50000 * np.exp(0.3 * market + 0.7 * btc_noise)
# Asset D (BONK): trending / momentum
drift = np.cumsum(rng.normal(0.002, 0.03, n_bars))
assets["BONK"] = 0.00001 * np.exp(drift)
# Asset E (JUP): independent random walk
jup_walk = np.cumsum(rng.normal(0, 0.02, n_bars))
assets["JUP"] = 1.0 * np.exp(jup_walk)
return assets
# ── Report Printing ────────────────────────────────────────────────
def print_pairs_report(
results: list[dict],
top_n: int = 3,
) -> None:
"""Print formatted pairs scanning report.
Args:
results: List of pair analysis results from analyze_pair().
top_n: Number of top pairs to show in detail.
"""
# Sort by quality score descending
results.sort(key=lambda x: x["quality_score"], reverse=True)
print("=" * 80)
print(" PAIRS SCANNER — MEAN-REVERSION RANKING")
print("=" * 80)
# Summary table
print(f"\n{'Rank':<5} {'Pair':<12} {'Quality':<9} {'Coint?':<8} "
f"{'Hurst':<7} {'Half-Life':<11} {'Z-Score':<9} {'Signal'}")
print("-" * 80)
for i, r in enumerate(results, 1):
coint_flag = "YES" if r["is_cointegrated"] else "no"
hl_str = f"{r['half_life']:.1f}" if r["half_life"] > 0 else "N/A"
print(f"{i:<5} {r['pair']:<12} {r['quality_score']:<9.1f} {coint_flag:<8} "
f"{r['hurst']:<7.3f} {hl_str:<11} {r['current_z']:+.3f} {r['signal']}")
# Detailed view of top pairs
print(f"\n{'=' * 80}")
print(f" TOP {top_n} PAIRS — DETAILED ANALYSIS")
print(f"{'=' * 80}")
for i, r in enumerate(results[:top_n], 1):
print(f"\n--- #{i}: {r['pair']} (Quality: {r['quality_score']:.1f}/100) ---")
print(f" Correlation:")
print(f" Pearson: {r['pearson_r']:+.4f}")
print(f" Spearman: {r['spearman_r']:+.4f}")
print(f" Cointegration (Engle-Granger):")
print(f" Direction: {r['dependent']} = f({r['independent']})")
print(f" Hedge ratio: {r['hedge_ratio']:.6f}")
print(f" ADF t-stat: {r['coint_t']:.4f}")
print(f" ADF p-value: {r['coint_p']:.4f}")
print(f" Cointegrated: {'YES' if r['is_cointegrated'] else 'NO'}")
print(f" Spread Properties:")
print(f" Hurst exponent: {r['hurst']:.4f} "
f"({'mean-reverting' if r['hurst'] < 0.5 else 'trending'})")
hl_str = f"{r['half_life']:.1f} bars" if r["half_life"] > 0 else "N/A"
print(f" Half-life: {hl_str}")
print(f" Lookback window: {r['lookback']} bars")
print(f" Current Status:")
print(f" Z-score: {r['current_z']:+.4f}")
print(f" Signal: {r['signal']}")
if r["half_life"] > 0 and r["is_cointegrated"]:
print(f" Suggested Parameters:")
print(f" Lookback: {r['lookback']} bars")
print(f" Entry z: +/-2.0")
print(f" Exit z: 0.0")
print(f" Max hold: {int(3 * r['half_life'])} bars")
# Overall summary
cointegrated_count = sum(1 for r in results if r["is_cointegrated"])
mr_count = sum(1 for r in results if r["hurst"] < 0.5)
print(f"\n{'=' * 80}")
print(" SUMMARY")
print(f"{'=' * 80}")
print(f" Total pairs scanned: {len(results)}")
print(f" Cointegrated pairs: {cointegrated_count}")
print(f" Mean-reverting spreads (H < 0.5): {mr_count}")
if results:
best = results[0]
print(f" Best pair: {best['pair']} (quality {best['quality_score']:.1f}/100)")
print("\n Note: This analysis is for informational purposes only.")
print(" It does not constitute financial advice or a trading recommendation.")
print(f"{'=' * 80}")
# ── Main ────────────────────────────────────────────────────────────
def main() -> None:
"""Entry point: parse arguments and run pairs scanner."""
parser = argparse.ArgumentParser(
description="Scan multiple assets for mean-reverting pairs."
)
parser.add_argument(
"--demo",
action="store_true",
help="Use synthetic data with known cointegration structure.",
)
parser.add_argument(
"--top",
type=int,
default=3,
help="Number of top pairs to show in detail (default: 3).",
)
args = parser.parse_args()
if not args.demo:
print("Currently only --demo mode is supported.")
print("Usage: python scripts/pairs_scanner.py --demo")
print("\nTo extend with live data, integrate with Birdeye or DexScreener API.")
sys.exit(1)
print("Running in DEMO mode with synthetic data.")
print(f"Generating {NUM_DEMO_ASSETS} assets with known cointegration structure.\n")
print(" SOL, ETH: Cointegrated pair (shared market factor + MR spread)")
print(" BTC: Partially correlated with SOL")
print(" BONK: Trending (momentum, not mean-reverting)")
print(" JUP: Independent random walk\n")
assets = generate_demo_assets(
n_assets=NUM_DEMO_ASSETS,
n_bars=DEMO_BARS,
seed=SEED,
)
# Test all pairs
names = list(assets.keys())
pairs = list(itertools.combinations(names, 2))
print(f"Testing {len(pairs)} pairs...\n")
results: list[dict] = []
for name_a, name_b in pairs:
result = analyze_pair(name_a, name_b, assets[name_a], assets[name_b])
if result is not None:
results.append(result)
print(f" {name_a}/{name_b}: quality={result['quality_score']:.1f}, "
f"H={result['hurst']:.3f}, "
f"coint={'Y' if result['is_cointegrated'] else 'N'}")
print()
print_pairs_report(results, top_n=args.top)
if __name__ == "__main__":
main()