协整关系(cointegration)和配对交易

时间:2024-02-25 19:46:55

https://zhuanlan.zhihu.com/p/158871695

最近做的小项目是协整关系用于配对交易。顺便复习了协整和相关性之间的差异。应该来说,协整关系是时间序列的一个重点内容,多次碰到,值得注意。

一、相关性(correlation)VS协整关系(cointegration)

1、相关性(correlation)

  • 定义:通常是两个序列X 和 Y之间的线性依存关系(linear dependence),

[公式]

  • XY相关系数corr(X,Y)=0,未必独立(独立定义P(XY)=P(X)P(Y)),因为XY可能服从非线性的相关关系;而独立一定不相关。
  • corr(X,Y)=0,是指X的线性组合无法解释Y。
  • 相关系数介于[-1,1]之间—— ±1是完全线性相关。
  • 检测相关性,pearsonr检测的是具体值之间的相关性,spearsonr检测的是rank之间的相关性(券商多因子中提到的RankIC一般就是spearmanr)。
from scipy.stats import pearsonr, spearmanr
pearson_corr, pearson_pvalue = pearsonr(X, Y)
spearnman_corr, spearnman_pvalue = spearmanr(X, Y)

2、平稳性(Stationarity)

  • 定义:如果数据产生过程的参数(例如均值和方差)不随着时间变化,那么数据平稳。
  • 例如,时间序列 [公式] 的均值和方差是常数、和时间t无关,那么 [公式] 具有平稳性。
  • 如果用平稳的分析刻画一个均值/方差随时间变化的时间序列,那么将会导致garbage in garbage out。
    • 例如,下图series B,是一个随着时间t而均值不断增大的曲线。

  • 测试平稳性:使用augmented Dickey Fuller test,P值越小,说明越可能是平稳的时间序列。
from statsmodels.tsa.stattools import adfuller
pvalue = adfuller(some_series)[1]

3、单整(Integration)——不是很重要

  • 单整阶数(order of integration)
    • [公式] :如果一个时间序列Y的移动平均表达 [公式] ,满足条件 [公式] ,那么 [公式] 服从 [公式] ;其中 [公式] 是随机过程的白噪声(残差项), [公式] 是残差项的权重, [公式] 是起决定作用的序列。
    • 注意: [公式]
  • [公式] :如果一个序列X的一阶差分服从[公式] ,那么X服从[公式]
  • 实际运用:资产收益率一般被认为是服从[公式] ,资产价格被认为服从[公式] 。

4、协整(Cointegration)

  • 问题:单整 [公式] 的时间序列未必平稳,但是如果通过一阶差分得到平稳的时间序列,那么刻画的是短期的动态。这里需要协整关系。
  • 定义:如果 [公式] 和 [公式] 均为[公式],但是线性组合 [公式] ,那么[公式] 和 [公式]之间存在协整关系。
  • 通常表达, [公式] ,其中 [公式] 是平稳的时间序列,那么当回归系数 [公式] 和[公式]均和时间t无关的时候,那么就能够用 [公式] 预测 [公式] 。
  • 直觉
    • 当两个资产价格服从协整关系的时候,那么其线性组合满足均值回复(mean reverting)的性质。
    • [公式] ,那么从 [公式] 中剔除[公式]的影响之后, [公式] 是平稳的序列、满足均值回复。如果[公式]超出了这个关系,那么就能够在突破上下限的时候进行套利交易。
  • 测试协整关系
from statsmodels.tsa.stattools import coint 
_, pvalue, _ = coint(X1, X2) 

P值越小,说明X1和X2的协整关系越强。

5、协整VS相关性

  • 协整不一定相关
import pandas as pd
import numpy as np
from scipy.stats import pearsonr
from statsmodels.tsa.stattools import coint 
import matplotlib.pyplot as plt

x1 = pd.Series(np.random.normal(0,1,1000))
x2 = x1.copy()
for i in range(10):
    x2[(100*i):(100*i+100)] = (-1)**i
    
