本文继续记录多股回测时可能遇到的异常情况。
坑描述
多股回测时,当日期达到所有股票的技术指标都能够计算出有效值后,backtrader才开始进行回测。由于这种逻辑的存在,如果某些股票在回测周期的最后几天才能计算出技术指标,那么就会导致回测只在最后几天进行,前面大片回测时间被浪费。
坑重现
为了重现上述现象,做如下回测设定(与笔记(35)相同):
- 使用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’]进行回测时,策略的next方法从2019年1月29日开始运行。
当使用组合[‘002321’, ‘002322’, ‘002323’]进行回测时,策略的next方法从2019年12月31日开始运行。
坑分析
002321和002322在回测周期内(2019年1月1日至2019年12月31日)共有244根日K线,第1根K线出现在2019年1月2日,第20根K线出现在2019年1月29日,20日均线指标从2019年1月29日起可计算得到有效值。
002323在回测周期内共有20根K线,第1根K线出现在2019年12月4日,第20根K线出现在2019年12月31日,20日均线指标从2019年12月31日起可计算得到有效值。
当回测组合[‘002321’, ‘002322’]时,所有回测股票自2019年1月29日起,都能计算得到回测策略所使用技术指标的有效值,因此回测从2019年1月29日开始进行。
当回测组合[‘002321’, ‘002322’, ‘002323’]时,所有回测股票自2019年12月31日起,都能计算得到回测策略所使用技术指标的有效值,因此回测从2019年12月31日开始进行。而回测周期到2019年12月31日结束,也就是由于002323参与回测,整个回测过程只运行了一天,有效回测周期大幅缩减,可以认为回测失败。
经过backtrader源码分析,多股回测时,当日期达到所有股票的技术指标都能够计算出有效值后,backtrader才开始进行回测。
在backtrader中定义了最小周期的概念,当指标经过最小周期时,才能计算出有效值。例如,20日均线的最小周期就是20,即在经过20根K线时,才能计算出20日均线的第一个有效值。那么,在多股回测时,就要求所有参与回测的股票,必须都经过各自所有指标的最小周期,回测才会开始。
当经过最小周期计算出指标的有效值后,股票后续由于停盘等原因造成K线缺失,不会影响回测过程的正常执行,策略的next方法仍会按日依次执行。
避坑方案
为了避免由于个别股票的参与,导致回测过程受到影响,出现有效回测周期大幅缩减的异常情况,可以参考以下方案(在实验中,将当前上市交易的所有股票都考虑在回测范围内):
- 方案1:
统计回测周期内所有股票的K线数目,按照K线的数目对股票进行分组。这样绝大多数股票由于未停牌应该具有相同的K线数目(不考虑九十年代大量股票未上市的情况)而被分到一组中,并且该分组内所包含的股票数量最多。因此可以对该分组内的股票进行回测。
- 根据回测周期内的K线数目对股票进行分组
# 根据回测周期内的K线数目对股票进行分组
def analyze_backtest_bar_size(fromdate, todate):
# 读入股票代码
stk_code_file = '../TQDat/TQDown2020v1/data/stock_code_update.csv'
stk_pools = pd.read_csv(stk_code_file, encoding = 'gbk')
total_size = stk_pools.shape[0] # 当前上市股票总数
# 字典,key为K线的根数,value是一个list,里面包含拥有key根K线的股票代码
size_dict = defaultdict(list)
# 遍历所有股票,分析K线数目
for i in range(total_size):
stk_code = stk_pools['code'][stk_pools.index[i]]
stk_code = '%06d' % stk_code
# 读入数据
datapath = '../TQDat/day/stk/' + stk_code + '.csv'
size_dict[bar_size(datapath, fromdate.strftime('%Y-%m-%d'), todate.strftime('%Y-%m-%d'))].append(stk_code)
return size_dict
分组结果保存在字典size_dict中。在该字典中,key是各个分组中每个股票在回测周期内的K线数目,value是对应的股票列表,即形式为:
{ ...,
243 : ['000010', '000422', '000504', ...],
244 : ['000001', '000002', '000005', ...],
...
}
所表示的意思是,000010、000422、000504等股票在回测周期(2019年1月1日至2019年12月31日)内有243根日K线,000001、000002、000005等股票在回测周期内有244根日K线。
下一步将选择分组内股票数目最多的组参与回测。
- 获取回测股票代码
# 获取回测股票代码
def backtest_stks(min_period, fromdate, todate):
size_dict = analyze_backtest_bar_size(fromdate, todate)
# 按拥有相同K线数目股票列表大小对字典进行排序
sorted_size_list = sorted(size_dict.items(), key = lambda x:len(x[1]), reverse = True)
# 取出拥有相同K线数目的股票列表中,股票数目最多的列表,其key值就是列表中股票参与回测的K线根数
backtest_bar_size = sorted_size_list[0][0]
# 获取回测股票列表
return size_dict[backtest_bar_size]
在得到字典size_dict之后,对字典按照value值所表示的列表的大小进行排序,即按照每个分组中股票数目的多少进行排序,然后选取股票数目最多的一组参与回测。
在所有股票中,回测周期2019年1月1日至2019年12月31日内,K线数目最多为244根。全年均无停盘的情况下,股票的K线数目为244。
而拥有244根K线的这组股票数目确实也是最多的,达到了3098只股票(当前上市交易的股票总数目为3860)。
最后,用这3098只股票进行回测,程序单机运行约40分钟,回测的最终市值为1080960.00元,年化收益率8.10%(凑合吧)。
- 方案2:
方案1中,对分组得到的股票数目最多的这组股票进行了回测,已经可以选出绝大部分的股票。考虑到上文提及“当经过最小周期计算出指标的有效值后,股票后续由于停盘等原因造成K线缺失,不会影响回测过程的正常执行,策略的next方法仍会按日依次执行”,那么有更多的股票是可以参与到回测过程中的。
例如,在本实验中,000001在回测周期内有244根K线,那么它将参与回测过程中,2019年1月29日为最小周期的最后一天,策略的next方法从2019年1月29日起开始执行。000010在回测周期内有243根K线,仅在2019年4月25日停盘一天,2019年1月29日也是它的最小周期的最后一天,那么000010参与回测后,策略的next方法仍可以从2019年1月29日起开始执行,且不会影响后续回测过程的正常执行。因此,000010也可以参与到回测中。
按照以下步骤筛选参与回测的股票:
-
根据回测周期内的K线数目对股票进行分组,获取股票数目最多的分组(参考方案1)
-
在股票数目最多的分组中,选取一只股票作为参考股票
# 获取其中一只股票作为参考
ref_stk = size_dict[backtest_bar_size][0]
size_dict为分组信息,backtest_bar_size为回测最大的日线根数(实验中为244),索引[0]表示取分组中的第一只股票。
- 计算参考股票最小周期的起止日期
# 计算最小周期的起止日期
def cal_minperiod_fromtodate(stk, min_period, fromdate, todate):
# 读取股票数据
datapath = '../TQDat/day/stk/' + stk + '.csv'
stk_df = pd.read_csv(datapath, encoding = 'gbk')
# 获取fromdate后的数据
stk_df = stk_df[(stk_df['date'] >= fromdate.strftime('%Y-%m-%d'))]
# 获取fromdate后的第一个日期
stk_from = stk_df.iloc[0].at['date']
# 获取最小周期内的最后一个日期
stk_to = stk_df.iloc[min_period - 1].at['date']
# 判断是否超过结束日期
check_to = datetime.datetime.strptime(stk_from, '%Y-%m-%d')
if check_to > todate:
print('Date Error!')
sys.exit(1)
return stk_from, stk_to
在读取参考股票的数据后,在回测周期内的第一根K线的日期为最小周期的开始日期(也是回测的真实开始日期),在此之后的最小周期减1得到日期为最小周期的结束日期。
最小周期的起止日期用于后续筛选参与回测的股票,只有最小周期的起止日期与参考股票完全一致的股票才应参与回测。
- 获取参与回测的股票列表
# 判断单只股票是否参与回测
def check_stk(stk, min_period, fromdate, todate):
datapath = '../TQDat/day/stk/' + stk + '.csv'
return min_period == bar_size(datapath, fromdate, todate)
# 获取回测股票列表
def cal_stk_list(size_dict, min_period, backtest_bar_size, fromdate, todate):
# 返回值
stk_list = []
# 遍历字典中的股票
for k, v in size_dict.items():
# K线数目不足最小周期的直接剔除
if k < min_period:
continue
# K线数目大于等于最多共有K线数目的股票参加回测
elif k >= backtest_bar_size:
stk_list.extend(v)
# 其他情况下,判断股票是否包含参考股票最初最小周期数目的K线,若包含则参与回测
else:
for stk in v:
if check_stk(stk, min_period, fromdate, todate):
stk_list.append(stk)
return stk_list
对分组信息size_dict分三类进行处理:
-
对于K线数目少于最小周期的股票,进行直接剔除
-
对于K线数目大于等于最多共有K线数目(实验中为244)的股票,将参与回测
-
其他情况下,判断股票是否与参考股票拥有相同的最小周期起止日期,若是则参与回测,否则剔除
按照上面的步骤,方案2共筛选出3492只股票参与回测(较方案1增加了394只),程序单机运行约60分钟,回测的最终市值为1077636.00元,年化收益率7.76%(也还行吧)。
总结
-
在多股回测时,要求所有参与回测的股票,必须都经过各自所有技术指标的最小周期,能够计算出所有技术指标的有效值后,回测才会开始。
-
如果不针对指标的最小周期问题,对股票进行筛选剔除,可能会造成有效回测周期大幅缩减。
-
当经过最小周期计算出指标的有效值后,股票后续由于停盘等原因造成K线缺失,不会影响回测过程的正常执行,策略的next方法仍会按日依次执行。
-
可按回测周期内K线数目对股票分组,对股票数目最多的这组进行回测,并筛选出其他组中与该组拥有相同最小周期起止日期的股票参与回测,以提升参与回测的股票总数。
方案2代码:
from __future__ import (absolute_import, division, print_function,
unicode_literals)
import sys
import datetime # 用于datetime对象操作
import os.path # 用于管理路径
import backtrader as bt # 引入backtrader框架
import pandas as pd
from collections import defaultdict
MIN_PERIOD = 20
# 统计回测周期内K线数量
def bar_size(datapath, fromdate, todate):
df = pd.read_csv(datapath)
return len(df[(df['date'] >= fromdate)
& (df['date'] <= todate)])
# 计算最小周期的起止日期
def cal_minperiod_fromtodate(stk, min_period, fromdate, todate):
# 读取股票数据
datapath = '../TQDat/day/stk/' + stk + '.csv'
stk_df = pd.read_csv(datapath, encoding = 'gbk')
# 获取fromdate后的数据
stk_df = stk_df[(stk_df['date'] >= fromdate.strftime('%Y-%m-%d'))]
# 获取fromdate后的第一个日期
stk_from = stk_df.iloc[0].at['date']
# 获取最小周期内的最后一个日期
stk_to = stk_df.iloc[min_period - 1].at['date']
# 判断是否超过结束日期
check_to = datetime.datetime.strptime(stk_from, '%Y-%m-%d')
if check_to > todate:
print('Date Error!')
sys.exit(1)
return stk_from, stk_to
# 判断单只股票是否参与回测
def check_stk(stk, min_period, fromdate, todate):
datapath = '../TQDat/day/stk/' + stk + '.csv'
return min_period == bar_size(datapath, fromdate, todate)
# 获取回测股票列表
def cal_stk_list(size_dict, min_period, backtest_bar_size, fromdate, todate):
# 返回值
stk_list = []
# 遍历字典中的股票
for k, v in size_dict.items():
# K线数目不足最小周期的直接剔除
if k < min_period:
continue
# K线数目大于等于最多共有K线数目的股票参加回测
elif k >= backtest_bar_size:
stk_list.extend(v)
# 其他情况下,判断股票是否包含参考股票最初最小周期数目的K线,若包含则参与回测
else:
for stk in v:
if check_stk(stk, min_period, fromdate, todate):
stk_list.append(stk)
return stk_list
# 根据回测周期内的K线数目对股票进行分组
def analyze_backtest_bar_size(fromdate, todate):
# 读入股票代码
stk_code_file = '../TQDat/TQDown2020v1/data/stock_code_update.csv'
stk_pools = pd.read_csv(stk_code_file, encoding = 'gbk')
total_size = stk_pools.shape[0] # 当前上市股票总数
# 字典,key为K线的根数,value是一个list,里面包含拥有key根K线的股票代码
size_dict = defaultdict(list)
# 遍历所有股票,分析K线数目
for i in range(total_size):
stk_code = stk_pools['code'][stk_pools.index[i]]
stk_code = '%06d' % stk_code
# 读入数据
datapath = '../TQDat/day/stk/' + stk_code + '.csv'
size_dict[bar_size(datapath, fromdate.strftime('%Y-%m-%d'), todate.strftime('%Y-%m-%d'))].append(stk_code)
return size_dict
# 获取回测股票代码
def backtest_stks(min_period, fromdate, todate):
size_dict = analyze_backtest_bar_size(fromdate, todate)
# 按拥有相同K线数目股票列表大小对字典进行排序
sorted_size_list = sorted(size_dict.items(), key = lambda x:len(x[1]), reverse = True)
# 取出拥有相同K线数目的股票列表中,股票数目最多的列表,其key值就是列表中股票参与回测的K线根数
backtest_bar_size = sorted_size_list[0][0]
# 获取其中一只股票作为参考
ref_stk = size_dict[backtest_bar_size][0]
# 获取最小周期的起止时间
stk_from, stk_to = cal_minperiod_fromtodate(ref_stk, min_period, fromdate, todate)
# 获取回测股票列表
return cal_stk_list(size_dict, min_period, backtest_bar_size, stk_from, stk_to)
# 创建策略
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) # 卖卖卖
fromdate = datetime.datetime(2019, 1, 1)
todate = datetime.datetime(2019, 12, 31)
cerebro = bt.Cerebro() # 创建cerebro
for stk_code in backtest_stks(MIN_PERIOD, fromdate, todate):
# 读入数据
datapath = '../TQDat/day/stk/' + stk_code + '.csv'
#print(stk_code)
# 创建价格数据
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())
为了便于相互交流学习,已建微信群,感兴趣的读者请加微信。