如何高效、优雅地进行高频策略回测?
由lizhuo111创建,最终由lizhuo111 被浏览 180 用户
今天与大家探讨高频策略的回测框架。高频策略的研发,有两个显著的特点: 一是数据量大,与日频相比,分钟频率就是百倍的数据量, 到秒级别更达到上千倍的差异。 二是对交易细节敏感,回测系统要尽可能去模拟真实交易的情形,甚至要比真实交易更严格,这样研发出来的高频策略才有实盘的价值。所以高频策略要考虑的细节很多,决策时间点,成交价,手续费,流动性等。细节考虑的不到位,策略回测和实盘交易就会差异很大,降低策略研发的价值和效率。 如何在大数据量前提下,尽可能的将细节考虑到位,就是高频策略回测系统的挑战,也就是严谨和高效的权衡。
下面和大家一起构建一个秒级别的策略回测框架。 一般来说,回测框架会包含以下几个模块: 数据处理, 策略逻辑,交易清算,绩效报告
数据处理:
数据模块是最基础和重要的环节,直接决定了回测框架的结构和效率,我们展开来看。 1) 加载原始数据 这里我们从parquet文件中加载原始的tick数据。 2) 将tick数据转换成10s级别的K线数据。 tick数据的结构以及通过resample转换成K线数据我们在上篇文章《》已经介绍过了,这里增加2个字段:
tick_resample['Bid'] = tick_df['bp01'].resample(freq, label='right').mean() tick_resample['Ask'] = tick_df['sp01'].resample(freq, label='right').mean() 把10s内的买卖委托价格的均值作为该K线的买卖委托价,这个价格我们用来做成交价格的模拟
3)准备策略决策数据 策略决策有可能用的K线已有的字段,也有可能需要基于已有字段衍生新字段。本例中我们基于Close衍生MA5和MA20两个字段
tick_df['MA5'] = tick_df['Close'].rolling(window=5).mean() tick_df['MA20'] = tick_df['Close'].rolling(window=20).mean() MA_S_arr = np.array(trade_df['MA5']) MA_L_arr = np.array(trade_df['MA20'])
对于高频数据处理,我们一定要尽可能地避免使用循环,用循环的地方多了,效率就很难提起来。我们使用rolling函数实现滚动计算指定长度的均值,这个内置的滚动机制要比显式的写循环计算快得多。为了方便策略逻辑使用,把决策用的数据转换成array格式,效率会提升不少。
策略逻辑
本例我们先设定一个简单的策略,更复杂的策略逻辑实现留到后面文章。 如果当前无持仓,MA5上穿MA20, 则开一手多单。 如果当前无持仓,MA5下穿MA20, 则开一手空单。 无论多单还是空单,持仓时间达到1分钟就平仓。 虽然逻辑很简单,但要实现出来,我们需要依赖当前的持仓状态,还要对持仓时间计数。所以无法避免地要写一层循环来实现。
策略逻辑
for tk in range(1,trade_df.shape[0]): if pre_hold == 0: if (MA_S_arr[tk-2] < MA_L_arr[tk-2]) & (MA_S_arr[tk-1] > MA_L_arr[tk-1]) : #开多逻辑 trade = 1 hold = 1 elif (MA_S_arr[tk-2] > MA_L_arr[tk-2]) & (MA_S_arr[tk-1] < MA_L_arr[tk-1]) : # 开空逻辑 trade = -1 hold = -1 else: trade = 0 hold = pre_hold else: if hold_time == 5: trade = -pre_hold hold = 0 hold_time = 0 else: hold_time += 1 trade = 0 hold = pre_hold trade_list.append(trade) pre_hold = hold
我们取一个具体的时间点来看看策略逻辑形成的交易点: 绿色框线处可以看到, 21:04:30时 MA5<MA20,21:04:40时 MA5>MA20,此时形成MA5对MA20的上穿,发出买入信号,买入交易是在21:04:40的下一根K线内完成,这笔交易持有时间1分钟, 在21:05:40的下根K线内完成卖出平仓交易。
交易清算
清算模块的核心就是又快又准确地把账目算对。 先做数据准备,在策略逻辑模块我们得到了trade_list, 是每个时间点的下单列表,把trade_list合并到trade_df中,并生成pre_cp, hold, pre_hold序列
trade_df['trade'] = [0] + trade_list trade_df['hold'] = trade_df['trade'].cumsum() trade_df['pre_hold'] = [0] + trade_df['hold'].iloc[:-1].tolist() trade_df['pre_cp'] = [trade_df['Close'].iloc[0]] + trade_df['Close'].iloc[:-1].tolist()
接下来计算每个时间点的盈亏值(pnl), 此时要分几种情况分别计算: 无交易,无持仓, pnl=0 无需处理 无交易,有持仓, 这种情况也简单,只需用最新价(cp)和前一根K线收盘价(pre_cp)来计算持仓盈亏
idx = trade_df['hold'] != 0 trade_df.loc[idx,'pnl'] = trade_df.loc[idx,'hold'] * (trade_df.loc[idx,'price'] - trade_df.loc[idx,'pre_cp'])*multiplier
持仓收益 = 持仓量*(最新价-前收价)*合约乘数
下面处理有交易情况的盈亏计算,分成买入/卖出两种情况,我们重点讨论买入的情况,卖出相反处理即可。
idx = trade_df['trade'] > 0 trade_df.loc[idx, 'trade_price'] = trade_df.loc[idx, 'Ask'] trade_df.loc[idx, 'fee'] = trade_df.loc[idx, 'trade'] * (trade_df.loc[idx, 'trade_price']) * multiplier * fee
先定位买入订单的位置索引,并指定买入订单的成交价为下一根K线的Ask均价, 也就是对手盘的均价。这里大家可以自行修改成交价的设定,可以假设为下根K线的开盘价/收盘价/平均价, 对手均价是比较严格的假设情况了,相当于我们要主动承担市场的买卖盘均价。 手续费 = 交易量 * 成交价* 合约乘数 * 手续费率
对于股票,买入单肯定是开仓的情况,而期货而言,买入单可能是买入开多,也可能是买入平空,所以我们分别处理。 买入开多:
idx1 = idx & (trade_df['pre_hold'] >= 0) #开多 trade_df.loc[idx1, 'pnl'] = trade_df.loc[idx1, 'trade'] * (trade_df.loc[idx1, 'price'] - trade_df.loc[idx1, 'trade_price']) * multiplier - trade_df.loc[idx1, 'fee']
买入开多情况的盈亏 = 交易量 *(最新价 - 交易价) * 合约乘数 - 手续费 这里注意是的,我们在这根K线的某个时间点以交易价买入后,就形成了持仓,从买入时间点到这根K线收盘这段时间的持仓收益,不能忽略了。
买入平空
idx2 = idx & (trade_df['pre_hold'] < 0) # 平空 trade_df.loc[idx2, 'pnl'] = trade_df.loc[idx2, 'pre_hold'] * (trade_df.loc[idx2, 'trade_price'] - trade_df.loc[idx2, 'pre_cp']) * multiplier - trade_df.loc[idx2, 'fee']
买入平空情况的盈亏 = 交易量 * (交易价 - 前收价) * 合约乘数 - 手续费 同样的,我们在这根K线的某个时间点以交易价平仓,从前根K线收盘到买入时间点这段时间的持仓收益,也应计算在内。
以上几步,就把买入交易的场景清算完成了。卖出交易也同理操作,分成卖出开仓和卖出平仓两种情况即可。
同样我们以上面的例子来看清算后得到的数据 先看21:04:50的买入交易: 成交价(trade_price)=15085.00 是该时间点的Ask价格,即对手均价。 手续费(fee) = 11508550.00015 = 11.31 盈亏(pnl) = 1(15080-15085)*5 - fee = -36.31
其他几个时间点的pnl大家可以手动算算。
绩效报告 一般计算绩效指标要先把高频数据转换成日频,用resample函数进行频率转换。并统计日频的手续费(fee),交易数量(trade_num),账户总资金(balance), 日收益率(rtn), 日净值(nav)
daily_df = pd.DataFrame(trade_df['pnl'].resample('D').sum()) daily_df['fee'] = trade_df['fee'].resample('D').sum() daily_df['trade_num'] = trade_df['trade'].abs().resample('D').sum() daily_df = daily_df.loc[date_list,:] daily_df['balance'] = daily_df['pnl'].cumsum() +balance daily_df['rtn'] = daily_df['pnl'] / daily_df['balance'] daily_df['nav'] = daily_df['rtn'].cumsum() + 1
转换后的日频数据如下:
绩效指标结果如下:交易天数(tradeDays), 总收益率(totalRtn), 年化收益率(yearlyRtn), 年化波动率(yearlyVol), 最大回撤(maxDD), 夏普比率(sharp), 卡玛比率(calmar)
可以看到这是一个每天亏钱的策略,这并不意外,如此简单的逻辑放在秒级别的高频策略上,不亏钱才意外。 当然我们先不关注策略本身赚还是亏, 而是把整个回测框架的逻辑和流程搞清楚,先有一套高效严谨的回测系统,才能高效专注地去研发策略。
回测性能 再看我们整套回测框架的性能,我们分别对每个模块进行计时,结果如下:
整个回测流程耗时1.56s,其中90%的时间是消耗在数据准备阶段。整个回测的时间长度是17个交易日,10s频率共计35000+个决策点。 当然,随着我们增加回测的长度,代码数量, 策略逻辑复杂度等因素,肯定会影响回测用时。