【PA交易】BackTrader(三): 多周期K线的Resampling

前言

这是BackTrader数据源相关系列的最后一篇。如果没有读过前面两篇,大概率完全不知道在讲什么,请务必前往阅读:

本篇重点讨论如何做到多周期数据的协同使用。核心内容关于使用cerebro.resample AP与前两篇中tick数据源的结合使用。因为BackTrader对于多周期的支持已经非常完善,这篇更像是一份文档的阅读笔记和实操分享。读者需要关注的核心要点还是关于如何结合前面两篇所实现的tick数据源。故:

本文阅读必须已经充分阅读和实操官方Quickstart Guide - Backtrader,同时多数代码以及测试数据都基于前两篇文章,不建议单独阅读本篇文章。

关于resampledata API

首先,BackTrader提供了cerebro.resampledata函数。这个函数可以动态的在运行策略时,根据提供的参数进行合Bar。使用的例子可以从mixing-timeframes.py中找到:

cerebro = bt.Cerebro()
data = btfeeds.BacktraderCSVData(dataname=args.data)

cerebro.adddata(data)

cerebro.resampledata(data, timeframe=bt.TimeFrame.Days, compression=3)
cerebro.addstrategy(St, multi=args.multi)

cerebro.run(stdstats=False, runonce=False)

其中:

  • 参数 timeframe 指明了周期级别,示例中为日线级别;
  • 参数 compression 则指明要将多少个bar合为一个,示例中为3日线。

Backtrader提供的cerebro.resampledata API实际上复制了当前传入的数据源,并且会创建如下类的一个实例:

class DataClone(AbstractDataBase):
    _clone = True

    def __init__(self):
        self.data = self.p.dataname
        self._dataname = self.data._dataname

        # Copy date/session parameters
        self.p.fromdate = self.p.fromdate
        self.p.todate = self.p.todate
        self.p.sessionstart = self.data.p.sessionstart
        self.p.sessionend = self.data.p.sessionend

        self.p.timeframe = self.data.p.timeframe
        self.p.compression = self.data.p.compression

严格意义上这中复制数据源的方式,对于我们目前的实现十分不友好。因为复制数据源意味着需要不断地像我们的MyDataReader进行读取数据操作。但是在我们的例子中,从MyDataReader中通过编程手法进行一些调整以适配这个设计,并不复杂。不过从编程角度看,这个API设计并不算灵活,不过BackTrader现在主要营收来自于Oanda等经纪商。作为居间公司,所以很多封装颗粒度大也正常。

作为示例程序,我们可以使用一个简陋的方式解决重复拉取tick数据的问题:在MyDataReader中指定会重复读取的次数,然后在read_tick时如果读取次数没有达到预期的次数,则跳过本次tick的真实读取和合并分钟bar,这个方式仅仅是为了演示和测试方便,如果自己实现一个完备的数据模块应该使用更加精细化的处理方式。

简陋实现如下:


class MyDataReaderMulti(ABCDataReader):
    def __init__(self, df, bars_period: = 1, bars_count =1):
        # ....
        self._bars_count = bars_count
        self._cur_tick = None
        self._cur_tick_fetched = 0  # 表明当前的tick所读取的次数, 最大不超过self._bars_count
    
    
    def read_tick(self):
        # ...
        
        # 拉取tick达到指定次数之后采取重新获取tick否则直接返回当前的tick, 
        # 合并的分钟bar也不变
        if self._cur_tick is not None:
            tick = self._cur_tick
            self._cur_tick_fetched += 1
            if self._cur_tick_fetched >= self._bars_count:
                self._cur_tick = None
                self._cur_tick_fetched =0
            tickdt = tick['tickdt']
            print(f'fetch tick data: {tickdt}')
            return tick

        # ....

        # 返回tick前重置self._cur_tick 和 增加 self._cur_tick_fetched
        self._cur_tick = tick
        self._cur_tick_fetched += 1
        return tick

在cerebro.resampledata API中,生成clone的数据源后,API会向其添加filter。这些filter在AbstractDataBase中持有和使用。设置的filter的实际调用位于resamplerfilter.py的Resampler::__call__()方法。理解cerebro.resampledata的整个核心机制,在于理解这个方法。我们稍微花些时间看下Resampler的流程。不要忘了我们在试图制造一台印钞机,相比于这套系统带来的可能收益,花上一些时间透彻理解其中的底层实现是非常划算的。

这块的调试方法可以将断点打在AbstractDataBase的load方法上,在load方法上有一个循环会检查所有的过滤器:

for ff, fargs, fkwargs in self._filters:

这个位置可以根据self._name检查不同的数据源的名称(前提是调用cerebro.resampledata指定了数据源名称,见下面调用示例)。当看到需要详细检查的数据源时,单步进入ff() 方法,也就是Resampler::__call__()方法调试,阅读框架的合bar逻辑代码。

