Applying Research

Kalman Filters and Statistical Arbitrage

Introduction

This page explains how to you can use the Research Environment to develop and test a Kalman Filters and Statistical Arbitrage hypothesis, then put the hypothesis in production.

Create Hypothesis

In finance, we can often observe that 2 stocks with similar background and fundamentals (e.g. AAPL vs MSFT, SPY vs QQQ) move in similar manner. They could be correlated, although not necessary, but their price difference/sum (spread) is stationary. We call this cointegration. Thus, we could hypothesize that extreme spread could provide chance for arbitrage, just like a mean reversion of spread. This is known as pairs trading. Likewise, this could also be applied to more than 2 assets, this is known as statistical arbitrage.

However, although the fluctuation of the spread is stationary, the mean of the spread could be changing by time due to different reasons. Thus, it is important to update our expectation on the spread in order to go in and out of the market in time, as the profit margin of this type of short-window trading is tight. Kalman Filter could come in handy in this situation. We can consider it as an updater of the underlying return Markov Chain's expectation, while we're assuming the price series is a Random Process.

In this example, we're making a hypothesis on trading the spread on cointegrated assets is profitable. We'll be using forex pairs EURUSD, GBPUSD, USDCAD, USDHKD and USDJPY for this example, skipping the normalized price difference selection.

Prerequisites

You must understand how to work with pandas DataFrames and Series. If you are not familiar with pandas, refer to the pandas documentation.

Import Libraries

We'll need to import libraries to help with data processing, model building, validation and visualization. Import arch, pykalman, scipy, statsmodels, numpy, matplotlib and pandas libraries by the following:

from arch.unitroot.cointegration import engle_granger
from pykalman import KalmanFilter
from scipy.optimize import minimize
from statsmodels.tsa.vector_ar.vecm import VECM

import numpy as np
from matplotlib import pyplot as plt
from pandas.plotting import register_matplotlib_converters
register_matplotlib_converters()

Get Historical Data

To begin, we retrieve historical data for researching.

  1. Instantiate a QuantBook.
  2. qb = QuantBook()
  3. Select the desired tickers for research.
  4. assets = ["EURUSD", "GBPUSD", "USDCAD", "USDHKD", "USDJPY"]
  5. Call the AddForex method with the tickers, and their corresponding resolution. Then store their Symbols.
  6. for i in range(len(assets)):
        qb.AddForex(assets[i],Resolution.Minute)

    If you do not pass a resolution argument, Resolution.Minute is used by default.

  7. Call the History method with qb.Securities.Keys for all tickers, time argument(s), and resolution to request historical data for the symbol.
  8. history = qb.History(qb.Securities.Keys, datetime(2021, 1, 1), datetime(2021, 12, 31), Resolution.Daily)

Coinegration

We'll have to test if the assets are cointegrated. If so, we'll have to obtain the cointegration vector(s).

Cointegration Testing

  1. Select the close column and then call the unstack method.
  2. df = history['close'].unstack(level=0)
  3. Call np.log to convert the close price into log-price series to eliminate compounding effect.
  4. log_price = np.log(data)
  5. Apply Engle Granger Test to check if the series are cointegrated.
  6. coint_result = engle_granger(log_price.iloc[:, 0], log_price.iloc[:, 1:], trend='n', method='bic')

    It shows a p-value < 0.05 for the unit test, with lag-level 0. This proven the log price series are cointegrated in realtime. The spread of the 5 forex pairs are stationary.

Get Cointegration Vectors

We would use a VECM model to obtain the cointegrated vectors.

  1. Initialize a VECM model by following the unit test parameters, then fit to our data.
  2. vecm_result = VECM(log_price, k_ar_diff=0, coint_rank=len(assets)-1, deterministic='n').fit()
  3. Obtain the Beta attribute. This is the cointegration subspaces' unit vectors.
  4. beta = vecm_result.beta
  5. Check the spread of different cointegration subspaces.
  6. spread = log_price @ beta
  7. Plot the results.
  8. fig, axs = plt.subplots(beta.shape[1], figsize=(15, 15))
    fig.suptitle('Spread for various cointegrating vectors')
    for i in range(beta.shape[1]):
        axs[i].plot(spread.iloc[:, i])
        axs[i].set_title(f"The {i+1}th normalized cointegrating subspace")
    plt.show()

Optimization of Cointegration Subspaces

Although the 4 cointegratoin subspaces are not looking stationarym, we can optimize for a mean-reverting portfolio by putting various weights in different subspaces. We use the Portmanteau statistics as a proxy for the mean reversion. So we formulate:

