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