Crypto Quantitative Trading Part 2: Advanced Strategies and Backtesting Framework

In Part 1, we established the fundamentals of cryptocurrency quantitative trading and built data collection infrastructure. Now we’ll develop actual trading strategies and build a robust backtesting framework to evaluate their performance before risking real capital.

Why Backtesting is Critical

The #1 Rule of Quantitative Trading:

"Never trade a strategy with real money
until you've thoroughly backtested it."

Why?
- Most strategies that sound good fail in practice
- Backtesting reveals hidden risks and edge cases
- Proper testing prevents catastrophic losses
- Statistical validation builds confidence

The Danger of Overfitting

 1# Example: Overfitted vs Robust Strategy
 2
 3# ❌ Overfitted (curve-fit to historical data)
 4def overfitted_strategy():
 5    """
 6    Buy when:
 7    - RSI crosses 31.7 (not 30 or 32)
 8    - MACD histogram > 42.3
 9    - Hour of day is 14 or 23
10    - Day of month is prime number
11
12    This likely worked great on historical data
13    but will fail miserably going forward.
14    """
15    pass
16
17# ✅ Robust (based on sound principles)
18def robust_strategy():
19    """
20    Buy when:
21    - Oversold (RSI < 30)
22    - Positive momentum (MACD positive)
23    - Simple, interpretable rules
24    - Works across multiple timeframes
25
26    This strategy has economic rationale
27    and is likely to generalize to new data.
28    """
29    pass

Building a Backtesting Framework

Core Architecture

  1from dataclasses import dataclass
  2from typing import List, Dict, Optional
  3from enum import Enum
  4import pandas as pd
  5import numpy as np
  6from datetime import datetime
  7
  8class OrderSide(Enum):
  9    BUY = "buy"
 10    SELL = "sell"
 11
 12class OrderType(Enum):
 13    MARKET = "market"
 14    LIMIT = "limit"
 15
 16@dataclass
 17class Order:
 18    """Represents a trading order"""
 19    timestamp: datetime
 20    symbol: str
 21    side: OrderSide
 22    order_type: OrderType
 23    quantity: float
 24    price: Optional[float] = None  # For limit orders
 25    filled_price: Optional[float] = None
 26    filled_quantity: float = 0.0
 27    status: str = "pending"  # pending, filled, cancelled
 28    commission: float = 0.0
 29
 30@dataclass
 31class Position:
 32    """Represents a trading position"""
 33    symbol: str
 34    quantity: float
 35    entry_price: float
 36    entry_time: datetime
 37    current_price: float = 0.0
 38    unrealized_pnl: float = 0.0
 39    realized_pnl: float = 0.0
 40
 41@dataclass
 42class Trade:
 43    """Represents a completed trade"""
 44    entry_time: datetime
 45    exit_time: datetime
 46    symbol: str
 47    side: OrderSide
 48    entry_price: float
 49    exit_price: float
 50    quantity: float
 51    pnl: float
 52    pnl_percent: float
 53    commission: float
 54    duration: float  # hours
 55
 56class BacktestEngine:
 57    """
 58    Core backtesting engine
 59    """
 60
 61    def __init__(self, initial_capital: float = 100000,
 62                 commission_rate: float = 0.001,
 63                 slippage: float = 0.0005):
 64        """
 65        Initialize backtesting engine
 66
 67        initial_capital: Starting capital in USD
 68        commission_rate: Commission per trade (0.001 = 0.1%)
 69        slippage: Estimated slippage (0.0005 = 0.05%)
 70        """
 71        self.initial_capital = initial_capital
 72        self.capital = initial_capital
 73        self.commission_rate = commission_rate
 74        self.slippage = slippage
 75
 76        self.positions: Dict[str, Position] = {}
 77        self.orders: List[Order] = []
 78        self.trades: List[Trade] = []
 79        self.equity_curve: List[Dict] = []
 80
 81        self.current_time = None
 82        self.current_prices: Dict[str, float] = {}
 83
 84    def update_time(self, timestamp: datetime, prices: Dict[str, float]):
 85        """Update current time and prices"""
 86        self.current_time = timestamp
 87        self.current_prices = prices
 88
 89        # Update position values
 90        for symbol, position in self.positions.items():
 91            if symbol in prices:
 92                position.current_price = prices[symbol]
 93                position.unrealized_pnl = (
 94                    (position.current_price - position.entry_price) *
 95                    position.quantity
 96                )
 97
 98        # Record equity
 99        total_equity = self.calculate_total_equity()
