| Overall Statistics |
|
Total Orders 3205 Average Win 0.10% Average Loss -0.11% Compounding Annual Return 23.976% Drawdown 12.800% Expectancy 0.312 Start Equity 1000000000 End Equity 2123122249.62 Net Profit 112.312% Sharpe Ratio 0.863 Sortino Ratio 0.975 Probabilistic Sharpe Ratio 64.472% Loss Rate 29% Win Rate 71% Profit-Loss Ratio 0.86 Alpha 0.032 Beta 0.797 Annual Standard Deviation 0.135 Annual Variance 0.018 Information Ratio 0.117 Tracking Error 0.094 Treynor Ratio 0.146 Total Fees $2851621.08 Estimated Strategy Capacity $260000000.00 Lowest Capacity Asset NTRS R735QTJ8XC9X Portfolio Turnover 3.39% Drawdown Recovery 190 |
# region imports
from AlgorithmImports import *
# endregion
class FactorMomentumDistressAlgorithm(QCAlgorithm):
"""
Long-only equity strategy using 10 momentum/distress factors.
Universe: SPY ETF constituents. Monthly rebalance. $1B capital.
Factors (6 momentum, 4 distress):
1. 252-21 momentum (12m return minus 1m return) - momentum
2. 63-day momentum (3-month return) - momentum
3. 21-day momentum (1-month return) - momentum
4. RSI 14 (oversold = distress) - distress
5. Rate-of-change 126-day (6-month) - momentum
6. Price / SMA200 - 1 - momentum
7. STD 21-day (short-term vol distress) - distress
8. STD 63-day (volatility distress) - distress
9. MACD (line - signal) - momentum
10. Bollinger %B (position within bands) - distress
"""
# Composite score: momentum factors positive, distress negative
# Momentum (higher = better): 1(252-21), 2(63d), 3(21d), 5(ROC126), 6(Price/SMA200), 9(MACD)
# Distress (higher = worse): 7(STD21), 8(STD63)
# Distress (higher = better): 4(RSI), 10(BB%B)
_signs = [1, 1, 1, 1, 1, 1, -1, -1, 1, 1]
_NUM_LONG = 50 # The number of assets to hold.
def initialize(self) -> None:
self.set_start_date(2023, 1, 1)
self.set_cash(1_000_000_000)
self.settings.seed_initial_prices = True
self.settings.minimum_order_margin_portfolio_percentage = 0
# Add a universe that selects a subset of the SPY ETF Constituents to hold.
self._selection_data_by_symbol = {}
self.universe_settings.resolution = Resolution.DAILY
date_rule = self.date_rules.month_start("SPY")
self.universe_settings.schedule.on(date_rule)
self._universe = self.add_universe(self.universe.etf("SPY"), self._select_assets)
# Add a Scheduled Event to rebalance the portfolio at the start of each month.
self._weight_by_symbol = {}
self.schedule.on(date_rule, self.time_rules.at(8, 0), self._rebalance)
# Add warm-up to prime the indicators.
self.set_warm_up(timedelta(400))
def _select_assets(self, fundamentals: list[Fundamental]) -> list[Symbol]:
# Update the indicator of all stocks in the universe dataset and
# get the subset of stocks that have their indicator ready.
ready_stocks = [
f for f in fundamentals
if self._selection_data_by_symbol.setdefault(f.symbol, SelectionData(self, f)).update(f)
]
# As assests leave the Fundamental dataset, delete their SelectionData object.
for symbol in self._selection_data_by_symbol.keys() - {f.symbol for f in fundamentals}:
del self._selection_data_by_symbol[symbol]
# During warm-up, keep the universe empty.
if self.is_warming_up:
return []
# Compute 10 factors for each symbol with enough history
factor_data = {
f.symbol: self._selection_data_by_symbol[f.symbol].compute_factors()
for f in ready_stocks
}
# Standardize the raw factor values.
scored = list(factor_data.items())
X = np.array([fv for _, fv in scored])
mean, std = X.mean(0), X.std(0)
std[std == 0] = 1
z_matrix = ((X - mean) / std).T
# Aggregate the factor values so each stock has one composite score.
composites = []
for i, (sym, _) in enumerate(scored):
score = sum(self._signs[j] * z_matrix[j][i] for j in range(10))
composites.append((sym, score))
# Select the n stocks with the largest scores.
composites.sort(key=lambda x: x[1], reverse=True)
selected = [s for s, _ in composites[:self._NUM_LONG]]
# Calculate the target weight of each stock.
weight = 1 / len(selected)
self._weight_by_symbol = {s: weight for s in selected}
# Select the assets that we want to hold.
return list(self._weight_by_symbol.keys())
def _rebalance(self) -> None:
if not self._weight_by_symbol:
return
targets = [PortfolioTarget(s, w) for s, w in self._weight_by_symbol.items()]
self.set_holdings(targets, liquidate_existing_holdings=True)
class SelectionData:
def __init__(self, algorithm, f):
self._algorithm = algorithm
self._price_scale_factor = f.price_scale_factor
# Define some indicators to help calculate the factor values.
self._price = Identity()
self._roc_by_period = {period: RateOfChange(period) for period in [1, 21, 63, 126, 252]}
self._sma = SimpleMovingAverage(200)
self._rsi = RelativeStrengthIndex(14, MovingAverageType.WILDERS)
self._macd = MovingAverageConvergenceDivergence(12, 26, 9)
self._bb = BollingerBands(20, 2.0)
self._indicators = [self._price, self._sma, self._rsi, self._macd, self._bb] + list(self._roc_by_period.values())
# Define the factors.
self._factors = [
# 1. 252-21 momentum (12m return minus 1m return)
IndicatorExtensions.minus(self._roc_by_period[252], self._roc_by_period[21]),
# 2. 63-day momentum
self._roc_by_period[63],
# 3. 21-day momentum
self._roc_by_period[21],
# 4. RSI 14 (Wilder's smoothing)
self._rsi,
# 5. ROC 126-day
self._roc_by_period[126],
# 6. Price / SMA200 - 1
IndicatorExtensions.minus(IndicatorExtensions.over(self._price, self._sma), 1),
# 7. STD 21-day (daily return volatility)
IndicatorExtensions.of(StandardDeviation(21), self._roc_by_period[1]),
# 8. STD 63-day (daily return volatility)
IndicatorExtensions.of(StandardDeviation(63), self._roc_by_period[1]),
# 9. MACD histogram (EMA12 - EMA26, signal = EMA9 of MACD line)
self._macd.histogram,
# 10. Bollinger %B = (price - lower) / (upper - lower)
self._bb.percent_b
]
def update(self, f):
# If there hasn't been a split or dividend since the last trading
# day, just update the indicator like normal.
if f.price_scale_factor == self._price_scale_factor:
return all([indicator.update(f.end_time, f.price) for indicator in self._indicators])
# Otherwise, reset the indicator and warm it up with the new
# adjusted history.
self._price_scale_factor = f.price_scale_factor
for indicator in self._indicators:
indicator.reset()
history = self._algorithm.history[TradeBar](
f.symbol,
253,
Resolution.DAILY,
data_normalization_mode=DataNormalizationMode.SCALED_RAW
)
for bar in history:
for indicator in self._indicators:
indicator.update(bar)
return self._roc_by_period[252].is_ready
def compute_factors(self):
return [f.current.value for f in self._factors]