$$\begin{equation*} \begin{aligned} & \underset{w}{\text{minimize}} & & \mathrm (\frac {w^{T}M_{1}w} {w^{T}M_{0}w}) ^{2} \\ & \text{subject to} & & w^{T}M_{0}w = \nu\\ &&& 1^Tw = 0\\ & \text{where} & & M_i \triangleq Cov(s_t, s_{t+i}) = E[(s_t - E[s_t]) (s_{t+i} - E[s_{t+i}])^T] \\ \end{aligned} \end{equation*}$$

with s is spread, v is predetermined desirable variance level (the larger the higher the profit, but lower the trading frequency)

  1. We set the weight on each vector is between -1 and 1. While overall sum is 0.
  2. 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)}]
  3. Optimize the Portmanteau statistics.
  4. 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")
  5. Normalize the result.
  6. opt.x = opt.x/np.sum(abs(opt.x))
    for i in range(len(opt.x)):
        print(f"The weight put on {i+1}th normalized cointegrating subspace: {opt.x[i]}")
  7. Plot the weighted spread.
  8. new_spread = spread @ opt.x
    new_spread.plot(title="Weighted spread", figsize=(15, 10))
    plt.ylabel("Spread")
    plt.show()

Kalman Filter

The weighted spread looks more stationary. However, the fluctuation half-life is very long accrossing zero. We aim to trade as much as we can to maximize the profit of this strategy. Kalman Filter then comes into the play. It could modify the expectation of the next step based on smoothening the prediction and actual probability distribution of return.

Image Source: Understanding Kalman Filters, Part 3: An Optimal State Estimator. Melda Ulusoy (2017). MathWorks. Retreived from: https://www.mathworks.com/videos/understanding-kalman-filters-part-3-optimal-state-estimator--1490710645421.html

  1. Initialize a KalmanFilter.
  2. In this example, we use 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].

    kalmanFilter = 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'])
    kalmanFilter = kalmanFilter.em(new_spread.iloc[:20], n_iter=5)
    (filtered_state_means, filtered_state_covariances) = kalmanFilter.filter(new_spread.iloc[:20])
  3. Obtain the current Mean and Covariance Matrix expectations.
  4. currentMean = filtered_state_means[-1, :]
    currentCov = filtered_state_covariances[-1, :]
  5. Initialize a mean series for spread normalization using the KalmanFilter's results.
  6. mean_series = np.array([None]*(new_spread.shape[0]-100))
  7. Roll over the Kalman Filter to obtain the mean series.
  8. for i in range(100, new_spread.shape[0]):
        (currentMean, currentCov) = kalmanFilter.filter_update(filtered_state_mean = currentMean,
                                                               filtered_state_covariance = currentCov,
                                                               observation = new_spread.iloc[i])
        mean_series[i-100] = float(currentMean)
  9. Obtain the normalized spread series.
  10. normalized_spread = (new_spread.iloc[100:] - mean_series)
  11. Plot the normalized spread series.
  12. plt.figure(figsize=(15, 10))
    plt.plot(normalized_spread, label="Processed spread")
    plt.title("Normalized spread series")
    plt.ylabel("Spread - Expectation")
    plt.legend()
    plt.show()

Determine Trading Threshold

Now we need to determine the threshold of entry. We want to maximize profit from each trade (variance of spread) x frequency of entry. To do so, we formulate:

$$\begin{equation*} \begin{aligned} & \underset{f}{\text{minimize}} & & \begin{Vmatrix} \bar{f} - f \end{Vmatrix}_{2}^{2} + \lambda\ \begin{Vmatrix}Df\end{Vmatrix}_{2}^{2} \\ & \text{where} & & \bar{f_j} = \frac{\sum_{t=1}^T 1_{\{spread_t\ >\ set\ level_j\}}}{T}\\ &&& D = \begin{bmatrix} 1 & -1 & & &\\ & 1 & -1 & &\\ & & \ddots & \ddots & \\ & & & 1 & -1 \end{bmatrix} \in \mathbb{R}^{(j - 1) \times j}\\ \end{aligned} \end{equation*}$$

so $f^* = (I+\lambda D^TD)^{-1}\bar{f}$

  1. Initialize 50 set levels for testing.
  2. s0 = np.linspace(0, max(normalized_spread), 50)
  3. Calculate the profit levels using the 50 set levels.
  4. 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]
  5. Set trading frequency matrix.
  6. D = np.zeros((49, 50))
    for i in range(D.shape[0]):
        D[i, i] = 1
        D[i, i+1] = -1
  7. Set level of lambda.
  8. l = 1.0
  9. Obtain the normalized profit level.
  10. 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)]
  11. Get the maximum profit level as threshold.
  12. threshold = s0[s_star.index(max(s_star))]
    print(f"The optimal threshold is {threshold}")
  13. Plot the result.
  14. plt.figure(figsize=(15, 10))
    plt.plot(s0, s_star)
    plt.title("Profit of mean-revertion trading")
    plt.xlabel("Threshold")
    plt.ylabel("Profit")
    plt.show()