100        self.equity_curve.append({
101            'timestamp': timestamp,
102            'capital': self.capital,
103            'position_value': total_equity - self.capital,
104            'total_equity': total_equity,
105        })
106
107    def calculate_total_equity(self) -> float:
108        """Calculate total portfolio equity"""
109        position_value = sum(
110            pos.quantity * pos.current_price
111            for pos in self.positions.values()
112        )
113        return self.capital + position_value
114
115    def place_order(self, symbol: str, side: OrderSide,
116                    quantity: float, order_type: OrderType = OrderType.MARKET,
117                    limit_price: Optional[float] = None) -> Order:
118        """
119        Place an order
120
121        Returns: Order object
122        """
123        order = Order(
124            timestamp=self.current_time,
125            symbol=symbol,
126            side=side,
127            order_type=order_type,
128            quantity=quantity,
129            price=limit_price,
130        )
131
132        # Execute market orders immediately
133        if order_type == OrderType.MARKET:
134            self._execute_order(order)
135
136        self.orders.append(order)
137        return order
138
139    def _execute_order(self, order: Order):
140        """Execute an order"""
141        if order.symbol not in self.current_prices:
142            order.status = "rejected"
143            return
144
145        # Calculate execution price with slippage
146        current_price = self.current_prices[order.symbol]
147        if order.side == OrderSide.BUY:
148            execution_price = current_price * (1 + self.slippage)
149        else:
150            execution_price = current_price * (1 - self.slippage)
151
152        # Calculate costs
153        order_value = order.quantity * execution_price
154        commission = order_value * self.commission_rate
155
156        # Check if we have enough capital (for buy orders)
157        if order.side == OrderSide.BUY:
158            total_cost = order_value + commission
159            if total_cost > self.capital:
160                order.status = "rejected"
161                return
162
163        # Execute the order
164        order.filled_price = execution_price
165        order.filled_quantity = order.quantity
166        order.commission = commission
167        order.status = "filled"
168
169        # Update positions
170        if order.side == OrderSide.BUY:
171            self._open_position(order)
172        else:
173            self._close_position(order)
174
175    def _open_position(self, order: Order):
176        """Open or add to a position"""
177        if order.symbol in self.positions:
178            # Add to existing position (average entry price)
179            pos = self.positions[order.symbol]
180            total_quantity = pos.quantity + order.filled_quantity
181            total_cost = (
182                pos.entry_price * pos.quantity +
183                order.filled_price * order.filled_quantity
184            )
185            pos.entry_price = total_cost / total_quantity
186            pos.quantity = total_quantity
187        else:
188            # Create new position
189            self.positions[order.symbol] = Position(
190                symbol=order.symbol,
191                quantity=order.filled_quantity,
192                entry_price=order.filled_price,
193                entry_time=order.timestamp,
194                current_price=order.filled_price,
195            )
196
197        # Update capital
198        self.capital -= (order.filled_quantity * order.filled_price + order.commission)
199
200    def _close_position(self, order: Order):
201        """Close or reduce a position"""
202        if order.symbol not in self.positions:
203            order.status = "rejected"
204            return
205
206        pos = self.positions[order.symbol]
207
208        # Calculate P&L
209        pnl = (order.filled_price - pos.entry_price) * order.filled_quantity
210        pnl -= order.commission
211
212        # Create trade record
213        trade = Trade(
214            entry_time=pos.entry_time,
215            exit_time=order.timestamp,
216            symbol=order.symbol,
217            side=OrderSide.BUY,  # Position side
218            entry_price=pos.entry_price,
219            exit_price=order.filled_price,
220            quantity=order.filled_quantity,
221            pnl=pnl,
222            pnl_percent=(order.filled_price / pos.entry_price - 1) * 100,
223            commission=order.commission,
224            duration=(order.timestamp - pos.entry_time).total_seconds() / 3600,
225        )
226        self.trades.append(trade)
227
228        # Update capital
229        self.capital += (order.filled_quantity * order.filled_price - order.commission)
230
231        # Update or remove position
232        pos.quantity -= order.filled_quantity
233        pos.realized_pnl += pnl
234
235        if pos.quantity <= 0:
236            del self.positions[order.symbol]
237
238    def get_position(self, symbol: str) -> Optional[Position]:
239        """Get current position for a symbol"""
240        return self.positions.get(symbol)
241
242    def get_equity_curve(self) -> pd.DataFrame:
243        """Get equity curve as DataFrame"""
244        return pd.DataFrame(self.equity_curve).set_index('timestamp')
245
246    def get_trades_df(self) -> pd.DataFrame:
247        """Get all trades as DataFrame"""
248        if not self.trades:
249            return pd.DataFrame()
250
251        return pd.DataFrame([vars(t) for t in self.trades])

Strategy Base Class

 1from abc import ABC, abstractmethod
 2
 3class TradingStrategy(ABC):
 4    """
 5    Base class for trading strategies
 6    """
 7
 8    def __init__(self, name: str):
 9        self.name = name
10        self.params = {}
11
12    @abstractmethod
13    def generate_signals(self, df: pd.DataFrame) -> pd.Series:
14        """
15        Generate trading signals
16
17        Returns: Series with values -1 (sell), 0 (hold), 1 (buy)
18        """
19        pass
20
21    @abstractmethod
22    def calculate_position_size(self, signal: float, capital: float,
23                                current_price: float) -> float:
24        """
25        Calculate position size for a signal
26
27        Returns: Position size (quantity)
28        """
29        pass
30
31    def set_params(self, **kwargs):
32        """Update strategy parameters"""
33        self.params.update(kwargs)
34
35    def get_description(self) -> str:
36        """Get strategy description"""
37        return f"{self.name} with params: {self.params}"

