基于逐笔成交的高频回测系统兼论K线回测的缺陷

Author: 小草, Created: 2020-06-04 16:48:02, Updated: 2023-10-08 19:46:18

img

我在币安做空超涨做多超跌多币种对冲策略时,同时发布了一个回测引擎。并第一篇报告基于一小时K线回测,验证了策略的有效性。但实际公开策略的休眠时间时1s,是一个相当高频的策略,用小时K线回测显然无法得出精确结果。后来补充了分钟线回测的结果,回测收益提高了很多,但还是无法确定秒级情况下应该用什么参数,对整个策略的理解也不是很清晰。主要原因是基于K线的回测的重要弊端。

基于K线回测的问题

首先什么是历史K线?一根K线数据包含高开低收四个价格、起始两个时间以及区间成交量。大部分量化平台和框架都是基于K线回测的,FMZ量化平台也提供了tick级回测。K线回测的速度很快,大部分情况下也没问题,但是也有非常严重的缺陷,特别是回测多品种策略和高频策略,几乎无法得出正确的结论。

首先是时间问题,K线数据最高价和最低价的时间是没有给出的,不用考虑,但最重要的开盘和收盘价起始并不是开盘和收盘时间。即使不太冷门的交易品种,也往往十几秒都没有交易,而我们回测多品种策略时,往往默认它们的开盘价和收盘价是同时的,这也是基于收盘价回测的基础。

想象一下用分钟线回测两个品种的套利,它们的差价通常10元,现在发现10:01时刻,A合约收盘价为100,B合约为112,差价是12元,于是策略开始对冲,某个时刻差价回归,策略赚了2元的回归利润。

而实际情况可能是在10:00:45,A合约产生了一笔100元的成交,此后没有交易,B合约在10:00:58发生了一笔112元的成交,在10:01这一刻,两个价格都是不存在的,此时的盘口价格是多少呢,对冲能够吃到多少差价呢?都无法知道。一个可能的情况是:在10:00:58时,A合约的买一卖一盘口是101.9-102.1,根本没有2元差价。这就会对我们的策略优化产生很大的误导。

其次是撮合问题,真实的撮合是价格优先,时间优先。如果买家超过卖一价,一般会直接以卖一价成交,反之进入订单簿等待。K线数据显然没有买一卖一价,是无法模拟细节层次的撮合。

最后是策略本身成交对市场的影响,如果是小资金回测,影响不大。但如果成交量占比很大,会对市场产生冲击。不仅是立即成交时价格滑点会很大,如果回测你的买单成交了,实际上抢占了其它原来要买入交易者的成交,蝴蝶效应下会对市场产生影响。而这种影响无法量化给出,只能凭借经验说高频交易只能容纳小资金。

基于实时深度和tick的回测

FMZ提供了实盘级回测,能够获取到真实的历史20档深度,实时的秒级tick,逐笔成交等数据,并基于此做了实盘回放功能。这样的回测数据量极大,回测速度也很慢,一般只能回测两天。对于相对高频或者对时间判断要求严格的策略,实盘级回测是必须的。FMZ收集的交易对和时间并不长,但也有700多亿条历史数据。目前的撮合机制是如果买单大于卖一会不看量立即完全撮合,小于卖一进入撮合队列排队。这样的回测机制解决了K线回测的前两个问题,但还是无法解决最后一个问题。并且由于数据量实在太大,回测速度和时间范围都有限制。

img

基于逐笔成交订单流的回测机制

K线信息太少,深度也有可能是假深度,但有一种数据是市场真实的成交意愿,反映了最真实的交易历史———那就是逐笔成交。本文将提出一个基于订单流的高频回测系统,将大大减少实盘级回测的数据量,并且一定程度的模拟成交量对市场的影响。

