分享

backtrader实现真正的多股组合操作策略,看这一篇就够了,用定时器进入策略逻辑

 禁忌石 2022-05-10 发布于浙江省

以下策略来自我们编写的教程和视频课程

多股组合操作,是一种高级的操作模式。多股组合操作通常有两种模式,一种是定期调仓,即定期再平衡,比如每周1调仓,或每月1号调仓等。另一种是非定期调仓,比如每日判断,进行调仓,或者每隔n天进行调仓。

定期调仓(再平衡)通常通过定时器timer来进入调仓逻辑,而非定期调仓通常通过传统的策略next方法进入调仓逻辑。当然,这并非绝对。

这两种多股调仓操作,都不能用backtrader内置的自动确定最小期的方法来做(比如为了求20日均线,自动跳过前20个bar),因为有些股票有交易的日期很靠后,它的最小期很大,其他股票也会采用这个最小期,这会导致其他股票浪费最小期前的数据,因此,必须自己控制最小期,也就是prenext方法里必须写上self.next()直接跳转到next。然后如果用到了比如5日均线这样的指标,你要自己判断数据对象线长度是否够长。下面的案例策略没有用到此类技术指标,因此没有判断线长,理论上从第一根bar就可执行逻辑。

尽管网上有一个用backtrader执行多股组合回测的案例,但并未很好地处理好多股回测中的一些问题。本文将给出完善的处理方案。

(基于next的非定期再平衡的策略实现请参考我们编写的教程和视频课程)

本文介绍基于定时器timer的多股定期再平衡策略的实现.

本案例的目的是介绍使用backtrader进行组合管理时,要注意的一些技术要点,策略本身仅供参考。策略的大致逻辑如下:每年5月1日,9月1日,11月1日进行组合再平衡操作(若该日休市,则顺延到开市日进行再平衡操作)。

首先加载一组股票(股票池),在再平衡日,从股票池挑出至少上市3年,且净资产收益率roe>0.1,市盈率 pe在0到100间的股票,这组选出的股票再按成交量从大到小排序,选出前100只股票(如果选出的股票少于100只,则按实际来),将全部账户价值按等比例分配买入这些股票。

该策略反应了如下几个技术要点,把这些要点整明白,基本上就可用于实战了,代码更详细的解读特别是定时器timer的用法参考我们编写的教程和视频课程:

1 扩展PandasData类

2 第一个数据应该对应指数,作为时间基准

3 数据预处理:删除原始数据中无交易的及缺指标的记录

4 先平仓再执行后续买卖

5 下单量的计算方法

6 如何保证先卖后买以空出资金

7 怎样按明日开盘价计算下单数量

8 为行情数据对象提供名字

9 买卖数量如何设为100的整数倍

10 设置符合中国股市的佣金模式,考虑印花税