Trading Strategy #1: Trend Following

Moving Average Crossover Strategy

  1class MovingAverageCrossover(TradingStrategy):
  2    """
  3    Classic trend following strategy using moving average crossovers
  4
  5    Buy: Fast MA crosses above Slow MA
  6    Sell: Fast MA crosses below Slow MA
  7    """
  8
  9    def __init__(self, fast_period=20, slow_period=50, atr_period=14):
 10        super().__init__("MA Crossover")
 11        self.params = {
 12            'fast_period': fast_period,
 13            'slow_period': slow_period,
 14            'atr_period': atr_period,
 15        }
 16
 17    def generate_signals(self, df: pd.DataFrame) -> pd.Series:
 18        """Generate signals based on MA crossover"""
 19        fast = df['close'].ewm(span=self.params['fast_period']).mean()
 20        slow = df['close'].ewm(span=self.params['slow_period']).mean()
 21
 22        # Generate crossover signals
 23        signals = pd.Series(0, index=df.index)
 24
 25        # Buy signal: Fast crosses above Slow
 26        signals[(fast > slow) & (fast.shift() <= slow.shift())] = 1
 27
 28        # Sell signal: Fast crosses below Slow
 29        signals[(fast < slow) & (fast.shift() >= slow.shift())] = -1
 30
 31        return signals
 32
 33    def calculate_position_size(self, signal: float, capital: float,
 34                                current_price: float) -> float:
 35        """
 36        Calculate position size using fixed fractional method
 37
 38        Risk 2% of capital per trade
 39        """
 40        if signal == 0:
 41            return 0.0
 42
 43        risk_per_trade = capital * 0.02
 44        position_value = capital * 0.95  # Use 95% of capital max
 45
 46        # Calculate quantity
 47        quantity = min(
 48            position_value / current_price,
 49            risk_per_trade / (current_price * 0.02)  # 2% stop loss
 50        )
 51
 52        return quantity
 53
 54# Example usage
 55def backtest_ma_crossover(df: pd.DataFrame, initial_capital=100000):
 56    """
 57    Backtest MA Crossover strategy
 58    """
 59    # Initialize
 60    engine = BacktestEngine(initial_capital=initial_capital)
 61    strategy = MovingAverageCrossover(fast_period=20, slow_period=50)
 62
 63    # Generate signals
 64    signals = strategy.generate_signals(df)
 65
 66    # Run backtest
 67    position = None
 68
 69    for timestamp, row in df.iterrows():
 70        # Update engine
 71        engine.update_time(timestamp, {'BTC/USDT': row['close']})
 72
 73        signal = signals.loc[timestamp]
 74
 75        # Get current position
 76        current_pos = engine.get_position('BTC/USDT')
 77
 78        # Execute signals
 79        if signal == 1 and current_pos is None:
 80            # Buy signal and no position
 81            quantity = strategy.calculate_position_size(
 82                signal,
 83                engine.capital,
 84                row['close']
 85            )
 86            engine.place_order('BTC/USDT', OrderSide.BUY, quantity)
 87
 88        elif signal == -1 and current_pos is not None:
 89            # Sell signal and have position
 90            engine.place_order(
 91                'BTC/USDT',
 92                OrderSide.SELL,
 93                current_pos.quantity
 94            )
 95
 96    return engine
 97
 98# Run backtest
 99from crypto_quantitative_trading_part1_fundamentals import CryptoDataCollector
100
101collector = CryptoDataCollector('binance')
102df = collector.fetch_historical_data('BTC/USDT', '1h', '2023-01-01', '2024-01-01')
103
104# Add indicators
105from crypto_quantitative_trading_part1_fundamentals import TrendIndicators
106df['sma_20'] = TrendIndicators.sma(df['close'], 20)
107df['sma_50'] = TrendIndicators.sma(df['close'], 50)
108
109# Backtest
110result = backtest_ma_crossover(df)
111equity = result.get_equity_curve()
112trades = result.get_trades_df()
113
114print(f"\nBacktest Results:")
115print(f"Initial Capital: ${result.initial_capital:,.2f}")
116print(f"Final Equity: ${result.calculate_total_equity():,.2f}")
117print(f"Total Return: {(result.calculate_total_equity() / result.initial_capital - 1) * 100:.2f}%")
118print(f"Number of Trades: {len(trades)}")

Trend Following with ADX Filter

 1class ADXTrendFollowing(TradingStrategy):
 2    """
 3    Enhanced trend following with ADX filter
 4
 5    Only trade when trend is strong (ADX > threshold)
 6    """
 7
 8    def __init__(self, fast_period=12, slow_period=26, adx_period=14, adx_threshold=25):
 9        super().__init__("ADX Trend Following")
