← Back to Home
Can PCA Reveal the True Momentum of Crypto

Can PCA Reveal the True Momentum of Crypto

The cryptocurrency market is a wild frontier, characterized by rapid movements and often high correlations between assets. While individual coins surge and retreat, is there an underlying “collective momentum” that dictates the broader market direction? Can we identify the true leadership and derive trading signals from it?

This article explores a fascinating quantitative approach: using Principal Component Analysis (PCA) on a basket of correlated cryptocurrencies to identify this collective momentum. We’ll then build a trading strategy in Backtrader that trades a target asset based on the momentum of this derived “market factor.”

The Challenge of Correlated Assets

Imagine you’re tracking Bitcoin (BTC), Ethereum (ETH), Solana (SOL), and Cardano (ADA). Often, when BTC moves, the others follow. This strong inter-correlation means that looking at the momentum of just one asset might not give you the full picture. What if we could extract the most significant, shared movement from this group and use that as our primary signal?

Enter Principal Component Analysis (PCA)

PCA is a statistical technique that transforms a set of correlated variables into a set of uncorrelated variables called principal components (PCs). Each PC captures a certain amount of the total variance in the data.

Building the PCA Momentum Indicator

Our strategy starts with a custom Backtrader indicator, PCAMomentumIndicator, responsible for calculating PC1 and its momentum.

import backtrader as bt
import yfinance as yf
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from sklearn.decomposition import PCA
from sklearn.preprocessing import StandardScaler
import warnings

warnings.filterwarnings("ignore")

# For Jupyter/IPython to display plots
# %matplotlib inline

