关于性能回溯测试和核外内存执行
最近有两次https://redit.com/r/algotrading 这是本文的灵感所在。
- 一条虚假声称反向交易者无法应对 160 万蜡烛的线索:reddit/r/algotrading——一个性能反向测试系统?
** 还有一个要求能够对 8000 多只股票进行回溯测试:reddit/r/algotrading——回溯测试支持 1000 多只股票的 LIB?
*作者询问了一个框架,该框架可以对“超出核心/内存”**“因为显然它无法将所有这些数据加载到内存”***
当然,我们将通过反向交易者来解决这些概念
两百万支蜡烛
为了做到这一点,第一件事是产生的蜡烛数量。考虑到第一张海报上说的是 77 支股票和 160 万支蜡烛,这相当于每支股票 20779 支蜡烛,所以我们将做下面的工作来获得好的数字
-
制作 100 支蜡烛
-
每支存货产生 20000 支蜡烛
即:100 个文件,共 200 万支蜡烛。
剧本
import numpy as np
import pandas as pd
COLUMNS = ['open', 'high', 'low', 'close', 'volume', 'openinterest']
CANDLES = 20000
STOCKS
dateindex = pd.date_range(start='2010-01-01', periods=CANDLES, freq='15min')
for i in range(STOCKS):
data = np.random.randint(10, 20, size=(CANDLES, len(COLUMNS)))
df = pd.DataFrame(data * 1.01, dateindex, columns=COLUMNS)
df = df.rename_axis('datetime')
df.to_csv('candles{:02d}.csv'.format(i))
这将生成 100 个文件,从candles00.csv
开始,一直到candles99.csv
。实际值并不重要。拥有标准的datetime
、OHLCV
组件(和OpenInterest
)才是重要的。
测试系统
-
硬件/操作系统:将使用带有 Intel i7 和 32 GB 内存的Windows 1015.6“笔记本电脑。
-
Python:CPython
3.6.1
和pypy3 6.0.0
-
Misc:一个持续运行的应用程序,占用大约 20%的 CPU。像 Chrome(102 个进程)、Edge、Word、Powerpoint、Excel 和一些小型应用程序等常见的可疑程序正在运行
反向交易者默认配置
让我们回忆一下backtrader的默认运行时配置:
-
如果可能,预加载所有数据源
-
如果所有数据源都可以预加载,则以批处理模式运行(名为
runonce
-
首先预先计算所有指标
-
一步一步地检查策略逻辑和代理
在默认批次runonce
模式下执行质询
我们的测试脚本(完整的源代码见底部)将打开这 100 个文件,并使用默认的backtrader配置处理它们。
$ ./two-million-candles.py
Cerebro Start Time: 2019-10-26 08:33:15.563088
Strat Init Time: 2019-10-26 08:34:31.845349
Time Loading Data Feeds: 76.28
Number of data feeds: 100
Strat Start Time: 2019-10-26 08:34:31.864349
Pre-Next Start Time: 2019-10-26 08:34:32.670352
Time Calculating Indicators: 0.81
Next Start Time: 2019-10-26 08:34:32.671351
Strat warm-up period Time: 0.00
Time to Strat Next Logic: 77.11
End Time: 2019-10-26 08:35:31.493349
Time in Strategy Next Logic: 58.82
Total Time in Strategy: 58.82
Total Time: 135.93
Length of data feeds: 20000
内存使用:观察到 348MB 的峰值
实际上,大部分时间都花在预加载数据上(98.63
秒),其余时间则花在策略上,包括在每次迭代中通过代理(73.63
秒)。总时间为173.26
秒。
根据您希望如何计算,性能为:
- 考虑到整个运行时间,
14,713
烛光/秒
底线:上面两条 reddit 线中的backtrader无法处理 160 万支蜡烛的 1st中的索赔为假。
与pypy
一起做
既然线程声称使用pypy
没有帮助,那么让我们看看使用它时会发生什么。
$ ./two-million-candles.py
Cerebro Start Time: 2019-10-26 08:39:42.958689
Strat Init Time: 2019-10-26 08:40:31.260691
Time Loading Data Feeds: 48.30
Number of data feeds: 100
Strat Start Time: 2019-10-26 08:40:31.338692
Pre-Next Start Time: 2019-10-26 08:40:31.612688
Time Calculating Indicators: 0.27
Next Start Time: 2019-10-26 08:40:31.612688
Strat warm-up period Time: 0.00
Time to Strat Next Logic: 48.65
End Time: 2019-10-26 08:40:40.150689
Time in Strategy Next Logic: 8.54
Total Time in Strategy: 8.54
Total Time: 57.19
Length of data feeds: 20000
天哪!总时间已从135.93
秒降至57.19
秒。性能增加了一倍多。
演出:34,971
烛光/秒
内存使用:出现 269 兆字节的峰值。
这也是对标准 CPython 解释器的重要改进。
处理从核心内存中取出的内存
如果考虑到backtrader有几个用于执行回溯测试会话的配置选项,包括优化缓冲区和仅使用所需的最小数据集(理想情况下仅使用大小为1
的缓冲区,这只会在理想情况下发生),那么所有这些都可以得到改进
将使用的选项为exactbars=True
。来自exactbars
的文档(在实例化或调用run
时为Cerebro
提供的参数)
`True` or `1`: all “lines” objects reduce memory usage to the
automatically calculated minimum period.
If a Simple Moving Average has a period of 30, the underlying data
will have always a running buffer of 30 bars to allow the
calculation of the Simple Moving Average
* This setting will deactivate `preload` and `runonce`
* Using this setting also deactivates **plotting**
为了最大限度地优化,并且由于绘图将被禁用,也将使用以下选项:stdstats=False
,这将禁用现金、价值和交易的标准观察者(用于绘图,不再在范围内)
$ ./two-million-candles.py --cerebro exactbars=False,stdstats=False
Cerebro Start Time: 2019-10-26 08:37:08.014348
Strat Init Time: 2019-10-26 08:38:21.850392
Time Loading Data Feeds: 73.84
Number of data feeds: 100
Strat Start Time: 2019-10-26 08:38:21.851394
Pre-Next Start Time: 2019-10-26 08:38:21.857393
Time Calculating Indicators: 0.01
Next Start Time: 2019-10-26 08:38:21.857393
Strat warm-up period Time: 0.00
Time to Strat Next Logic: 73.84
End Time: 2019-10-26 08:39:02.334936
Time in Strategy Next Logic: 40.48
Total Time in Strategy: 40.48
Total Time: 114.32
Length of data feeds: 20000
演出:17,494
烛光/秒
内存使用量:75 兆字节(从回溯测试会话开始到结束稳定)
让我们与上一次非优化运行进行比较
-
没有花费超过
76
秒的时间预加载数据,而是立即开始回溯测试,因为数据没有预加载 -
总时间为
114.32
秒 vs135.93
秒。15.90%
的改进。 -
68.5%
内存使用的改进。
笔记
实际上,我们可以向脚本投掷 1 亿支蜡烛,消耗的内存量将保持固定在75 Mbytes
用pypy
再做一遍
现在我们知道了如何优化,让我们用pypy
的方式来做吧。
$ ./two-million-candles.py --cerebro exactbars=True,stdstats=False
Cerebro Start Time: 2019-10-26 08:44:32.309689
Strat Init Time: 2019-10-26 08:44:32.406689
Time Loading Data Feeds: 0.10
Number of data feeds: 100
Strat Start Time: 2019-10-26 08:44:32.409689
Pre-Next Start Time: 2019-10-26 08:44:32.451689
Time Calculating Indicators: 0.04
Next Start Time: 2019-10-26 08:44:32.451689
Strat warm-up period Time: 0.00
Time to Strat Next Logic: 0.14
End Time: 2019-10-26 08:45:38.918693
Time in Strategy Next Logic: 66.47
Total Time in Strategy: 66.47
Total Time: 66.61
Length of data feeds: 20000
演出:30,025
烛光/秒
内存使用量:恒定在49 Mbytes
将其与之前的等效运行进行比较:
-
66.61
秒与114.32
或41.73%
运行时间的改进 -
49 Mbytes
vs75 Mbytes
或34.6%
改善。
笔记
在本例中,pypy
与批次(runonce
模式)相比,无法超过自己的时间,即57.19
秒。这是意料之中的,因为预加载时,计算器指示在矢量化模式下完成,这就是pypy
的 JIT 优势所在
无论如何,它仍然做得很好,在内存消耗方面有一个重要的改进
一次完整的交易
脚本可以创建指标(移动平均数)并使用移动平均数的交叉对 100 个数据馈送执行短/长策略。让我们用pypy
来做,并且知道批处理模式更好,就这样吧。
$ ./two-million-candles.py --strat indicators=True,trade=True
Cerebro Start Time: 2019-10-26 08:57:36.114415
Strat Init Time: 2019-10-26 08:58:25.569448
Time Loading Data Feeds: 49.46
Number of data feeds: 100
Total indicators: 300
Moving Average to be used: SMA
Indicators period 1: 10
Indicators period 2: 50
Strat Start Time: 2019-10-26 08:58:26.230445
Pre-Next Start Time: 2019-10-26 08:58:40.850447
Time Calculating Indicators: 14.62
Next Start Time: 2019-10-26 08:58:41.005446
Strat warm-up period Time: 0.15
Time to Strat Next Logic: 64.89
End Time: 2019-10-26 09:00:13.057955
Time in Strategy Next Logic: 92.05
Total Time in Strategy: 92.21
Total Time: 156.94
Length of data feeds: 20000
演出:12,743
烛光/秒
记忆使用:观察到1300 Mbytes
的峰值。
执行时间明显增加(指标+交易),但为什么内存使用量会增加?
在得出任何结论之前,让我们运行它,创建指标,但不进行交易
$ ./two-million-candles.py --strat indicators=True
Cerebro Start Time: 2019-10-26 09:05:55.967969
Strat Init Time: 2019-10-26 09:06:44.072969
Time Loading Data Feeds: 48.10
Number of data feeds: 100
Total indicators: 300
Moving Average to be used: SMA
Indicators period 1: 10
Indicators period 2: 50
Strat Start Time: 2019-10-26 09:06:44.779971
Pre-Next Start Time: 2019-10-26 09:06:59.208969
Time Calculating Indicators: 14.43
Next Start Time: 2019-10-26 09:06:59.360969
Strat warm-up period Time: 0.15
Time to Strat Next Logic: 63.39
End Time: 2019-10-26 09:07:09.151838
Time in Strategy Next Logic: 9.79
Total Time in Strategy: 9.94
Total Time: 73.18
Length of data feeds: 20000
演出:27,329
烛光/秒
内存使用:600 Mbytes
(在优化exactbars
模式下做同样的操作只消耗60 Mbytes
,但由于pypy
本身无法优化太多,执行时间增加)
有了这一点:在交易时,内存使用量确实增加了。原因是Order
和Trade
对象是由代理创建、传递和保存的。
笔记
考虑到数据集包含随机值,这会产生大量的交叉,从而产生大量的订单和交易。对于常规数据集,不应出现类似的行为。
结论
虚假主张
以上已经证明为伪,因为反向交易者可以处理 160 万支蜡烛和更多蜡烛。
全体的
-
backtrader可以使用默认配置轻松处理
2M
蜡烛(带内存数据预加载) -
backtrader可以在非预加载优化模式下运行,将缓冲区减少到最小,以进行核心外内存回溯测试
-
在优化的非预加载模式下进行回溯测试时,内存消耗的增加来自代理产生的管理开销。
-
即使在交易、使用指标和经纪人不断阻挠的情况下,其表现也是
12,473
烛光/秒 -
尽可能使用
pypy
(例如,如果不需要绘图)
在这些情况下使用 Python 和/或backtrader
使用pypy
,启用交易,并使用随机数据集(高于正常交易数量),总共处理了整个 2M 条:
156.94
秒,即:几乎2 minutes and 37 seconds
考虑到这是在一台同时运行多个其他东西的笔记本电脑上完成的,可以得出结论,2M
条是可以完成的。
那么8000
股票的情况呢?
执行时间必须按 80 调整,因此:
- 运行此随机集场景需要
12,560 seconds
(或几乎210 minutes
或3 hours and 30 minutes
)。
即使假设一个标准的数据集产生的操作要少得多,人们仍然会谈论在小时(3 or 4
小时)内的回溯测试
由于经纪人的行为,交易时,内存使用量也会增加,可能需要一些千兆字节。
笔记
这里不能简单地再乘以 80,因为示例脚本使用随机数据,并且尽可能频繁地进行交易。在任何情况下,所需的 RAM 量都是非常重要的
因此,一个只有backtrader作为研究和回溯测试工具的工作流似乎有些牵强。
关于工作流的讨论
有两个标准的工作流程需要考虑使用 TytT0.
-
使用
backtrader
做所有事情,即:研究和回溯测试合一 -
使用
pandas
进行研究,获得想法是否正确的概念,然后使用backtrader
进行回溯测试,以尽可能准确地进行验证,可能将庞大的数据集简化为更适合常规 RAM 场景的数据集。
提示
我们可以想象用类似于dask
的东西替换pandas
来执行核心外内存
测试脚本
这里是源代码
#!/usr/bin/env python
# -*- coding: utf-8; py-indent-offset:4 -*-
###############################################################################
import argparse
import datetime
import backtrader as bt
class St(bt.Strategy):
params = dict(
indicators=False,
indperiod1=10,
indperiod2=50,
indicator=bt.ind.SMA,
trade=False,
)
def __init__(self):
self.dtinit = datetime.datetime.now()
print('Strat Init Time: {}'.format(self.dtinit))
loaddata = (self.dtinit - self.env.dtcerebro).total_seconds()
print('Time Loading Data Feeds: {:.2f}'.format(loaddata))
print('Number of data feeds: {}'.format(len(self.datas)))
if self.p.indicators:
total_ind = self.p.indicators * 3 * len(self.datas)
print('Total indicators: {}'.format(total_ind))
indname = self.p.indicator.__name__
print('Moving Average to be used: {}'.format(indname))
print('Indicators period 1: {}'.format(self.p.indperiod1))
print('Indicators period 2: {}'.format(self.p.indperiod2))
self.macross = {}
for d in self.datas:
ma1 = self.p.indicator(d, period=self.p.indperiod1)
ma2 = self.p.indicator(d, period=self.p.indperiod2)
self.macross[d] = bt.ind.CrossOver(ma1, ma2)
def start(self):
self.dtstart = datetime.datetime.now()
print('Strat Start Time: {}'.format(self.dtstart))
def prenext(self):
if len(self.data0) == 1: # only 1st time
self.dtprenext = datetime.datetime.now()
print('Pre-Next Start Time: {}'.format(self.dtprenext))
indcalc = (self.dtprenext - self.dtstart).total_seconds()
print('Time Calculating Indicators: {:.2f}'.format(indcalc))
def nextstart(self):
if len(self.data0) == 1: # there was no prenext
self.dtprenext = datetime.datetime.now()
print('Pre-Next Start Time: {}'.format(self.dtprenext))
indcalc = (self.dtprenext - self.dtstart).total_seconds()
print('Time Calculating Indicators: {:.2f}'.format(indcalc))
self.dtnextstart = datetime.datetime.now()
print('Next Start Time: {}'.format(self.dtnextstart))
warmup = (self.dtnextstart - self.dtprenext).total_seconds()
print('Strat warm-up period Time: {:.2f}'.format(warmup))
nextstart = (self.dtnextstart - self.env.dtcerebro).total_seconds()
print('Time to Strat Next Logic: {:.2f}'.format(nextstart))
self.next()
def next(self):
if not self.p.trade:
return
for d, macross in self.macross.items():
if macross > 0:
self.order_target_size(data=d, target=1)
elif macross < 0:
self.order_target_size(data=d, target=-1)
def stop(self):
dtstop = datetime.datetime.now()
print('End Time: {}'.format(dtstop))
nexttime = (dtstop - self.dtnextstart).total_seconds()
print('Time in Strategy Next Logic: {:.2f}'.format(nexttime))
strattime = (dtstop - self.dtprenext).total_seconds()
print('Total Time in Strategy: {:.2f}'.format(strattime))
totaltime = (dtstop - self.env.dtcerebro).total_seconds()
print('Total Time: {:.2f}'.format(totaltime))
print('Length of data feeds: {}'.format(len(self.data)))
def run(args=None):
args = parse_args(args)
cerebro = bt.Cerebro()
datakwargs = dict(timeframe=bt.TimeFrame.Minutes, compression=15)
for i in range(args.numfiles):
dataname = 'candles{:02d}.csv'.format(i)
data = bt.feeds.GenericCSVData(dataname=dataname, **datakwargs)
cerebro.adddata(data)
cerebro.addstrategy(St, **eval('dict(' + args.strat + ')'))
cerebro.dtcerebro = dt0 = datetime.datetime.now()
print('Cerebro Start Time: {}'.format(dt0))
cerebro.run(**eval('dict(' + args.cerebro + ')'))
def parse_args(pargs=None):
parser = argparse.ArgumentParser(
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
description=(
'Backtrader Basic Script'
)
)
parser.add_argument('--numfiles', required=False, default=100, type=int,
help='Number of files to rea')
parser.add_argument('--cerebro', required=False, default='',
metavar='kwargs', help='kwargs in key=value format')
parser.add_argument('--strat', '--strategy', required=False, default='',
metavar='kwargs', help='kwargs in key=value format')
return parser.parse_args(pargs)
if __name__ == '__main__':
run()