Overall Statistics
Total Orders
975
Average Win
2.34%
Average Loss
-1.23%
Compounding Annual Return
13.564%
Drawdown
60.500%
Expectancy
0.283
Start Equity
100000
End Equity
457079.42
Net Profit
357.079%
Sharpe Ratio
0.473
Sortino Ratio
0.518
Probabilistic Sharpe Ratio
2.838%
Loss Rate
56%
Win Rate
44%
Profit-Loss Ratio
1.91
Alpha
0
Beta
0
Annual Standard Deviation
0.199
Annual Variance
0.04
Information Ratio
0.571
Tracking Error
0.199
Treynor Ratio
0
Total Fees
$6284.93
Estimated Strategy Capacity
$68000000.00
Lowest Capacity Asset
NU XU6VS5CTSTB9
Portfolio Turnover
7.58%
# region imports
from AlgorithmImports import *
from io import StringIO
from typing import List, Dict
from pandas.core.frame import DataFrame
from pandas.core.series import Series
# endregion

class VixFilterType(Enum):
    VVIX = 1
    VIX_RANK = 2
    VIX_RATIO = 3

class SystematicInnovationFactorinStocks(QCAlgorithm):

    _momentum_period: int = 12 * 21
    _momentum_count: int = 5

    _vix_filter_type: VixFilterType = VixFilterType.VIX_RANK
    _vix_filter_value_threshold: float = 0.5

    _safe_asset_ticker: str = 'IEF'

    def initialize(self) -> None:
        self.set_start_date(2013, 1, 1)
        self.set_cash(100_000)

        self.set_brokerage_model(BrokerageName.INTERACTIVE_BROKERS_BROKERAGE, AccountType.MARGIN)
        self.settings.minimum_order_margin_portfolio_percentage = 0
        self.settings.daily_precise_end_time = False

        self._price_data: Dict[Symbol, float] = {}
        self._long: List[Symbol] = []
        self._selected_stock_universe: List[Symbol] = []

        market: Symbol = self.add_equity('SPY', Resolution.DAILY).symbol
        self._safe_asset: Symbol = self.add_equity(self._safe_asset_ticker, Resolution.DAILY).symbol

        file: str = self.Download(f'data.quantpedia.com/backtesting_data/economic/most_innovative_companies.csv')
        self._innovative_companies_df: DataFrame = pd.read_csv(StringIO(file), delimiter=';')
        self._innovative_companies_df.set_index('date', inplace=True)

        tickers: np.ndarray = np.array([list(self._innovative_companies_df[col].values) for col in list(self._innovative_companies_df.columns)])
        self._unique_tickers: List[str] = list(set(tickers.reshape(1, tickers.size)[0]))

        # subscribe to VIX filter asset/s
        if self._vix_filter_type == VixFilterType.VVIX:
            iv: Symbol = self.add_data(CBOE, "VVIX", Resolution.DAILY).symbol
            self._signal_assets = [iv]
        elif self._vix_filter_type == VixFilterType.VIX_RANK:
            iv: Symbol = self.add_data(CBOE, "VIX", Resolution.DAILY).symbol
            self._signal_assets = [iv]
        elif self._vix_filter_type == VixFilterType.VIX_RATIO:
            iv: Symbol = self.add_data(CBOE, "VIX", Resolution.DAILY).symbol
            iv_3m: Symbol = self.add_data(CBOE, "VIX3M", Resolution.DAILY).symbol
            self._signal_assets = [iv, iv_3m]

        self._selection_month: int = 6
        self._selection_flag: bool = False
        self._rebalance_flag: bool = False

        self.universe_settings.resolution = Resolution.DAILY
        self.add_universe(self._fundamental_selection_function)
        self.schedule.on(self.date_rules.month_end(market), self.time_rules.after_market_close(market), self._selection)
        self.schedule.on(self.date_rules.every_day(), self.time_rules.after_market_close(market), self._daily_selection)

    def on_securities_changed(self, changes: SecurityChanges) -> None:
        for security in changes.added_securities:
            security.set_leverage(5)

    def _fundamental_selection_function(self, fundamental: List[Fundamental]) -> List[Symbol]:
        # update the rolling window every day
        for stock in fundamental:
            symbol: Symbol = stock.symbol

            if symbol in self._price_data:
                self._price_data[symbol].add(stock.adjusted_price)
        
        if not self._selection_flag:
            return Universe.UNCHANGED
        self._selection_flag = False
        
        # select new stock universe
        self._long.clear()

        # select only dataset related stocks
        selected: List[Fundamental] = [
            f.symbol for f in fundamental if f.symbol.value in self._unique_tickers
        ]

        # warmup price rolling windows
        for symbol in selected:
            if symbol in self._price_data:
                continue
            
            self._price_data[symbol] = RollingWindow[float](self._momentum_period)

            history: DataFrame = self.history(TradeBar, symbol, self._momentum_period, Resolution.DAILY)
            if history.empty:
                self.log(f"Not enough data for {symbol} yet.")
                continue
            
            closes: Series = history.loc[symbol].close
            for time, close in closes.items():
                self._price_data[symbol].add(close)

        # pick top momentum stocks each year
        if self.time.year in self._innovative_companies_df.index:
            momentum_by_symbol: Dict[Symbol, float] = {
                s : self._price_data[s][0] / self._price_data[s][self._momentum_period - 1] - 1 
                for s in selected 
                if s.value in self._innovative_companies_df.loc[self.time.year].values
                and s in self._price_data
                and self._price_data[s].is_ready
            }
            
            if len(momentum_by_symbol) >= self._momentum_count:
                sorted_by_momentum: List[Symbol] = sorted(momentum_by_symbol, key=momentum_by_symbol.get, reverse=True)
                self._long = sorted_by_momentum[:self._momentum_count]
        
        return self._long

    def on_data(self, slice: Slice) -> None:
        if not self._rebalance_flag:
            return
        self._rebalance_flag = False

        # trade safe asset
        move_to_safe_asset = False

        if self._vix_filter_type == VixFilterType.VVIX:
            vvix: float = self.securities[self._signal_assets[0]].price
            if vvix > self._vix_filter_value_threshold:
                move_to_safe_asset = True
        elif self._vix_filter_type == VixFilterType.VIX_RANK:
            vix_rank: float = self._get_VIX_rank(self._signal_assets[0])
            if vix_rank > self._vix_filter_value_threshold:
                move_to_safe_asset = True
        elif self._vix_filter_type == VixFilterType.VIX_RATIO:
            vix_ratio: float = self.securities[self._signal_assets[0]].price / self.securities[self._signal_assets[1]].price
            if vix_ratio > self._vix_filter_value_threshold:
                move_to_safe_asset = True

        rebalance: bool = False
        long: List[Symbol] = []

        if move_to_safe_asset:
            long = [self._safe_asset]
            if not self.portfolio[self._safe_asset].invested:
                rebalance = True
        else:
            long = self._long
            if self.portfolio[self._safe_asset].invested:
                rebalance = True

        # trade execution
        if rebalance:
            targets: List[PortfolioTarget] = []
            for symbol in long:
                if slice.contains_key(symbol) and slice[symbol]:
                    targets.append(PortfolioTarget(symbol, 1 / len(long)))
            
            self.set_holdings(targets, True)
    
    def _daily_selection(self) -> None:
        self._rebalance_flag = True

    def _selection(self) -> None:
        if self.time.month == self._selection_month:
            # safe asset in subscribed and has data
            if self.securities.contains_key(self._safe_asset) and self.securities[self._safe_asset].get_last_data():
                self._selection_flag = True
        
        # self._rebalance_flag = True

    def _get_VIX_rank(self, vix: Symbol, lookback: int = 150) -> float:
        history: DataFrame = self.history(CBOE, vix, lookback, Resolution.DAILY)
        rank: float = ((self.securities[vix].price - min(history["low"])) / (max(history["high"]) - min(history["low"])))
        return rank