我下载了最近5天币安XTZ永续合约的逐笔成交(下载地址:https://www.fmz.com/upload/asset/1ff487b007e1a848ead.csv ),作为一个不算热门的品种,共有213000条数据,先看一下数据的构成:

[['XTZ', 1590981301905, 2.905, 0.4, 'False\n'],
 ['XTZ', 1590981303044, 2.903, 3.6, 'True\n'],
 ['XTZ', 1590981303309, 2.903, 3.7, 'True\n'],
 ['XTZ', 1590981303738, 2.903, 238.1, 'True\n'],
 ['XTZ', 1590981303892, 2.904, 0.1, 'False\n'],
 ['XTZ', 1590981305250, 2.904, 0.1, 'False\n'],
 ['XTZ', 1590981305643, 2.903, 197.3, 'True\n'],

数据是二维列表,按成交时间顺序排序。具体意义分别是:品种名称、成交价格、成交时间戳、成交数量、是否是卖单主动成交。有买有卖,每一笔成交都包含了买方和卖方,如果买方是做市maker,卖方是主动成交taker,则最后一个数据是True。

首先根据成交方向,可以相当精确的推测出市场上的买一和卖一,如果是主动卖出单,则此时的买一价就是成交价,如果主动买入单,则卖一价为成交价,有新的成交就更新新的盘口,未更新的保留上一次结果。很容易的推出以上数据的最后时刻,买一价为2.903,卖一价为2.904。

根据订单流,可以这样撮合:以一笔买单为例,价格为price,下单量为amount,此时盘口买一卖一分别为bid,ask。如果price低于ask高于bid,则先判断为maker,并且可以优先撮合成交,则此后在订单存在时间内所有的成交价低于或等于price的逐笔成交都与此订单撮合(如果price低于或等于bid,则不能优先成交,成交价低于price的订单都与此订单撮合),撮合价为price,交易量为逐笔成交的成交量,直到订单完全成交或者撤单。如果价格高于ask,判断为taker,此后在订单存在时间内所有的成交价低于或等于price的逐笔成交都与此订单撮合,撮合价为逐笔成交的成交价。区分maker和taker是因为基本上交易所鼓励挂单,有手续费的优惠,对于高频策略,必须考虑这种区别。

可以很容易看到这种撮合的一个问题,如果订单为taker,实际情况是能立即成交,而不是等待新的订单与之撮合。首先我们并没有考虑盘口挂单量,就算有数据,直接判断成交也改变了深度,影响了市场。而基于新订单的撮合,相当于把历史中真实存在的订单替换成你的订单,无论如何也不会超出市场本身成交量的限制,最终盈利也不可能超过行情产生的最大盈利。部分的撮合机制也影响了订单的成交量,进而影响策略的收益,定量的反映出了策略容量。不会出现传统回测,资金量放大一倍收益就放大一倍的情况。

还有一些小细节,如果订单买价等于买一,实际上仍然有一定的概率以买一价被撮合的,需要考虑挂单的优先级和成交概率等,较为复杂,这里就不考虑了。

撮合代码

交易所对象可以参考开头的介绍,基本不变,只添加了maker和taker手续费的区别,以及优化了回测的速度。下面将主要介绍撮合代码。

    symbol = 'XTZ'
    loop_time = 0
    intervel = 1000 #策略的休眠时间为1000ms
    init_price = data[0][2] #初始价格
    e = Exchange([symbol],initial_balance=1000000,maker_fee=maker_fee,taker_fee=taker_fee,log='') #初始化交易所
    depth = {'ask':data[0][2], 'bid':data[0][2]} #深度
    order = {'buy':{'price':0,'amount':0,'maker':False,'priority':False,'id':0},
             'sell':{'price':0,'amount':0,'maker':False,'priority':False,'id':0}} #订单
    for tick in data:
        price = int(tick[2]/tick_sizes[symbol])*tick_sizes[symbol] #成交价格
        trade_amount = tick[3] #成交数量
        time_stamp = tick[1] #成交时间戳
        if tick[4] == 'False\n':
            depth['ask'] = price
        else:
            depth['bid'] = price
        
        if depth['bid'] < order['buy']['price']:
            order['buy']['priority'] = True
        if depth['ask'] > order['sell']['price']:
            order['sell']['priority'] = True
        if price > order['buy']['price']:
            order['buy']['maker'] = True
        if price < order['sell']['price']:
            order['sell']['maker'] = True
        
        #订单网络延时也可以作为撮合条件之一,这里没考虑
        cond1 = order['buy']['priority'] and order['buy']['price'] >= price and order['buy']['amount'] > 0
        cond2 = not order['buy']['priority'] and order['buy']['price'] > price and order['buy']['amount'] > 0
        cond3 = order['sell']['priority'] and order['sell']['price'] <= price and order['sell']['amount'] > 0
        cond4 = not order['sell']['priority'] and order['sell']['price'] < price and order['sell']['amount'] > 0

        if cond1 or cond2:
            buy_price = order['buy']['price'] if order['buy']['maker'] else price
            e.Buy(symbol, buy_price, min(order['buy']['amount'],trade_amount), order['buy']['id'], order['buy']['maker'])
            order['buy']['amount'] -= min(order['buy']['amount'],trade_amount)
            e.Update(time_stamp,[symbol],{symbol:price})
        if cond3 or cond4:
            sell_price = order['sell']['price'] if order['sell']['maker'] else price
            e.Sell(symbol, sell_price, min(order['sell']['amount'],trade_amount), order['sell']['id'], order['sell']['maker'])
            order['sell']['amount'] -= min(order['sell']['amount'],trade_amount)
            e.Update(time_stamp,[symbol],{symbol:price})

        if time_stamp - loop_time > intervel:
            order = get_order(e,depth,order) #交易逻辑,这里未给出
            loop_time += int((time_stamp - loop_time)/intervel)*intervel

几个细节要注意一下:

  • 1.当有新成交时,要先去撮合订单,再去根据最新的价格去下单。
  • 2.每个订单都有两个属性:maker——是否为maker,priority——撮合优先级,以买单为例,当买价小于卖一,标记为maker,当买价大于买一是标记为优先撮合,priority决定了价格等于买价是是否撮合,maker决定了手续费。
  • 3.订单的maker和priority是更新的,如下了一笔很大的超过盘口的买单,当出现一个价格大于买价时,此时剩余的成交量将是maker。
  • 4.策略的intervel是必须的,它可以代表行情的延时。

网格策略的回测

终于到了实际的回测阶段,我们这里回测一个最经典的网格策略,来看看有没有达到预期的效果。策略原理为价格每上涨1%,我们就持有一定价值的空单(反之持有多单),计算好买单卖单提前挂好。代码就不放出了。把所有代码封装到Grid('XTZ',100,0.3,1000,maker_fee=-0.00002,taker_fee=0.0003)函数中,参数分别为:交易对,价格偏离1%的持有价值,挂单密度0.3%,休眠间隔ms,挂单手续费,吃单手续费。

最近5天XTZ的行情处于震荡阶段,很适合网格。 img

我们先回测不同的持仓大小对收益的影响,传统的回测机制回测出来的收益肯定会随着持仓的增加等比增加。

e1 = Grid('XTZ',100,0.3,1000,maker_fee=-0.00002,taker_fee=0.0003)
print(e1.account['USDT'])
e2 = Grid('XTZ',1000,0.3,1000,maker_fee=-0.00002,taker_fee=0.0003)
print(e2.account['USDT'])
e3 = Grid('XTZ',10000,0.3,1000,maker_fee=-0.00002,taker_fee=0.0003)
print(e3.account['USDT'])
e4 = Grid('XTZ',100000,0.3,1000,maker_fee=-0.00002,taker_fee=0.0003)
print(e4.account['USDT'])

共回测了四组,持仓价值分别为100,1000,10000,100000,回测总用时1.3s。结果如下:

{'realised_profit': 28.470993031132966, 'margin': 0.7982662957624465, 'unrealised_profit': 0.0104554474048441, 'total': 10000028.481448, 'leverage': 0.0, 'fee': -0.3430967859046398, 'maker_fee': -0.36980249726699727, 'taker_fee': 0.026705711362357405}
{'realised_profit': 275.63148945320177, 'margin': 14.346335829979132, 'unrealised_profit': 4.4382117331794045e-14, 'total': 10000275.631489, 'leverage': 0.0, 'fee': -3.3102045933457784, 'maker_fee': -3.5800688964477048, 'taker_fee': 0.2698643031019274}
{'realised_profit': 2693.8701498889504, 'margin': 67.70120400534114, 'unrealised_profit': 0.5735269329348516, 'total': 10002694.443677, 'leverage': 0.0001, 'fee': -33.984021415250744, 'maker_fee': -34.879233866850974, 'taker_fee': 0.8952124516001403}
{'realised_profit': 22610.231198585603, 'margin': 983.3853688758861, 'unrealised_profit': -20.529965947304365, 'total': 10022589.701233, 'leverage': 0.002, 'fee': -200.87094000385412, 'maker_fee': -261.5849078470078, 'taker_fee': 60.71396784315319}

可以看到最终已实现利润分别为持仓价值的28.4%,27.5%,26.9%,22.6%。这也符合实际情况,持仓的价值越大,挂单的价值越大,越可能出现部分成交的情况,最终实现的收益相对于挂单量也就越小。下图是持仓价值分别为100和10000的相对收益对比: img

我们还可以回测不同的参数对回测收益的影响,如挂单密度、休眠时间、手续费等。以休眠时间为例,改为100ms,对比休眠时间1000ms,看看收益情况。回测结果如下:

{'realised_profit': 29.079440803790423, 'margin': 0.7982662957624695, 'unrealised_profit': 0.0104554474048441, 'total': 10000029.089896, 'leverage': 0.0, 'fee': -0.3703702128662524, 'maker_fee': -0.37938946377435134, 'taker_fee': 0.009019250908098965}

收益提高了一些,这是由于策略只挂了一组订单,会有一些订单由于来不及改变而吃不到波动的价格,休眠时间减少改善了这个问题。这也说明了网格策略挂多组订单的重要性。

总结

本文创新的提出了一种新的基于订单流的回测系统,可以部分模拟挂单、吃单、部分成交、延时等撮合情况,部分反映出了策略资金量对收益的影响,对于高频策略和对冲策略有重要的参考价值,高精度的回测为策略参数优化指明了方向。也经过了长期实盘验证。并且较好的控制了回测所需的数据量,回测速度也非常快。


Related

More

eblly 策略原理为价格每上涨1% 多久时间上涨1%?

eblly 策略原理为价格每上涨1% --------------------- 多久时间上涨1%?

homily 索引都上GB了。哎

dsaidasi 这数据量得多大,网站受得了吗

小草 /upload/asset/1ff487b007e1a848ead.csv

小草 这种回测方式还好,带深度的回测数据量太大了

小草 比实盘级回测小很多了,看交易繁忙程度,一天1M-20M