🚀 AI One-Click Generation of quantconnect Strategy Code

How to Implement a Factor-Based Stock Ranking Strategy in Python

quantconnect | Published on: 2025/11/27
The following content is generated by EasyQuant .

Problem Description

Help me write a stock trading strategy that picks stocks based on their alpha relative to a benchmark like the SPY.

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).

  1. Universe: We manually define a universe of liquid stocks (e.g., the Dow 30) to ensure data availability and liquidity.
  2. Benchmark: We use SPY (S&P 500 ETF) as the market proxy.
  3. Lookback Period: We analyze the last 60 trading days of history.
  4. Math: For every stock, we run a linear regression against the benchmark:
    $$R_{stock} = \alpha + \beta(R_{market}) + \epsilon$$
  5. Selection: We rank stocks by their Alpha (highest to lowest).
  6. 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

  1. Data Handling (unstack):
    QuantConnect's History call 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.

  2. 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.
  3. Rebalancing Schedule:
    The strategy uses self.Schedule.On to ensure trades only happen once a month. This reduces transaction costs and noise compared to daily rebalancing.

  4. 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.

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 TrailingStopRiskManagementModel in Initialize to 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.