额外的,有2点需要注意,且非常重要,在实际写策略时最好统一做好normalization工作,避免将一些平台差异性引入到策略书写中:

  1. Resampler的合Bar的时间标记使用的是结束时间进行标记,比如11:00的bar对应的是从10点~到10:59:59之间的数据,在检查结果的时候需要额外关注。个人习惯其手动调整为开始时间。
  2. Resampler不会处理包括8:59:00或者15:00:00的初始和结束tick等情况, 并且nan值会传导到合并出的bar。这点在数据模块中需要提前预处理。

对于从bar结束时间到开始的转换,这里提供一个简单的实现,不一定是最好的方案,但是可以实现这个功能:

def convert_dt_str_to_period_start(dt: str, period: int):
    # 解析输入的日期时间字符串为datetime对象
    dt_obj = datetime.strptime(dt, "%Y-%m-%d %H:%M:%S")

    # 计算距离dt_obj最近的,小于dt_obj的period分钟倍数的时间
    # 首先计算dt_obj时间的分钟部分
    # 计算当前时间超过最近的period分钟倍数的时间差(以分钟为单位)
    remains = dt_obj.minute % period
    # 如果余数为0,说明当前时间本身就是period的整数倍,因此需要向前推一个周期
    if remains == 0:
        diff_minutes = -period
    else:
        diff_minutes = -remains

    # 使用timedelta根据计算出的差值调整时间
    period_start_dt = dt_obj + timedelta(minutes=diff_minutes)

    # 将调整后的时间格式化回字符串并返回
    return period_start_dt.strftime("%Y-%m-%d %H:%M:%S")

使用方法

虽然前文讨论了很多,但是实际上cerebro.resampledata的使用非常简单。因为我们在上一篇中已经将tick合并为了分钟bar,我们将合并的bar周期调整为1分钟,之后使用的tick_feed作为参数传入即可,如下方法可以调用框架的API实现5、15、60三个周期的K bar自动合并:

df = pd.read_csv('./datas/DCE.m2501.tick.202402.csv')
df['tickdt'] = pd.to_datetime(df['tickdt'])
data_reader = drm.MyDataReaderMulti(df, 1, 3)
tick_feed = bfeed.MyDataFeedWithBar(data_reader, 0)
cerebro.adddata(tick_feed)
cerebro.resampledata(tick_feed, name='5minbar', timeframe=bt.TimeFrame.Minutes, compression=5)
cerebro.resampledata(tick_feed, name='15minbar', timeframe=bt.TimeFrame.Minutes, compression=15)
cerebro.resampledata(tick_feed, name='60minbar', timeframe=bt.TimeFrame.Minutes, compression=60)

cerebro.run()

MyDataReaderMulti的构造函数参数中,

  • 第二个参数表明从tick要合并的KBar周期,具体可参见上一篇的介绍;
  • 第三个参数为本篇新增,用于前述跳过的tick拉取次数,为了适配BT对于不同周期重复拉取的局限性。

作为市场微观分析,通常我们主要关注分钟周期。对于日线级别以上的合并可能需要更多的额外处理,这里不做讨论。所以例子这里使用了5、15、60分钟K线。

如前所述,使用重采样后,会直接添加几个新的数据源(详见cerebro.resampledata代码实现)。所以在使用时候可以根据自己定义的周期为不同数据源定义一些别名:

# Create a Stratey
class TestStrategy(bt.Strategy):
    def __init__(self):
        # ....
        # 5Min bar 字段: 
        self.datetime1 = self.datas[1].datetime
        self.close1 = self.datas[1].close
        self.low1 = self.datas[1].low
        self.high1 = self.datas[1].high
        self.open1 = self.datas[1].open
        # 15Min bar 字段:
        self.datetime2 = self.datas[2].datetime
        self.close2 = self.datas[2].close
        self.low2 = self.datas[2].low
        self.high2 = self.datas[2].high
        self.open2 = self.datas[2].open
        # 60Min bar 字段: 
        self.datetime3 = self.datas[3].datetime
        self.close3 = self.datas[3].close
        self.low3 = self.datas[3].low
        self.high3 = self.datas[3].high
        self.open3 = self.datas[3].open
        self.local_tz = get_localzone()

对应bar的数据访问和使用直接使用别名即可,例如下面这段代码打印了每个周期的数据:

