Overall Statistics
Total Orders
2616
Average Win
0.09%
Average Loss
-0.08%
Compounding Annual Return
-1.245%
Drawdown
10.000%
Expectancy
-0.055
Start Equity
1000000.00
End Equity
939195.20
Net Profit
-6.080%
Sharpe Ratio
-1.232
Sortino Ratio
-1.403
Probabilistic Sharpe Ratio
0.004%
Loss Rate
57%
Win Rate
43%
Profit-Loss Ratio
1.19
Alpha
-0.022
Beta
0.004
Annual Standard Deviation
0.017
Annual Variance
0
Information Ratio
-0.65
Tracking Error
0.111
Treynor Ratio
-5.099
Total Fees
$1092.77
Estimated Strategy Capacity
$55000000.00
Lowest Capacity Asset
USDJPY 8G
Portfolio Turnover
30.55%
#region imports
from AlgorithmImports import *
from pykalman import KalmanFilter
from scipy.optimize import minimize
from statsmodels.tsa.vector_ar.vecm import VECM
#endregion

class KalmanFilterStatisticalArbitrageDemo(QCAlgorithm):
    
    def initialize(self):
        #1. Required: Five years of backtest history
        self.set_start_date(2014, 1, 1)
        self.set_end_date(2019, 1, 1)
    
        #2. Required: Alpha Streams Models:
        self.set_brokerage_model(BrokerageName.ALPHA_STREAMS)
    
        #3. Required: Significant AUM Capacity
        self.set_cash(1000000)
    
        #4. Required: Benchmark to SPY
        self.set_benchmark("SPY")
    
        self.assets = ["EURUSD", "GBPUSD", "USDCAD", "USDHKD", "USDJPY"]
        
        # Add Equity ------------------------------------------------ 
        for i in range(len(self.assets)):
            self.add_forex(self.assets[i], Resolution.MINUTE).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.date_rules.week_start(), 
            self.time_rules.at(0, 0), 
            self.recalibrate)
        
        # Set Scheduled Event Method For Kalman Filter updating.
        self.schedule.on(self.date_rules.every_day(), 
            self.time_rules.before_market_close("EURUSD"), 
            self.every_day_before_market_close)
            
            
    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
        # Initialize a VECM model following the unit test parameters, then fit to our data.
        vecm_result = VECM(log_price, k_ar_diff=0, coint_rank=len(self.assets)-1, deterministic='n').fit()
        
        # Obtain the Beta attribute. This is the cointegration subspaces' unit vectors.
        beta = vecm_result.beta
        
        # Check the spread of different cointegration subspaces.
        spread = log_price @ beta
        
        ### Optimization of Cointegration Subspaces
        # We set the weight on each vector is between -1 and 1. While overall sum is 0.
        x0 = np.array([-1**i/beta.shape[1] for i in range(beta.shape[1])])
        bounds = tuple((-1, 1) for i in range(beta.shape[1]))
        constraints = [{'type':'eq', 'fun':lambda x: np.sum(x)}]
        
        # Optimize the Portmanteau statistics
        opt = minimize(lambda w: ((w.T @ np.cov(spread.T, spread.shift(1).fillna(0).T)[spread.shape[1]:, :spread.shape[1]] @ w)/(w.T @ np.cov(spread.T) @ w))**2,
                       x0=x0,
                       bounds=bounds,
                       constraints=constraints,
                       method="SLSQP")
        
        # Normalize the result
        opt.x = opt.x/np.sum(abs(opt.x))
        new_spread = spread @ opt.x
        
        ### 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 = new_spread.iloc[:20].mean(),
                          observation_covariance = new_spread.iloc[:20].var(),
                          em_vars=['transition_covariance', 'initial_state_covariance'])
        self.kalman_filter = self.kalman_filter.em(new_spread.iloc[:20], n_iter=5)
        (filtered_state_means, filtered_state_covariances) = self.kalman_filter.filter(new_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]*(new_spread.shape[0]-20))
        
        # Roll over the Kalman Filter to obtain the mean series.
        for i in range(20, new_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 = new_spread.iloc[i])
            mean_series[i-20] = float(self.current_mean)
        
        # Obtain the normalized spread series.
        normalized_spread = (new_spread.iloc[20:] - mean_series)
        
        ### Determine Trading Threshold
        # Initialize 50 set levels for testing.
        s0 = np.linspace(0, max(normalized_spread), 50)
        
        # Calculate the profit levels using the 50 set levels.
        f_bar = np.array([None]*50)
        for i in range(50):
            f_bar[i] = len(normalized_spread.values[normalized_spread.values > s0[i]])               / normalized_spread.shape[0]
            
        # Set trading frequency matrix.
        D = np.zeros((49, 50))
        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(50) + l * D.T@D) @ f_bar.reshape(-1, 1)
        s_star = [f_star[i]*s0[i] for i in range(50)]
        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.
        trading_weight = beta @ opt.x
        self.trading_weight = trading_weight / np.sum(abs(trading_weight))
        
            
    def every_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 = 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:
            orders = []
            for i in range(len(self.assets)):
                orders.append(PortfolioTarget(self.assets[i], self.trading_weight[i]))
                self.set_holdings(orders)
                
            self.state = 1
                
        elif normalized_spread > self.threshold:
            orders = []
            for i in range(len(self.assets)):
                orders.append(PortfolioTarget(self.assets[i], -1 * self.trading_weight[i]))
                self.set_holdings(orders)
                
            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