Overall Statistics |
Total Orders 366 Average Win 246.14% Average Loss -12.01% Compounding Annual Return 0% Drawdown 102.300% Expectancy 11.725 Start Equity 1000000 End Equity -206320.4 Net Profit -120.632% Sharpe Ratio 0.893 Sortino Ratio 1.011 Probabilistic Sharpe Ratio 22.203% Loss Rate 41% Win Rate 59% Profit-Loss Ratio 20.50 Alpha 1.741 Beta 9.718 Annual Standard Deviation 2.518 Annual Variance 6.341 Information Ratio 0.902 Tracking Error 2.434 Treynor Ratio 0.231 Total Fees $13902.51 Estimated Strategy Capacity $0 Lowest Capacity Asset XLK XCZJLCMQ99WM|XLK RGRPZX100F39 Portfolio Turnover 5.94% |
#region imports from AlgorithmImports import * from arch.unitroot.cointegration import engle_granger from pykalman import KalmanFilter #endregion class PairsTrade(QCAlgorithm): def Initialize(self): # Required: Five years of backtest history self.SetStartDate(2019, 1, 1) # Required: Alpha Streams Models: self.SetBrokerageModel(BrokerageName.InteractiveBrokersBrokerage) # Required: Significant AUM Capacity self.SetCash(1000000) # Required: Benchmark to SPY self.SetBenchmark("SPY") self.assets = ["XLK", "XLU"] # Add Equity ------------------------------------------------ for asset in self.assets: self.AddEquity(asset, Resolution.Minute) # Add Options ------------------------------------------------ self.option_symbols = [] for asset in self.assets: option = self.AddOption(asset) option.SetFilter(-2, +2, 90, 180) # ATM options with expiration between 90 and 180 days self.option_symbols.append(option.Symbol) # Instantiate our model self.recalibrate() # Set a variable to indicate the trading bias of the portfolio self.state = 0 # Set Scheduled Event Method For Kalman Filter updating. self.Schedule.On(self.DateRules.WeekStart(), self.TimeRules.At(0, 0), self.recalibrate) # Set Scheduled Event Method For trading every 2 days. self.Schedule.On(self.DateRules.Every(DayOfWeek.Monday), self.TimeRules.BeforeMarketClose("XLK"), self.every_other_day_before_market_close) self.Schedule.On(self.DateRules.Every(DayOfWeek.Wednesday), self.TimeRules.BeforeMarketClose("XLK"), self.every_other_day_before_market_close) self.Schedule.On(self.DateRules.Every(DayOfWeek.Friday), self.TimeRules.BeforeMarketClose("XLK"), self.every_other_day_before_market_close) self.option_contracts = {} def recalibrate(self): qb = self history = qb.History(self.assets, 252*2, Resolution.Daily) if history.empty: return # Select the close column and then call the unstack method data = history['close'].unstack(level=0) # Convert into log-price series to eliminate compounding effect log_price = np.log(data) ### Get Cointegration Vectors # Get the cointegration vector coint_result = engle_granger(log_price.iloc[:, 0], log_price.iloc[:, 1], trend="c", lags=0) coint_vector = coint_result.cointegrating_vector[:2] # Get the spread spread = log_price @ coint_vector ### Kalman Filter # Initialize a Kalman Filter. Using the first 20 data points to optimize its initial state. We assume the market has no regime change so that the transitional matrix and observation matrix is [1]. self.kalman_filter = KalmanFilter(transition_matrices = [1], observation_matrices = [1], initial_state_mean = spread.iloc[:20].mean(), observation_covariance = spread.iloc[:20].var(), em_vars=['transition_covariance', 'initial_state_covariance']) self.kalman_filter = self.kalman_filter.em(spread.iloc[:20], n_iter=5) (filtered_state_means, filtered_state_covariances) = self.kalman_filter.filter(spread.iloc[:20]) # Obtain the current Mean and Covariance Matrix expectations. self.current_mean = filtered_state_means[-1, :] self.current_cov = filtered_state_covariances[-1, :] # Initialize a mean series for spread normalization using the Kalman Filter's results. mean_series = np.array([None]*(spread.shape[0]-20)) # Roll over the Kalman Filter to obtain the mean series. for i in range(20, spread.shape[0]): (self.current_mean, self.current_cov) = self.kalman_filter.filter_update(filtered_state_mean = self.current_mean, filtered_state_covariance = self.current_cov, observation = spread.iloc[i]) mean_series[i-20] = float(self.current_mean) # Obtain the normalized spread series. normalized_spread = (spread.iloc[20:] - mean_series) ### Determine Trading Threshold # Initialize 50 set levels for testing. set_levels = self.GetParameter("set_levels", 50) s0 = np.linspace(0, max(normalized_spread), set_levels) # Calculate the profit levels using the 50 set levels. f_bar = np.array([None]*set_levels) for i in range(set_levels): f_bar[i] = len(normalized_spread.values[normalized_spread.values > s0[i]]) / normalized_spread.shape[0] # Set trading frequency matrix. D = np.zeros((set_levels-1, set_levels)) for i in range(D.shape[0]): D[i, i] = 1 D[i, i+1] = -1 # Set level of lambda. l = 1.0 # Obtain the normalized profit level. f_star = np.linalg.inv(np.eye(set_levels) + l * D.T@D) @ f_bar.reshape(-1, 1) s_star = [f_star[i]*s0[i] for i in range(set_levels)] self.threshold = s0[s_star.index(max(s_star))] # Set the trading weight. We would like the portfolio absolute total weight is 1 when trading. self.trading_weight = coint_vector / np.sum(abs(coint_vector)) def every_other_day_before_market_close(self): qb = self # Get the real-time log close price for all assets and store in a Series series = pd.Series() for symbol in qb.Securities.Keys: series[symbol] = np.log(qb.Securities[symbol].Close) # Get the spread spread = np.sum(series * self.trading_weight) # Update the Kalman Filter with the Series (self.current_mean, self.current_cov) = self.kalman_filter.filter_update(filtered_state_mean = self.current_mean, filtered_state_covariance = self.current_cov, observation = spread) # Obtain the normalized spread. normalized_spread = spread - self.current_mean # ============================== # Mean-reversion if normalized_spread < -self.threshold: self.trade_options("call", "put") self.state = 1 elif normalized_spread > self.threshold: self.trade_options("put", "call") self.state = -1 # Out of position if spread recovered elif self.state == 1 and normalized_spread > -self.threshold or self.state == -1 and normalized_spread < self.threshold: self.Liquidate() self.state = 0 def OnData(self, slice): # Store option chains for symbol in self.option_symbols: if symbol in slice.OptionChains: self.option_contracts[symbol] = slice.OptionChains[symbol] def trade_options(self, option_type_asset1, option_type_asset2): contracts = [] for i, asset in enumerate(self.assets): symbol = self.option_symbols[i] if symbol in self.option_contracts: chain = self.option_contracts[symbol] contract = self.get_atm_option(chain, option_type_asset1 if i == 0 else option_type_asset2) if contract: contracts.append(contract) if len(contracts) == 2: # Calculate the initial margin requirement for each contract initial_margin_0 = self.get_initial_margin_requirement(contracts[0], 1).Value initial_margin_1 = self.get_initial_margin_requirement(contracts[1], 1).Value # Calculate the weights for each asset weight_0 = abs(self.trading_weight[0]) weight_1 = abs(self.trading_weight[1]) # Calculate the maximum number of contracts based on 10% of the remaining buying power buying_power = self.Portfolio.Cash * 0.1 total_margin_needed = weight_0 * initial_margin_0 + weight_1 * initial_margin_1 max_num_contracts = buying_power / total_margin_needed num_contracts_0 = int(max_num_contracts * weight_0) num_contracts_1 = int(max_num_contracts * weight_1) if num_contracts_0 > 0 and num_contracts_1 > 0: # Calculate limit prices based on current market prices limit_price_0 = self.Securities[contracts[0].Symbol].Price limit_price_1 = self.Securities[contracts[1].Symbol].Price if option_type_asset1 == "call" and option_type_asset2 == "put": self.LimitOrder(contracts[0].Symbol, num_contracts_0, limit_price_0) # Buy ATM call for the undervalued asset self.LimitOrder(contracts[1].Symbol, num_contracts_1, limit_price_1) # Buy ATM put for the overvalued asset elif option_type_asset1 == "put" and option_type_asset2 == "call": self.LimitOrder(contracts[0].Symbol, num_contracts_0, limit_price_0) # Buy ATM put for the undervalued asset self.LimitOrder(contracts[1].Symbol, num_contracts_1, limit_price_1) # Buy ATM call for the overvalued asset def get_initial_margin_requirement(self, contract, quantity): parameters = InitialMarginParameters(self.Securities[contract.Symbol], quantity) initial_margin = self.Securities[contract.Symbol].BuyingPowerModel.GetInitialMarginRequirement(parameters) return initial_margin def get_atm_option(self, chain, option_type): if option_type == "call": calls = sorted([x for x in chain if x.Right == OptionRight.Call and x.Strike <= chain.Underlying.Price], key=lambda x: x.Strike) if calls: return calls[-1] elif option_type == "put": puts = sorted([x for x in chain if x.Right == OptionRight.Put and x.Strike >= chain.Underlying.Price], key=lambda x: x.Strike) if puts: return puts[0] return None