使用 pandas 研究 Python 中的回测环境
回测是将交易策略理念应用于历史数据以确定过去表现的研究过程。具体而言,回测人员无法保证策略的未来表现。然而,它们是策略管道研究过程的重要组成部分,允许在将策略投入生产之前对其进行筛选。
本文(以及后续文章)将概述一个用 Python 编写的基本面向对象回测系统。这个早期系统主要是一个“教学辅助工具”,用于演示回测系统的不同组件。随着我们阅读文章的深入,我们将添加更复杂的功能。
回测概述
设计一个强大的回测系统的过程极其困难。有效地模拟影响算法交易系统性能的所有组件是一项挑战。数据粒度差、经纪商的订单路由不透明、订单延迟和无数其他因素共同改变了策略的“真实”性能与回测性能。
在开发回测系统时,人们很容易想不断地“从头开始重写它”,因为越来越多的因素在评估性能方面至关重要。没有一个回测系统是永远完成的,在开发过程中必须判断系统是否已经捕获了足够多的因素。
考虑到这些问题,这里介绍的回测器会有些简单。随着我们进一步探讨问题(投资组合优化、风险管理、交易成本处理),回测器将变得更加强大。
回测系统的类型
通常有两种类型的回测系统值得关注。第一种是基于研究的,主要用于早期阶段,其中将测试许多策略以选择那些进行更认真评估的策略。这些研究回测系统通常用 Python、R 或 MatLab 编写,因为在此阶段开发速度比执行速度更重要。
第二种回测系统是基于事件的。也就是说,它在与交易执行系统本身类似(如果不是完全相同)的执行循环中执行回测过程。它将真实地模拟市场数据和订单执行过程,以便对策略进行更严格的评估。
后者的系统通常使用 C++ 或 Java 等高性能语言编写,执行速度至关重要。对于较低频率的策略(尽管仍为日内策略),在这种情况下使用 Python 绰绰有余。
Python 中的面向对象研究回测器
现在将讨论面向对象研究的回测环境的设计和实现。选择面向对象作为软件设计范例的原因如下:
- 可以预先指定每个组件的接口,而随着项目的进展,可以修改(或替换)每个组件的内部结构
- 通过预先指定接口,可以有效地测试每个组件的行为(通过单元测试)
- 扩展系统时,可以通过继承或组合的方式,在其他组件的基础上构建新组件
在此阶段,回测器的设计易于实施,具有合理的灵活性,但牺牲了真实的市场准确性。特别是,此回测器只能处理作用于单一工具的策略。稍后,回测器将进行修改以处理多组工具。对于初始回测器,需要以下组件:
- Strategy - Strategy 类接收 Pandas DataFrame 的条形图,即特定频率的开盘-最高-最低-收盘-成交量 (OHLCV) 数据点列表。该策略将生成一个信号列表,其中包含时间戳和集合中的元素{1,0,−1}分别表示长信号、保持信号或短信号。
- Portfolio - 大部分回测工作将在 Portfolio 类中进行。它将接收一组信号(如上所述)并创建一系列头寸,以对应现金部分。Portfolio 对象的工作是生成权益曲线、纳入基本交易成本并跟踪交易。
- 绩效- 绩效对象采用投资组合并生成一组有关其绩效的统计数据。特别是它将输出风险/回报特征(夏普比率、索提诺比率和信息比率)、交易/利润指标和回撤信息。
缺少了什么?
可以看出,该回测器不包含任何对投资组合/风险管理、执行处理(即无限价订单)的引用,也不会提供复杂的交易成本建模。这在现阶段不是什么大问题。它使我们熟悉了创建面向对象的回测器和 Pandas/NumPy 库的过程。随着时间的推移,它会得到改进。
执行
我们现在将继续概述每个对象的实现。
战略
在此阶段,策略对象必须非常通用,因为它将处理预测、均值回归、动量和波动性策略。这里考虑的策略将始终基于时间序列,即“价格驱动”。此回测程序的早期要求是派生的策略类将接受条形图列表 (OHLCV) 作为输入,而不是分笔报价(逐笔交易价格)或订单簿数据。因此,这里考虑的最细粒度将是 1 秒条形图。
Strategy 类也将始终生成信号建议。这意味着它将为Portfolio 实例提供做多/做空或持仓的建议。这种灵活性使我们能够创建多个 Strategy “顾问”,它们提供一组信号,更高级的 Portfolio 类可以接受这些信号,以确定实际要进入的仓位。
类的接口将通过利用抽象基类方法来实现。抽象基类是无法实例化的对象,因此只能创建派生类。Python 代码在下面的文件中给出backtest.py
。Strategy 类要求任何子类都实现该generate_signals
方法。
为了防止 Strategy 类被直接实例化(因为它是抽象的!),必须使用模块中的ABCMeta
和abstractmethod
对象abc
。我们设置类的一个属性,称为__metaclass__
等于ABCMeta
,然后用装饰器装饰该方法。generate_signals``abstractmethod
# backtest.py
from abc import ABCMeta, abstractmethod
class Strategy(object):
"""Strategy is an abstract base class providing an interface for
all subsequent (inherited) trading strategies.
The goal of a (derived) Strategy object is to output a list of signals,
which has the form of a time series indexed pandas DataFrame.
In this instance only a single symbol/instrument is supported."""
__metaclass__ = ABCMeta
@abstractmethod
def generate_signals(self):
"""An implementation is required to return the DataFrame of symbols
containing the signals to go long, short or hold (1, -1 or 0)."""
raise NotImplementedError("Should implement generate_signals()!")
虽然上述接口很简单,但当每个特定类型的策略都继承此类时,它将变得更加复杂。最终,此设置中的 Strategy 类的目标是为要发送到 Portfolio 的每种工具提供多头/空头/持有信号列表。
文件夹
投资组合类是大多数交易逻辑所在的位置。对于此研究回测器,投资组合负责确定头寸规模、风险分析、交易成本管理和执行处理(即开盘市价、收盘市价订单)。在稍后阶段,这些任务将被分解为单独的组件。目前,它们将合并到一个类中。
本课程充分利用了 pandas,并提供了一个很好的例子,说明该库可以节省大量时间,特别是在“样板”数据整理方面。顺便说一句,使用 pandas 和 NumPy 的主要技巧是避免使用语法迭代任何数据集for d in ...
。这是因为 NumPy(pandas 的基础)通过矢量化操作优化了循环。因此,在使用 pandas 时,您将很少看到(如果有的话!)直接迭代。
Portfolio 类的目标是最终生成一系列交易和一条权益曲线,这些将由 Performance 类进行分析。为了实现这一点,必须从 Strategy 对象中为其提供交易建议列表。稍后,这将是一组 Strategy 对象。
需要告知 Portfolio 类如何为特定的交易信号集部署资本、如何处理交易成本以及将使用哪种形式的订单。Strategy 对象在数据条上进行操作,因此必须对订单执行时实现的价格做出假设。由于任何条的最高/最低价格都是先验未知的,因此只能使用开盘价和收盘价进行交易。实际上,使用市价订单时无法保证订单将以这些特定价格之一成交,因此充其量只能是一个近似值。
除了假设订单会被执行之外,该回测器还会忽略所有保证金/经纪限制的概念,并假设可以自由地做多或做空任何工具,而不受任何流动性限制。这显然是一个非常不切实际的假设,但以后可以放宽。
以下列表继续backtest.py
:
# backtest.py
class Portfolio(object):
"""An abstract base class representing a portfolio of
positions (including both instruments and cash), determined
on the basis of a set of signals provided by a Strategy."""
__metaclass__ = ABCMeta
@abstractmethod
def generate_positions(self):
"""Provides the logic to determine how the portfolio
positions are allocated on the basis of forecasting
signals and available cash."""
raise NotImplementedError("Should implement generate_positions()!")
@abstractmethod
def backtest_portfolio(self):
"""Provides the logic to generate the trading orders
and subsequent equity curve (i.e. growth of total equity),
as a sum of holdings and cash, and the bar-period returns
associated with this curve based on the 'positions' DataFrame.
Produces a portfolio object that can be examined by
other classes/functions."""
raise NotImplementedError("Should implement backtest_portfolio()!")
在此阶段,策略和投资组合抽象基类已经引入。现在我们可以生成这些类的一些具体派生实现,以便生成一个可行“玩具策略”。
我们将首先生成一个名为 的策略子类RandomForecastStrategy
,其唯一任务是生成随机选择的长/短信号!虽然这显然是一种无意义的交易策略,但它将通过演示面向对象的回测框架来满足我们的需求。因此,我们将开始一个名为 的新文件random_forecast.py
,其中列出了随机预测器,如下所示:
# random_forecast.py
import numpy as np
import pandas as pd
import Quandl # Necessary for obtaining financial data easily
from backtest import Strategy, Portfolio
class RandomForecastingStrategy(Strategy):
"""Derives from Strategy to produce a set of signals that
are randomly generated long/shorts. Clearly a nonsensical
strategy, but perfectly acceptable for demonstrating the
backtesting infrastructure!"""
def __init__(self, symbol, bars):
"""Requires the symbol ticker and the pandas DataFrame of bars"""
self.symbol = symbol
self.bars = bars
def generate_signals(self):
"""Creates a pandas DataFrame of random signals."""
signals = pd.DataFrame(index=self.bars.index)
signals['signal'] = np.sign(np.random.randn(len(signals)))
# The first five elements are set to zero in order to minimise
# upstream NaN errors in the forecaster.
signals['signal'][0:5] = 0.0
return signals
现在我们有了一个“具体的”预测系统,我们必须创建一个 Portfolio 对象的实现。此对象将包含大部分回测代码。它旨在创建两个单独的 DataFrames,第一个是框架positions
,用于存储在任何特定栏中持有的每种工具的数量。第二个,portfolio
实际上包含每个栏中所有持股的市场价格,以及假设初始资本的现金总数。这最终提供了一条权益曲线,可用于评估策略绩效。
投资组合对象虽然在界面上非常灵活,但在如何处理交易成本、市场订单等方面需要做出特定的选择。在这个基本的例子中,我认为可以很容易地做多/做空一种工具,没有任何限制或保证金,直接以开盘价买入或卖出,零交易成本(包括滑点、费用和市场影响),并指定每笔交易直接购买的股票数量。
以下是清单的延续random_forecast.py
:
# random_forecast.py
class MarketOnOpenPortfolio(Portfolio):
"""Inherits Portfolio to create a system that purchases 100 units of
a particular symbol upon a long/short signal, assuming the market
open price of a bar.
In addition, there are zero transaction costs and cash can be immediately
borrowed for shorting (no margin posting or interest requirements).
Requires:
symbol - A stock symbol which forms the basis of the portfolio.
bars - A DataFrame of bars for a symbol set.
signals - A pandas DataFrame of signals (1, 0, -1) for each symbol.
initial_capital - The amount in cash at the start of the portfolio."""
def __init__(self, symbol, bars, signals, initial_capital=100000.0):
self.symbol = symbol
self.bars = bars
self.signals = signals
self.initial_capital = float(initial_capital)
self.positions = self.generate_positions()
def generate_positions(self):
"""Creates a 'positions' DataFrame that simply longs or shorts
100 of the particular symbol based on the forecast signals of
{1, 0, -1} from the signals DataFrame."""
positions = pd.DataFrame(index=signals.index).fillna(0.0)
positions[self.symbol] = 100*signals['signal']
return positions
def backtest_portfolio(self):
"""Constructs a portfolio from the positions DataFrame by
assuming the ability to trade at the precise market open price
of each bar (an unrealistic assumption!).
Calculates the total of cash and the holdings (market price of
each position per bar), in order to generate an equity curve
('total') and a set of bar-based returns ('returns').
Returns the portfolio object to be used elsewhere."""
# Construct the portfolio DataFrame to use the same index
# as 'positions' and with a set of 'trading orders' in the
# 'pos_diff' object, assuming market open prices.
portfolio = self.positions*self.bars['Open']
pos_diff = self.positions.diff()
# Create the 'holdings' and 'cash' series by running through
# the trades and adding/subtracting the relevant quantity from
# each column
portfolio['holdings'] = (self.positions*self.bars['Open']).sum(axis=1)
portfolio['cash'] = self.initial_capital - (pos_diff*self.bars['Open']).sum(axis=1).cumsum()
# Finalise the total and bar-based returns based on the 'cash'
# and 'holdings' figures for the portfolio
portfolio['total'] = portfolio['cash'] + portfolio['holdings']
portfolio['returns'] = portfolio['total'].pct_change()
return portfolio
这为我们提供了基于此类系统生成权益曲线所需的一切。最后一步是将所有内容与一个__main__
函数绑定在一起:
if __name__ == "__main__":
# Obtain daily bars of SPY (ETF that generally
# follows the S&P500) from Quandl (requires 'pip install Quandl'
# on the command line)
symbol = 'SPY'
bars = Quandl.get("GOOG/NYSE_%s" % symbol, collapse="daily")
# Create a set of random forecasting signals for SPY
rfs = RandomForecastingStrategy(symbol, bars)
signals = rfs.generate_signals()
# Create a portfolio of SPY
portfolio = MarketOnOpenPortfolio(symbol, bars, signals, initial_capital=100000.0)
returns = portfolio.backtest_portfolio()
print returns.tail(10)
程序的输出如下。根据您选择的日期范围和使用的随机种子,您的输出将与下面的输出不同:
SPY holdings cash total returns
Date
2014-01-02 -18398 -18398 111486 93088 0.000097
2014-01-03 18321 18321 74844 93165 0.000827
2014-01-06 18347 18347 74844 93191 0.000279
2014-01-07 18309 18309 74844 93153 -0.000408
2014-01-08 -18345 -18345 111534 93189 0.000386
2014-01-09 -18410 -18410 111534 93124 -0.000698
2014-01-10 -18395 -18395 111534 93139 0.000161
2014-01-13 -18371 -18371 111534 93163 0.000258
2014-01-14 -18228 -18228 111534 93306 0.001535
2014-01-15 18410 18410 74714 93124 -0.001951
在这种情况下,该策略亏损了,考虑到预测器的随机性,这并不奇怪!接下来的步骤是创建一个Performance
接受 Portfolio 实例的对象,并提供一个绩效指标列表,以此为基础决定是否过滤掉该策略。
我们还可以改进 Portfolio 对象,以便更实际地处理交易成本(例如 Interactive Brokers 佣金和滑点)。我们还可以直接将预测引擎纳入 Strategy 对象,这将(希望)产生更好的结果。