在本文中,我们将编写一个日内交易策略。它将使用经典的交易理念,即“均值回归交易对”。在这个例子中,我们将利用两只交易型开放式指数基金(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()
现在可以看到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天:
SPY-IWM线性回归对冲比lookback期敏感性分析
请注意,在金融危机期间,2009年SPY的缩减幅度很大。该策略在此阶段也处于动荡期。另请注意,由于SPY在此期间的强烈趋势性质反映了标准普尔500指数,去年业绩有所恶化。
请注意,在计算z-score的利差时,我们仍然需要考虑“前视偏差”。此外,所有这些计算都是在没有交易成本的情况下进行的。一旦考虑到这些因素,这种策略肯定会表现得很差。手续费和滑点目前都是未确定的。此外,该策略是以ETF的小数单位进行交易,这也是非常不现实的。
在以后的文章中,我们将创造一个更为复杂的事件驱动backtester将会把以上这些因素考虑在内,使我们在资金曲线和性能指标表现出更多的信心。