Crypto Momentum Strategy Backtest: From Signal Generation to Risk Control
Crypto Momentum Strategy Backtest: From Signal Generation to Risk Control
Momentum strategies exploit the tendency of winning assets to keep winning. In crypto, where trends can persist for months, momentum effects are particularly strong.
The Momentum Framework
import pandas as pd
import numpy as np
from typing import List, Dict
class MomentumStrategy:
def __init__(
self,
lookback: int = 30, # Signal lookback (days)
holding: int = 7, # Holding period (days)
top_n: int = 3, # Long top N assets
stop_loss_pct: float = 0.10, # 10% stop loss
position_size_method: str = "equal", # or "vol_parity"
):
self.lookback = lookback
self.holding = holding
self.top_n = top_n
self.stop_loss_pct = stop_loss_pct
self.position_size_method = position_size_method
def generate_signals(
self, prices: pd.DataFrame
) -> pd.DataFrame:
"""
Generate momentum signals for a universe of assets.
prices: DataFrame with columns = asset names,
index = dates, values = prices
"""
# Calculate momentum score (return over lookback)
momentum = prices.pct_change(self.lookback)
# Rank assets by momentum each day
ranks = momentum.rank(axis=1, ascending=False)
# Signal: 1 for top_n, 0 otherwise
signals = (ranks <= self.top_n).astype(int)
return signals
def apply_position_sizing(
self, signals: pd.DataFrame, prices: pd.DataFrame
) -> pd.DataFrame:
"""Convert signals to position weights."""
if self.position_size_method == "equal":
# Equal weight across selected assets
n_selected = signals.sum(axis=1).replace(0, 1)
weights = signals.div(n_selected, axis=0)
elif self.position_size_method == "vol_parity":
# Inverse volatility weighting
vol = prices.pct_change().rolling(20).std()
inv_vol = (1 / vol).replace(
[np.inf, -np.inf], 0
).fillna(0)
weighted = signals * inv_vol
total = weighted.sum(axis=1).replace(0, 1)
weights = weighted.div(total, axis=0)
return weights
def backtest(
self, prices: pd.DataFrame
) -> Dict:
"""Run full backtest with risk management."""
signals = self.generate_signals(prices)
weights = self.apply_position_sizing(signals, prices)
# Calculate returns
asset_returns = prices.pct_change()
strategy_returns = (weights.shift(1) * asset_returns).sum(
axis=1
)
# Apply stop-loss
cumulative = (1 + strategy_returns).cumprod()
peak = cumulative.cummax()
drawdown = (cumulative - peak) / peak
# When drawdown exceeds stop_loss, go flat
stopped = drawdown < -self.stop_loss_pct
strategy_returns[stopped] = 0
# Rebalance signal (every holding period)
rebal_mask = np.arange(len(strategy_returns)) % self.holding == 0
# (Simplified: in production, hold positions for full period)
# Performance metrics
total_return = (
(1 + strategy_returns).prod() - 1
) * 100
annual_return = (
(1 + strategy_returns).prod()
** (365 / len(strategy_returns)) - 1
) * 100
volatility = strategy_returns.std() * np.sqrt(365) * 100
sharpe = (
annual_return / volatility
) if volatility > 0 else 0
max_dd = drawdown.min() * 100
win_rate = (
(strategy_returns > 0).sum()
/ (strategy_returns != 0).sum()
) * 100
return {
'total_return_pct': round(total_return, 2),
'annual_return_pct': round(annual_return, 2),
'volatility_pct': round(volatility, 2),
'sharpe_ratio': round(sharpe, 2),
'max_drawdown_pct': round(max_dd, 2),
'win_rate_pct': round(win_rate, 1),
'total_trades': int(
signals.diff().abs().sum().sum() / 2
),
'stop_loss_triggers': int(stopped.sum()),
}
# Usage
# prices = pd.DataFrame with BTC, ETH, SOL, etc. as columns
# strategy = MomentumStrategy(
# lookback=14, holding=7, top_n=3,
# stop_loss_pct=0.15, position_size_method="vol_parity"
# )
# results = strategy.backtest(prices)
Walk-Forward Optimization
def walk_forward_test(
prices: pd.DataFrame,
train_window: int = 180,
test_window: int = 30,
) -> List[Dict]:
"""Walk-forward optimization to avoid overfitting."""
results = []
total_days = len(prices)
for start in range(0, total_days - train_window - test_window,
test_window):
train = prices.iloc[start:start + train_window]
test = prices.iloc[
start + train_window:
start + train_window + test_window
]
# Optimize on training data
best_sharpe = -np.inf
best_params = {}
for lookback in [7, 14, 30, 60]:
for top_n in [1, 3, 5]:
strat = MomentumStrategy(
lookback=lookback, top_n=top_n
)
train_result = strat.backtest(train)
if train_result['sharpe_ratio'] > best_sharpe:
best_sharpe = train_result['sharpe_ratio']
best_params = {
'lookback': lookback, 'top_n': top_n
}
# Test with best params
test_strat = MomentumStrategy(**best_params)
test_result = test_strat.backtest(test)
test_result['period'] = f"{test.index[0]} to {test.index[-1]}"
test_result['params'] = best_params
results.append(test_result)
return results
This momentum framework is the type of strategy that trades well on ClawDUX — the platform's AI verification engine can independently reproduce backtest results to validate the Sharpe ratio and drawdown claims before listing.
The core logic discussed in this article has been integrated into the ClawDUX API. Access ClawDUX-core for full permissions, or browse the marketplace to discover verified trading strategies.