11 涨跌停板的处理

  1. # 考虑中国佣金,下单量100的整数倍,涨跌停板,滑点
  2. # 考虑一个技术指标,展示怎样处理最小期问题
  3. from datetime import datetime, time
  4. from datetime import timedelta
  5. import pandas as pd
  6. import numpy as np
  7. import backtrader as bt
  8. import os.path # 管理路径
  9. import sys # 发现脚本名字(in argv[0])
  10. import glob
  11. from backtrader.feeds import PandasData # 用于扩展DataFeed
  12. # 创建新的data feed类
  13. class PandasDataExtend(PandasData):
  14. # 增加线
  15. lines = ('pe', 'roe', 'marketdays')
  16. params = (('pe', 15),
  17. ('roe', 16),
  18. ('marketdays', 17), ) # 上市天数
  19. class stampDutyCommissionScheme(bt.CommInfoBase):
  20. '''
  21. 本佣金模式下,买入股票仅支付佣金,卖出股票支付佣金和印花税.
  22. '''
  23. params = (
  24. ('stamp_duty', 0.005), # 印花税率
  25. ('commission', 0.001), # 佣金率
  26. ('stocklike', True),
  27. ('commtype', bt.CommInfoBase.COMM_PERC),
  28. )
  29. def _getcommission(self, size, price, pseudoexec):
  30. '''
  31. If size is greater than 0, this indicates a long / buying of shares.
  32. If size is less than 0, it idicates a short / selling of shares.
  33. '''
  34. if size > 0: # 买入,不考虑印花税
  35. return size * price * self.p.commission
  36. elif size < 0: # 卖出,考虑印花税
  37. return size * price * (self.p.stamp_duty + self.p.commission)
  38. else:
  39. return 0 # just in case for some reason the size is 0.
  40. class Strategy(bt.Strategy):
  41. params = dict(
  42. rebal_monthday=[1], # 每月1日执行再平衡
  43. num_volume=100, # 成交量取前100名
  44. period = 5,
  45. )
  46. # 日志函数
  47. def log(self, txt, dt=None):
  48. # 以第一个数据data0,即指数作为时间基准
  49. dt = dt or self.data0.datetime.date(0)
  50. print('%s, %s' % (dt.isoformat(), txt))
  51. def __init__(self):
  52. self.lastRanks = [] # 上次交易股票的列表
  53. # 0号是指数,不进入选股池,从1号往后进入股票池
  54. self.stocks = self.datas[1:]
  55. # 记录以往订单,在再平衡日要全部取消未成交的订单
  56. self.order_list = []
  57. # 移动平均线指标
  58. self.sma={d:bt.ind.SMA(d,period=self.p.period) for d in self.stocks}
  59. # 定时器
  60. self.add_timer(
  61. when= bt.Timer.SESSION_START,
  62. monthdays=self.p.rebal_monthday, # 每月1号触发再平衡
  63. monthcarry=True, # 若再平衡日不是交易日,则顺延触发notify_timer
  64. )
  65. def notify_timer(self, timer, when, *args, **kwargs):
  66. # 只在5,9,11月的1号执行再平衡
  67. if self.data0.datetime.date(0).month in [5,9,11]:
  68. self.rebalance_portfolio() # 执行再平衡
  69. # def next(self):
  70. # print('next 账户总值', self.data0.datetime.datetime(0), self.broker.getvalue())
  71. # for d in self.stocks:
  72. # if(self.getposition(d).size!=0):
  73. # print(d._name, '持仓' ,self.getposition(d).size)
  74. def notify_order(self, order):
  75. if order.status in [order.Submitted, order.Accepted]:
  76. # 订单状态 submitted/accepted,无动作
  77. return
  78. # 订单完成
  79. if order.status in [order.Completed]:
  80. if order.isbuy():
  81. self.log('买单执行,%s, %.2f, %i' % (order.data._name,
  82. order.executed.price, order.executed.size))
  83. elif order.issell():
  84. self.log('卖单执行, %s, %.2f, %i' % (order.data._name,
  85. order.executed.price, order.executed.size))
  86. else:
  87. self.log('订单作废 %s, %s, isbuy=%i, size %i, open price %.2f' %
  88. (order.data._name, order.getstatusname(), order.isbuy(), order.created.size, order.data.open[0]))
  89. # 记录交易收益情况
  90. def notify_trade(self, trade):
  91. if trade.isclosed:
  92. print('毛收益 %0.2f, 扣佣后收益 % 0.2f, 佣金 %.2f, 市值 %.2f, 现金 %.2f' %
  93. (trade.pnl, trade.pnlcomm, trade.commission, self.broker.getvalue(), self.broker.getcash()))
  94. def rebalance_portfolio(self):
  95. # 从指数取得当前日期
  96. self.currDate = self.data0.datetime.date(0)
  97. print('rebalance_portfolio currDate', self.currDate, len(self.stocks))
  98. # 如果是指数的最后一本bar,则退出,防止取下一日开盘价越界错
  99. if len(self.datas[0]) == self.data0.buflen():
  100. return
  101. # 取消以往所下订单(已成交的不会起作用)
  102. for o in self.order_list:
  103. self.cancel(o)
  104. self.order_list = [] # 重置订单列表
  105. # for d in self.stocks:
  106. # print('sma', d._name, self.sma[d][0],self.sma[d][1], d.marketdays[0])
  107. # 最终标的选取过程
  108. # 1 先做排除筛选过程
  109. self.ranks = [d for d in self.stocks if
  110. len(d) > 0 # 重要,到今日至少要有一根实际bar
  111. and d.marketdays > 3*365 # 到今天至少上市
  112. # 今日未停牌 (若去掉此句,则今日停牌的也可能进入,并下订单,次日若复牌,则次日可能成交)(假设原始数据中已删除无交易的记录)
  113. and d.datetime.date(0) == self.currDate
  114. and d.roe >= 0.1
  115. and d.pe < 100
  116. and d.pe > 0
  117. and len(d) >= self.p.period
  118. and d.close[0] > self.sma[d][1]
  119. ]
  120. # 2 再做排序挑选过程
  121. self.ranks.sort(key=lambda d: d.volume, reverse=True) # 按成交量从大到小排序
  122. self.ranks = self.ranks[0:self.p.num_volume] # 取前num_volume名
  123. if len(self.ranks) == 0: # 无股票选中,则返回
  124. return
  125. # 3 以往买入的标的,本次不在标的中,则先平仓
  126. data_toclose = set(self.lastRanks) - set(self.ranks)
  127. for d in data_toclose:
  128. print('sell 平仓', d._name, self.getposition(d).size)
  129. o = self.close(data=d)
  130. self.order_list.append(o) # 记录订单
  131. # 4 本次标的下单
  132. # 每只股票买入资金百分比,预留2%的资金以应付佣金和计算误差
  133. buypercentage = (1-0.02)/len(self.ranks)
  134. # 得到目标市值
  135. targetvalue = buypercentage * self.broker.getvalue()
  136. # 为保证先卖后买,股票要按持仓市值从大到小排序
  137. self.ranks.sort(key=lambda d: self.broker.getvalue([d]), reverse=True)
  138. self.log('下单, 标的个数 %i, targetvalue %.2f, 当前总市值 %.2f' %
  139. (len(self.ranks), targetvalue, self.broker.getvalue()))
  140. for d in self.ranks:
  141. # 按次日开盘价计算下单量,下单量是100的整数倍
  142. size = int(
  143. abs((self.broker.getvalue([d]) - targetvalue) / d.open[1] // 100 * 100))
  144. validday = d.datetime.datetime(1) # 该股下一实际交易日
  145. if self.broker.getvalue([d]) > targetvalue: # 持仓过多,要卖
  146. # 次日跌停价近似值
  147. lowerprice = d.close[0]*0.9+0.02
  148. o = self.sell(data=d, size=size, exectype=bt.Order.Limit,
  149. price=lowerprice, valid=validday)
  150. else: # 持仓过少,要买
  151. # 次日涨停价近似值
  152. upperprice = d.close[0]*1.1-0.02
  153. o = self.buy(data=d, size=size, exectype=bt.Order.Limit,
  154. price=upperprice, valid=validday)
  155. self.order_list.append(o) # 记录订单
  156. self.lastRanks = self.ranks # 跟踪上次买入的标的
  157. ##########################
  158. # 主程序开始
  159. #########################
  160. cerebro = bt.Cerebro(stdstats=False)
  161. cerebro.addobserver(bt.observers.Broker)
  162. cerebro.addobserver(bt.observers.Trades)
  163. # cerebro.broker.set_coc(True) # 以订单创建日的收盘价成交
  164. # cerebro.broker.set_coo(True) # 以次日开盘价成交
  165. datadir = './dataswind' # 数据文件位于本脚本所在目录的data子目录中
  166. datafilelist = glob.glob(os.path.join(datadir, '*')) # 数据文件路径列表
  167. maxstocknum = 20 # 股票池最大股票数目
  168. # 注意,排序第一个文件必须是指数数据,作为时间基准
  169. datafilelist = datafilelist[0:maxstocknum] # 截取指定数量的股票池
  170. print(datafilelist)
  171. # 将目录datadir中的数据文件加载进系统
  172. for fname in datafilelist:
  173. df = pd.read_csv(
  174. fname,
  175. skiprows=0, # 不忽略行
  176. header=0, # 列头在0行
  177. )
  178. # df = df[~df['交易状态'].isin(['停牌一天'])] # 去掉停牌日记录
  179. df['date'] = pd.to_datetime(df['date']) # 转成日期类型
  180. df = df.dropna()
  181. # print(df.info())
  182. # print(df.head())
  183. data = PandasDataExtend(
  184. dataname=df,
  185. datetime=0, # 日期列
  186. open=2, # 开盘价所在列
  187. high=3, # 最高价所在列
  188. low=4, # 最低价所在列
  189. close=5, # 收盘价价所在列
  190. volume=6, # 成交量所在列
  191. pe=7,
  192. roe=8,
  193. marketdays=9,
  194. openinterest=-1, # 无未平仓量列
  195. fromdate=datetime(2002, 4, 1), # 起始日2002, 4, 1
  196. todate=datetime(2015, 12, 31), # 结束日 2015, 12, 31
  197. plot=False
  198. )
  199. ticker = fname[-13:-4] # 从文件路径名取得股票代码
  200. cerebro.adddata(data, name=ticker)
  201. cerebro.addstrategy(Strategy)
  202. startcash = 10000000
  203. cerebro.broker.setcash(startcash)
  204. # 防止下单时现金不够被拒绝。只在执行时检查现金够不够。
  205. cerebro.broker.set_checksubmit(False)
  206. comminfo = stampDutyCommissionScheme(stamp_duty=0.001, commission=0.001)
  207. cerebro.broker.addcommissioninfo(comminfo)
  208. results = cerebro.run()
  209. print('最终市值: %.2f' % cerebro.broker.getvalue())
  210. # cerebro.plot()
  211. 复制代码

以上这个策略,其实也可以通过next方法进入策略逻辑,具体代码和详情请参考我们的教程。


作者:扫地僧2020
 

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多