Test Hypothesis

To test the hypothesis. We wish to obtain a profiting strategy.

  1. Set the trading weight. We would like the portfolio absolute total weight is 1 when trading.
  2. trading_weight = beta @ opt.x
    trading_weight /= np.sum(abs(trading_weight))
  3. Set up the trading data.
  4. testing_ret = data.pct_change().iloc[1:].shift(-1)   # Shift 1 step backward as forward return result
    equity = pd.DataFrame(np.ones((testing_ret.shape[0], 1)), index=testing_ret.index, columns=["Daily value"])
  5. Set the buy and sell preiod when the spread exceeds the threshold.
  6. buy_period = normalized_spread[normalized_spread < -threshold].index
    sell_period = normalized_spread[normalized_spread > threshold].index
  7. Trade the portfolio.
  8. equity.loc[buy_period, "Daily value"] = testing_ret.loc[buy_period] @ trading_weight + 1
    equity.loc[sell_period, "Daily value"] = testing_ret.loc[sell_period] @ -trading_weight + 1
  9. Get the total portfolio value.
  10. value = equity.cumprod()
  11. Plot the result.
  12. value.plot(title="Equity Curve", figsize=(15, 10))
    plt.ylabel("Portfolio Value")
    plt.show()

Set Up Algorithm

Once we are confident in our hypothesis, we can export this code into backtesting. One way to accomodate this model into backtest is to create a scheduled event which uses our model to predict the expected return.

def Initialize(self) -> None:

    #1. Required: Five years of backtest history
    self.SetStartDate(2014, 1, 1)

    #2. Required: Alpha Streams Models:
    self.SetBrokerageModel(BrokerageName.AlphaStreams)

    #3. Required: Significant AUM Capacity
    self.SetCash(1000000)

    #4. Required: Benchmark to SPY
    self.SetBenchmark("SPY")

    self.assets = ["EURUSD", "GBPUSD", "USDCAD", "USDHKD", "USDJPY"]
    
    # Add Equity ------------------------------------------------ 
    for i in range(len(self.assets)):
        self.AddForex(self.assets[i], Resolution.Minute)
        
    # Instantiate our model
    self.Recalibrate()
    
    # Set a variable to indicate the trading bias of the portfolio
    self.state = 0
    
    # Set Scheduled Event Method For Recalibrate Our Model Every Week.
    self.Schedule.On(self.DateRules.WeekStart(), 
        self.TimeRules.At(0, 0), 
        self.Recalibrate)
    
    # Set Scheduled Event Method For Kalman Filter updating.
    self.Schedule.On(self.DateRules.EveryDay(), 
        self.TimeRules.BeforeMarketClose("EURUSD"), 
        self.EveryDayBeforeMarketClose)

We'll also need to create a function to train and update our model from time to time. We will switch qb with self and replace methods with their QCAlgorithm counterparts as needed. In this example, this is not an issue because all the methods we used in research also exist in QCAlgorithm.

def Recalibrate(self) -> None:
    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.kalmanFilter = 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.kalmanFilter = self.kalmanFilter.em(new_spread.iloc[:20], n_iter=5)
    (filtered_state_means, filtered_state_covariances) = self.kalmanFilter.filter(new_spread.iloc[:20])
    
    # Obtain the current Mean and Covariance Matrix expectations.
    self.currentMean = filtered_state_means[-1, :]
    self.currentCov = 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.currentMean, self.currentCov) = self.kalmanFilter.filter_update(filtered_state_mean = self.currentMean,
                                                                filtered_state_covariance = self.currentCov,
                                                                observation = new_spread.iloc[i])
        mean_series[i-20] = float(self.currentMean)
    
    # 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))

Now we export our model into the scheduled event method for trading. We will switch qb with self and replace methods with their QCAlgorithm counterparts as needed. In this example, this is not an issue because all the methods we used in research also exist in QCAlgorithm.

def EveryDayBeforeMarketClose(self) -> None:
    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.currentMean, self.currentCov) = self.kalmanFilter.filter_update(filtered_state_mean = self.currentMean,
                                                                        filtered_state_covariance = self.currentCov,
                                                                        observation = spread)
        
    # Obtain the normalized spread.
    normalized_spread = spread - self.currentMean

    # ==============================
    
    # 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.SetHoldings(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.SetHoldings(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

Reference

  1. A Signal Processing Perspective on Financial Engineering. Y. Feng, D. P. Palomer (2016). Foundations and Trends in Signal Processing. 9(1-2). p173-200.

Clone Example Project

You can also see our Videos. You can also get in touch with us via Discord.

Did you find this page helpful?

Contribute to the documentation: