一个运用SPY与IWM之间均值回归的日内交易策略

Author: , Created: 2019-07-01 11:47:08, Updated: 2023-10-26 20:07:32

img

在本文中,我们将编写一个日内交易策略。它将使用经典的交易理念,即“均值回归交易对”。在这个例子中,我们将利用两只交易型开放式指数基金(ETF),SPY和IWM,它们在纽约证券交易所(NYSE)交易,并试图代表美国股市指数,分别是“S&P500和“Russell 2000"。

该策略通过做多一种ETF和做空另一种ETF来创造一个“利差”。多空比可以用许多方式定义,例如利用统计协整时间序列的方法。在这种场景中,我们将通过滚动线性回归计算SPY和IWM之间的对冲比率。这将允许我们在SPY和IWM之间创建“利差”,其被标准化为z-score。当z-score超过某一阈值时,将产生交易信号,因为我们相信这个“利差”将恢复到均值。

该策略的基本原理是,SPY和IWM都大致代表了同样的市场情况,即一组大型和小型美国公司的股价表现。前提是,如果接受价格的“均值回归”理论,那么它总有回归的时候,因为“事件”可能会在很短的时间内分别影响S&P500和Russell 2000,但他们之间的“利差”总会回归到正常的均值,且两者长期的价格序列总是协整(cointegrated)的。

策略

该策略按以下步骤执行:

数据 - 从2007年4月到2014年2月,分别获得SPY和IWM的1分钟k线图。

处理 - 把数据正确对齐,并且删除相互有缺少的k线。(只要有一边缺少,则两边都删除)

差价 - 两只ETF之间的对冲比率采用滚动线性回归计算。定义为使用回溯窗口的β回归系数,该回溯窗口向前移动1根k线并重新计算回归系数。因此,对冲比例βi,bi根K线是通过计算跨越点从bi-1-k到bi-1,以用来回溯k线。

Z-Score - 标准利差的值是以通常的方式来计算。这意味着减去利差的均值(样本)并除以利差的标准差(样本)。这样做的理由是使阈值参数更容易理解,因为Z-Score是一个无量纲的量(dimensionless quantity)。我有意在计算中引入了“前视偏差”,以显示它会有多么微妙。试试看吧!

交易 - 当负z-score值下降到低于预定(或后优化)阈值时产生做多信号,而做空信号则与此相反。当z-score的绝对值降至额外阈值以下时,将生成平仓信号。对于这个策略,我(有点随意)选择了|z| = 2作为开仓阈值,|z| = 1作为平仓阈值。假设均值回归在利差中发挥作用,以上这些将有望捕捉到这种套利关系并提供不错的利润。

也许深入理解策略的最佳方式是实际实现它。以下部分详细介绍了用于实现此均值回归策略的完整Python代码(单个文件)。我已经添加了详细的代码注释,以帮助你更好的理解。

Python实现

与所有Python/pandas教程一样,必须按照本教程中描述的Python环境来设置。设置完成后,第一项任务是导入必要的Python库。这个对于使用matplotlib和pandas是必需的。

我使用的特定库版本如下:

Python - 2.7.3 NumPy - 1.8.0 pandas - 0.12.0 matplotlib - 1.1.0

让我们继续并导入这些库:

# mr_spy_iwm.py

import matplotlib.pyplot as plt
import numpy as np
import os, os.path
import pandas as pd

以下函数create_pairs_dataframe导入两个包含两个symbol的日内k线的CSV文件。在我们的例子中,这将是SPY和IWM。然后它会创建一个单独的“数据帧对”,这个“数据帧对”将使用两个原始文件的索引。由于错过的交易和错误,它们的时间戳可能会有所不同。这是使用像pandas这样的数据分析库的主要好处之一。我们以非常有效的方式处理“样板”代码。

