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.”
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?
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.
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] = signalExplanation of the
PCAMomentumIndicator:
__init__): Sets up
StandardScaler and PCA objects. Crucially, it
calls _download_basket_data() once to get
historical prices for all assets in the basket. This is efficient as it
avoids repeated downloads._download_basket_data(): Fetches
historical Close prices for all specified tickers using
yfinance. It calculates daily percentage returns, which are
the inputs for PCA. This function handles multi-index DataFrames from
yfinance correctly and includes basic error handling.next(): Backtrader calls this method
on each new bar. It checks if enough data is available and then decides
if it’s time to _update_pca() based on the
recalc_frequency. Finally, it calls
_update_current_values() to calculate and update the
indicator’s output lines (pc1, pc1_momentum,
signal)._update_pca(): This is where the core
PCA logic resides.
lookback window (e.g., 252 days/1 year) of
recent returns to fit the PCA model, focusing on recent correlation
structures.StandardScaler normalizes the returns, ensuring that
assets with higher price ranges don’t disproportionately influence
PCA.pca.fit_transform() method performs PCA. We then
extract the first principal component (pc1_series).target_asset. This ensures that a “positive” PC1 value
consistently means a positive market sentiment, and vice versa. This is
important because the direction of PC1 is arbitrary._update_current_values(): This method
calculates the momentum of the latest PC1 value by comparing it to an
earlier PC1 value (momentum_lookback periods ago). This
momentum forms our direct trading signal.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:
PCAMomentumIndicator and accesses its
pc1_momentum and signal lines directly._generate_signal):
current_signal_direction
(from PC1 momentum) combined with a signal_threshold to
filter out weak momentum.confirmation_periods parameter allows us to require
the signal to persist for a certain number of days before a trade is
executed, reducing whipsaws._execute_signal):
Calculates position size based on a percentage of available cash and
places buy/sell orders. It also sets up initial stop-loss and
take-profit levels._manage_position): This is critical for
controlling risk and exiting trades. It checks for:
notify_order, notify_trade) provides
visibility into trade execution and performance during 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
)When you run this code, you’ll get a detailed output. It’s important to remember that:
momentum_lookback, signal_threshold,
confirmation_periods, stop_loss_pct,
take_profit_pct, max_holding_period) are
crucial. A small change can drastically alter results. ## Is This Strategy “Any
Good”?
The question of whether this strategy is “good” depends entirely on your criteria:
scikit-learn), and
applies a sophisticated statistical concept (PCA) to financial data. The
code is clean, modular, and well-commented. The handling of look-ahead
bias and PC1 sign flipping is commendable.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.