Overall Statistics
Total Orders
3551
Average Win
0.46%
Average Loss
-0.53%
Compounding Annual Return
14.935%
Drawdown
43.000%
Expectancy
0.094
Start Equity
100000
End Equity
212535.31
Net Profit
112.535%
Sharpe Ratio
0.428
Sortino Ratio
0.455
Probabilistic Sharpe Ratio
8.717%
Loss Rate
41%
Win Rate
59%
Profit-Loss Ratio
0.87
Alpha
0.02
Beta
1.087
Annual Standard Deviation
0.249
Annual Variance
0.062
Information Ratio
0.173
Tracking Error
0.157
Treynor Ratio
0.098
Total Fees
$4778.03
Estimated Strategy Capacity
$0
Lowest Capacity Asset
DASH XK7HM0U7VFQD
Portfolio Turnover
15.22%
Drawdown Recovery
509
# QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals.
# Lean Algorithmic Trading Engine v2.0. Copyright 2014 QuantConnect Corporation.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from AlgorithmImports import *
from datetime import timedelta

class SpyWeeklyRotation(QCAlgorithm):

    _selection_data_by_symbol = {}
    
    def initialize(self):
        self.set_start_date(2020, 1, 1)
        self.set_end_date(2025, 6, 1)

        self.set_cash(100000)

        self.set_benchmark("SPY")

        # Seed the price of each asset that enters the universe with its last known price so you can trade it 
        # on the same morning it enters the universe without getting warnings.
        self.settings.seed_initial_prices = True

        # Add a chained universe that selects the SPY constituents trading above their 200-day SMA.
        spy = Symbol.create("SPY", SecurityType.EQUITY, Market.USA)
        self.universe_settings.resolution = Resolution.DAILY
        self._universe = self.add_universe(self.universe.etf(spy), self._fundamental_selection)

        # Trade daily at market open since the trading signal is generated on a daily resolution.
        self.schedule.on(self.date_rules.every_day(spy), self.time_rules.after_market_open(spy, 1), self._rebalance)
    
    def _fundamental_selection(self, fundamental: List[Fundamental]) -> List[Symbol]:
        # Create/Update an SMA indicator for each asset that enters the ETF.
        universe_symbols = []
        for f in fundamental:
            # Ignore stocks trading below $1 to avoid illiquid penny stocks.
            if f.price < 1:
                continue
            universe_symbols.append(f.symbol)
            if f.symbol not in self._selection_data_by_symbol:
                self._selection_data_by_symbol[f.symbol] = SelectionData(self, f.symbol)
            self._selection_data_by_symbol[f.symbol].update_price(self.time, f.adjusted_price)
            self._selection_data_by_symbol[f.symbol].update_volume(self.time, f.volume)
    
        # Remove indicators for assets that are no longer in the ETF to release the computational resources.
        symbols_to_remove = [s for s in self._selection_data_by_symbol.keys() if s not in universe_symbols]
        for symbol in symbols_to_remove:
            self._selection_data_by_symbol.pop(symbol)

        # Select only stocks with 20 days average volume > 1,000,000 to ensure sufficient liquidity.
        # Ignore stocks with RSI < 50 
        # Calculate RateOfChange(200) and select only those with ROC > 0
        # Sort by highest ROC and pick top 10 stocks. 

        # Select the Equities where rsi is not overbought and volume is above threshold.
        selected = [
            selection_data for _, selection_data in self._selection_data_by_symbol.items() if selection_data.is_volume_above_threshold()
            and selection_data.is_rsi_not_overbought()
        ]
        
        # Sort by RateOfChange(200) descending and take top 10
        selected = sorted(selected, key=lambda x: x.roc_value() or float('-inf'), reverse=True)[:10]
        selected = [selection_data.symbol for selection_data in selected]

        # Plot the results.
        self.plot("Universe", "Possible", len(list(fundamental)))
        self.plot("Universe", "Selected", len(selected))
    
        return selected

    def _rebalance(self) -> None:
        # If current day is not Monday return 
        if self.time.weekday() != 0:
            return
        
        self.log(f"Rebalancing Portfolio on {self.time}")

        # Form an equal-weighted portfolio with the Equities that are selected in the universe.
        symbols = [symbol for symbol in self._universe.selected if self.securities[symbol].price]
        if not symbols:
            return
        weight = 1 / len(symbols)
        self.set_holdings([PortfolioTarget(symbol, weight) for symbol in symbols], liquidate_existing_holdings=True)


# Define a separate class to contain the SMA indicator.
class SelectionData(object):

    symbol = None
    def __init__(self, algorithm:QCAlgorithm, symbol, period=200):
        
        self.symbol = symbol
        #  Create the SMA indicator for trend detection and filtering.
        self._sma = SimpleMovingAverage(period)
        algorithm.warm_up_indicator(symbol, self._sma, Resolution.DAILY)

        self._rsi = RelativeStrengthIndex(14)
        algorithm.warm_up_indicator(symbol, self._rsi, Resolution.DAILY)

        self._roc = RateOfChange(200)
        algorithm.warm_up_indicator(symbol, self._roc, Resolution.DAILY)

        self._volumeSMA = SimpleMovingAverage(20)
        algorithm.warm_up_indicator(symbol, self._volumeSMA, Resolution.DAILY, selector=lambda x: x.volume)
        
    def update_price(self,time, value):
        self._sma.update(time, value)
        self._rsi.update(time, value)
        self._roc.update(time, value)

    def update_volume(self,time, value):
        self._volumeSMA.update(time, value)

    def is_rsi_not_overbought(self):
        return self._rsi.is_ready and self._rsi.current.value < 50
    
    def is_volume_above_threshold(self, threshold=1_000_000):
        return self._volumeSMA.is_ready and self._volumeSMA.current.value > threshold
    
    def roc_value(self):
        return self._roc.current.value if self._roc.is_ready else None