Problem Description
Solution
To select stocks based on fundamental data like the P/E ratio in QuantConnect, you must use the Universe Selection mechanism. This involves two steps:
- Coarse Selection: Filters the entire market (thousands of stocks) based on price, volume, and whether they have fundamental data. This is fast and reduces the search space.
- Fine Selection: Filters the remaining stocks based on specific corporate fundamentals (e.g.,
ValuationRatios.PERatio).
Below is a complete algorithm that selects the top 10 liquid stocks with the lowest P/E ratios (Value Investing approach) and rebalances the portfolio daily.
# region imports
from AlgorithmImports import *
# endregion
class FundamentalSelectionAlgorithm(QCAlgorithm):
def initialize(self):
# 1. Set Strategy Settings
self.set_start_date(2020, 1, 1)
self.set_end_date(2023, 1, 1)
self.set_cash(100000)
# 2. Set Universe Settings
# We need Daily resolution to access fundamental data efficiently
self.universe_settings.resolution = Resolution.DAILY
# 3. Add Universe Selection
# We pass two functions: one for coarse selection (price/volume)
# and one for fine selection (fundamentals)
self.add_universe(self.coarse_selection_function, self.fine_selection_function)
# Variables to hold our target symbols
self._target_symbols = []
def coarse_selection_function(self, coarse):
"""
Filters the universe by price, volume, and fundamental data availability.
"""
# Filter for stocks that have fundamental data, price > 5, and decent volume
selected = [
x for x in coarse
if x.has_fundamental_data and x.price > 5 and x.dollar_volume > 1000000
]
# Sort by dollar volume (descending) to get the most liquid stocks
sorted_by_dollar_vol = sorted(selected, key=lambda x: x.dollar_volume, reverse=True)
# Return the top 200 symbols to be passed to the Fine Selection function
return [x.symbol for x in sorted_by_dollar_vol[:200]]
def fine_selection_function(self, fine):
"""
Filters the coarse list based on P/E Ratio.
"""
# Filter: Ensure P/E ratio is positive (exclude companies losing money)
# 'valuation_ratios' contains fields like PERatio, PBRatio, etc.
filtered = [x for x in fine if x.valuation_ratios.pe_ratio > 0]
# Sort: Ascending order (Lowest P/E first -> Value Strategy)
sorted_by_pe = sorted(filtered, key=lambda x: x.valuation_ratios.pe_ratio)
# Select the top 10 stocks
self._target_symbols = [x.symbol for x in sorted_by_pe[:10]]
return self._target_symbols
def on_data(self, data):
"""
Trading Logic: Rebalance portfolio to the target symbols.
"""
# If we have no targets yet, return
if not self._target_symbols:
return
# 1. Liquidate securities that are no longer in our target list
for symbol in self.portfolio.keys():
if self.portfolio[symbol].invested and symbol not in self._target_symbols:
self.liquidate(symbol)
# 2. Buy/Rebalance target securities
# We allocate 100% of equity equally among the target symbols (10% each if 10 stocks)
count = len(self._target_symbols)
if count > 0:
for symbol in self._target_symbols:
if self.securities[symbol].is_tradable:
self.set_holdings(symbol, 1.0 / count)
def on_securities_changed(self, changes):
"""
Event fired when securities are added or removed from the universe.
Useful for logging.
"""
for security in changes.removed_securities:
self.log(f"Removed {security.symbol} from Universe")
for security in changes.added_securities:
self.log(f"Added {security.symbol} to Universe")
Key Components Explained
-
self.add_universe(coarse, fine):
This registers the two filtering steps. The engine runscoarsefirst. The symbols returned bycoarseare populated with fundamental data and passed tofine. -
coarse_selection_function:x.has_fundamental_data: Crucial check. If this is False, the stock will not appear in the Fine step.x.dollar_volume: We sort by liquidity to ensure we can easily enter and exit positions. We limit the output to 200 to keep the Fine selection fast.
-
fine_selection_function:x.valuation_ratios.pe_ratio: This accesses the Morningstar fundamental data.- Logic: We filter for
pe_ratio > 0to avoid companies with negative earnings, then sort ascending to find "cheap" stocks relative to earnings.
-
on_data:
This method runs daily (since resolution is Daily). It checks theself._target_symbolslist updated by the universe selection and adjusts the portfolio holdings to match.
Common Fundamental Properties
You can access other fundamental data in the fine object similarly:
- Market Cap:
x.market_cap - Price to Book:
x.valuation_ratios.pb_ratio - Earnings Per Share:
x.earning_reports.basic_eps.twelve_months - Return on Equity:
x.operation_ratios.roe.value
Q&A
Q: How often does the Universe Selection run?
A: By default, Universe Selection runs once per day at midnight. The on_data method will then receive the data for the selected securities during market hours.
Q: Can I combine P/E ratio with other metrics?
A: Yes. In the fine_selection_function, you can create complex logic. For example:
filtered = [x for x in fine if x.valuation_ratios.pe_ratio < 15 and x.valuation_ratios.pb_ratio < 1.5]
Q: Why do I need the Coarse selection? Can't I just use Fine?
A: QuantConnect requires a Coarse selection to filter the 8,000+ available US equities down to a smaller number (usually < 500) before loading the heavy fundamental data objects. This ensures the backtest runs efficiently.
Q: What happens if a stock is delisted?
A: The on_securities_changed event will fire with the stock in changes.removed_securities. The logic in on_data handles this by liquidating positions that are no longer in the target list.