plt.figure(figsize=(6,3))
plt.plot(x1, c=\'blue\')
plt.plot(x2, c=\'red\')
plt.show()    

_, pv, _ = coint(x1, x2)
print("Cointegration test p-value : ", pv)
correl, pv = pearsonr(x1, x2)
print("Correlation : ", correl)

结果:协整关系非常显著p-value = 0.0;但是基本没有相关性。

  • 相关不一定协整
ret1 = np.random.normal(1,1,100)
ret2 = np.random.normal(2,1,100)
s1 = pd.Series(np.cumsum(ret1))
s2 = pd.Series(np.cumsum(ret2))
plt.figure(figsize=(6,3))
plt.plot(s1)
plt.plot(s2)
plt.show()
_, pv, _ = coint(s1, s2)
print("Cointegration test p-value : ", pv)
correl, pv = pearsonr(s1, s2)
print("Correlation : ", correl)

结果:相关性基本接近1,但是协整关系不显著。

因此,在配对交易策略中,协整关系是重点,相关性不用考虑。

二、配对交易策略(paris trading)

1、原理

  • 因为假设X和Y服从 [公式] 的关系,我们重点关注回归系数 [公式] 的变化。诚然,[公式]不会一直是一个固定不变的常数,我们允许[公式]在一个上下限波动;
  • [公式]超出了上限的时候,说明 [公式] “太贵”了、 [公式] “太便宜”了,对应的策略是short [公式]+long [公式]
  • [公式]超出了下限的时候,说明 [公式] “太便宜”了、 [公式] “太贵”了,对应的策略是long [公式]+short [公式]
  • 配对交易一般用于期货交易,因为期货允许short。

2、数据

链接:

提取码:uyt4

这里取出协整关系最强的期货合约“CU.SHF铜主连合约”和“SF.CZC硅铁主连合约”的日度“结算价格”数据。

import pandas as pd 
import numpy as np
import matplotlib.pyplot as plt
from statsmodels.tsa.stattools import coint 

data = pd.read_excel(r\'...\')

#测试相关性和协整关系
_, pv_coint, _ = coint(data[\'CU.SHF\'], data[\'SF.CZC\'])
corr, pv_corr = pearsonr(data[\'CU.SHF\'], data[\'SF.CZC\'])
print("Cointegration pvalue : %0.4f"%pv_coint)
print("Correlation coefficient is %0.4f and pvalue is %0.4f"%(corr, pv_corr))

结果

Cointegration pvalue : 0.0000 
Correlation coefficient is 0.8770 and pvalue is 0.0000

协整关系和相关性都很强。

3、画出结算价走势

def plotSettlePrice(df, var1, var2, var1_name, var2_name, title):
    temp = df[[var1, var2]].dropna()
    fig = plt.figure(figsize=(10,5))
    # 解决中文显示问题
    plt.rcParams[\'font.sans-serif\'] = [\'SimHei\']
    plt.rcParams[\'axes.unicode_minus\'] = False
    
    ax1 = fig.add_subplot(111)
    ax1.plot(temp[var1], c = \'blue\')
    ax1.set_ylabel(var1_name)
    ax1.set_title(title)
    plt.legend(loc = \'upper left\', labels=[var1_name])
    ax1.set_xlabel(\'年份\')    
    
    ax2 = ax1.twinx()  # this is the important function
    ax2.plot(temp[var2], c = \'orange\')
    ax2.set_ylabel(var2_name)
    ax2.set_xlabel(\'年份\')        
    plt.legend(loc = \'upper right\', labels=[var2_name])
    plt.show()    

plotSettlePrice(data, \'CU.SHF\', \'SF.CZC\', \'CU.SHF铜\', \'SF.CZC硅铁\',
                \'CU.SHF铜 和 SF.CZC硅铁 价格走势图\')

4、 计算CU.SHF结算价/SF.CZC结算价的比例Ratios

  • 这个Ratios即为 [公式] 中的 [公式] 。
  • 我们默认 [公式] 。
  • 计算结算价比例,并且画出比例的频率直方图,基本符合正态分布。
def getRatios(df, var1, var2, plotOrNot):
    df1 = df[[var1, var2]].dropna()
    S1 = df1[var1]
    S2 = df1[var2]
    ratios = S1 / S2  
    if plotOrNot:
        plt.figure(figsize=(10,5))            
        ratios.hist(bins = 200)
        plt.title("Ratios histogram")
        plt.ylabel(\'Frequency\')
        plt.xlabel(\'Intervals\')
        plt.show()
    return S1, S2, ratios

S1, S2, ratios = getRatios(data, \'CU.SHF\', \'SF.CZC\', 1)

5、Z-Score

  • 定义: [公式]
  • ZScore将交易信号Ratios标准化处理。
    • 为什么要标准化处理?
      • 因为我们需要通过价格比例Ratios找到交易信号。但是Ratios的分布取决于具体的合约;而我们希望任何两个合约输入到程序中,都能够按照固定的模式找到交易信号。
      • 怎样才是触发交易的信号?Ratios具体在哪个上限以上、才说明了CU更贵、SF更便宜,下降到哪个下限以下、才说明了相反情况?
      • 因此,我们把Ratios标准化为Z-Score,并且认为在一个标准差(-1,1)内的波动是正常的;超过一个标准差(-∞,-1)&(1, ∞)即为触发了交易信号。这样,即便选取其他的合约,也不会受到β的不同而大改程序。
  • 画出ZScore的时间走势,同时画出±1的水平直线,如果ZScore和±1的水平直线相交,我们认为触发了交易信号。
def getZScore(ratios, plotOrNot):
    
    zScore = (ratios - ratios.mean()) / ratios.std()    
    
    if plotOrNot:
        zScore.plot(figsize=(10,5))
        plt.axhline(zScore.mean(), color=\'black\')
        plt.axhline(1.0, color=\'red\', linestyle=\'--\')
        plt.axhline(-1.0, color=\'green\', linestyle=\'--\')
        plt.legend([\'Ratio z-score\', \'Mean\', \'+1\', \'-1\'])
        plt.title("zScore time series")
        plt.xlabel(\'Date\')
        plt.ylabel(\'Intervals\')

        plt.show()
    
        plt.figure(figsize=(10,5))
        zScore.hist(bins = 200)
        plt.title("zScore histogram")
        plt.ylabel(\'Frequency\')
        plt.xlabel(\'Intervals\')        
        plt.show()
    return zScore

zScore = getZScore(ratios, 1)

6、特征工程

  • ZScore作为信号过于薄弱。因为它集中的是“整个时期”的关系;而我们交易必然是一个动态变化的过程。这里需要不断计算“一段时间”的ZScore,而不是“整个时期”的ZScore。
    • 例如,在1月1号~2月1号的区间,我们得到一个ZScore,用于得到2月2号的交易信号;然后,在在1月2号~2月2号的区间,再次得到一个ZScore,用于得到2月3号的交易信号;以此类推;这是一个滚动的过程。
  • 因此,我们滚动计算一段区间的ZScore。这里,我选取了Ratios滚动5日的均值、Ratios滚动60日的均值以及Ratios滚动60日的标准差,计算真正的信号ZScore_moving。
  • 这里5、60的区间能够通过机器学习的算法进行调整,得到最好的结果的滚动区间。例如GPlearn。
  • 同时拆分训练集和测试集。
def getMovingIndex(ratios, train_pct, w1, w2, plotOrNot):
    ### w1 < w2,拆分训练集+验证集,训练集的比例是train_pct    
    length = len(ratios)
    trainLength = int(train_pct * length)
    train = ratios[:trainLength]
    test = ratios[trainLength:]
    
    # 计算指标moving_average, moving_std,以及moving_z_score:这里可以使用gplearn!!
    # 希望通过moving_z_score找到信号
    ratios_mavg1 = train.rolling(window=w1, center=False).mean()
    ratios_mavg2 = train.rolling(window=w2, center=False).mean()
    std = train.rolling(window=w2, center=False).std()
    zscore_mv = (ratios_mavg1 - ratios_mavg2) / std
    
    if plotOrNot:
        plt.figure(figsize=(10,5))
        zscore_mv.hist(bins = 200)
        plt.title("zScore with signals histogram")
        plt.ylabel(\'Frequency\')
        plt.xlabel(\'Intervals\')        
        plt.show()        
        
        plt.figure(figsize=(10,5))
        plt.plot(train.index, train.values)
        plt.plot(ratios_mavg1.index, ratios_mavg1.values)
        plt.plot(ratios_mavg2.index, ratios_mavg2.values)
        plt.legend([\'Ratio\',\'%dd Ratio MA\'%w1, \'%dd Ratio MA\'%w2])
        plt.ylabel(\'Ratio\')
        plt.show()    
    
        plt.figure(figsize=(10,5))
        zscore_mv.plot()
        plt.axhline(0, color=\'black\')
        plt.axhline(1.0, color=\'red\', linestyle=\'--\')
        plt.axhline(-1.0, color=\'green\', linestyle=\'--\')
        plt.legend(loc=\'upper right\', labels = [\'Rolling Ratio z-Score\', \'Mean\', \'+1\', \'-1\'])
        plt.show()
    return train, test, zscore_mv

train, test, zscore_mv = getMovingIndex(ratios, 0.7, 5, 60, 1)

7、找到交易信号

  • 在得到了ZScore_mv和±1相交的点之后,即为触发了交易信号。这里再把交易信号投射到Ratios曲线上。
  • 信号-1<= zscore_mv <=1,那么buy=0和sell=0;
    • 注意这里的buy是指“buy ratios”,sell是指“sell ratios”;注意这里定义的buy和sell的操作是同时操作两个合约。
  • buy只有在信号<-1的时候出现,long CU.SHF,short SF.CZC;
  • sell只有在信号>1的时候出现,short CU.SHF,long SF.CZC。
def getTradeSignal(train, zscore_mv, w2, plotOrNot):
    # Plot the ratios and buy and sell signals from z score
    plt.figure(figsize=(10,5))

    train[w2:].plot()
    buy = train.copy()
    sell = train.copy()
    
    # 信号ratios = CU.SHF / SF.CZC,衍生出buy和sell。
    # 其他时候ratios = 0.
    buy[zscore_mv > -1] = 0
    sell[zscore_mv < 1] = 0
    
    if plotOrNot:
        buy[60:].plot(color=\'g\', linestyle=\'None\', marker=\'^\')
        sell[60:].plot(color=\'r\', linestyle=\'None\', marker=\'^\')
        x1, x2, y1, y2 = plt.axis()
        plt.axis((x1,x2,ratios.min(),ratios.max()))
        plt.legend([\'Ratio\', \'Buy Signal\', \'Sell Signal\'])
        plt.show()    

    return buy, sell

buy, sell = getTradeSignal(train, zscore_mv, 60, 1)

8、两个合约具体交易

  • 将Ratios上找到的交易信号,再次投射到具体的合约上。
def Trade2Contract(df, var1, var2, buy, sell, w2):
    S1, S2, ratios = getRatios(df, var1, var2, 0)
    plt.figure(figsize=(10,5))
    S1 = S1.reindex(index = buy.index)
    S2 = S2.reindex(index = buy.index)
    S1[w2:].plot(color=\'b\')
    S2[w2:].plot(color=\'c\')

    # buyR和sellR先填充0。
    buyR = 0 * S1.copy()
    sellR = 0 * S1.copy()
    
    # 即buy只有在信号ratios<-1的时候保持ratios原值,此刻long S1=CU.SHF,short S2=SF.CZC
    buyR[buy!=0] = S1[buy!=0]
    sellR[buy!=0] = S2[buy!=0]

    # 即sell只有在信号ratios>1的时候保持ratios原值,此刻short S1=CU.SHF,long S2=SF.CZC。
    buyR[sell != 0] = S2[sell != 0]
    sellR[sell != 0] = S1[sell != 0]

    buyR[w2:].plot(color=\'g\', linestyle=\'None\', marker=\'^\')
    sellR[w2:].plot(color=\'r\', linestyle=\'None\', marker=\'^\')
    x1, x2, y1, y2 = plt.axis()
    plt.axis((x1, x2, min(S1.min(), S2.min()),max(S1.max(), S2.max())))

    plt.legend([var1, var2, \'Buy Signal\', \'Sell Signal\'])
    plt.show()    

Trade2Contract(data, \'CU.SHF\', \'SF.CZC\', buy, sell, 60)

9、总体

  • 在连续日度交易的程序中,我们需要注意
    • 原始资金=0,通过short一方合约得到资金、再long另一方合约,因此是“空手套白狼”;
    • 当ZScore_mv<-1,buy "ratios"——long CU + short SF,那么CU的仓位+,SF仓位-;
    • 当ZScore_mv >1,sell "ratios"——short CU + long SF,那么CU的仓位-,SF仓位+;
    • 当ZScore_mv回落到[-0.5,0.5]之内,我们进行反向操作、立刻清仓,所有仓位清零;同时赚的钱=开仓的钱-清仓的钱。
    • 注意,第i日的得到的信号,用于第i+1日的交易,不要用到未来信息。
def PairsTrade(S1, S2, window1, window2):
    # If window length is 0, algorithm doesn\'t make sense, so exit
    if (window1 == 0) or (window2 == 0):
        return 0
    # Compute rolling mean and rolling standard deviation
    ratios = S1 / S2
    mv_ave1 = ratios.rolling(window=window1, center=False).mean()
    mv_ave2 = ratios.rolling(window=window2, center=False).mean()
    mv_std = ratios.rolling(window=window2, center=False).std()
    zscore = (mv_ave1 - mv_ave2) / mv_std
    
    # Simulate trading
    # Start with no money and no positions
    money = 0
    countS1 = 0
    countS2 = 0
    length = len(ratios)
    for i in range(length-1):
        
        # 如果信号zscore > 1,那么short s1(s1仓位-1),得到的钱long s2*ratios(s1仓位+1*ratios)。
        if zscore[i] > 1:
            money += S1[i+1] - S2[i+1] * ratios[i+1]
            countS1 -= 1
            countS2 += ratios[i+1]
        
        # 如果信号zscore < -1,那么short ratios*s2,得到的钱long s1。
        elif zscore[i] < -1:
            money -= S1[i+1] - S2[i+1] * ratios[i+1]
            countS1 += 1
            countS2 -= ratios[i+1]
        
        # 如果信号zscore处在(-0.5, 0.5)之间,清仓——反向操作,用此刻的价格*此刻的仓位作为清仓的成本。同时仓位清零。
        elif abs(zscore[i]) < 0.5:
            money += countS1 * S1[i+1] + countS2 * S2[i+1]
            countS1 = 0
            countS2 = 0            
            
    return money

print(PairsTrade(S1, S2, 5, 60))

最后结果为804202.5827628284。

10、改进

  • 信号能够通过gplearn进行改进。
  • 具体的交易需要考虑手续费以及取整等问题。
  • 便于更多的合约进入。
  • 回测框架。

 

以上就是配对交易的基本思想。欢迎评论~