10        self.params = {
11            'fast_period': fast_period,
12            'slow_period': slow_period,
13            'adx_period': adx_period,
14            'adx_threshold': adx_threshold,
15        }
16
17    def generate_signals(self, df: pd.DataFrame) -> pd.Series:
18        """Generate signals with ADX filter"""
19        from crypto_quantitative_trading_part1_fundamentals import TrendIndicators
20
21        # Calculate indicators
22        fast = df['close'].ewm(span=self.params['fast_period']).mean()
23        slow = df['close'].ewm(span=self.params['slow_period']).mean()
24        adx, plus_di, minus_di = TrendIndicators.adx(
25            df['high'], df['low'], df['close'], self.params['adx_period']
26        )
27
28        # Generate signals
29        signals = pd.Series(0, index=df.index)
30
31        # Only trade when ADX indicates strong trend
32        strong_trend = adx > self.params['adx_threshold']
33
34        # Buy: Fast > Slow AND ADX > threshold AND +DI > -DI
35        buy_condition = (
36            (fast > slow) &
37            (fast.shift() <= slow.shift()) &
38            strong_trend &
39            (plus_di > minus_di)
40        )
41        signals[buy_condition] = 1
42
43        # Sell: Fast < Slow OR ADX weakening
44        sell_condition = (
45            (fast < slow) &
46            (fast.shift() >= slow.shift())
47        ) | (adx < self.params['adx_threshold'] * 0.7)
48
49        signals[sell_condition] = -1
50
51        return signals
52
53    def calculate_position_size(self, signal: float, capital: float,
54                                current_price: float) -> float:
55        """Kelly Criterion based position sizing"""
56        if signal == 0:
57            return 0.0
58
59        # Simplified Kelly: f = (p * b - q) / b
60        # Assuming 55% win rate, 1.5:1 reward:risk
61        win_rate = 0.55
62        reward_risk = 1.5
63
64        kelly_fraction = (win_rate * reward_risk - (1 - win_rate)) / reward_risk
65        kelly_fraction = max(0, min(kelly_fraction, 0.25))  # Cap at 25%
66
67        position_value = capital * kelly_fraction
68        quantity = position_value / current_price
69
70        return quantity

Trading Strategy #2: Mean Reversion

Bollinger Band Mean Reversion

 1class BollingerMeanReversion(TradingStrategy):
 2    """
 3    Mean reversion strategy using Bollinger Bands
 4
 5    Buy: Price touches lower band (oversold)
 6    Sell: Price reaches middle band or upper band
 7    """
 8
 9    def __init__(self, bb_period=20, bb_std=2.0, rsi_period=14):
10        super().__init__("Bollinger Mean Reversion")
11        self.params = {
12            'bb_period': bb_period,
13            'bb_std': bb_std,
14            'rsi_period': rsi_period,
15        }
16
17    def generate_signals(self, df: pd.DataFrame) -> pd.Series:
18        """Generate mean reversion signals"""
19        from crypto_quantitative_trading_part1_fundamentals import (
20            VolatilityIndicators, MomentumIndicators
21        )
22
23        # Calculate indicators
24        bb_middle, bb_upper, bb_lower = VolatilityIndicators.bollinger_bands(
25            df['close'],
26            self.params['bb_period'],
27            self.params['bb_std']
28        )
29        rsi = MomentumIndicators.rsi(df['close'], self.params['rsi_period'])
30
31        # Calculate Bollinger Band position
32        bb_position = (df['close'] - bb_lower) / (bb_upper - bb_lower)
33
34        signals = pd.Series(0, index=df.index)
35
36        # Buy: Price at lower band AND RSI oversold
37        buy_condition = (
38            (bb_position < 0.1) &  # Near lower band
39            (rsi < 30) &  # Oversold
40            (df['close'] > df['close'].shift())  # Starting to bounce
41        )
42        signals[buy_condition] = 1
43
44        # Sell: Price at middle band or upper band
45        sell_condition = (
46            (bb_position > 0.5) |  # Above middle
47            (rsi > 70)  # Overbought
48        )
49        signals[sell_condition] = -1
50
51        return signals
52
53    def calculate_position_size(self, signal: float, capital: float,
54                                current_price: float) -> float:
55        """
56        Fixed fractional position sizing
57        Risk 1% per trade (mean reversion is riskier)
58        """
59        if signal == 0:
60            return 0.0
61
62        risk_per_trade = capital * 0.01
63        position_value = capital * 0.5  # Max 50% of capital
64
65        quantity = min(
66            position_value / current_price,
67            risk_per_trade / (current_price * 0.03)  # 3% stop loss
68        )
69
70        return quantity

