Combining Multiple Factors Like Momentum and Value: A QuantConnect Implementation Guide
Combine momentum and value factors by ranking assets separately on each signal, applying a static 25%/25%/50% weight allocation, and constructing a market-neutral long-short portfolio rebalanced monthly.
The awesome-systematic-trading repository provides a curated collection of quantitative strategies for the QuantConnect Lean engine. Its implementation of value and momentum factors across asset classes demonstrates production-ready patterns for combining multiple alpha signals while avoiding common pitfalls like look-ahead bias and factor crowding.
Unified Data Pipeline for Multi-Factor Strategies
All factor inputs must be time-aligned and free of forward-looking bias. In static/strategies/value-and-momentum-factors-across-asset-classes.py, the strategy loads specialized data classes including QuantpediaBondYield and CountryPE that enforce ascending chronological order and return fresh PythonData objects daily. This ensures momentum calculations (requiring 12 months of price history) and valuation metrics (like book-to-market) share identical timestamps without data leakage.
Factor Normalization and Ranking Methodology
Raw returns and valuation metrics operate on different scales, making normalization essential. The repository handles this by ranking assets separately on each factor before combination:
- 12-month momentum (long-term trend)
- 1-month momentum (short-term continuation)
- Valuation (book-to-market or inverse P/E)
The code computes percentile ranks for each metric independently, preventing any single factor from dominating the portfolio due to scale differences. This rank-based approach also reduces sensitivity to outliers in raw return distributions.
Weight Allocation and Portfolio Construction
Static weighting provides transparency and risk control. According to the source code in value-and-momentum-factors-across-asset-classes.py, the implementation uses a 25% / 25% / 50% split across long-term momentum, short-term momentum, and value signals respectively.
After combining ranks using these weights, the strategy constructs a zero-investment long-short portfolio:
- Long the top quartile (highest combined scores)
- Short the bottom quartile (lowest combined scores)
This market-neutral structure isolates the combined factor premium while eliminating beta exposure to the underlying market.
Rebalancing Cadence and Risk Controls
The implementation rebalances monthly, matching the typical decay horizon for momentum signals and valuation updates while avoiding excessive turnover from daily rebalancing. This cadence balances signal freshness against transaction costs.
For risk management, the repository's broader strategy suite (e.g., volatility-risk-premium-effect.py) demonstrates additional controls using PortfolioTarget and RiskManagementModel to cap gross exposure and scale positions by volatility.
Production Implementation
Below is a complete QuantConnect algorithm implementing these best practices:
# Multi-factor (Momentum + Value) implementation
# Based on: value-and-momentum-factors-across-asset-classes.py
from AlgorithmImports import *
class MomentumValueCombo(QCAlgorithm):
def Initialize(self):
self.SetStartDate(2010, 1, 1)
self.SetCash(100000)
# Universe selection
self.AddUniverse(self.CoarseSelectionFilter)
# Monthly rebalancing schedule
self.rebalance = self.DateRules.MonthStart(self.Securities.Keys[0])
def CoarseSelectionFilter(self, coarse):
# Select top 200 liquid securities
selected = [c.Symbol for c in sorted(coarse,
key=lambda x: x.DollarVolume, reverse=True)[:200]]
return selected
def OnData(self, data):
if not self.rebalance.IsReady:
return
# 1. Calculate momentum factors
momentum_12m = {}
momentum_1m = {}
for symbol in self.ActiveSecurities.Keys:
# 12-month momentum
history = self.History(symbol, 252, Resolution.Daily)
if len(history) < 252:
continue
price_now = history.iloc[-1].close
price_12m = history.iloc[0].close
momentum_12m[symbol] = price_now / price_12m - 1
# 1-month momentum
history_1m = self.History(symbol, 21, Resolution.Daily)
if len(history_1m) < 21:
continue
price_1m = history_1m.iloc[-1].close
price_1m_prev = history_1m.iloc[0].close
momentum_1m[symbol] = price_1m / price_1m_prev - 1
# 2. Calculate value factor (inverse P/E as book-to-market proxy)
value = {}
for symbol in self.ActiveSecurities.Keys:
pe = self.GetQuantpediaPERatio(symbol)
if pe is None or pe <= 0:
continue
value[symbol] = 1.0 / pe
# 3. Rank and combine with static weights
combined_score = {}
valid_symbols = momentum_12m.keys() & momentum_1m.keys() & value.keys()
for s in valid_symbols:
rank_m12 = self.Rank(momentum_12m, s)
rank_m1 = self.Rank(momentum_1m, s)
rank_val = self.Rank(value, s)
# 25% / 25% / 50% weighting per repository design
combined_score[s] = 0.25 * rank_m12 + 0.25 * rank_m1 + 0.5 * rank_val
# 4. Long-short quartile construction
sorted_symbols = sorted(combined_score, key=combined_score.get, reverse=True)
n = len(sorted_symbols) // 4
top_quartile = sorted_symbols[:n]
bottom_quartile = sorted_symbols[-n:]
# 5. Equal-weighted positions (50% long, 50% short)
weight = 0.5 / len(top_quartile) if top_quartile else 0
for symbol in top_quartile:
self.SetHoldings(symbol, weight)
for symbol in bottom_quartile:
self.SetHoldings(symbol, -weight)
def Rank(self, series, symbol):
"""Return percentile rank (0-1) of symbol within series."""
if symbol not in series:
return 0
values = np.array(list(series.values()))
return np.mean(values < series[symbol])
def GetQuantpediaPERatio(self, symbol):
"""Wrapper for Quantpedia valuation data."""
# Simplified representation of Quantpedia data access
if symbol in self.Securities:
# In production, this uses QuantpediaPERatio data class
return self.Securities[symbol].Fundamentals.ValuationRatios.PERatio
return None
Summary
- Normalize via ranking: Calculate separate percentile ranks for momentum and value signals to ensure equal contribution regardless of raw scale differences.
- Use static weights: The
value-and-momentum-factors-across-asset-classes.pystrategy employs a 25%/25%/50% split across 12-month momentum, 1-month momentum, and value. - Maintain market neutrality: Construct zero-investment portfolios by going long the top quartile and short the bottom quartile of combined scores.
- Rebalance monthly: Align rebalancing frequency with signal decay horizons to minimize turnover while capturing factor premiums.
- Leverage QuantConnect infrastructure: Use
History()for lookback calculations and custom data classes likeQuantpediaPERatiofor fundamental data integration.
Frequently Asked Questions
Why rank factors separately before combining them?
Separate ranking prevents factors with larger numerical ranges (like 12-month returns of ±50%) from overwhelming factors with smaller ranges (like P/E ratios of 5-30). By converting each factor to a 0-1 percentile scale first, you ensure each signal contributes proportionally to its designated weight in the final composite score.
How does the 25%/25%/50% weight allocation work in practice?
According to the implementation in static/strategies/value-and-momentum-factors-across-asset-classes.py, the 50% allocation to value reflects its typically stronger predictive power in long-horizon backtests, while the 25% splits to short-term and long-term momentum capture different aspects of price trends. These weights are static and transparent, allowing easy adjustment for risk-parity or inverse-volatility frameworks.
What prevents look-ahead bias when combining momentum and value data?
The repository uses QuantConnect's PythonData classes (e.g., QuantpediaBondYield, CountryPE) that strictly enforce point-in-time availability. When calculating 12-month momentum, the code calls self.History(symbol, 252, Resolution.Daily) which only returns data available as of the current algorithm date, ensuring valuation metrics and price history are perfectly aligned without future information.
Can this framework accommodate additional factors like quality or size?
Yes. The architecture supports extension by adding new factor calculations to the OnData method, ranking them separately, and adjusting the static weight allocation. For example, you could add a quality dictionary using return-on-equity data, calculate rank_quality, and update the combined score to 0.2 * rank_momentum + 0.3 * rank_value + 0.5 * rank_quality while maintaining the same quartile-based long-short construction.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →