研究平台进阶——Python进行数据分析和策略回测

Author: 小草, Created: 2021-08-13 11:48:22, Updated: 2023-09-20 11:19:00

img

研究平台进阶


FMZ内置了jupyter notebook,帮助用户熟悉平台API以及进行策略研究,支持 Python3 C++11/17 以及 Javascript 的学习环境。notebook+Python是非常强大的工具,在进行数据分析、策略研究时几乎必不可少。FMZ平台自带的回测虽然很好用,但并不适合复杂和大数据量的策略。本篇文章将介绍jupyter notebook的一些进阶使用技巧,实现了对任意交易对、多交易对策略的回测。

jupyter使用

可以使用FMZ自带的研究环境,但联网不方便。推荐自己本机安装anaconda3,自带notebook,和常用的关于数学计算的相关库,共用本机的网络环境,性能也更好。也推荐使用Google的云端colab,虽然有一些储存上的限制,但是免费且性能强大,适合机器学习相关的研究。

学习教程

具体notebook和Python的使用技巧,网上的教程非常多,搜索Python量化、jupyter notebook教程,可以找到很多的资料。需要自己学习掌握爬虫、数据处理、回测、策略设计、画图等一系列基础。

数据获取

交易所一般都提供了API获取历史数据K线,有的也提供了逐笔成交。我们需要用爬虫把这些数据获取并保存。也可以直接接收交易所推送的数据,自己建立本地数据库储存。

接下来将演示如何在币安上获取永续合约的K线数据并且进行储存。

首先找到币安永续合约文档:https://binance-docs.github.io/apidocs/futures/cn/#c59e471e81 。可以看到需要的参数和返回的数据格式。通常API获取的K线数量有限制,币安最大为1000,因此需要循环迭代获取。其他交易所的情况与币安类似。注意需要网络能连接外网才能爬取到K线。

币安支持的周期有:1m 3m 5m 15m 30m 1h 2h 4h 6h 8h 12h 1d 3d 1w 1M

import requests #网络请求常用库
from datetime import date,datetime
import time
import pandas as pd
def GetKlines(symbol='BTC',start='2020-8-10',end='2021-8-10',period='1h'):
    Klines = []
    start_time = int(time.mktime(datetime.strptime(start, "%Y-%m-%d").timetuple()))*1000
    end_time = int(time.mktime(datetime.strptime(end, "%Y-%m-%d").timetuple()))*1000
    while start_time < end_time:
        res = requests.get('https://fapi.binance.com/fapi/v1/klines?symbol=%sUSDT&interval=%s&startTime=%s&limit=1000'%(symbol,period,start_time))
        res_list = res.json()
        Klines += res_list
        #print(datetime.utcfromtimestamp(start_time/1000).strftime('%Y-%m-%d %H:%M:%S') ,len(res_list))
        start_time = res_list[-1][0]
    return pd.DataFrame(Klines,columns=['time','open','high','low','close','amount','end_time','volume','count','buy_amount','buy_volume','null']).astype('float')
df = GetKlines(symbol='BTC',start='2021-1-1',end='2021-8-10',period='1h')

数据的存储和读取都可以使用pandas库自带的功能,格式为csv,可以用excel软件直接打开。

币安返回的K线数据除了包含高开低收成交量,还有总成交额、主动买入量、成交笔数等,这些都是很有价值的信息,可以用于构建策略。

df.to_csv('btc_klines.csv')
df = pd.read_csv('btc_klines.csv',index_col=0)
df
               time      open      high       low     close     amount  \
0     1596988800000  11575.08  11642.00  11566.07  11591.37   6541.466   
1     1596992400000  11591.39  11610.23  11526.90  11534.39   6969.252   
2     1596996000000  11534.39  11656.69  11527.93  11641.07   6439.365   
3     1596999600000  11641.06  11665.90  11624.20  11635.30   3911.582   
4     1597003200000  11635.29  11684.00  11635.29  11673.81   3461.004   
...             ...       ...       ...       ...       ...        ...   
8805  1628658000000  45627.72  45894.53  45540.00  45801.45  10296.202   
8806  1628661600000  45801.46  46270.00  45800.01  46087.86  26409.962   
8807  1628665200000  46087.87  46450.00  46087.87  46367.38  23969.309   
8808  1628668800000  46367.37  46643.13  46002.01  46217.01  23472.769   
8809  1628672400000  46217.01  46329.69  46046.54  46297.16   6579.477   

           end_time        volume   count  buy_amount    buy_volume  null  
0     1596992399999  7.592336e+07   25724    3127.898  3.630633e+07     0  
1     1596995999999  8.057780e+07   27403    3390.424  3.920162e+07     0  
2     1596999599999  7.469135e+07   25403    3446.186  3.997906e+07     0  
3     1597003199999  4.555459e+07   17820    1842.413  2.145768e+07     0  
4     1597006799999  4.036804e+07   15513    1660.575  1.936981e+07     0  
...             ...           ...     ...         ...           ...   ...  
8805  1628661599999  4.710187e+08  112187    4988.565  2.282399e+08     0  
8806  1628665199999  1.215164e+09  247170   13696.301  6.302708e+08     0  
8807  1628668799999  1.110210e+09  232348   11990.951  5.554267e+08     0  
8808  1628672399999  1.086549e+09  229533   12334.292  5.711837e+08     0  
8809  1628675999999  3.039580e+08   78812    3313.055  1.530718e+08     0  

[8810 rows x 12 columns]
df.index = pd.to_datetime(df.time,unit='ms') # 把索引转化为时间,方便画图参考
df.close.plot(figsize=(15,6),grid = True); #收盘价
<Figure size 1080x432 with 1 Axes>
(df.buy_amount.rolling(150).mean()/df.amount.rolling(150).mean()).plot(figsize=(15,6),grid = True); #平滑后的主动买入量比例
#主动买入比例见底回升往往对应着价格的回升,但主动买入比例长期均值在49%
<Figure size 1080x432 with 1 Axes>
(df['count'].rolling(100).mean()).plot(figsize=(15,6),grid = True); #平滑后成交笔数,处于低位可能酝酿着行情
<Figure size 1080x432 with 1 Axes>

回测引擎

以前的文章也给出过Python回测引擎,这里给出一个优化的版本。USDT本位(或者其他计价货币本位)的永续合约和现货非常相似,区别是永续可以加杠杆并且持有负的数量(相当于做空),可以共用一个回测引擎。币本位交割合约特殊,用币结算,需要专门的回测。

下面给出的是一个简单例子,可以实现多币种现货或多币种永续回测。忽略了很多细节:如期货的杠杆、保证金占用、资金费率、爆仓机制、做市和吃单成交、省略了订单维护等,但通常不影响正常的回测结果。并且撮合的价格和数量、账户的更新都是需要外部传入。读者可以在这个基础上完善。

Exchange类介绍:

  • account:USDT代表基础货币,并非必须是USDT为基础货币,realised_profit:已实现盈亏,unrealised_profit:未实现盈亏,total:总权益,fee:手续费。其余各个交易对,amount数量(做空时为负),hold_price:持有价格,value:持仓价值,price:当前价格。
  • trade_symbols:交易对数组,也可只传入一个交易对,默认的计价货币是USDT,但也可以进行其他计价货币的回测。
  • fee:手续费,为了简洁,不区分maker和taker。
  • initial_balance:初始资金,默认交易对的初始数量都是0 。
  • Buy函数:买入,对应永续的做多或平空,直接以传入的价格和数量成交,没有撮合的机制。
  • Sell函数:卖出。
  • Update函数:更新账户信息,需要传入所有交易对的价格字典。
class Exchange:
    
    def __init__(self, trade_symbols, fee=0.0004, initial_balance=10000):
        self.initial_balance = initial_balance #初始的资产
        self.fee = fee
        self.trade_symbols = trade_symbols
        self.account = {'USDT':{'realised_profit':0, 'unrealised_profit':0, 'total':initial_balance, 'fee':0}}
        for symbol in trade_symbols:
            self.account[symbol] = {'amount':0, 'hold_price':0, 'value':0, 'price':0, 'realised_profit':0,'unrealised_profit':0,'fee':0}
            
    def Trade(self, symbol, direction, price, amount):
        
        cover_amount = 0 if direction*self.account[symbol]['amount'] >=0 else min(abs(self.account[symbol]['amount']), amount)
        open_amount = amount - cover_amount
        self.account['USDT']['realised_profit'] -= price*amount*self.fee #扣除手续费
        self.account['USDT']['fee'] += price*amount*self.fee
        self.account[symbol]['fee'] += price*amount*self.fee

        if cover_amount > 0: #先平仓
            self.account['USDT']['realised_profit'] += -direction*(price - self.account[symbol]['hold_price'])*cover_amount  #利润
            self.account[symbol]['realised_profit'] += -direction*(price - self.account[symbol]['hold_price'])*cover_amount
            
            self.account[symbol]['amount'] -= -direction*cover_amount
            self.account[symbol]['hold_price'] = 0 if self.account[symbol]['amount'] == 0 else self.account[symbol]['hold_price']
            
        if open_amount > 0:
            total_cost = self.account[symbol]['hold_price']*direction*self.account[symbol]['amount'] + price*open_amount
            total_amount = direction*self.account[symbol]['amount']+open_amount
            
            self.account[symbol]['hold_price'] = total_cost/total_amount
            self.account[symbol]['amount'] += direction*open_amount
                    
    
    def Buy(self, symbol, price, amount):
        self.Trade(symbol, 1, price, amount)
        
    def Sell(self, symbol, price, amount):
        self.Trade(symbol, -1, price, amount)
        
    def Update(self, close_price): #对资产进行更新
        self.account['USDT']['unrealised_profit'] = 0
        for symbol in self.trade_symbols:
            self.account[symbol]['unrealised_profit'] = (close_price[symbol] - self.account[symbol]['hold_price'])*self.account[symbol]['amount']
            self.account[symbol]['price'] = close_price[symbol]
            self.account[symbol]['value'] = abs(self.account[symbol]['amount'])*close_price[symbol]
            self.account['USDT']['unrealised_profit'] += self.account[symbol]['unrealised_profit']
        self.account['USDT']['total'] = round(self.account['USDT']['realised_profit'] + self.initial_balance + self.account['USDT']['unrealised_profit'],6)
#测试,可以看到这里没有强调交易所是USDT本位还是现货,实际上结果是一样的,这也是不做区分的原因
e = Exchange(['BTC'], fee=0.0004, initial_balance=10000) #创建一个Exchange对象,只有一个交易对BTC
e.Buy('BTC',40000, 0.1)#以40000价格买入0.1个BTC
e.Sell('BTC',41000, 0.1)#以41000价格卖出0.1个BTC
e.Update({'BTC':41000}) #更新账户信息
print(e.account) #最终账户信息
print('利润:',round(e.account['USDT']['total']-e.initial_balance,2))
{'USDT': {'realised_profit': 96.76, 'unrealised_profit': 0.0, 'total': 10096.76, 'fee': 3.24}, 'BTC': {'amount': 0.0, 'hold_price': 0, 'value': 0.0, 'price': 41000, 'realised_profit': 100.0, 'unrealised_profit': 0.0, 'fee': 3.24}}
利润: 96.76

网格策略回测

首先回测一个经典的永续网格策略,这个策略最近在平台上很受欢迎,与现货网格相比不用持币、可加杠杆,比现货网格方便不少。但由于无法直接回测,不利于筛选币种。这里用刚才的引擎回测测试下。

围观置顶有官方实盘从2021年4月4号开始,持仓价值是150,网格间距是0.01,目前收益为3600USDT。用同样的参数用5minK线回测收益为3937USDT,由于实盘开始的持仓价值不到150USDT,所以结果还是相当准确的。如果把网格间距改为0.005,收益将达到5226U。网格间距0.005显然是比0.01更好的参数,需要通过回测才能发现。

K线周期越短相应的回测结果越准确,需要的数据量越大,读者可以将symbol参数改为自己想回测的交易对试试。

symbol = 'TRX'
df = GetKlines(symbol=symbol,start='2021-4-4',end='2021-8-11',period='5m')
value = 150
pct = 0.01

e = Exchange([symbol], fee=0.0002, initial_balance=10000)
init_price = df.loc[0,'close']
res_list = [] #用于储存中间结果
for row in df.iterrows():
    kline = row[1] #这样会测一根K线只会产生一个买单或一个卖单,不是特别精确
    buy_price = (value / pct - value) / ((value / pct) / init_price + e.account[symbol]['amount']) #买单价格,由于是挂单成交,也是最终的撮合价格
    sell_price = (value / pct + value) / ((value / pct) / init_price + e.account[symbol]['amount'])

    if kline.low < buy_price: #K线最低价低于当前挂单价,买单成交
        e.Buy(symbol,buy_price,value/buy_price)
    if kline.high > sell_price:
        e.Sell(symbol,sell_price,value/sell_price)
    e.Update({symbol:kline.close})
    res_list.append([kline.time, kline.close, e.account[symbol]['amount'], e.account['USDT']['total']-e.initial_balance])
