本文继续记录多股回测时遇到的异常情况。
坑描述
-
backtrader在读取日线数据时,会自动给date数据添加“时:分:秒.毫秒(23:59:59.999990)”信息。
-
而通常用户在指定回测周期的开始和结束日期时,只会精确到日,时分秒信息会被backtrader默认以0补全。
由于上述两个事实的存在,假如用户指定回测周期的结束日期有日线数据(由于非交易日、停盘等原因,可能没有日线数据),那么在backtrader中,回测周期的结束时间就会被设定为该日的00:00:00,而backtrader读取的该日日线数据的时间标识为该日的23:59:59.999990,大于了设定的结束时间,因此该日的日线数据不会参与回测。这样就导致了参与回测的日K线数据,比预期少一根,从而引发计算技术指标时出现读写数组越界错误,典型的报错信息为:
IndexError: array assignment index out of range
坑重现
为了重现上述现象,做如下回测设定(与笔记(34)基本一致,只是将30日线变为20日线来判断交易条件):
- 使用20日均线作为买卖条件的判断标准:
MIN_PERIOD = 20
# 可配置策略参数
params = dict(
period = MIN_PERIOD, # 均线周期
stake = 100, # 单笔交易股票数目
)
def __init__(self):
self.inds = dict()
for i, d in enumerate(self.datas):
self.inds[d] = bt.ind.SMA(d.close, period=self.p.period)
- 买入条件:收盘价高于20日均线
if not len(pos): # 不在场内,则可以买入
if d.close[0] > self.inds[d][0]: # 达到买入条件
self.buy(data = d, size = self.p.stake) # 买买买
- 卖出条件:收盘价低于20日均线
elif d.close[0] < self.inds[d][0]: # 达到卖出条件
self.close(data = d) # 卖卖卖
- 回测周期:2019年1月1日至2019年12月31日
fromdate = datetime.datetime(2019, 1, 1)
todate = datetime.datetime(2019, 12, 31)
- 股票组合:使用[‘002321’, ‘002322’]的组合与[‘002321’, ‘002322’, ‘002323’]的组合做对比
stk_pools = ['002321', '002322']
#stk_pools = ['002321', '002322', '002323'
在回测周期内,002321日K线共244根,002322日K线共244根,002323日K线共20根(长期停盘)。
当使用组合[‘002321’, ‘002322’]进行回测时,程序可以正常运行;当使用组合[‘002321’, ‘002322’, ‘002323’]进行回测时,程序会报错IndexError而退出。
坑分析
002321和002322在回测周期内(2019年1月1日至2019年12月31日)都有244根日K线,因此足以有效计算出20日均线指标进行回测。而002323在回测周期内有20根K线,本应能够计算出20日均线数据,然而程序却报错退出。
正如本文开头提及的情况:
-
backtrader在读入日线数据时,会自动给date数据添加“时:分:秒.毫秒(23:59:59.999990)”信息。那么,2019年12月31日的K线数据在backtrader中标记的时间为2019年12月31日23时59分59.999990秒。
-
设定的回测周期的截止日期为2019年12月31日,时分秒信息会被backtrader默认以0补全。那么,回测截止时间在backtrader中为2019年12月31日0时0分0秒。
002323在2019年12月31日是有K线数据的,但在backtrader中,该日K线数据的时间标签(2019年12月31日23时59分59.999990秒)大于回测截止时间(2019年12月31日0时0分0秒),因此不会参与回测。这样,002323参与回测的K线数量为19,无法计算出20日均线数值,出现了笔记(34)提到的可用K线数量少于计算技术指标时的最小周期数的情况,计算指标时访问数组越界,程序报错退出。
避坑方案
按照预期,回测周期中结束日期当日的K线数据也应该参与回测。为了实现这一预期,可对结束日期进行小的修改,以下是两种可选方案:
- 方案1:
向回测周期的结束日期后添加时分秒信息:
# option 1
#todate = datetime.datetime(2019, 12, 31)
todate = datetime.datetime(2019, 12, 31, 23, 59, 59, 999990)
由于backtrader在读取日K数据时,会在日期后自动添加23时59分59.999990秒,所以这里也将结束日期后添加相同的时分秒数据。这样在做比较时,2019年12月31日的K线数据就不会因为超过了结束时间而被弃用。
- 方案2
方案1的代码看上去不够整洁,方案2在将把数据添加到cerebro之前对结束日期进行加1日操作:
# 创建价格数据
data = bt.feeds.GenericCSVData(
dataname = datapath,
fromdate = fromdate,
# option 2
#todate = todate,
todate = todate + datetime.timedelta(days=1),
nullvalue = 0.0,
dtformat = ('%Y-%m-%d'),
datetime = 0,
open = 1,
high = 2,
low = 3,
close = 4,
volume = 5,
openinterest = -1
)
todate初始值为2019年12月31日,加1天后值为2020年1月1日,在backtrader中就会将结束时间补全为2020年1月1日0时0分0秒。此后,当backtrader读取2019年12月31日的K线数据(时间标记为2019年12月31日23时59分59.999990秒)时,该K线就会被正常读取并参与回测,同时也能保证2020年1月1日的K线不参与回测。
- 回测结果
通过以上的避坑操作,使用[‘002321’, ‘002322’]的组合与[‘002321’, ‘002322’, ‘002323’]的组合进行回测时,程序均可以正常运行,但是运行结果不同。回测组合[‘002321’, ‘002322’]时,策略的next方法从2019年1月29日开始运行;回测组合[‘002321’, ‘002322’, ‘002323’]时,策略的next方法从2019年12月31日才开始运行。
- 结果分析
在回测组合[‘002321’, ‘002322’, ‘002323’]时,由于程序将回测周期的结束时间进行了微小的调整,所以002323在2019年12月31日的K线数据也参与到回测过程中,这样参与回测002323的K线数目为20,满足了计算技术指标的最小需求,因此不会被清洗剔除。
但是这样的操作又引发了新坑,在将002323加入回测流程后,回测从2019年12月31日才开始,具体原因后续笔记再做分析。
总结
-
backtrader在读取日线数据时,会自动给date数据添加“时:分:秒.毫秒(23:59:59.999990)”信息。
-
用户指定回测周期的开始和结束日期时若无时分秒信息,时分秒会被backtrader默认以0补全。
-
在不采取措施的情况下,截止日期当日的K线数据不会参与到回测过程中,由此可能造成计算技术指标时访问数组越界,程序报错退出。
-
可对回测周期的结束日期做小的调整,来避免以上非预期情况的发生。
from __future__ import (absolute_import, division, print_function,
unicode_literals)
import datetime # 用于datetime对象操作
import os.path # 用于管理路径
import backtrader as bt # 引入backtrader框架
import pandas as pd
MIN_PERIOD = 20
# 统计回测周期内K线数量
def bar_size(datapath, fromdate, todate):
df = pd.read_csv(datapath)
return len(df[(df['date'] >= fromdate.strftime('%Y-%m-%d'))
& (df['date'] <= todate.strftime('%Y-%m-%d'))])
# 创建策略
class SmaStrategy(bt.Strategy):
# 可配置策略参数
params = dict(
period = MIN_PERIOD, # 均线周期
stake = 100, # 单笔交易股票数目
)
def __init__(self):
self.inds = dict()
for i, d in enumerate(self.datas):
self.inds[d] = bt.ind.SMA(d.close, period=self.p.period)
def next(self):
print(self.datetime.date())
for i, d in enumerate(self.datas):
pos = self.getposition(d)
if not len(pos): # 不在场内,则可以买入
if d.close[0] > self.inds[d][0]: # 达到买入条件
self.buy(data = d, size = self.p.stake) # 买买买
elif d.close[0] < self.inds[d][0]: # 达到卖出条件
self.close(data = d) # 卖卖卖
cerebro = bt.Cerebro() # 创建cerebro
#stk_pools = ['002321', '002322']
stk_pools = ['002321', '002322', '002323']
for stk_code in stk_pools:
# 读入数据
datapath = '../TQDat/day/stk/' + stk_code + '.csv'
fromdate = datetime.datetime(2019, 1, 1)
# option 1
#todate = datetime.datetime(2019, 12, 31, 23, 59, 59, 999990)
todate = datetime.datetime(2019, 12, 31)
# 剔除无效股票
if MIN_PERIOD > bar_size(datapath, fromdate, todate):
continue
# 创建价格数据
data = bt.feeds.GenericCSVData(
dataname = datapath,
fromdate = fromdate,
# option 2
#todate = todate,
todate = todate + datetime.timedelta(days=1),
nullvalue = 0.0,
dtformat = ('%Y-%m-%d'),
datetime = 0,
open = 1,
high = 2,
low = 3,
close = 4,
volume = 5,
openinterest = -1
)
# 在Cerebro中添加股票数据
cerebro.adddata(data, name = stk_code)
cerebro.broker.setcash(1000000.0) # 设置启动资金
cerebro.addstrategy(SmaStrategy) # 添加策略
cerebro.run() # 遍历所有数据
print('Final Portfolio Value: %.2f' % cerebro.broker.getvalue())
博客内容只用于交流学习,不构成投资建议,盈亏自负!
个人博客:http://coderx.com.cn/(优先更新)
项目最新代码:https://gitee.com/sl/quant_from_scratch
欢迎大家转发、留言。有微信群用于学习交流,感兴趣的读者请扫码加微信!
如果认为博客对您有帮助,可以扫码进行捐赠,感谢!
微信二维码 | 微信捐赠二维码 |
---|---|