RSI Mean Reversion with Volume Confirmation

 1class RSIVolumeMeanReversion(TradingStrategy):
 2    """
 3    Mean reversion using RSI with volume confirmation
 4
 5    Buy: RSI oversold + volume spike (capitulation)
 6    Sell: RSI overbought + volume spike (exhaustion)
 7    """
 8
 9    def __init__(self, rsi_period=14, volume_period=20, volume_threshold=1.5):
10        super().__init__("RSI Volume Mean Reversion")
11        self.params = {
12            'rsi_period': rsi_period,
13            'volume_period': volume_period,
14            'volume_threshold': volume_threshold,
15        }
16
17    def generate_signals(self, df: pd.DataFrame) -> pd.Series:
18        """Generate RSI + Volume signals"""
19        from crypto_quantitative_trading_part1_fundamentals import MomentumIndicators
20
21        # Calculate RSI
22        rsi = MomentumIndicators.rsi(df['close'], self.params['rsi_period'])
23
24        # Calculate volume ratio
25        avg_volume = df['volume'].rolling(self.params['volume_period']).mean()
26        volume_ratio = df['volume'] / avg_volume
27
28        signals = pd.Series(0, index=df.index)
29
30        # Buy: RSI < 30 + Volume spike (panic selling)
31        buy_condition = (
32            (rsi < 30) &
33            (volume_ratio > self.params['volume_threshold'])
34        )
35        signals[buy_condition] = 1
36
37        # Sell: RSI > 70 + Volume spike (FOMO buying)
38        sell_condition = (
39            (rsi > 70) &
40            (volume_ratio > self.params['volume_threshold'])
41        )
42        signals[sell_condition] = -1
43
44        return signals
45
46    def calculate_position_size(self, signal: float, capital: float,
47                                current_price: float) -> float:
48        """Scale position by signal strength"""
49        if signal == 0:
50            return 0.0
51
52        base_size = capital * 0.02 / current_price
53        return base_size

Trading Strategy #3: Pairs Trading

Bitcoin-Ethereum Pairs Strategy

 1class BTCETHPairsTrad(TradingStrategy):
 2    """
 3    Statistical arbitrage between BTC and ETH
 4
 5    Trade the spread between BTC/ETH ratio and its mean
 6    """
 7
 8    def __init__(self, lookback_period=30, entry_z_score=2.0, exit_z_score=0.5):
 9        super().__init__("BTC-ETH Pairs Trading")
10        self.params = {
11            'lookback_period': lookback_period,
12            'entry_z_score': entry_z_score,
13            'exit_z_score': exit_z_score,
14        }
15
16    def generate_signals(self, btc_df: pd.DataFrame, eth_df: pd.DataFrame) -> Dict:
17        """
18        Generate pairs trading signals
19
20        Returns: Dict with 'btc_signal' and 'eth_signal'
21        """
22        # Calculate BTC/ETH ratio
23        ratio = btc_df['close'] / eth_df['close']
24
25        # Calculate rolling statistics
26        period = self.params['lookback_period']
27        ratio_mean = ratio.rolling(period).mean()
28        ratio_std = ratio.rolling(period).std()
29
30        # Calculate z-score
31        z_score = (ratio - ratio_mean) / ratio_std
32
33        # Generate signals
34        btc_signals = pd.Series(0, index=btc_df.index)
35        eth_signals = pd.Series(0, index=eth_df.index)
36
37        # When z-score is high: BTC overvalued, ETH undervalued
38        # Short BTC, Long ETH
39        overvalued = z_score > self.params['entry_z_score']
40        btc_signals[overvalued] = -1
41        eth_signals[overvalued] = 1
42
43        # When z-score is low: BTC undervalued, ETH overvalued
44        # Long BTC, Short ETH
45        undervalued = z_score < -self.params['entry_z_score']
46        btc_signals[undervalued] = 1
47        eth_signals[undervalued] = -1
48
49        # Exit when z-score returns to mean
50        exit_long = (z_score < self.params['exit_z_score']) & (z_score.shift() >= self.params['exit_z_score'])
51        exit_short = (z_score > -self.params['exit_z_score']) & (z_score.shift() <= -self.params['exit_z_score'])
52
53        btc_signals[exit_long | exit_short] = 0
54        eth_signals[exit_long | exit_short] = 0
55
56        return {
57            'btc_signal': btc_signals,
58            'eth_signal': eth_signals,
59            'z_score': z_score,
60        }
61
62    def calculate_position_size(self, signal: float, capital: float,
63                                current_price: float) -> float:
64        """Equal dollar weighting for pairs"""
65        if signal == 0:
66            return 0.0
67
68        # Use 50% of capital for each leg
69        position_value = capital * 0.5
70        quantity = position_value / current_price
71
72        return quantity

Risk Management

