| Overall Statistics |
|
Total Orders 3554 Average Win 0.09% Average Loss -0.12% Compounding Annual Return 16.034% Drawdown 12.100% Expectancy 0.192 Start Equity 1000000000 End Equity 1683620540.32 Net Profit 68.362% Sharpe Ratio 0.48 Sortino Ratio 0.559 Probabilistic Sharpe Ratio 35.931% Loss Rate 34% Win Rate 66% Profit-Loss Ratio 0.81 Alpha -0.018 Beta 0.773 Annual Standard Deviation 0.133 Annual Variance 0.018 Information Ratio -0.436 Tracking Error 0.095 Treynor Ratio 0.083 Total Fees $3269722.42 Estimated Strategy Capacity $220000000.00 Lowest Capacity Asset CINF R735QTJ8XC9X Portfolio Turnover 4.45% Drawdown Recovery 187 |
# region imports
from AlgorithmImports import *
# endregion
class FactorMomentumDistressAlgorithm(QCAlgorithm):
"""
Long-short equity strategy using 10 momentum/distress factors.
Universe: SPY ETF constituents. Monthly rebalance. $1B capital.
Dollar-neutral: +0.5 long / -0.5 short (100% gross).
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
"""
# --- factor parameters ---
_MOM_252 = 252
_MOM_63 = 63
_MOM_21 = 21
_RSI_PERIOD = 14
_ROC_PERIOD = 126
_SMA200_PERIOD = 200
_STD21_PERIOD = 21
_STD63_PERIOD = 63
_MACD_FAST = 12
_MACD_SLOW = 26
_MACD_SIGNAL = 9
_BB_PERIOD = 20
_BB_K = 2.0
# --- universe / portfolio parameters ---
_UNIVERSE_CAP = 500
_NUM_LONG = 50
_LOOKBACK = 260 # 253 needed for 252-day momentum + buffer
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
self.universe_settings.resolution = Resolution.DAILY
self.universe_settings.leverage = 2.0
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,
)
self._weight_by_symbol = {}
self.schedule.on(date_rule, self.time_rules.at(8, 0), self._rebalance)
# ------------------------------------------------------------------ #
# Universe selection — compute factors via history() batch request #
# ------------------------------------------------------------------ #
def _select_assets(self, fundamentals: list[Fundamental]) -> list[Symbol]:
if len(fundamentals) < self._NUM_LONG:
return Universe.UNCHANGED
# Pre-screen to top UNIVERSE_CAP by dollar volume
sorted_points = sorted(fundamentals, key=lambda f: f.dollar_volume, reverse=True)
candidates = [f.symbol for f in sorted_points[:self._UNIVERSE_CAP]]
# Pull daily close history for all candidates in one batch
history = self.history(candidates, self._LOOKBACK, Resolution.DAILY)
if history.empty:
return Universe.UNCHANGED
# Compute 10 factors for each symbol with enough history
factor_data = {}
for symbol, group in history.groupby(level=0):
closes = group["close"].dropna().values
if len(closes) < self._MOM_252 + 1:
continue
factors = self._compute_factors(closes)
if factors is not None:
factor_data[symbol] = factors
if len(factor_data) < self._NUM_LONG:
return Universe.UNCHANGED
# Z-score each factor column
scored = list(factor_data.items())
n = len(scored)
factor_matrix = [[] for _ in range(10)]
for _, fv in scored:
for i in range(10):
factor_matrix[i].append(fv[i])
z_matrix = []
for col in factor_matrix:
mean = sum(col) / len(col)
var = sum((v - mean) ** 2 for v in col) / len(col)
std = math.sqrt(var)
if std == 0:
z_matrix.append([0.0] * n)
else:
z_matrix.append([(v - mean) / std for v in col])
# 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]
composites = []
for i, (sym, _) in enumerate(scored):
score = sum(signs[j] * z_matrix[j][i] for j in range(10))
composites.append((sym, score))
composites.sort(key=lambda x: x[1], reverse=True)
longs = [s for s, _ in composites[:self._NUM_LONG]]
# Dollar-neutral: +0.5 long / -0.5 short (100% gross)
self._weight_by_symbol = {}
lw = 1 / len(longs)
for s in longs:
self._weight_by_symbol[s] = lw
return list(self._weight_by_symbol.keys())
# ------------------------------------------------------------------ #
# Rebalance #
# ------------------------------------------------------------------ #
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)
# ------------------------------------------------------------------ #
# Factor computation #
# ------------------------------------------------------------------ #
def _compute_factors(self, closes: np.ndarray) -> list[float] | None:
n = len(closes)
if n < self._MOM_252 + 1:
return None
price = float(closes[-1])
# 1. 252-21 momentum (12m return minus 1m return)
f1 = (price / float(closes[-253]) - 1.0) - (price / float(closes[-22]) - 1.0)
# 2. 63-day momentum
f2 = price / float(closes[-64]) - 1.0
# 3. 21-day momentum
f3 = price / float(closes[-22]) - 1.0
# 4. RSI 14 (Wilder's smoothing)
f4 = self._rsi(closes, self._RSI_PERIOD)
# 5. ROC 126-day
f5 = (price - float(closes[-127])) / float(closes[-127]) * 100.0
# 6. Price / SMA200 - 1
sma200 = float(np.mean(closes[-self._SMA200_PERIOD:]))
f6 = price / sma200 - 1.0 if sma200 != 0 else 0.0
# 7. STD 21-day (daily return volatility)
rets = np.diff(closes[-self._STD21_PERIOD - 1:]) / closes[-self._STD21_PERIOD - 1:-1]
f7 = float(np.std(rets, ddof=1))
# 8. STD 63-day (daily return volatility)
rets63 = np.diff(closes[-self._STD63_PERIOD - 1:]) / closes[-self._STD63_PERIOD - 1:-1]
f8 = float(np.std(rets63, ddof=1))
# 9. MACD histogram (EMA12 - EMA26, signal = EMA9 of MACD line)
f9 = self._macd_hist(closes)
# 10. Bollinger %B = (price - lower) / (upper - lower)
sma20 = float(np.mean(closes[-self._BB_PERIOD:]))
std20 = float(np.std(closes[-self._BB_PERIOD:], ddof=1))
upper = sma20 + self._BB_K * std20
lower = sma20 - self._BB_K * std20
bw = upper - lower
f10 = (price - lower) / bw if bw != 0 else 0.5
return [f1, f2, f3, f4, f5, f6, f7, f8, f9, f10]
def _rsi(self, prices: np.ndarray, period: int) -> float:
"""RSI with Wilder's smoothing."""
deltas = np.diff(prices)
if len(deltas) < period:
return 50.0
gains = np.where(deltas > 0, deltas, 0.0)
losses = np.where(deltas < 0, -deltas, 0.0)
avg_gain = float(np.mean(gains[:period]))
avg_loss = float(np.mean(losses[:period]))
for i in range(period, len(deltas)):
avg_gain = (avg_gain * (period - 1) + float(gains[i])) / period
avg_loss = (avg_loss * (period - 1) + float(losses[i])) / period
if avg_loss == 0:
return 100.0
rs = avg_gain / avg_loss
return 100.0 - (100.0 / (1.0 + rs))
def _macd_hist(self, closes: np.ndarray) -> float:
"""MACD histogram: MACD line minus signal line."""
if len(closes) < self._MACD_SLOW + self._MACD_SIGNAL:
return 0.0
a12 = 2.0 / (self._MACD_FAST + 1)
a26 = 2.0 / (self._MACD_SLOW + 1)
a9 = 2.0 / (self._MACD_SIGNAL + 1)
ema12 = float(closes[0])
ema26 = float(closes[0])
macd_line = ema12 - ema26
signal = macd_line
for i in range(1, len(closes)):
c = float(closes[i])
ema12 = a12 * c + (1 - a12) * ema12
ema26 = a26 * c + (1 - a26) * ema26
macd_line = ema12 - ema26
signal = a9 * macd_line + (1 - a9) * signal
return macd_line - signal