| Overall Statistics |
|
Total Orders 765 Average Win 1.14% Average Loss -0.83% Compounding Annual Return 49.375% Drawdown 41.700% Expectancy 0.558 Start Equity 100000 End Equity 742858.24 Net Profit 642.858% Sharpe Ratio 1.204 Sortino Ratio 1.457 Probabilistic Sharpe Ratio 66.307% Loss Rate 34% Win Rate 66% Profit-Loss Ratio 1.37 Alpha 0.237 Beta 1.095 Annual Standard Deviation 0.272 Annual Variance 0.074 Information Ratio 1.371 Tracking Error 0.179 Treynor Ratio 0.299 Total Fees $946.71 Estimated Strategy Capacity $1400000000.00 Lowest Capacity Asset GLD T3SKPOF94JFP Portfolio Turnover 2.92% Drawdown Recovery 597 |
# region imports
from AlgorithmImports import *
# endregion
class Top25UniverseMomentumGld(QCAlgorithm):
"""Top-25 universe momentum strategy with GLD residual allocation."""
def initialize(self):
self.set_start_date(self.end_date - timedelta(5 * 365))
self.set_cash(100000)
self.set_benchmark("QQQ")
self.universe_settings.resolution = Resolution.DAILY
self._momentum_lookback = 90
self._vol_lookback = 20
self._max_positions = 10
self._max_single_weight = 0.12
self._min_momentum = 0.03
self._weight_tolerance = 0.0075
self._window_by_symbol = {}
self._active_symbols = []
self._growth_symbols = []
self._pending_targets = None
self._target_weight_by_symbol = {}
self._gld = self._add_symbol("GLD")
self._qqq = self._add_symbol("QQQ")
tickers = ["NVDA", "AVGO", "AMD", "ARM", "TSM",
"ASML", "AMAT", "LRCX", "MU", "MRVL",
"ANET", "SMCI", "PLTR", "NOW", "SNOW",
"DDOG", "NET", "CRWD", "MDB", "PANW",
"APP", "SHOP", "UBER", "ORCL", "MSFT",
"GOOGL", "AMZN", "META", "TSLA", "COIN"
]
for ticker in tickers:
symbol = self._add_symbol(ticker)
self._active_symbols.append(symbol)
self._growth_symbols.append(symbol)
self.add_universe(self.universe.top(25))
self.schedule.on(self.date_rules.month_start(self._qqq), self.time_rules.at(8, 0), self._rebalance)
self.set_warm_up(self._momentum_lookback + 2, Resolution.DAILY)
def _add_symbol(self, ticker):
symbol = self.add_equity(ticker, Resolution.DAILY).symbol
self._window_by_symbol[symbol] = RollingWindow[float](self._momentum_lookback + 2)
self._target_weight_by_symbol[symbol] = 0.0
return symbol
def on_warmup_finished(self):
self._rebalance()
def on_securities_changed(self, changes):
for security in changes.added_securities:
symbol = security.symbol
if symbol not in self._active_symbols:
self._active_symbols.append(symbol)
self._target_weight_by_symbol[symbol] = 0.0
if symbol not in self._window_by_symbol:
self._window_by_symbol[symbol] = RollingWindow[float](self._momentum_lookback + 2)
for security in changes.removed_securities:
symbol = security.symbol
if symbol not in self._growth_symbols and symbol in self._active_symbols:
self._active_symbols.remove(symbol)
def on_data(self, data):
for symbol, window in self._window_by_symbol.items():
if symbol in data.bars:
window.add(float(data.bars[symbol].close))
self._execute_pending_targets(data)
def _rebalance(self):
if self.is_warming_up:
return
targets = {symbol: 0.0 for symbol in self._target_weight_by_symbol}
candidates = self._select_momentum_candidates()
if candidates:
inv_vol_sum = sum(1.0 / item.volatility for item in candidates)
if inv_vol_sum > 0:
for item in candidates:
targets[item.symbol] = min(self._max_single_weight, (1.0 / item.volatility) / inv_vol_sum)
# Split any uninvested residual between QQQ and GLD only while QQQ is trending up.
residual = max(0.0, 1.0 - sum(abs(weight) for weight in targets.values()))
qqq_prices = self._prices(self._qqq)
if qqq_prices is not None and qqq_prices[-1] / qqq_prices[0] - 1.0 > 0 and qqq_prices[-1] > float(np.mean(qqq_prices[-90:])):
targets[self._qqq] += residual * 0.50
targets[self._gld] += residual * 0.50
else:
targets[self._gld] += residual
self._pending_targets = targets
def _select_momentum_candidates(self):
candidates = []
for symbol in sorted(self._active_symbols, key=lambda symbol: symbol.value):
if symbol in [self._gld, self._qqq]:
continue
prices = self._prices(symbol)
if prices is None:
continue
momentum = prices[-1] / prices[0] - 1.0
recent = np.array(prices[-self._vol_lookback - 1:])
returns = np.diff(recent) / recent[:-1]
if len(returns) < self._vol_lookback:
continue
volatility = float(np.std(returns))
if volatility > 0 and momentum >= self._min_momentum:
candidates.append(MomentumCandidate(symbol, momentum, volatility))
return sorted(candidates, key=lambda item: item.momentum)[-self._max_positions:]
def _execute_pending_targets(self, data):
if self._pending_targets is None:
return
remaining_targets = {}
blocked_reduction = False
for symbol in sorted(self._pending_targets, key=lambda symbol: self._pending_targets[symbol] - self._current_weight(symbol)):
target = self._pending_targets[symbol]
current = self._current_weight(symbol)
if abs(target - current) < self._weight_tolerance:
self._target_weight_by_symbol[symbol] = target
continue
if blocked_reduction and target > current:
remaining_targets[symbol] = target
continue
security = self.securities[symbol]
if not security.has_data or security.price <= 0 or symbol not in data.bars:
remaining_targets[symbol] = target
if target < current:
blocked_reduction = True
continue
self.set_holdings(symbol, target)
self._target_weight_by_symbol[symbol] = target
self._pending_targets = remaining_targets if remaining_targets else None
def _current_weight(self, symbol):
return self.portfolio[symbol].holdings_value / self.portfolio.total_portfolio_value
def _prices(self, symbol):
window = self._window_by_symbol.get(symbol)
if window is None or not window.is_ready:
return None
values = [window[i] for i in range(window.count)]
values.reverse()
return values
class MomentumCandidate:
def __init__(self, symbol, momentum, volatility):
self.symbol = symbol
self.momentum = momentum
self.volatility = volatility