class PCAMomentumIndicator(bt.Indicator):
    """
    Custom indicator that performs PCA on a basket of assets and calculates PC1 momentum.
    It downloads historical data for the basket once at initialization.
    """
    lines = ('pc1', 'pc1_momentum', 'signal') # Define output lines for the indicator
    
    params = (
        ('tickers', ["BTC-USD", "ETH-USD", "SOL-USD", "ADA-USD"]), # Basket of assets for PCA
        ('target_asset', "BTC-USD"), # The asset we'll actually trade
        ('n_components', 1), # We only care about the first principal component
        ('momentum_lookback', 20), # Lookback period for PC1 momentum calculation
        ('recalc_frequency', 20),  # Recalculate PCA every N periods to adapt to changing correlations
        ('min_periods', 50),       # Minimum historical periods needed to perform PCA
    )
    
    plotinfo = dict(
        plot=True,
        subplot=True, # Plot on a separate subplot below the price chart
        plotname='PCA Momentum'
    )
    
    plotlines = dict(
        pc1=dict(color='purple', alpha=0.7),
        pc1_momentum=dict(color='orange', alpha=0.8),
        signal=dict(_plotskip=True) # Don't plot the raw signal line itself
    )
    
    def __init__(self):
        self.scaler = StandardScaler() # Used to standardize returns before PCA
        self.pca = PCA(n_components=self.params.n_components) # PCA model
        
        self._last_pca_calculation = 0 # Track when PCA was last recalculated
        self._pc1_values = [] # Store historical PC1 values
        
        # Download data for all assets in the basket at the start
        # Note: This is done once to avoid repeated downloads during backtest iteration
        self._download_basket_data() 
        
        self.addminperiod(self.params.min_periods) # Ensure enough data before calculations
        
    def _download_basket_data(self):
        """Downloads historical data for all assets in the specified basket."""
        try:
            # Use a broad date range for basket data to ensure availability
            start_date = "2019-01-01" 
            end_date = "2025-06-30" # Extended to capture current date for forward testing
            
            print(f"Downloading basket data for PCA: {self.params.tickers} from {start_date} to {end_date}...")
            
            # Download all tickers. auto_adjust=False is used per user instructions.
            # droplevel(1, 1) handles the MultiIndex from yfinance when downloading multiple tickers.
            self.basket_data = yf.download(
                self.params.tickers,
                start=start_date,
                end=end_date,
                auto_adjust=False,
                progress=False
            ).droplevel(axis=1, level=1)
            
            if self.basket_data.empty:
                raise ValueError("No basket data downloaded. Check tickers or date range.")
            
            # Extract 'Close' prices for all tickers
            self.basket_prices = self.basket_data['Close'].copy()
            
            # Calculate daily percentage returns for PCA input
            self.basket_returns = self.basket_prices.pct_change().dropna()
            
            print(f"Basket data loaded: {len(self.basket_returns)} rows, {len(self.basket_returns.columns)} assets.")
            
        except Exception as e:
            print(f"Error downloading basket data: {e}. Falling back to single-asset momentum.")
            self.basket_returns = None # Set to None to trigger fallback in next()
            
    def next(self):
        """Called for each new bar (day) of the main data feed."""
        # Ensure we have enough data to perform initial PCA
        if len(self.data) < self.params.min_periods:
            return
        
        # Recalculate PCA periodically or if it's the first time
        should_recalc = (len(self.data) - self._last_pca_calculation >= self.params.recalc_frequency or
                         len(self._pc1_values) == 0)
        
        if should_recalc:
            self._update_pca()
            
        # Update current indicator values using the latest PC1
        self._update_current_values()
            
    def _update_pca(self):
        """Performs PCA calculation on recent historical returns."""
        try:
            if self.basket_returns is None:
                # Fallback if basket data couldn't be loaded (e.g., download error)
                # In this case, PC1 will simply be the target asset's own return
                current_return = (self.data.close[0] / self.data.close[-1] - 1) if len(self.data) > 1 else 0
                self._pc1_values.append(current_return)
                self._last_pca_calculation = len(self.data)
                return
            
            current_date = self.data.datetime.date(0)
            
            # Filter basket returns up to the current backtest date to avoid look-ahead bias
            basket_data_filtered = self.basket_returns[
                self.basket_returns.index.date <= current_date
            ]
            
            if len(basket_data_filtered) < self.params.min_periods:
                return # Not enough data for reliable PCA
            
            # Use a lookback window (e.g., last year of data) for PCA calculation
            lookback = min(len(basket_data_filtered), 252) 
            recent_returns = basket_data_filtered.tail(lookback).dropna()
            
            if len(recent_returns) < 20: # Ensure enough non-NaN data for PCA
                return
            
            # Standardize the returns (mean=0, std=1) before applying PCA
            scaled_returns = self.scaler.fit_transform(recent_returns.values)
            self.pca.fit(scaled_returns) # Fit PCA model
            
            # Transform the scaled returns to get the principal components
            pc_components = self.pca.transform(scaled_returns)
            pc1_series = pc_components[:, 0] # Extract the first principal component
            
            # Optional: Ensure PC1 is positively correlated with the target asset.
            # The direction of PC1 is arbitrary; this ensures it aligns with intuitive market movement.
            if self.params.target_asset in recent_returns.columns:
                target_returns = recent_returns[self.params.target_asset].values
                correlation = np.corrcoef(pc1_series, target_returns)[0, 1]
                if correlation < 0:
                    pc1_series = -pc1_series # Flip if negatively correlated
            
            # Store the latest PC1 values. We'll only use the last one in `_update_current_values`.
            # This logic needs refinement for proper stream processing in Backtrader
            # For simplicity, we are taking the last computed value.
            self._pc1_values = list(pc1_series) 
            self._last_pca_calculation = len(self.data)
            
        except Exception as e:
            print(f"PCA calculation error at {current_date}: {e}. Skipping PCA update.")
            # If PCA fails, revert to a non-signaling state or use a simpler momentum
            if len(self._pc1_values) == 0:
                self._pc1_values = [0] # Initialize to avoid errors
            
    def _update_current_values(self):
        """Updates the indicator's output lines with the latest PC1 and its momentum."""
        if not self._pc1_values: # Ensure PC1 values are available
            self.lines.pc1[0] = 0
            self.lines.pc1_momentum[0] = 0
            self.lines.signal[0] = 0
            return

        # Get the latest calculated PC1 value
        current_pc1 = self._pc1_values[-1] 
        self.lines.pc1[0] = current_pc1
        
        # Calculate momentum of PC1 (current PC1 vs. PC1 from momentum_lookback periods ago)
        if len(self._pc1_values) >= self.params.momentum_lookback + 1:
            momentum_start_idx = -(self.params.momentum_lookback + 1)
            pc1_momentum = current_pc1 - self._pc1_values[momentum_start_idx]
        else:
            pc1_momentum = 0
            
        self.lines.pc1_momentum[0] = pc1_momentum
        
        # Generate a simple signal: 1 for positive momentum, -1 for negative
        signal = 1 if pc1_momentum > 0 else (-1 if pc1_momentum < 0 else 0)
        self.lines.signal[0] = signal

