Overall Statistics |
Total Trades 0 Average Win 0% Average Loss 0% Compounding Annual Return 0% Drawdown 0% Expectancy 0 Net Profit 0% Sharpe Ratio 0 Probabilistic Sharpe Ratio 0% Loss Rate 0% Win Rate 0% Profit-Loss Ratio 0 Alpha 0 Beta 0 Annual Standard Deviation 0 Annual Variance 0 Information Ratio -0.531 Tracking Error 0.143 Treynor Ratio 0 Total Fees $0.00 |
import pandas as pd from statsmodels.regression.linear_model import OLS from statsmodels.tsa.stattools import adfuller class JamesTExampleMeanReversionAlgo(QCAlgorithm): def Initialize(self): self.SetStartDate(2015, 1, 1) self.SetEndDate(2017, 1, 1) self.SetCash(1000) # Add tradable universe self.tickers = ['MSFT', 'AAPL', 'IBM', 'TSLA', # Arbitrary universe; Test some assets manually in Research OR take a large universe, and take only significant betas in OLS 'GOOG', 'FB', 'NFLX', 'SPY'] for ticker in self.tickers: self.AddEquity(ticker, Resolution.Minute) self.significanceLevel = 0.05 self.ols_betas = None self.spread_moments = (None, None) # Historical (mean, std) of spread self.RunModel() ## Doing this means we estimate cointegration dynamics once at backtest start # Unlikely the dynamics stay constant over time, so play around with re-estimating # the coefficients in a ScheduledFunction (i.e. weekly, monthly, etc) def OnData(self, data): """ This function is called whenever new data is received from the engine. """ # Data Integrity check for ticker in self.tickers: if not data.ContainsKey(ticker): return # Ensure model converged if (self.ols_betas is None) or (self.spread_moments[0] is None) or (self.spread_moments[1] is None): return # Form our residual series tickerCloses = {} # Dict of { Security ID: Price } for ticker in self.tickers: close = data[ticker].Close tickerCloses[str(self.Symbol(ticker).ID)] = close # In RunModel(), the columns of historical data are QC's Security IDs so we map: Ticker -> Security ID spread = self.ols_betas.multiply(tickerCloses) # Transform into z-scores for interpretability spread_standardized = ( spread - self.spread_moments[0] ) / self.spread_moments[1] # Trading Logic: Short the spread if 1 standard deviation above mean if spread_standardized > 1: self.Debug('Shorting spread') for n, beta in enumerate(self.ols_betas): ticker = str(self.Symbol(self.ols_betas.index[n]).Value) # Map Security ID -> Ticker # Short positive coefficients if (beta > 0): self.SetHoldings(ticker, -beta) continue # Long negative coefficients if (beta < 0): self.SetHoldings(ticker, beta) continue # Trading Logic: Long the spread if 1 standard deviation below mean if spread_standardized < -1: self.Debug('Long spread') for n, beta in enumerate(self.ols_betas): ticker = str(self.Symbol(self.ols_betas.index[n]).Value) # Long positive coefficients if (beta > 0): self.SetHoldings(ticker, beta) continue # Short negative coefficients if (beta < 0): self.SetHoldings(ticker, -beta) continue return def RunModel(self): self.ols_betas = None # Get some historical data history = self.History(self.Securities.Keys, timedelta(weeks=52), Resolution.Minute) close = history['close'].unstack(level=0).dropna() ## Engle-Granger ## # 1. Pre-test all time series are I(1) unitRootProcesses = [] for col in close.columns: res = adfuller(close[col], regression='c', autolag='aic') if res[1] > self.significanceLevel: # Accept null hypothesis that X's are unit root I(1) processes unitRootProcesses.append(col) # 2. Run OLS of Y on X filteredData = close.loc[:, unitRootProcesses] if filteredData.empty: return Y = filteredData.iloc[:, 0] X = filteredData.iloc[:, 1:] X['Intercept'] = 1 ols_results = OLS(Y, X, hasconst=True).fit() # Use OLS to estimate coefficients of: Y = alpha + beta_1*X_1 + beta_2*X_2 + ... (*) ols_betas = ols_results.params # These are the estimates in a pandas.Series: [ Intercept=alpha, X_1=beta_1, X_2=beta_2, ...] ols_betas = dict(-ols_betas) # Take negative betas and add coefficient of 1 for Y ols_betas[filteredData.columns[0]] = 1 # Why? We are rearranging (*) into: Y - beta_1*X_1 - beta_2*X_2 - ... = alpha + epsilon ols_betas.pop('Intercept', None) # Drop the intercept term (shift spread along y-axis so it mean-reverts around 0) # Normalize ols_betas so absolute norm is 1 # We can now interpret betas as portfolio weights that make the spread stationary ols_betas = pd.Series(ols_betas) norm = ols_betas.abs().sum() ols_betas = ols_betas / norm self.ols_betas = ols_betas # 3. Form residual series (spread). Test if I(0) spread = filteredData.multiply(ols_betas, axis=1).sum(axis=1) # Given X, reconstruct: epsilon = Y - beta_1*X_1 - beta_2*X_2 - ... res = adfuller(spread, regression='nc', autolag='aic') if res[1] > self.significanceLevel: # Reject null hypothesis => Spread is stationary self.Debug('Model did not converge.') return # Compute/Store historical mean/std of spread self.spread_moments = (spread.mean(axis=0), spread.std(axis=0)) ## If the spread is stationary, then mean/variance are constant over time # In other words, if cointegration persists in future we expect spread to revert around mu with variance sigma^2 self.Debug('Model converged.')