# mr_spy_iwm.py
def create_pairs_dataframe(datadir, symbols):
    """Creates a pandas DataFrame containing the closing price
    of a pair of symbols based on CSV files containing a datetime
    stamp and OHLCV data."""
 
    # Open the individual CSV files and read into pandas DataFrames
    print "Importing CSV data..."
    sym1 = pd.io.parsers.read_csv(os.path.join(datadir, '%s.csv' % symbols[0]),
                                  header=0, index_col=0, 
                                  names=['datetime','open','high','low','close','volume','na'])
    sym2 = pd.io.parsers.read_csv(os.path.join(datadir, '%s.csv' % symbols[1]),
                                  header=0, index_col=0, 
                                  names=['datetime','open','high','low','close','volume','na'])
 
    # Create a pandas DataFrame with the close prices of each symbol
    # correctly aligned and dropping missing entries
    print "Constructing dual matrix for %s and %s..." % symbols    
    pairs = pd.DataFrame(index=sym1.index)
    pairs['%s_close' % symbols[0].lower()] = sym1['close']
    pairs['%s_close' % symbols[1].lower()] = sym2['close']
    pairs = pairs.dropna()
    return pairs

下一步是在SPY和IWM之间进行滚动线性回归。在这个场景中,IWM是预测器(‘x’),SPY是响应(‘y’)。我设置了一个100根k线的默认回溯窗口。如上所述,这是策略的参数。为了使策略被认为是稳健的,我们理想地希望能看到一份返回报告在回溯期呈现出凸函数状态(或其他性能度量表现)。因此,在代码的后期阶段,我们将通过改变范围内的回顾期来进行灵敏度分析。

在计算完SPY-IWM的线性回归模型中滚动β系数,将其添加到DataFrame对并删除空行。这就构建好了第一组K线,其等于回溯长度的修剪度量。然后,我们创建了两只ETF的利差,分别为SPY的单位和IWM的-βi的单位。显然,这不是现实的情况,因为我们正在采用少量的IWM,这在实际实施中是不可能的。

最后,我们创建利差的z-score,通过减去利差的平均值并通过标准化利差的标准差来计算。需要注意的是这里有一个相当微妙的“前视偏差”存在。我故意将其留在代码中,因为我想强调在研究中犯这样的错误是多么容易。计算整个利差时间序列的平均值和标准差。如果这是为了反映真实的历史准确性,那么这些信息将无法获得,因为它隐含地利用了未来的信息。因此,我们应该使用滚动均值和stdev来计算z-score。

# mr_spy_iwm.py
 
def calculate_spread_zscore(pairs, symbols, lookback=100):
    """Creates a hedge ratio between the two symbols by calculating
    a rolling linear regression with a defined lookback period. This
    is then used to create a z-score of the 'spread' between the two
    symbols based on a linear combination of the two."""
     
    # Use the pandas Ordinary Least Squares method to fit a rolling
    # linear regression between the two closing price time series
    print "Fitting the rolling Linear Regression..."
    model = pd.ols(y=pairs['%s_close' % symbols[0].lower()], 
                   x=pairs['%s_close' % symbols[1].lower()],
                   window=lookback)
 
    # Construct the hedge ratio and eliminate the first 
    # lookback-length empty/NaN period
    pairs['hedge_ratio'] = model.beta['x']
    pairs = pairs.dropna()
 
    # Create the spread and then a z-score of the spread
    print "Creating the spread/zscore columns..."
    pairs['spread'] = pairs['spy_close'] - pairs['hedge_ratio']*pairs['iwm_close']
    pairs['zscore'] = (pairs['spread'] - np.mean(pairs['spread']))/np.std(pairs['spread'])
    return pairs

在create_long_short_market_signals中,创建交易信号。这些是通过z-score的值超过阈值来计算的。当z-score的绝对值小于或等于另一个(幅度较小)阈值时,给出平仓信号。

为了实现这种情况,有必要为每根k线确立交易策略是“开仓”还是“平仓”。 long_market和short_market是定义的两个变量,用于跟踪多头和空头头寸。不幸的是,与向量化方法相比,以迭代方式编程更加简单,因此计算起来很慢。尽管1分钟的k线图每个CSV文件需要约700,000个数据点,但在我的旧台式机上计算它仍然相对较快!