Explanation of the PCAMomentumIndicator:

The PCA Momentum Trading Strategy

Now, let’s build the PCAMomentumStrategy that uses our custom indicator:

class PCAMomentumStrategy(bt.Strategy):
    """
    PCA Momentum Strategy:
    - Long when PC1 momentum turns positive and confirmed.
    - Short when PC1 momentum turns negative and confirmed.
    - Uses fixed stop-loss, take-profit, and max holding period for exits.
    - Also exits on momentum reversal.
    """
    
    params = (
        # PCA Parameters (passed to the indicator)
        ('tickers', ["BTC-USD", "ETH-USD", "SOL-USD", "ADA-USD"]),
        ('target_asset', "BTC-USD"),
        ('n_components', 1),
        ('momentum_lookback', 20),
        
        # Signal Parameters
        ('signal_threshold', 0.0),      # Minimum momentum change for a signal
        ('confirmation_periods', 1),    # Periods to confirm a signal (e.g., 1 means immediate, 2+ means persistent)
        
        # Risk Management Parameters
        ('stop_loss_pct', 0.05),        # Percentage stop loss (e.g., 0.05 for 5%)
        ('take_profit_pct', 0.10),      # Percentage take profit (e.g., 0.10 for 10%)
        ('position_size', 0.95),        # Fraction of available cash to use per trade
        
        # Trading Parameters
        ('max_holding_period', 50),     # Max days to hold a position before exiting
        
        # Logging
        ('printlog', True),             # Enable/disable trade logging
        ('log_signals_only', False),    # If True, only log trade executions/closings
    )
    
    def __init__(self):
        # Instantiate our custom PCA Momentum indicator
        self.pca_momentum = PCAMomentumIndicator(
            tickers=self.params.tickers,
            target_asset=self.params.target_asset,
            n_components=self.params.n_components,
            momentum_lookback=self.params.momentum_lookback
            # recalc_frequency and min_periods are internal to indicator, no need to pass here
        )
        
        # Variables to track current position details
        self.entry_price = None
        self.entry_date = None
        self.stop_price = None
        self.target_price = None
        self.position_type = None # 'long' or 'short'
        
        # Variables for signal confirmation logic
        self.signal_confirmation = 0
        self.last_signal = 0 # Track the previous signal to detect changes
        
        # Performance tracking
        self.trade_count = 0
        self.winning_trades = 0
        self.total_pnl = 0
        
        # Order management (to avoid sending multiple orders)
        self.order = None
    
    def log(self, txt, dt=None, force=False):
        """Custom logging function with optional filtering."""
        if self.params.printlog and (not self.params.log_signals_only or force):
            dt = dt or self.datas[0].datetime.date(0)
            print(f'{dt.isoformat()}: {txt}') # Using isoformat for cleaner date print
            
    def notify_order(self, order):
        """Called when an order status changes."""
        if order.status in [order.Submitted, order.Accepted]:
            return # Order submitted/accepted - no action needed yet
        
        # Log execution details
        if order.status in [order.Completed]:
            if order.isbuy():
                self.log(f'BUY EXECUTED: Price ${order.executed.price:.2f}, Size: {order.executed.size}', force=True)
            else: # Sell order
                self.log(f'SELL EXECUTED: Price ${order.executed.price:.2f}, Size: {order.executed.size}', force=True)
        elif order.status in [order.Canceled, order.Margin, order.Rejected]:
            self.log(f'Order {order.status}', force=True)
            
        self.order = None # Clear the order reference
    
    def notify_trade(self, trade):
        """Called when a trade is closed."""
        if not trade.isclosed:
            return # Only interested in closed trades
        
        self.trade_count += 1
        self.total_pnl += trade.pnlcomm # Accumulate net profit/loss
        
        if trade.pnl > 0:
            self.winning_trades += 1
        
        win_rate = (self.winning_trades / self.trade_count) * 100 if self.trade_count > 0 else 0
        holding_days = (trade.dtclose - trade.dtopen) # Calculate holding period
        
        self.log(f'TRADE CLOSED: PnL ${trade.pnlcomm:.2f}, Days Held: {holding_days}, '
                 f'Win Rate: {win_rate:.1f}%', force=True)
                 
    def next(self):
        """Main strategy logic executed on each new bar (day)."""
        # Skip if there's a pending order, or not enough data for indicators
        if (self.order or
            len(self.data) < 50 or # Minimum data for strategy initialization
            np.isnan(self.pca_momentum.pc1_momentum[0])): # Ensure indicator values are valid
            return
        
        current_price = self.data.close[0]
        pc1_momentum = self.pca_momentum.pc1_momentum[0]
        current_signal_direction = self.pca_momentum.signal[0] # 1 for long, -1 for short
        
        # Always check position management first
        if self.position:
            self._manage_position(current_price, current_signal_direction)
            return # If in position, only manage it, don't look for new entries
            
        # If not in position, generate and execute new signals
        signal_to_execute = self._generate_signal(pc1_momentum, current_signal_direction)
        
        if signal_to_execute != 0:
            self._execute_signal(signal_to_execute, current_price, pc1_momentum)
            
    def _generate_signal(self, pc1_momentum, current_signal_direction):
        """Determines if a valid trade signal is present based on momentum and confirmation."""
        signal = 0
        
        # Check if momentum is above a minimum threshold (to filter weak signals)
        if abs(pc1_momentum) < self.params.signal_threshold:
            self.signal_confirmation = 0 # Reset confirmation if signal is weak
            self.last_signal = 0
            return 0
            
        # Detect a change in momentum direction
        if (current_signal_direction == 1 and self.last_signal <= 0): # Momentum turned positive (from non-positive)
            signal = 1
        elif (current_signal_direction == -1 and self.last_signal >= 0): # Momentum turned negative (from non-negative)
            signal = -1
            
        # Update signal confirmation count
        if signal == self.last_signal and signal != 0:
            self.signal_confirmation += 1
        else:
            self.signal_confirmation = 1 # Reset if signal changed or is zero
            self.last_signal = signal
            
        # Require 'confirmation_periods' before acting on a signal
        if self.signal_confirmation < self.params.confirmation_periods:
            return 0 # Not confirmed yet
            
        return signal
            
    def _execute_signal(self, signal, current_price, pc1_momentum):
        """Places a buy or sell order based on the generated signal."""
        # Calculate position size based on a percentage of available cash
        available_cash = self.broker.getcash()
        position_value = available_cash * self.params.position_size
        size = int(position_value / current_price)
        
        if size <= 0:
            self.log(f'Not enough cash to open position with size {size}', force=True)
            return
            
        if signal == 1: # Long signal
            self.order = self.buy(size=size)
            self.entry_price = current_price
            self.entry_date = self.data.datetime.date(0)
            self.position_type = 'long'
            
            # Set fixed stop loss and take profit prices
            self.stop_price = current_price * (1 - self.params.stop_loss_pct)
            self.target_price = current_price * (1 + self.params.take_profit_pct)
            
            self.log(f'LONG SIGNAL: PC1 Momentum {pc1_momentum:.4f}, '
                     f'Stop: ${self.stop_price:.2f}, Target: ${self.target_price:.2f}', force=True)
                     
        elif signal == -1: # Short signal
            self.order = self.sell(size=size) # Sell for shorting
            self.entry_price = current_price
            self.entry_date = self.data.datetime.date(0)
            self.position_type = 'short'
            
            # Set fixed stop loss and take profit prices for short
            self.stop_price = current_price * (1 + self.params.stop_loss_pct)
            self.target_price = current_price * (1 - self.params.take_profit_pct)
            
            self.log(f'SHORT SIGNAL: PC1 Momentum {pc1_momentum:.4f}, '
                     f'Stop: ${self.stop_price:.2f}, Target: ${self.target_price:.2f}', force=True)
    
    def _manage_position(self, current_price, current_signal_direction):
        """Manages an open position (checks for exits)."""
        if not self.position:
            return
        
        current_date = self.data.datetime.date(0)
        
        # 1. Check Max Holding Period
        if self.entry_date:
            holding_days = (current_date - self.entry_date).days
            if holding_days >= self.params.max_holding_period:
                self._close_position("Max Holding Period Reached")
                return
        
        # 2. Check Stop Loss / Take Profit
        if self.position.size > 0: # Long position
            if self.stop_price is not None and current_price <= self.stop_price:
                self._close_position(f"Stop Loss Hit: ${self.stop_price:.2f}")
                return
            elif self.target_price is not None and current_price >= self.target_price:
                self._close_position(f"Take Profit Hit: ${self.target_price:.2f}")
                return
        else: # Short position
            if self.stop_price is not None and current_price >= self.stop_price:
                self._close_position(f"Stop Loss Hit: ${self.stop_price:.2f}")
                return
            elif self.target_price is not None and current_price <= self.target_price:
                self._close_position(f"Take Profit Hit: ${self.target_price:.2f}")
                return
                
        # 3. Check Momentum Reversal (exit if momentum turns against position)
        if ((self.position.size > 0 and current_signal_direction == -1) or # Long but signal turned short
            (self.position.size < 0 and current_signal_direction == 1)):   # Short but signal turned long
            self._close_position("Momentum Reversal")
            
    def _close_position(self, reason):
        """Closes the current open position."""
        if self.position.size > 0: # If long, sell to close
            self.order = self.close() # self.sell()
        else: # If short, buy to close
            self.order = self.close() # self.buy()
            
        self.log(f'POSITION CLOSED: {reason}', force=True)
        self._reset_position_vars() # Clear tracking variables
        
    def _reset_position_vars(self):
        """Resets all position tracking variables after a trade is closed."""
        self.entry_price = None
        self.entry_date = None
        self.stop_price = None
        self.target_price = None
        self.position_type = None
        self.signal_confirmation = 0 # Reset confirmation for new signals
    
    def stop(self):
        """Called at the very end of the backtest to print final summary."""
        final_value = self.broker.getvalue()
        win_rate = (self.winning_trades / self.trade_count * 100) if self.trade_count > 0 else 0
        
        print('='*60)
        print('PCA MOMENTUM STRATEGY FINAL RESULTS')
        print('='*60)
        print(f'Final Portfolio Value: ${final_value:,.2f}')
        print(f'Total PnL: ${self.total_pnl:.2f}')
        print(f'Total Trades: {self.trade_count}')
        print(f'Winning Trades: {self.winning_trades}')
        print(f'Win Rate: {win_rate:.1f}%')
        print(f'Asset Basket: {", ".join(self.params.tickers)}')
        print(f'Target Asset: {self.params.target_asset}')
        print(f'Momentum Lookback: {self.params.momentum_lookback} days')
        print('='*60)

