基于逐笔成交的高频回测系统兼论K线回测的缺陷
在FMZ文库发表了一篇文章,是我最近两个月研究的总结,部分解决了当前所有回测系统的一个通病,并且可用于高频交易回测和套利回测,值得参考,原文地址:https://www.fmz.com/digest-topic/5719
我在币安做空超涨做多超跌多币种对冲策略时,同时发布了一个回测引擎。并第一篇报告基于一小时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线回测的前两个问题,但还是无法解决最后一个问题。并且由于数据量实在太大,回测速度和时间范围都有限制。
基于逐笔成交订单流的回测机制
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,