要迭代一个pandas DataFrame(这无疑是一个不常见的操作),有必要使用iterrows方法,它提供了一个迭代的生成器:

# mr_spy_iwm.py
 
def create_long_short_market_signals(pairs, symbols, 
                                     z_entry_threshold=2.0, 
                                     z_exit_threshold=1.0):
    """Create the entry/exit signals based on the exceeding of 
    z_enter_threshold for entering a position and falling below
    z_exit_threshold for exiting a position."""
 
    # Calculate when to be long, short and when to exit
    pairs['longs'] = (pairs['zscore'] <= -z_entry_threshold)*1.0
    pairs['shorts'] = (pairs['zscore'] >= z_entry_threshold)*1.0
    pairs['exits'] = (np.abs(pairs['zscore']) <= z_exit_threshold)*1.0
 
    # These signals are needed because we need to propagate a
    # position forward, i.e. we need to stay long if the zscore
    # threshold is less than z_entry_threshold by still greater
    # than z_exit_threshold, and vice versa for shorts.
    pairs['long_market'] = 0.0
    pairs['short_market'] = 0.0
 
    # These variables track whether to be long or short while
    # iterating through the bars
    long_market = 0
    short_market = 0
 
    # Calculates when to actually be "in" the market, i.e. to have a
    # long or short position, as well as when not to be.
    # Since this is using iterrows to loop over a dataframe, it will
    # be significantly less efficient than a vectorised operation,
    # i.e. slow!
    print "Calculating when to be in the market (long and short)..."
    for i, b in enumerate(pairs.iterrows()):
        # Calculate longs
        if b[1]['longs'] == 1.0:
            long_market = 1            
        # Calculate shorts
        if b[1]['shorts'] == 1.0:
            short_market = 1
        # Calculate exists
        if b[1]['exits'] == 1.0:
            long_market = 0
            short_market = 0
        # This directly assigns a 1 or 0 to the long_market/short_market
        # columns, such that the strategy knows when to actually stay in!
        pairs.ix[i]['long_market'] = long_market
        pairs.ix[i]['short_market'] = short_market
    return pairs

在这个阶段,我们更新了pairs以包含实际的多,空信号,这使我们能够确定我们是否需要开仓。现在我们需要创建一个投资组合来跟踪头寸的市场价值。第一项任务是创建一个结合了多头信号和空头信号的位置列。这将包含一列元素从(1,0,-1),其中1表示多头仓位,0代表无仓位(应该平仓),以及-1代表空头仓位。sym1和sym2列表示每根k线结束时SPY和IWM位置的市场价值。

一旦创建了ETF市场价值,我们将它们相加以在每根k线结束时产出总市值。然后通过该对象的pct_change方法将其转换为返回值。后续的代码行清除了错误的条目(NaN和inf元素),最后计算了完整的权益曲线。

# mr_spy_iwm.py
 
def create_portfolio_returns(pairs, symbols):
    """Creates a portfolio pandas DataFrame which keeps track of
    the account equity and ultimately generates an equity curve.
    This can be used to generate drawdown and risk/reward ratios."""
     
    # Convenience variables for symbols
    sym1 = symbols[0].lower()
    sym2 = symbols[1].lower()
 
    # Construct the portfolio object with positions information
    # Note that minuses to keep track of shorts!
    print "Constructing a portfolio..."
    portfolio = pd.DataFrame(index=pairs.index)
    portfolio['positions'] = pairs['long_market'] - pairs['short_market']
    portfolio[sym1] = -1.0 * pairs['%s_close' % sym1] * portfolio['positions']
    portfolio[sym2] = pairs['%s_close' % sym2] * portfolio['positions']
    portfolio['total'] = portfolio[sym1] + portfolio[sym2]
 
    # Construct a percentage returns stream and eliminate all 
    # of the NaN and -inf/+inf cells
    print "Constructing the equity curve..."
    portfolio['returns'] = portfolio['total'].pct_change()
    portfolio['returns'].fillna(0.0, inplace=True)
    portfolio['returns'].replace([np.inf, -np.inf], 0.0, inplace=True)
    portfolio['returns'].replace(-1.0, 0.0, inplace=True)
 
    # Calculate the full equity curve
    portfolio['returns'] = (portfolio['returns'] + 1.0).cumprod()
    return portfolio

