Problem Description
Solution
Here is a complete, robust stock trading strategy implemented in Python for the QuantConnect Lean Engine.
Strategy Logic: CAPM Alpha Ranking
This strategy is based on the Capital Asset Pricing Model (CAPM). It seeks to isolate "Alpha" ($\alpha$), which represents the returns of an asset that cannot be explained by the general market movement (Beta).
- Universe: We manually define a universe of liquid stocks (e.g., the Dow 30) to ensure data availability and liquidity.
- Benchmark: We use SPY (S&P 500 ETF) as the market proxy.
- Lookback Period: We analyze the last 60 trading days of history.
- Math: For every stock, we run a linear regression against the benchmark:
$$R_{stock} = \alpha + \beta(R_{market}) + \epsilon$$ - Selection: We rank stocks by their Alpha (highest to lowest).
- Execution: Every month, we go Long the top 5 stocks with the highest Alpha and liquidate positions that fall out of the top list.
Python Code
# region imports
from AlgorithmImports import *
import numpy as np
# endregion
class AlphaRankingAlgorithm(QCAlgorithm):
def Initialize(self):
# 1. Set Strategy Settings
self.SetStartDate(2020, 1, 1) # Backtest Start Date
self.SetEndDate(2024, 1, 1) # Backtest End Date
self.SetCash(100000) # Starting Capital
# 2. Define Universe and Benchmark
# We use a manual list of Dow 30 tickers for stability and speed
self.tickers = [
'AAPL', 'AMGN', 'AXP', 'BA', 'CAT', 'CRM', 'CSCO', 'CVX', 'DIS', 'DOW',
'GS', 'HD', 'HON', 'IBM', 'INTC', 'JNJ', 'JPM', 'KO', 'MCD', 'MMM',
'MRK', 'MSFT', 'NKE', 'PG', 'TRV', 'UNH', 'V', 'VZ', 'WBA', 'WMT'
]
self.symbols = [self.AddEquity(ticker, Resolution.Daily).Symbol for ticker in self.tickers]
# Add Benchmark (SPY)
self.benchmark = self.AddEquity("SPY", Resolution.Daily).Symbol
# 3. Parameters
self.lookback = 60 # Trading days for regression
self.top_n = 5 # Number of stocks to hold
self.rebalance_flag = False
# 4. Schedule Rebalancing (First trading day of every month)
self.Schedule.On(
self.DateRules.MonthStart(self.benchmark),
self.TimeRules.AfterMarketOpen(self.benchmark, 30),
self.TriggerRebalance
)
def TriggerRebalance(self):
"""Helper to trigger rebalance logic."""
self.rebalance_flag = True
def OnData(self, data):
"""Event handler for new data."""
# Only run logic if rebalance is triggered
if not self.rebalance_flag:
return
# Reset flag
self.rebalance_flag = False
# 1. Get Historical Data
# We request history for all symbols + benchmark
all_symbols = self.symbols + [self.benchmark]
history = self.History(all_symbols, self.lookback, Resolution.Daily)
if history.empty:
self.Debug("History data is empty.")
return
# 2. Prepare Data Structure
# Unstack to get a DataFrame where columns are Symbols and rows are Time
closes = history['close'].unstack(level=0)
# Calculate daily percentage returns
returns = closes.pct_change().dropna()
# Ensure we have enough data points after dropping NaNs
if len(returns) < 30:
return
# Separate Benchmark Returns
if self.benchmark not in returns:
return
benchmark_ret = returns[self.benchmark].values
# 3. Calculate Alpha for each symbol
alphas = {}
for symbol in self.symbols:
if symbol not in returns:
continue
stock_ret = returns[symbol].values
# Linear Regression: Y = Alpha + Beta * X
# X = Benchmark Returns, Y = Stock Returns
# We stack benchmark returns with a column of 1s to calculate the intercept (Alpha)
A = np.vstack([benchmark_ret, np.ones(len(benchmark_ret))]).T
try:
# lstsq returns [slope (beta), intercept (alpha)]
beta, alpha = np.linalg.lstsq(A, stock_ret, rcond=None)[0]
alphas[symbol] = alpha
except Exception as e:
self.Debug(f"Regression failed for {symbol}: {str(e)}")
continue
# 4. Select Top Stocks
# Sort dictionary by value (alpha) in descending order
sorted_by_alpha = sorted(alphas.items(), key=lambda x: x[1], reverse=True)
# Get top N symbols
selected_symbols = [x[0] for x in sorted_by_alpha[:self.top_n]]
# 5. Execute Trades
# Liquidate positions that are not in our selected list
for holding_symbol in self.Portfolio.Keys:
if self.Portfolio[holding_symbol].Invested and holding_symbol not in selected_symbols:
self.Liquidate(holding_symbol)
# Allocate equal weight to selected stocks (100% / top_n)
if len(selected_symbols) > 0:
target_weight = 1.0 / len(selected_symbols)
for symbol in selected_symbols:
self.SetHoldings(symbol, target_weight)
self.Log(f"Rebalanced. Top Alphas: {[s.Value for s in selected_symbols]}")
Key Implementation Details
-
Data Handling (
unstack):
QuantConnect'sHistorycall returns a MultiIndex DataFrame (Symbol, Time). We use.unstack(level=0)to pivot this so that columns represent symbols. This makes vectorized calculations with Pandas much easier. -
Linear Regression (
np.linalg.lstsq):
We use NumPy's least squares function.- Equation: $Y = mX + c$
- Mapping: $Y$ is the Stock Return, $X$ is the Benchmark Return.
- Intercept ($c$): This is the Alpha. It represents the return generated by the stock assuming the market return was 0.
- Slope ($m$): This is the Beta. It represents the stock's volatility relative to the market.
-
Rebalancing Schedule:
The strategy usesself.Schedule.Onto ensure trades only happen once a month. This reduces transaction costs and noise compared to daily rebalancing. -
Risk Management:
- Diversification: By selecting the
top_n = 5, we avoid putting all capital into a single volatile stock. - Liquidity: We restrict the universe to the Dow 30 (blue-chip stocks) to ensure orders are filled easily without slippage issues.
- Diversification: By selecting the
How to Extend This
- Dynamic Universe: Instead of a hardcoded list, use
self.AddUniverse(self.CoarseSelectionFunction)to dynamically select the top 500 stocks by dollar volume every day. - Stop Loss: Add a
TrailingStopRiskManagementModelinInitializeto automatically cut losses if a selected high-alpha stock suddenly reverses. - Beta Neutral: Instead of just buying high alpha, you could short the benchmark (SPY) equal to the weighted beta of your portfolio to hedge out market risk, leaving you with pure alpha exposure.
Q&A: QuantConnect Strategy Design
Q: Why do we use np.vstack with np.ones in the regression?
A: Standard linear regression requires an intercept term. By stacking a column of ones next to the benchmark returns, we create a matrix that allows the linear algebra solver to calculate the Y-intercept (Alpha). Without the ones, the regression would force the line through the origin (0,0), assuming Alpha is zero.
Q: Can I use this strategy for Crypto?
A: Yes. You would change AddEquity to AddCrypto, change the benchmark to BTCUSD, and update the ticker list to crypto pairs (e.g., ETHUSD, SOLUSD).
Q: What happens if the history request returns empty data?
A: The code includes a check if history.empty: return. This prevents the algorithm from crashing during warm-up periods or data outages. It simply skips rebalancing for that specific interval.
Q: How does this handle dividends?
A: QuantConnect's default data feed for Equities is "Adjusted" for splits and dividends. The close price used in the history call is the adjusted close, so the calculated returns (and therefore Alpha) implicitly account for dividend performance.