Key Aspects of PCAMomentumStrategy:

Running the Backtest

To put it all to the test, we use a wrapper function run_pca_momentum_backtest that sets up the Backtrader engine, loads data, adds the strategy and analyzers, and runs the simulation.

def run_pca_momentum_backtest(
    # Data parameters
    target_asset="BTC-USD",
    tickers=["BTC-USD", "ETH-USD", "SOL-USD", "ADA-USD"], # Example crypto basket
    start_date="2021-01-01", # Start date for the main data feed
    end_date="2024-05-31", # End date for the main data feed
    initial_cash=100000,
    commission=0.001, # 0.1% commission
    
    # Strategy parameters
    n_components=1,
    momentum_lookback=20,
    signal_threshold=0.0,
    confirmation_periods=1,
    
    # Risk management
    stop_loss_pct=0.05,
    take_profit_pct=0.10,
    position_size=0.95,
    max_holding_period=50,
    
    # Output parameters
    printlog=True,
    show_plot=True
):
    """
    Function to run the PCA Momentum Strategy Backtest with configurable parameters.
    """
    
    print("="*80)
    print("PCA MOMENTUM STRATEGY BACKTEST EXECUTION")
    print("="*80)
    print(f"Target Asset: {target_asset}")
    print(f"Asset Basket: {', '.join(tickers)}")
    print(f"Period: {start_date} to {end_date}")
    print(f"Initial Cash: ${initial_cash:,.2f}")
    print(f"Commission: {commission*100:.2f}%")
    print(f"Momentum Lookback: {momentum_lookback} days")
    print(f"Risk Management: {stop_loss_pct*100:.1f}% SL, {take_profit_pct*100:.1f}% TP, Max Hold: {max_holding_period} days")
    print("="*80)
    
    # Download primary asset data for the main data feed
    print(f"Downloading main data for {target_asset}...")
    # Using auto_adjust=False and droplevel(1,1) as per user instructions
    df = yf.download(target_asset, start=start_date, end=end_date, auto_adjust=False, progress=False).droplevel(axis=1, level=1)
    
    if df.empty:
        print(f"Error: No data downloaded for {target_asset} in the specified period.")
        return None, None
    
    df = df[['Open', 'High', 'Low', 'Close', 'Volume']].copy()
    print(f"Downloaded {len(df)} bars for {target_asset}.")
    
    # Create Cerebro engine
    cerebro = bt.Cerebro()
    
    # Add the main data feed
    data = bt.feeds.PandasData(dataname=df)
    cerebro.adddata(data)
    
    # Add the strategy with all its parameters
    cerebro.addstrategy(
        PCAMomentumStrategy,
        tickers=tickers,
        target_asset=target_asset,
        n_components=n_components,
        momentum_lookback=momentum_lookback,
        signal_threshold=signal_threshold,
        confirmation_periods=confirmation_periods,
        stop_loss_pct=stop_loss_pct,
        take_profit_pct=take_profit_pct,
        position_size=position_size,
        max_holding_period=max_holding_period,
        printlog=printlog,
        log_signals_only=False # Ensure logs are detailed for analysis
    )
    
    # Configure broker and sizer
    cerebro.broker.setcash(initial_cash)
    cerebro.broker.setcommission(commission=commission)
    cerebro.addsizer(bt.sizers.PercentSizer, percents=position_size*100)
    
    # Add standard analyzers for performance evaluation
    cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name='sharpe', timeframe=bt.TimeFrame.Daily)
    cerebro.addanalyzer(bt.analyzers.DrawDown, _name='drawdown')
    cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name='trades')
    cerebro.addanalyzer(bt.analyzers.Returns, _name='returns')
    
    # Run the backtest
    print("\nRunning PCA momentum backtest...")
    results = cerebro.run()
    strategy = results[0] # Get the strategy instance to access analyzer results
    
    # Print final performance metrics
    print("\n" + "="*80)
    print("FINAL PERFORMANCE METRICS")
    print("="*80)
    
    final_value = cerebro.broker.getvalue()
    total_return = (final_value / initial_cash - 1) * 100
    
    print(f"Initial Portfolio Value: ${initial_cash:,.2f}")
    print(f"Final Portfolio Value: ${final_value:,.2f}")
    print(f"Absolute Return: {total_return:.2f}%")
    
    # Extract analyzer data
    sharpe_data = strategy.analyzers.sharpe.get_analysis()
    drawdown_data = strategy.analyzers.drawdown.get_analysis()
    trades_data = strategy.analyzers.trades.get_analysis()
    returns_data = strategy.analyzers.returns.get_analysis()
    
    sharpe_ratio = sharpe_data.get('sharperatio', 'N/A')
    if sharpe_ratio != 'N/A':
        print(f"Sharpe Ratio (Daily): {sharpe_ratio:.2f}")
    
    annual_return = returns_data.get('rnorm100', 0)
    print(f"Annualized Return: {annual_return:.2f}%")
    
    max_dd = drawdown_data.get('max', {}).get('drawdown', 0)
    print(f"Maximum Drawdown: {max_dd:.2f}%")
    
    total_trades = trades_data.get('total', {}).get('total', 0)
    print(f"Total Trades: {total_trades}")
    
    if total_trades > 0:
        won_trades = trades_data.get('won', {}).get('total', 0)
        win_rate = (won_trades / total_trades) * 100
        print(f"Win Rate: {win_rate:.1f}%")
        
        if 'pnl' in trades_data.get('won', {}):
            avg_win = trades_data['won']['pnl'].get('average', 0)
            print(f"Average Winning Trade PnL: ${avg_win:.2f}")
        if 'pnl' in trades_data.get('lost', {}):
            avg_loss = trades_data['lost']['pnl'].get('average', 0)
            print(f"Average Losing Trade PnL: ${avg_loss:.2f}")
            
    # Buy & Hold comparison (benchmark)
    buy_hold_return = ((df['Close'].iloc[-1] / df['Close'].iloc[0]) - 1) * 100
    print(f"Buy & Hold Return ({target_asset}): {buy_hold_return:.2f}%")
    print(f"Excess Return (vs. Buy & Hold): {total_return - buy_hold_return:.2f}%")
    
    print("="*80)
    
    # Plot results
    if show_plot:
        print("Generating charts (this may take a moment)...")
        # Ensure plot shows custom indicators and transactions
        cerebro.plot(style='candlestick', volume=False, figsize=(18, 12), iplot=False)
        plt.suptitle(f'PCA Momentum Strategy - {target_asset} ({start_date} to {end_date})', fontsize=16)
        plt.tight_layout()
        plt.show()
        
    return cerebro, results