res = pd.DataFrame(data=res_list, columns=['time','price','amount','profit'])
res.index = pd.to_datetime(res.time,unit='ms')
e.account
{'USDT': {'realised_profit': 3866.633149565143,
  'unrealised_profit': 70.54622281993666,
  'total': 13937.179372,
  'fee': 177.51000000000596},
 'TRX': {'amount': 36497.43208747655,
  'hold_price': 0.08203709078461048,
  'value': 3064.689372385406,
  'price': 0.08397,
  'realised_profit': 4044.143149565462,
  'unrealised_profit': 70.54622281993666,
  'fee': 177.51000000000596}}
res.profit.plot(figsize=(15,6),grid = True);
<Figure size 1080x432 with 1 Axes>
res.price.plot(figsize=(15,6),grid = True); #收盘价
<Figure size 1080x432 with 1 Axes>

现货平衡策略回测

这类策略也比较流行,但FMZ平台不太好回测多币种,正好用这个回测引擎回测下。我们选取四个主流币种BTC,ETH,LTC,XRP,分别配置25%的市值,每偏离1%平衡一次。

首先把四个币的过去1年的收盘价获取到,可以看到ETH涨幅最大,其余3个币的涨幅差不多。如果平均持有这四个币,最后的净值为4.5 。平衡策略经过回测最后净值为5.3,略有增强。

symbols = ['BTC','ETH','LTC','XRP']
data = {}
for symbol in symbols:
    df = GetKlines(symbol=symbol,start='2020-8-11',end='2021-8-11',period='1h')
    data[symbol] = df.close
df = pd.DataFrame([data[symbol].values for symbol in symbols],index=symbols).T
e = Exchange(symbols, fee=0.0004, initial_balance=10000)
res_list = []
for row in df.iterrows():
    prices = row[1]
    total = e.account['USDT']['total']
    e.Update(prices)
    for symbol in symbols:
        pct = e.account[symbol]['value']/total
        if pct > 0.26:
            e.Sell(symbol,prices[symbol],(pct-0.25)*total/prices[symbol])
        if pct < 0.24:
            e.Buy(symbol,prices[symbol],(0.25-pct)*total/prices[symbol])
    res_list.append([e.account[symbol]['value'] for symbol in symbols] + [e.account['USDT']['total']])
res = pd.DataFrame(data=res_list, columns=symbols+['total'])
(df/df.iloc[0,:]).plot(figsize=(15,6),grid = True); #归一化画出走势
<Figure size 1080x432 with 1 Axes>
(res.total/10000-(df/df.iloc[0,:]).mean(axis=1)).plot(figsize=(15,6),grid = True); #增强效果
<Figure size 1080x432 with 1 Axes>

海龟策略

海龟策略是一个经典的趋势策略,包含完整的加仓止损逻辑,具体可以参考:https://zhuanlan.zhihu.com/p/27987938 。我们这里将实现一个简单的版本用于回测。

海龟的策略周期对策略影响很大,不易选择太短的周期,这里选择6h。唐安奇通道周期选择5,仓位比例根据回测选择了0.003.当价格突破通道上轨开多1个单位,开仓后如果价格继续上涨0.3个波动率,继续加仓1个单位,价格跌破最近开仓价的2.5个波动率止损。空单原理相同。由于ETH的大牛市,海龟策略抓住了主要的趋势,最终实现了27倍的收益,期间最大杠杆4倍。

海龟的参数和周期关系密切,需要通过回测选择,每次开仓的单位也要注意不可过大,避免风险太高。

从最终的净值图可以看出,海龟是个长期策略,期间可能3-4个月都没收益,反复止损,但一旦有单边的大行情,海龟可以顺势累计很大的仓位并且拿到趋势的末尾,获得很多盈利。在上涨的末尾,策略会累积很多仓位,此时的波动会比较大,往往很大的盈利都回撤了。使用海龟策略需要接受海龟的缺点并且耐心等待。

