回测是指将交易策略应用于历史数据以评估其过去表现的研究过程。特别需要强调的是,回测结果并不能保证该策略未来的表现。然而,它们是策略研发流程中不可或缺的组成部分,允许我们在将策略投入实盘(生产环境)之前对其进行筛选。
在本文(及后续文章)中,将概述一个用Python 编写的面向对象回测系统。这个初期系统主要作为一个“教学辅助工具”,用于展示回测系统的不同组件。随着文章的推进,我们将会添加更复杂的功能。
回测概述
设计一个稳健的回测系统极其困难。要有效地模拟所有影响算法交易系统表现的因素具有很大挑战性。数据粒度不足、经纪商订单路由机制不透明、订单延迟等众多因素,都会导致策略的“真实”表现与回测结果产生差异。
在开发回测系统时,随着发现更多对评估绩效至关重要的因素,人们往往会忍不住想要“从头重写”系统。实际上,没有一个回测系统是真正“完成”的,在开发过程中必须做出判断:系统已经捕捉到了足够多的因素。
考虑到这些顾虑,此处展示的回测系统将相对简化。随着我们进一步探讨(投资组合优化、风险管理、交易成本处理)等问题,回测系统将变得更加稳健。
回测系统的类型
通常有两种类型的回测系统值得关注。
- 第一种是基于研究的回测系统:主要用于早期阶段,通过批量测试大量策略来筛选出值得深入评估的候选者。这些研究型回测系统通常用Python、R 或MatLab编写,因为在此阶段开发效率比执行速度更为重要。
- 第二种是基于事件的回测系统:这类系统采用与交易执行系统相似(甚至完全相同)的事件循环来执行回测过程。它将真实地模拟市场数据和订单执行过程,从而对策略进行更严格的评估。
后者通常用 C++ 或 Java等高性能语言编写,因为这种场景下执行速度至关重要。但对于低频策略(即使是日内交易),Python也完全能够胜任此类任务。
Python 中的面向对象研究型回测系统
接下来将讨论面向对象研究型回测环境的设计和实现方法。之所以选择面向对象作为软件设计范式,原因如下:
- 可以预先指定每个组件的接口,而随着项目进展,可以修改(或替换)每个组件的内部实现。
- 通过预先指定接口,可以有效地测试每个组件的行为(通过单元测试)。
- 在扩展系统时,可以通过继承或组合的方式,在现有组件之上或之外构建新组件。
在现阶段,回测系统的设计以易于实现和一定程度的灵活性为目标,暂时牺牲了真实市场精度。特别是,这个回测系统目前只能支持单品种策略。后续将扩展为多品种处理能力。初始版本需要以下组件:
策略 - Strategy类接收包含K线数据的Pandas DataFrame,即以特定频率采样的开盘价-最高价-最低价-收盘价-成交量 (OHLCV) 数据点列表。策略将生成信号列表,每个信号包含时间戳和来自集合 {1,0,−1}的元素{1,0,-1}的元素,分别代表做多、持仓、做空信号。
投资组合 - 大部分回测的工作将在Portfolio类中完成。该类接收上述信号集,结合现金配置生成持仓序列。Portfolio对象的职责是生成净值曲线、计入基础交易成本并跟踪交易情况。
绩效 -Performance对象接收投资组合数据并生成一组关于其表现的统计数据。特别是,它将输出风险/收益特征(夏普比率、索提诺比率和信息比率)、交易/盈利指标以及回撤信息。
缺失了什么?
可以看出,这个回测系统不包含任何关于投资组合/风险管理、执行处理(即没有限价单)的参考,也不会提供复杂的交易成本建模。在现阶段,这并不是什么大问题。它让我们能够熟悉创建面向对象回测系统以及使用 Pandas/NumPy 库的过程。随着时间推移,这些功能都将得到完善。
💻 实现方案
策略Strategy
在现阶段,策略对象必须保持足够通用性,因为它需要处理预测型、均值回归型、动量型和波动率型等多种策略。此处考虑的策略将始终基于时间序列,即“价格驱动”。此回测系统的一个早期需求是,衍生的策略类将接受 K线数据列表作为输入,而不是逐笔成交数据或订单簿数据。因此,此处考虑的最细粒度将是1秒级别的 K线。
策略也必须始终生成信号建议。这意味着它将向投资组合实例提供做多/做空或持仓的操作建议。这种灵活性将允许我们创建多个策略“顾问”,这种灵活性使我们能够创建多个策略"顾问"来提供信号集,供更高级的投资组合类接收并确定实际建仓位置。
类的接口将通过使用抽象基类方法来强制执行。抽象基类是无法直接实例化的对象,因此只能创建派生类。
相关Python代码保存在名为backtest.py的文件中。策略类要求所有子类必须实现generate_signals方法。为了防止策略类被直接实例化(因为它是一个抽象类!),需要从abc 模块中使用ABCMeta 和abstractmethod对象。我们将类的一个属性__metaclass__设置为ABCMeta,然后使用abstractmethod来修饰generate_signals。
# backtest.pyfrom abc import ABCMeta, abstractmethodclass 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()!")
虽然上述接口设计较为简洁,但当为各类具体策略创建子类时,其复杂性将会增加。在此框架下,策略类的根本目标是向投资组合提供针对各交易品种的做多/做空/持仓信号列表。
投资组合Portfolio
Portfolio类是交易逻辑的核心载体。对于这个研究型回测系统来说,投资组合负责确定头寸规模、风险分析、交易成本管理以及执行处理(即市价开盘单、市价收盘单)。在后期,这些任务将被分解至独立模块,但现在它们将被合并到一个类中。
这个类大量使用了pandas库,它提供了一个极佳的示例,展示了该库如何节省大量时间,特别是在处理“样板式”数据整理工作时。需要强调的是,使用 pandas 和 NumPy 的主要技巧是避免使用for d in ...语法遍历数据集。这是因为 NumPy(pandas 的底层基础)通过向量化操作来优化循环。因此,在使用 pandas 时,你几乎看不到(或完全看不到)直接的迭代操作。
Portfolio 类的目标最终是生成一系列交易记录和一条净值曲线,这些将由 Performance(绩效)类进行分析。为了实现这一目标,它必须接收来自Strategy对象的一系列交易建议。稍后,这将扩展为一组策略对象。
投资组合类需要被告知如何针对特定的交易信号集部署资金、如何处理交易成本以及将使用哪些形式的订单。策略对象是在K线数据上进行操作的,因此必须对订单执行时实现的价格做出假设。由于任何K线的最高价/最低价在事前都是未知的,因此只能使用开盘价和收盘价进行交易模拟。
实际上,当使用市价单时,无法保证订单一定会以这两个特定价格之一成交,所以最多只是一种近似模拟。。
除成交价格假设外,此回测系统还将忽略所有关于保证金/经纪商限制的概念,并假设可以自由地在任何标的物上建立多头或空头头寸,且不存在任何流动性限制。这显然是一种非常不切实际的假设,但可在后续版本中逐步完善。# backtest.pyclass 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()!")
在此阶段,我们已经引入了 Strategy(策略)和 Portfolio(投资组合)这两个抽象基类。现在,我们可以着手生成这些类的具体派生实现,以构建一个可运行的“示例策略”。
首先,我们将创建一个名为RandomForecastStrategy的策略子类,它的唯一任务就是生成随机选择的做多/做空信号。虽然这显然不是一个合理的交易策略,但它足以满足我们的需求——用于演示面向对象的回测框架。
因此,我们将创建一个名为random_forecast.py的新文件其中随机预测策略的实现代码如下:
# random_forecast.pyimport numpy as npimport pandas as pdimport Quandl # Necessary for obtaining financial data easilyfrom backtest import Strategy, Portfolioclass 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(投资组合)对象的实现。这个对象将包含大部分的回测代码。它旨在创建两个独立的 DataFrame:
- 第一个是持仓记录表
positions:用于存储在任何特定 K 线周期持有的各类资产数量。 - 第二个是投资组合总览表portfolio:实际上包含每根 K 线周期所有持仓的市值,并在假设初始本金的情况下计算现金余额。这最终提供了一条净值曲线,用于评估策略表现。
尽管投资组合对象的接口设计极具灵活性,但在处理交易成本、市价单等方面需要做出具体的选择。在这个基础示例中,我做了如下设定:
- 可自由进行多空交易,且无任何限制或保证金要求。
- 可以直接以 K 线的开盘价买入或卖出。
- 零交易成本(包含滑点、手续费和市场冲击)。
- 直接指定了每次交易购买的股票数量。
以下是random_forecast.py文件的后续代码清单:
# random_forecast.pyclass 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 returnsDate 2014-01-02 -18398 -18398 111486 93088 0.0000972014-01-03 18321 18321 74844 93165 0.0008272014-01-06 18347 18347 74844 93191 0.0002792014-01-07 18309 18309 74844 93153 -0.0004082014-01-08 -18345 -18345 111534 93189 0.0003862014-01-09 -18410 -18410 111534 93124 -0.0006982014-01-10 -18395 -18395 111534 93139 0.0001612014-01-13 -18371 -18371 111534 93163 0.0002582014-01-14 -18228 -18228 111534 93306 0.0015352014-01-15 18410 18410 74714 93124 -0.001951
在此例中,该策略出现了亏损,考虑到预测系统的随机性,这一结果并不令人意外。接下来的步骤是创建一个Performance对象,它接收一个Portfolio实例,并提供一系列绩效指标,以便我们据此决定是否过滤掉该策略。
- 改进交易成本模型:我们还可以进一步优化
Portfolio对象,使其对交易成本(例如盈透证券的佣金和滑点)的处理更加贴近现实。 - 集成预测引擎:我们也可以直接将一个预测引擎集成到
Strategy 对象中,以期更好的结果。
在接下来的文章中,我们将更深入地探讨这些概念。