Stop Loss and Take Profit

  1class RiskManager:
  2    """
  3    Comprehensive risk management system
  4    """
  5
  6    def __init__(self, max_position_size=0.3, max_daily_loss=0.02,
  7                 stop_loss=0.05, take_profit=0.15):
  8        """
  9        max_position_size: Maximum fraction of capital per position
 10        max_daily_loss: Maximum daily loss (fraction of capital)
 11        stop_loss: Stop loss percentage (0.05 = 5%)
 12        take_profit: Take profit percentage (0.15 = 15%)
 13        """
 14        self.max_position_size = max_position_size
 15        self.max_daily_loss = max_daily_loss
 16        self.stop_loss = stop_loss
 17        self.take_profit = take_profit
 18
 19        self.daily_pnl = 0.0
 20        self.daily_start_capital = 0.0
 21        self.last_reset_date = None
 22
 23    def check_stop_loss(self, position: Position) -> bool:
 24        """Check if position hit stop loss"""
 25        if position.quantity == 0:
 26            return False
 27
 28        pnl_percent = (position.current_price / position.entry_price - 1)
 29        return pnl_percent <= -self.stop_loss
 30
 31    def check_take_profit(self, position: Position) -> bool:
 32        """Check if position hit take profit"""
 33        if position.quantity == 0:
 34            return False
 35
 36        pnl_percent = (position.current_price / position.entry_price - 1)
 37        return pnl_percent >= self.take_profit
 38
 39    def check_daily_loss_limit(self, current_capital: float,
 40                               current_date: datetime) -> bool:
 41        """Check if daily loss limit reached"""
 42        # Reset daily tracking at start of new day
 43        if self.last_reset_date is None or current_date.date() != self.last_reset_date:
 44            self.daily_start_capital = current_capital
 45            self.daily_pnl = 0.0
 46            self.last_reset_date = current_date.date()
 47
 48        # Calculate daily loss
 49        daily_loss = (current_capital - self.daily_start_capital) / self.daily_start_capital
 50
 51        return daily_loss <= -self.max_daily_loss
 52
 53    def validate_position_size(self, quantity: float, price: float,
 54                               total_capital: float) -> float:
 55        """
 56        Validate and adjust position size
 57
 58        Returns: Adjusted quantity
 59        """
 60        position_value = quantity * price
 61        max_value = total_capital * self.max_position_size
 62
 63        if position_value > max_value:
 64            quantity = max_value / price
 65
 66        return quantity
 67
 68    def calculate_optimal_stop_loss(self, entry_price: float,
 69                                    atr: float, atr_multiplier: float = 2.0) -> float:
 70        """
 71        Calculate optimal stop loss based on ATR
 72
 73        atr: Average True Range
 74        atr_multiplier: Number of ATRs for stop
 75        """
 76        stop_distance = atr * atr_multiplier
 77        stop_price = entry_price - stop_distance
 78
 79        return stop_price
 80
 81# Enhanced backtesting with risk management
 82def backtest_with_risk_management(df: pd.DataFrame, strategy: TradingStrategy,
 83                                  initial_capital: float = 100000):
 84    """
 85    Backtest strategy with comprehensive risk management
 86    """
 87    engine = BacktestEngine(initial_capital=initial_capital)
 88    risk_manager = RiskManager(
 89        max_position_size=0.3,
 90        max_daily_loss=0.02,
 91        stop_loss=0.05,
 92        take_profit=0.15
 93    )
 94
 95    # Calculate ATR for stop loss
 96    from crypto_quantitative_trading_part1_fundamentals import VolatilityIndicators
 97    df['atr'] = VolatilityIndicators.atr(df['high'], df['low'], df['close'])
 98
 99    # Generate signals
100    signals = strategy.generate_signals(df)
101
102    # Run backtest
103    for timestamp, row in df.iterrows():
104        # Update engine
105        engine.update_time(timestamp, {'BTC/USDT': row['close']})
106
107        # Check daily loss limit
108        if risk_manager.check_daily_loss_limit(engine.capital, timestamp):
109            # Stop trading for the day
110            continue
111
112        current_pos = engine.get_position('BTC/USDT')
113
114        # Check stop loss / take profit
115        if current_pos is not None:
116            current_pos.current_price = row['close']
117
118            if risk_manager.check_stop_loss(current_pos):
119                # Hit stop loss - close position
120                engine.place_order('BTC/USDT', OrderSide.SELL, current_pos.quantity)
121                continue
122
123            if risk_manager.check_take_profit(current_pos):
124                # Hit take profit - close position
125                engine.place_order('BTC/USDT', OrderSide.SELL, current_pos.quantity)
126                continue
127
128        # Process signals
129        signal = signals.loc[timestamp]
130
131        if signal == 1 and current_pos is None:
132            # Buy signal
133            quantity = strategy.calculate_position_size(
134                signal, engine.capital, row['close']
135            )
136
137            # Validate position size
138            quantity = risk_manager.validate_position_size(
139                quantity, row['close'], engine.capital
140            )
141
142            if quantity > 0:
143                engine.place_order('BTC/USDT', OrderSide.BUY, quantity)
144
145        elif signal == -1 and current_pos is not None:
146            # Sell signal
147            engine.place_order('BTC/USDT', OrderSide.SELL, current_pos.quantity)
148
149    return engine

