# Applying Research

## Kalman Filters and Stat Arb

### 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.

### 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 AddForexadd_forex method with the tickers, and their corresponding resolution. Then store their Symbols.
6. for i in range(len(assets)):
qb.add_forex(assets[i],Resolution.MINUTE)

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

7. Call the Historyhistory method with qb.Securities.Keysqb.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)

### Cointegration

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))
for i in range(beta.shape[1]):
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]}")
8. new_spread = spread @ opt.x
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],
em_vars=['transition_covariance', 'initial_state_covariance'])
(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,
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.legend()
plt.show()

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]
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.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
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.set_start_date(2014, 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"]

for i in range(len(self.assets)):

# 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.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)

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.

### 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
x0=x0,
bounds=bounds,
constraints=constraints,
method="SLSQP")

# Normalize the result
opt.x = opt.x/np.sum(abs(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],
em_vars=['transition_covariance', 'initial_state_covariance'])

# 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.

# Roll over the Kalman Filter to obtain the mean series.
(self.currentMean, self.currentCov) = self.kalmanFilter.filter_update(filtered_state_mean = self.currentMean,
filtered_state_covariance = self.currentCov,
mean_series[i-20] = float(self.currentMean)

# Obtain the normalized spread series.

# Initialize 50 set levels for testing.

# Calculate the profit levels using the 50 set levels.
f_bar = np.array([None]*50)
for i in range(50):

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.
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)

# 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,

# ==============================

# Mean-reversion
orders = []
for i in range(len(self.assets)):
self.SetHoldings(orders)

self.state = 1

orders = []
for i in range(len(self.assets)):
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.