if __name__ == "__main__":
    # Example usage with specific parameters
    cerebro_instance, backtest_results = run_pca_momentum_backtest(
        target_asset="BTC-USD",
        tickers=["BTC-USD", "ETH-USD", "SOL-USD", "ADA-USD"], # Keep a basket of major altcoins
        start_date="2023-01-01",  # Recent period for a clearer view
        end_date="2024-05-31",    # Up to a recent date
        initial_cash=50000,
        commission=0.0005,        # Lower commission for crypto exchanges (0.05%)
        momentum_lookback=15,     # Shorter momentum lookback
        signal_threshold=0.001,   # Requires a small positive/negative momentum change
        confirmation_periods=2,   # Require 2 consecutive signals
        stop_loss_pct=0.04,       # Tighter stop loss (4%)
        take_profit_pct=0.08,     # Proportional take profit (8%)
        max_holding_period=40,    # Shorter maximum holding period
        printlog=True,            # Keep logging on for detailed insights
        show_plot=True            # Show the plot
    )

Initial Thoughts on Performance

When you run this code, you’ll get a detailed output. It’s important to remember that:

  1. Crypto Market is Unique: The chosen assets are cryptocurrencies, which are highly volatile and exhibit different dynamics than traditional stocks or commodities. Their correlations can change rapidly.
  2. Bull Market Bias: The period chosen (2023-2024 for BTC-USD) has largely been a strong bull market for cryptocurrencies. Momentum strategies often perform well in trending markets, but a simple Buy & Hold can also yield significant returns, often outpacing more complex strategies due to lower transaction costs and avoiding whipsaws.
  3. Parameter Sensitivity: The parameters (momentum_lookback, signal_threshold, confirmation_periods, stop_loss_pct, take_profit_pct, max_holding_period) are crucial. A small change can drastically alter results.

Pasted image 20250606055552.png ## Is This Strategy “Any Good”?

The question of whether this strategy is “good” depends entirely on your criteria:

Conclusion

This PCA Momentum strategy provides a fascinating glimpse into the world of multi-asset quantitative trading. It’s a testament to how statistical tools can be applied to derive unique trading signals. While the initial results might not instantly scream “profit machine,” the foundation is exceptionally strong. The journey from a promising idea to a robust, profitable strategy is an iterative process of refinement, rigorous testing, and adaptation to ever-changing market dynamics. This is an excellent step on that journey.