Performance Evaluation

Key Performance Metrics

  1class PerformanceAnalyzer:
  2    """
  3    Calculate comprehensive performance metrics
  4    """
  5
  6    @staticmethod
  7    def calculate_metrics(equity_curve: pd.DataFrame,
  8                         trades_df: pd.DataFrame,
  9                         initial_capital: float,
 10                         risk_free_rate: float = 0.04) -> Dict:
 11        """
 12        Calculate all performance metrics
 13
 14        risk_free_rate: Annual risk-free rate (default 4%)
 15        """
 16        if equity_curve.empty or trades_df.empty:
 17            return {}
 18
 19        # Total return
 20        final_equity = equity_curve['total_equity'].iloc[-1]
 21        total_return = (final_equity / initial_capital - 1) * 100
 22
 23        # Period
 24        days = (equity_curve.index[-1] - equity_curve.index[0]).days
 25        years = days / 365.25
 26
 27        # Annualized return
 28        annualized_return = ((final_equity / initial_capital) ** (1/years) - 1) * 100
 29
 30        # Returns series
 31        returns = equity_curve['total_equity'].pct_change().dropna()
 32
 33        # Volatility
 34        daily_vol = returns.std()
 35        annualized_vol = daily_vol * np.sqrt(365 * 24) * 100  # For hourly data
 36
 37        # Sharpe Ratio
 38        excess_return = annualized_return / 100 - risk_free_rate
 39        sharpe_ratio = excess_return / (annualized_vol / 100) if annualized_vol > 0 else 0
 40
 41        # Maximum Drawdown
 42        cumulative = (1 + returns).cumprod()
 43        running_max = cumulative.expanding().max()
 44        drawdown = (cumulative - running_max) / running_max
 45        max_drawdown = drawdown.min() * 100
 46
 47        # Win Rate
 48        winning_trades = len(trades_df[trades_df['pnl'] > 0])
 49        total_trades = len(trades_df)
 50        win_rate = (winning_trades / total_trades * 100) if total_trades > 0 else 0
 51
 52        # Average Win/Loss
 53        avg_win = trades_df[trades_df['pnl'] > 0]['pnl'].mean() if winning_trades > 0 else 0
 54        avg_loss = trades_df[trades_df['pnl'] < 0]['pnl'].mean() if total_trades - winning_trades > 0 else 0
 55
 56        # Profit Factor
 57        total_wins = trades_df[trades_df['pnl'] > 0]['pnl'].sum()
 58        total_losses = abs(trades_df[trades_df['pnl'] < 0]['pnl'].sum())
 59        profit_factor = total_wins / total_losses if total_losses > 0 else 0
 60
 61        # Expectancy
 62        expectancy = (win_rate / 100 * avg_win) - ((1 - win_rate / 100) * abs(avg_loss))
 63
 64        # Sortino Ratio (downside deviation)
 65        downside_returns = returns[returns < 0]
 66        downside_std = downside_returns.std() * np.sqrt(365 * 24)
 67        sortino_ratio = excess_return / downside_std if downside_std > 0 else 0
 68
 69        # Calmar Ratio
 70        calmar_ratio = (annualized_return / 100) / abs(max_drawdown / 100) if max_drawdown != 0 else 0
 71
 72        return {
 73            'initial_capital': initial_capital,
 74            'final_equity': final_equity,
 75            'total_return': total_return,
 76            'annualized_return': annualized_return,
 77            'annualized_volatility': annualized_vol,
 78            'sharpe_ratio': sharpe_ratio,
 79            'sortino_ratio': sortino_ratio,
 80            'calmar_ratio': calmar_ratio,
 81            'max_drawdown': max_drawdown,
 82            'total_trades': total_trades,
 83            'winning_trades': winning_trades,
 84            'losing_trades': total_trades - winning_trades,
 85            'win_rate': win_rate,
 86            'avg_win': avg_win,
 87            'avg_loss': avg_loss,
 88            'profit_factor': profit_factor,
 89            'expectancy': expectancy,
 90        }
 91
 92    @staticmethod
 93    def print_metrics(metrics: Dict):
 94        """Print metrics in readable format"""
 95        print("\n" + "="*60)
 96        print("PERFORMANCE METRICS")
 97        print("="*60)
 98
 99        print(f"\nCapital:")