def next(self):
    # Simply log the closing price of the series from the reference
	# ---------------------------------- tick数据
    self.log('Tick price, %.2f' % self.price[0], self.tickdt)
	# ---------------------------------- 1 min bar
    self.log(f'1min ar OHLC: ({self.open[0]}, {self.high[0]}, {self.low[0]}, {self.close[0]})', self.datetime)
    # ---------------------------------- 15 min bar
    self.log(f'5min bar OHLC: ({self.open1[0]}, {self.high1[0]}, {self.low1[0]}, {self.close1[0]})', self.datetime1)
    # ---------------------------------- 30 min bar
    self.log(f'15min bar OHLC: ({self.open2[0]}, {self.high2[0]}, {self.low2[0]}, {self.close2[0]})', self.datetime2)
    # ---------------------------------- 60 min bar
    self.log(f'60min bar OHLC: ({self.open3[0]}, {self.high3[0]}, {self.low3[0]}, {self.close3[0]})', self.datetime3)

在一个tick周期内,打印日志输出如下:

2024-02-01 14:53:39.602002: Tick price, 3141.00
2024-02-01 14:53:26.100004: 1min ar OHLC: (3140.0, 3141.0, 3140.0, 3141.0)
2024-02-01 14:50:00: 5min bar OHLC: (3139.0, 3141.0, 3139.0, 3140.0)
2024-02-01 14:45:00: 15min bar OHLC: (3137.0, 3139.0, 3137.0, 3139.0)
2024-02-01 14:00:00: 60min bar OHLC: (3149.0, 3151.0, 3138.0, 3140.0)

这部分代码和前面两篇文章关系比较密切,需要结合对前面文章的理解进行阅读。

合Bar的校验

作为这篇文章主要目的之一,合并bar的结果正确性非常重要。要知道,如果程序哪怕出一点点纰漏,都会导致策略的运行存在巨大的偏离预期。为了验证合并出的Bar是否正确,我们需要将bar使用一个DF进行存储。多个周期的bar存储到多个DF中。之后在程序运行完毕后,出于简便考虑,通过MPLFinance进行画图展示(或者直接使用BT的画图,但是存在些许适配性问题,后面在其他文章中讨论)。

注意这个操作仅仅是为了验证流程和代码正确性,并非是真正运行策略和执行指标所必须的。以前面60分钟线为例:

class TestStrategy(bt.Strategy):
    def __init__(self):
        # ....

        # 为生成bar创建一个DF临时存储, 用于验证数据
        self.cur_bar_dt3 = None
        self.cur_bar3 = None
        self.df3 = pd.DataFrame(columns=["datetime", "Open", "Close", "High", "Low"])
    
    def next(self):
        # ....策略正常处理流程
        
        # 将
        dt = 略 # 当前bar3所携带的datetime, 转换为str或者datetime, 框架会自行将datetime进行更新
        if self.cur_bar3 is not None and dt != self.cur_bar3["datetime"]:
            # 当前bar已经完成, 将当前bar添加到DF
            self.cur_bar3["datetime"] = convert_to_period_start(self.cur_bar3["datetime"], 60)
            self.df3 = append_to_df(self.df3, self.cur_bar3)
        self.cur_bar3 = {
            "datetime": dt,
            "Open": self.open3[0],
            "High": self.high3[0],
            "Low": self.low3[0],
            "Close": self.close3[0],
        }

    def stop():
        # 断点验证DF

这是一段简短的示例代码,演示如何验证合成的k线数据。几点简要说明如下:

  • self.datas[3].datetime会有框架在合成Bar之后自动填充,如前所述,使用结束时间。如果是自己画图,为了验证方便,根据习惯可以转换为开始时间。代码中使用了convert_to_period_start()。
  • append_to_df函数是将字典追加到DF的一个小封装,如果DF版本支持append也可以替代为df.append()
  • 代码中的dt是从LineBuffer的self.datas[3].datetime转换来的字符串或者datetime类型,看个人习惯。

当前bar存储

前文存储的cur_bar实际由平台生成,是bar完成状态。如果使用自定义的回测、交易结果可视化,可以考虑进行额外存储。这需要单独存储一个当前bar的状态,并根据tick数据动态更新,并在每次Bar完结后存储到结果DF中。这里暂不进行在讨论,后面文章讨论指标和可视化会单独讨论。

总结

截止至目前,BackTrader的数据源的分享基本完结。

量价因子不同于基本面因子和其他因子。多数基本面因子可以掺杂有很多人工分析成分在里面或者允许我们直接把数据丢进ML里面去做拟合躺赢。但是量价因子本身高度依赖于系统的稳定性和可靠性。所以我们在参考量价因子进行会测试,需要对不同的细节进行充分研究和细化,扣很多细节。文中提到了,我们是在试图发明一台印钞机,所以花多少时间精力也值得。

预告

后面我们下一篇关于BackTrader的文章应该是关于Order、Broker和Analyzer的魔改。依然会是使用继承的方法去实现自定义的经纪商。

另外最近忙着各种魔改,暂时没有整理数据,下篇会继续分享一些不错的测试数据。

如果之前文章【PA交易】BackTrader(一): 如何使用实时tick数据和蜡烛图-CSDN博客中分享的测试数据有什么问题可以私信沟通。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值