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
"ignore")
warnings.filterwarnings(
# 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.
"""
= ('pc1', 'pc1_momentum', 'signal') # Define output lines for the indicator
lines
= (
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
(
)
= dict(
plotinfo =True,
plot=True, # Plot on a separate subplot below the price chart
subplot='PCA Momentum'
plotname
)
= dict(
plotlines =dict(color='purple', alpha=0.7),
pc1=dict(color='orange', alpha=0.8),
pc1_momentum=dict(_plotskip=True) # Don't plot the raw signal line itself
signal
)
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
= "2019-01-01"
start_date = "2025-06-30" # Extended to capture current date for forward testing
end_date
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_date,
start=end_date,
end=False,
auto_adjust=False
progress=1, level=1)
).droplevel(axis
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
= (len(self.data) - self._last_pca_calculation >= self.params.recalc_frequency or
should_recalc 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
= (self.data.close[0] / self.data.close[-1] - 1) if len(self.data) > 1 else 0
current_return self._pc1_values.append(current_return)
self._last_pca_calculation = len(self.data)
return
= self.data.datetime.date(0)
current_date
# Filter basket returns up to the current backtest date to avoid look-ahead bias
= self.basket_returns[
basket_data_filtered 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
= min(len(basket_data_filtered), 252)
lookback = basket_data_filtered.tail(lookback).dropna()
recent_returns
if len(recent_returns) < 20: # Ensure enough non-NaN data for PCA
return
# Standardize the returns (mean=0, std=1) before applying PCA
= self.scaler.fit_transform(recent_returns.values)
scaled_returns self.pca.fit(scaled_returns) # Fit PCA model
# Transform the scaled returns to get the principal components
= self.pca.transform(scaled_returns)
pc_components = pc_components[:, 0] # Extract the first principal component
pc1_series
# 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:
= recent_returns[self.params.target_asset].values
target_returns = np.corrcoef(pc1_series, target_returns)[0, 1]
correlation if correlation < 0:
= -pc1_series # Flip if negatively correlated
pc1_series
# 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
= self._pc1_values[-1]
current_pc1 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:
= -(self.params.momentum_lookback + 1)
momentum_start_idx = current_pc1 - self._pc1_values[momentum_start_idx]
pc1_momentum else:
= 0
pc1_momentum
self.lines.pc1_momentum[0] = pc1_momentum
# Generate a simple signal: 1 for positive momentum, -1 for negative
= 1 if pc1_momentum > 0 else (-1 if pc1_momentum < 0 else 0)
signal self.lines.signal[0] = signal
Explanation 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(
=self.params.tickers,
tickers=self.params.target_asset,
target_asset=self.params.n_components,
n_components=self.params.momentum_lookback
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 or self.datas[0].datetime.date(0)
dt 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
= (self.winning_trades / self.trade_count) * 100 if self.trade_count > 0 else 0
win_rate = (trade.dtclose - trade.dtopen) # Calculate holding period
holding_days
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
self.pca_momentum.pc1_momentum[0])): # Ensure indicator values are valid
np.isnan(return
= self.data.close[0]
current_price = self.pca_momentum.pc1_momentum[0]
pc1_momentum = self.pca_momentum.signal[0] # 1 for long, -1 for short
current_signal_direction
# 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
= self._generate_signal(pc1_momentum, current_signal_direction)
signal_to_execute
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."""
= 0
signal
# 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)
= 1
signal elif (current_signal_direction == -1 and self.last_signal >= 0): # Momentum turned negative (from non-negative)
= -1
signal
# 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
= self.broker.getcash()
available_cash = available_cash * self.params.position_size
position_value = int(position_value / current_price)
size
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
= self.data.datetime.date(0)
current_date
# 1. Check Max Holding Period
if self.entry_date:
= (current_date - self.entry_date).days
holding_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."""
= self.broker.getvalue()
final_value = (self.winning_trades / self.trade_count * 100) if self.trade_count > 0 else 0
win_rate
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
="BTC-USD",
target_asset=["BTC-USD", "ETH-USD", "SOL-USD", "ADA-USD"], # Example crypto basket
tickers="2021-01-01", # Start date for the main data feed
start_date="2024-05-31", # End date for the main data feed
end_date=100000,
initial_cash=0.001, # 0.1% commission
commission
# Strategy parameters
=1,
n_components=20,
momentum_lookback=0.0,
signal_threshold=1,
confirmation_periods
# Risk management
=0.05,
stop_loss_pct=0.10,
take_profit_pct=0.95,
position_size=50,
max_holding_period
# Output parameters
=True,
printlog=True
show_plot
):"""
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
= yf.download(target_asset, start=start_date, end=end_date, auto_adjust=False, progress=False).droplevel(axis=1, level=1)
df
if df.empty:
print(f"Error: No data downloaded for {target_asset} in the specified period.")
return None, None
= df[['Open', 'High', 'Low', 'Close', 'Volume']].copy()
df print(f"Downloaded {len(df)} bars for {target_asset}.")
# Create Cerebro engine
= bt.Cerebro()
cerebro
# Add the main data feed
= bt.feeds.PandasData(dataname=df)
data
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=False # Ensure logs are detailed for analysis
log_signals_only
)
# Configure broker and sizer
cerebro.broker.setcash(initial_cash)=commission)
cerebro.broker.setcommission(commission=position_size*100)
cerebro.addsizer(bt.sizers.PercentSizer, percents
# Add standard analyzers for performance evaluation
='sharpe', timeframe=bt.TimeFrame.Daily)
cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name='drawdown')
cerebro.addanalyzer(bt.analyzers.DrawDown, _name='trades')
cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name='returns')
cerebro.addanalyzer(bt.analyzers.Returns, _name
# Run the backtest
print("\nRunning PCA momentum backtest...")
= cerebro.run()
results = results[0] # Get the strategy instance to access analyzer results
strategy
# Print final performance metrics
print("\n" + "="*80)
print("FINAL PERFORMANCE METRICS")
print("="*80)
= cerebro.broker.getvalue()
final_value = (final_value / initial_cash - 1) * 100
total_return
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
= strategy.analyzers.sharpe.get_analysis()
sharpe_data = strategy.analyzers.drawdown.get_analysis()
drawdown_data = strategy.analyzers.trades.get_analysis()
trades_data = strategy.analyzers.returns.get_analysis()
returns_data
= sharpe_data.get('sharperatio', 'N/A')
sharpe_ratio if sharpe_ratio != 'N/A':
print(f"Sharpe Ratio (Daily): {sharpe_ratio:.2f}")
= returns_data.get('rnorm100', 0)
annual_return print(f"Annualized Return: {annual_return:.2f}%")
= drawdown_data.get('max', {}).get('drawdown', 0)
max_dd print(f"Maximum Drawdown: {max_dd:.2f}%")
= trades_data.get('total', {}).get('total', 0)
total_trades print(f"Total Trades: {total_trades}")
if total_trades > 0:
= trades_data.get('won', {}).get('total', 0)
won_trades = (won_trades / total_trades) * 100
win_rate print(f"Win Rate: {win_rate:.1f}%")
if 'pnl' in trades_data.get('won', {}):
= trades_data['won']['pnl'].get('average', 0)
avg_win print(f"Average Winning Trade PnL: ${avg_win:.2f}")
if 'pnl' in trades_data.get('lost', {}):
= trades_data['lost']['pnl'].get('average', 0)
avg_loss print(f"Average Losing Trade PnL: ${avg_loss:.2f}")
# Buy & Hold comparison (benchmark)
= ((df['Close'].iloc[-1] / df['Close'].iloc[0]) - 1) * 100
buy_hold_return 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
='candlestick', volume=False, figsize=(18, 12), iplot=False)
cerebro.plot(stylef'PCA Momentum Strategy - {target_asset} ({start_date} to {end_date})', fontsize=16)
plt.suptitle(
plt.tight_layout()
plt.show()
return cerebro, results
if __name__ == "__main__":
# Example usage with specific parameters
= run_pca_momentum_backtest(
cerebro_instance, backtest_results ="BTC-USD",
target_asset=["BTC-USD", "ETH-USD", "SOL-USD", "ADA-USD"], # Keep a basket of major altcoins
tickers="2023-01-01", # Recent period for a clearer view
start_date="2024-05-31", # Up to a recent date
end_date=50000,
initial_cash=0.0005, # Lower commission for crypto exchanges (0.05%)
commission=15, # Shorter momentum lookback
momentum_lookback=0.001, # Requires a small positive/negative momentum change
signal_threshold=2, # Require 2 consecutive signals
confirmation_periods=0.04, # Tighter stop loss (4%)
stop_loss_pct=0.08, # Proportional take profit (8%)
take_profit_pct=40, # Shorter maximum holding period
max_holding_period=True, # Keep logging on for detailed insights
printlog=True # Show the plot
show_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.