主函数将它们结合在一起。日内CSV文件位于datadir路径。请务必修改以下代码以指向你的特定目录。

为了确定策略对lookback周期的敏感程度,有必要计算一系列lookback的性能指标。我选择了投资组合的最终总回报百分比作为绩效指标和lookback范围[50,200],增量为10.你可以在下面的代码中看到,之前的函数包含在此范围内的for循环中,其他阈值保持不变。· 最后的任务是使用matplotlib创建lookbacks对比returns的折线图:

# mr_spy_iwm.py
 
if __name__ == "__main__":
    datadir = '/your/path/to/data/'  # Change this to reflect your data path!
    symbols = ('SPY', 'IWM')
 
    lookbacks = range(50, 210, 10)
    returns = []
 
    # Adjust lookback period from 50 to 200 in increments
    # of 10 in order to produce sensitivities
    for lb in lookbacks: 
        print "Calculating lookback=%s..." % lb
        pairs = create_pairs_dataframe(datadir, symbols)
        pairs = calculate_spread_zscore(pairs, symbols, lookback=lb)
        pairs = create_long_short_market_signals(pairs, symbols, 
                                                z_entry_threshold=2.0, 
                                                z_exit_threshold=1.0)
 
        portfolio = create_portfolio_returns(pairs, symbols)
        returns.append(portfolio.ix[-1]['returns'])
 
    print "Plot the lookback-performance scatterchart..."
    plt.plot(lookbacks, returns, '-o')
    plt.show()

img

现在可以看到lookbacks与returns的图表。请注意,lookback有一个“全局”最大值,等于110根k线。如果我们看到lookbacks与returns无关的情况,这是因为:

SPY-IWM线性回归对冲比lookback期敏感性分析

没有向上倾斜的利润曲线,任何回测文章都是不完整的!因此,如果你希望绘制累积利润回报与时间的曲线,则可以使用以下代码。它将绘制从lookback参数研究中生成的最终投资组合。因此,有必要根据你希望可视化的图表选择lookback。该图表还绘制了同期SPY的回报以帮助比较:

# mr_spy_iwm.py
 
    # This is still within the main function
    print "Plotting the performance charts..."
    fig = plt.figure()
    fig.patch.set_facecolor('white')
 
    ax1 = fig.add_subplot(211,  ylabel='%s growth (%%)' % symbols[0])
    (pairs['%s_close' % symbols[0].lower()].pct_change()+1.0).cumprod().plot(ax=ax1, color='r', lw=2.)
 
    ax2 = fig.add_subplot(212, ylabel='Portfolio value growth (%%)')
    portfolio['returns'].plot(ax=ax2, lw=2.)
 
    fig.show()

以下权益曲线图表的lookback期为100天:

img

SPY-IWM线性回归对冲比lookback期敏感性分析

请注意,在金融危机期间,2009年SPY的缩减幅度很大。该策略在此阶段也处于动荡期。另请注意,由于SPY在此期间的强烈趋势性质反映了标准普尔500指数,去年业绩有所恶化。

请注意,在计算z-score的利差时,我们仍然需要考虑“前视偏差”。此外,所有这些计算都是在没​​有交易成本的情况下进行的。一旦考虑到这些因素,这种策略肯定会表现得很差。手续费和滑点目前都是未确定的。此外,该策略是以ETF的小数单位进行交易,这也是非常不现实的。

在以后的文章中,我们将创造一个更为复杂的事件驱动backtester将会把以上这些因素考虑在内,使我们在资金曲线和性能指标表现出更多的信心。


Related

More