100        print(f"  Initial: ${metrics['initial_capital']:,.2f}")
101        print(f"  Final: ${metrics['final_equity']:,.2f}")
102
103        print(f"\nReturns:")
104        print(f"  Total Return: {metrics['total_return']:.2f}%")
105        print(f"  Annualized Return: {metrics['annualized_return']:.2f}%")
106        print(f"  Annualized Volatility: {metrics['annualized_volatility']:.2f}%")
107
108        print(f"\nRisk-Adjusted Returns:")
109        print(f"  Sharpe Ratio: {metrics['sharpe_ratio']:.2f}")
110        print(f"  Sortino Ratio: {metrics['sortino_ratio']:.2f}")
111        print(f"  Calmar Ratio: {metrics['calmar_ratio']:.2f}")
112        print(f"  Maximum Drawdown: {metrics['max_drawdown']:.2f}%")
113
114        print(f"\nTrade Statistics:")
115        print(f"  Total Trades: {metrics['total_trades']}")
116        print(f"  Winning Trades: {metrics['winning_trades']}")
117        print(f"  Losing Trades: {metrics['losing_trades']}")
118        print(f"  Win Rate: {metrics['win_rate']:.2f}%")
119
120        print(f"\nPer-Trade Metrics:")
121        print(f"  Average Win: ${metrics['avg_win']:.2f}")
122        print(f"  Average Loss: ${metrics['avg_loss']:.2f}")
123        print(f"  Profit Factor: {metrics['profit_factor']:.2f}")
124        print(f"  Expectancy: ${metrics['expectancy']:.2f}")
125
126        print("="*60 + "\n")
127
128# Example usage
129result = backtest_with_risk_management(df, MovingAverageCrossover())
130equity = result.get_equity_curve()
131trades = result.get_trades_df()
132
133metrics = PerformanceAnalyzer.calculate_metrics(
134    equity, trades, result.initial_capital
135)
136PerformanceAnalyzer.print_metrics(metrics)

Complete Example: Strategy Comparison

 1def compare_strategies(df: pd.DataFrame, strategies: List[TradingStrategy],
 2                      initial_capital: float = 100000):
 3    """
 4    Compare multiple strategies side-by-side
 5    """
 6    results = {}
 7
 8    for strategy in strategies:
 9        print(f"\nBacktesting {strategy.name}...")
10
11        # Run backtest
12        engine = backtest_with_risk_management(df, strategy, initial_capital)
13
14        # Get results
15        equity = engine.get_equity_curve()
16        trades = engine.get_trades_df()
17
18        # Calculate metrics
19        metrics = PerformanceAnalyzer.calculate_metrics(
20            equity, trades, initial_capital
21        )
22
23        results[strategy.name] = {
24            'engine': engine,
25            'metrics': metrics,
26        }
27
28    # Print comparison
29    print("\n" + "="*80)
30    print("STRATEGY COMPARISON")
31    print("="*80)
32
33    # Create comparison DataFrame
34    comparison = pd.DataFrame({
35        name: result['metrics']
36        for name, result in results.items()
37    }).T
38
39    print(comparison[[
40        'total_return',
41        'annualized_return',
42        'sharpe_ratio',
43        'max_drawdown',
44        'win_rate',
45        'total_trades'
46    ]])
47
48    return results
49
50# Compare strategies
51strategies = [
52    MovingAverageCrossover(fast_period=20, slow_period=50),
53    ADXTrendFollowing(fast_period=12, slow_period=26),
54    BollingerMeanReversion(bb_period=20, bb_std=2.0),
55    RSIVolumeMeanReversion(rsi_period=14),
56]
57
58comparison = compare_strategies(df, strategies)

Conclusion and Next Steps

In Part 2, we’ve built a complete backtesting framework and implemented multiple trading strategies:

  • ✅ Robust backtesting engine with realistic costs
  • ✅ Multiple strategy types (trend following, mean reversion, pairs trading)
  • ✅ Comprehensive risk management
  • ✅ Professional performance metrics
  • ✅ Strategy comparison framework

Key Takeaways

  1. Always Backtest: Never trade without thorough testing
  2. Account for Costs: Slippage and commissions significantly impact results
  3. Manage Risk: Stop losses and position sizing are crucial
  4. Compare Strategies: No single strategy works in all market conditions
  5. Avoid Overfitting: Simple, robust strategies outperform complex curve-fit ones

Coming Up in Part 3

In the final post, we’ll focus on production deployment and optimization:

  • Walk-forward analysis and out-of-sample testing
  • Parameter optimization without overfitting
  • Live trading integration with exchange APIs
  • Real-time monitoring and alerting
  • Portfolio management across multiple strategies
  • Machine learning for strategy enhancement

Practice Exercises

 1# Exercise 1: Implement your own strategy
 2# Create a strategy combining multiple indicators
 3
 4# Exercise 2: Optimize parameters
 5# Find optimal parameters for MovingAverageCrossover using walk-forward analysis
 6
 7# Exercise 3: Multi-asset strategy
 8# Create a strategy that trades both BTC and ETH
 9
10# Exercise 4: Advanced risk management
11# Implement trailing stop loss and position scaling
12
13# Exercise 5: Strategy ensemble
14# Combine multiple strategies with dynamic weighting

Continue to Part 3: Crypto Quantitative Trading Part 3: Optimization and Production Deployment


Questions about backtesting or strategies? Drop them in the comments! In Part 3, we’ll deploy these strategies to production.

Yen

Yen

Yen