symbol = 'ETH'
df = GetKlines(symbol=symbol,start='2019-8-11',end='2021-8-11',period='6h')
df.index = pd.to_datetime(df.time,unit='ms')
M = 5 #唐奇安通道周期数
pct = 0.003 #加仓占总仓位比例
df['up'] = df['high'].rolling(M).max().shift(1) #唐奇安通道上轨,用于做多突破判断
df['down'] = df['low'].rolling(M).max().shift(1)
df['middle'] = (df['up']+df['down'])/2
df['true_range'] = pd.concat([df['high']-df['low'],df['high']-df['close'].shift(1),df['close'].shift(1)-df['low']],axis=1).max(axis=1)
df['N'] = df['true_range'].rolling(50).mean() #N相当于最近波动率,用于加仓和止损的判断
open_times = 0.3 #开仓判断
stop_times = 2.5 #止损
e = Exchange([symbol], fee=0.0004, initial_balance=10000) #手续费设置为吃单万4
res_list = [] 
last_price = 0 #上次开仓价
for row in df.iterrows():
    kline = row[1]
    if kline.isnull().sum() > 0: #略过无数据部分
        continue
    unit = e.account['USDT']['total']*pct/kline.N #开仓单位数量
    
    if kline.high >  kline.up and e.account[symbol]['amount'] == 0: #第一次开多仓
        e.Buy(symbol,kline.up,unit) #注意这里的交易价格
        last_price = kline.up
    if e.account[symbol]['amount'] > 0 and kline.high > last_price + open_times*kline.N: #多仓加仓
        e.Buy(symbol,last_price + open_times*kline.N,unit)
        last_price = last_price + open_times*kline.N
    if e.account[symbol]['amount'] > 0 and kline.low < last_price - stop_times*kline.N: #多仓止损
        e.Sell(symbol,last_price - stop_times*kline.N,e.account[symbol]['amount'])
        
    if kline.low <  kline.down and e.account[symbol]['amount'] == 0: #空仓开仓
        e.Sell(symbol,kline.down,unit)
        last_price = kline.down
    if e.account[symbol]['amount'] < 0 and kline.low < last_price - open_times*kline.N: #空仓加仓
        e.Sell(symbol,last_price - open_times*kline.N,unit)
        last_price = last_price - open_times*kline.N
    if e.account[symbol]['amount'] < 0 and kline.high > last_price + stop_times*kline.N: #空仓止损
        e.Buy(symbol,last_price + stop_times*kline.N,-e.account[symbol]['amount'])
        
    e.Update({symbol:kline.close})
    res_list.append([kline.time, kline.close, e.account[symbol]['amount']*kline.close, e.account['USDT']['total']])
res = pd.DataFrame(data=res_list, columns=['time','price','value','total'])
res.index = pd.to_datetime(res.time,unit='ms')
print('最终市值:',res['total'][-1])
最终市值: 280760.566996
res.total.plot(figsize=(15,6),grid = True);
<Figure size 1080x432 with 1 Axes>
(res.value/res.total).plot(figsize=(15,6),grid = True);
<Figure size 1080x432 with 1 Axes>

总结

熟练使用jupyter notebook研究平台,可以很方便的进行数据获取、数据分析、策略回测、图表展示等工作,是量化交易的必经之路。如果你现在对策略编写无头绪,不妨先分析数据。对于新手,推荐资源:

利用 Python 进行数据分析 : https://wizardforcel.gitbooks.io/pyda-2e/content/

Python 量化交易教程 : https://wizardforcel.gitbooks.io/python-quant-uqer/content/


More

Dady start_time = res_list[-1][0]导致头尾重复了

骨刀 请问文中的三个策略代码在哪里呀?

小草 文中的三个策略都可以更改交易对,更改参数进行回测

flywu 海龟策略代码有三个问题 1. 通道down 使用了max 2. 海龟回测逻辑有问题,按照回测的逻辑,连续两次加仓的话,第一次是在前一个k线中,第二次加仓在第二个k线中。比如上一条k线中加仓进入价格为4000,下次加仓应该在下一根k线中,假设为4400,但是下一个k线的最低价为4500,所以回测中加仓4400是没法实现的,这样计算出来的盈利能力是偏大的。 3. 下单数量有问题,e.buy应该是数量,unit算出来是持仓单位,还需要换算成数量