一种在比特币市场进行自动化网格做市的策略

一种在比特币市场进行自动化网格做市的策略

前言

俗话说,有波动的地方就有利润。

比特币的价值有没有另说,但波动性肯定有,而且是公认的大。故网格做市作为一种简单的做多波动性的策略就很适合应用于比特币的市场中。

我从两年前开始尝试对比特币进行手动的网格做市。我选择的平台是BitMEX,一个很重要的原因是它对所有交易者一视同仁,即使是小额的做市也能享受负手续费。以比特币为例:其Maker(提供流动性)费率为-0.025%,Taker(提取流动性)费率为0.075%。(如果我一天交易200美元,那我可以赚0.05美元的手续费,一个月就是1.5美元,如果本金不多的话,赚的手续费已经能赢过存银行的利率了。)

当时的策略是每隔50美元布置一个30美元的做市单,每天更新两三次。这样一天成交个六七单,如果行情稳定的话0.1个比特币的本金一个月能挣个5%(由于BitMEX都是用比特币进行结算,所以这里用币本位)。

但天下没有免费的午餐,网格做市最重要的问题就是处理单边行情。一旦比特币价格大幅上涨或下跌,手里将会有很多高风险的空头或多头头寸,若杠杆较大的话很容易爆仓。手动做市的话处理这种行情是比较盲目的,只有两种选择:

  • 狠心平仓,之前做市得到的利润可能会损失一大部分,甚至会亏损。
  • 咬牙坚持,大多数时候坚持一两周会出现更好的位置平仓,但如果遇到一年一遇的行情那连本金都没了。

这种没有量化指标的选择最终的结果就是

第一次被强制平仓时收到的邮件(2018/11/15)

即使后来调低了杠杆还是免不了在去年九月底和十月底被爆仓。于是这次疫情期间闲着没事趁机写个量化机器人,尝试新的策略来处理做市可能出现的问题。(可惜在调试的过程中杠杆还是有些大,没能逃过植树节那波。)

代码分析

BitMEX除了有负手续费的好处,对开发者也是十分的友好。API接口丰富,文档比较详细,还有testnet的测试网络用来测试bot的表现。(我一般用来测试脚本的bug,测试网络和真实市场差别很大,结果没有太大意义。)

我目前运行的脚本就是在官方给出的一个网格做市的机器人(BitMEX/sample-market-maker)基础上修改的,其文件结构如下:

.
├── market_maker 
│   ├── __init__.py
│   ├── market_maker.py # 负责执行具体的策略,主要修改这个文件
│   ├── bitmex.py # 官方给的REST API,封装了一部分基础的功能,不能频繁调用
│   ├── ws # 官方给的Websocket API,能实时获得ticker和order信息
│   │   ├── __init__.py
│   │   └── ws_thread.py
│   ├── auth # 用于登陆授权的一些文件
│   │   ├── __init__.py
│   │   ├── AccessTokenAuth.py
│   │   ├── APIKeyAuth.py
│   │   └── APIKeyAuthWithExpires.py
│   ├── utils # 这里的文件可以实现一些例如记录等简单的功能
│   │   ├── __init__.py
│   │   ├── constants.py
│   │   ├── dotdict.py
│   │   ├── errors.py
│   │   ├── log.py
│   │   └── math.py
│   └── settings.py # 官方自带的配置文件,不用修改
├── marketmaker.py # 自己写的脚本用于启动market_maker的run()函数
└── settings.py # 设置API key以及order step size等参数

除了在配置文件settings.py设置一些参数外,主要需要修改的是负责做市策略的文件market_maker.py,以下是其部分代码:

from __future__ import absolute_import
from time import sleep, time, gmtime, strftime
import sys
from os.path import getmtime
import random
import requests
import atexit
import signal


from market_maker import bitmex
from market_maker.settings import settings
from market_maker.utils import log, constants, errors, math

# Used for reloading the bot - saves modified times of key files
import os
watched_files_mtimes = [(f, getmtime(f)) for f in settings.WATCHED_FILES]


#
# Helpers
#
logger = log.setup_custom_logger('root')


class ExchangeInterface:
    # Include some functions like getting price or creating orders...


class OrderManager:
    def __init__(self):
        self.exchange = ExchangeInterface(settings.DRY_RUN)
        # Once exchange is created, register exit handler that will always cancel orders
        # on any error.
        atexit.register(self.exit)
        signal.signal(signal.SIGTERM, self.exit)

        logger.info("Using symbol %s." % self.exchange.symbol)

        if settings.DRY_RUN:
            logger.info("Initializing dry run. Orders printed below represent what would be posted to BitMEX.")
        else:
            logger.info("Order Manager initializing, connecting to BitMEX. Live run: executing real trades.")

        
        self.instrument = self.exchange.get_instrument()
        self.starting_qty = position['currentQty']
        self.running_qty = self.starting_qty
        self.current_qty = self.starting_qty
        self.current_cost = position['currentCost']
        self.current_comm = position['currentComm']
        self.interval_factor = 1.0
        self.target_usd = 0.0
        self.target_xbt = 0.0
        self.mid_qty = 0
        
        # In the new version, I retrieve the target information from database here



    def reset(self):
        self.exchange.cancel_all_orders()
        self.sanity_check()
        self.print_status()

        # Create orders and converge.
        self.place_orders()

    def print_status(self):
        """Print the current MM status."""

        margin = self.exchange.get_margin()
        position = self.exchange.get_position()
        self.instrument = self.exchange.get_instrument()
        existing_orders = self.exchange.get_orders()

        self.running_qty = position['currentQty']
        tickLog = self.instrument['tickLog']
        self.start_XBt = margin["marginBalance"]
        
        self.interval_factor = 1 / math.harmonicFactor(self.running_qty, settings.MIN_POSITION, settings.MAX_POSITION)

        # some code to record the data to database

        logger.info("Current Interval Factor: %.*f" % (2, float(self.interval_factor)))
        logger.info("Target XBT Balance: %.6f" % XBt_to_XBT(self.target_xbt))
        logger.info("Target USD Balance: %d" % self.target_usd)
        logger.info("Mid Quantity: %d" % self.mid_qty)
        logger.info("Current XBT Balance: %.6f" % XBt_to_XBT(self.start_XBt))
        logger.info("Current USD Balance: %.*f" % (2, self.instrument['markPrice'] * XBt_to_XBT(self.start_XBt)))
        logger.info("Current Contract Position: %d" % self.running_qty)
        if settings.CHECK_POSITION_LIMITS:
            logger.info("Position limits: %d/%d" % (settings.MIN_POSITION, settings.MAX_POSITION))
        if position['currentQty'] != 0:
            logger.info("Avg Cost Price: %.*f" % (tickLog, float(position['avgCostPrice'])))
            logger.info("Avg Entry Price: %.*f" % (tickLog, float(position['avgEntryPrice'])))
        logger.info("Contracts Traded This Run: %d" % (self.running_qty - self.starting_qty))
        logger.info("Total Contract Delta: %.4f XBT" % self.exchange.calc_delta()['spot'])

    def get_ticker(self):
        # Set up start positions


    def get_price_offset(self, index):
        """Given an index (1, -1, 2, -2, etc.) return the price for that side of the book.
           Negative is a buy, positive is a sell."""
        # Maintain existing spreads for max profit
        if settings.MAINTAIN_SPREADS:
            start_position = self.start_position_buy if index < 0 else self.start_position_sell
            # First positions (index 1, -1) should start right at start_position, others should branch from there

        else:
            # Offset mode: ticker comes from a reference exchange and we define an offset.
            start_position = self.start_position_buy if index < 0 else self.start_position_sell
            index = index + 1 if index < 0 else index - 1 # offset one more step from start price 

            # If we're attempting to sell, but our sell price is actually lower than the buy,
            # move over to the sell side.
            if index > 0 and start_position < self.start_position_buy:
                start_position = self.start_position_sell
            # Same for buys.
            if index < 0 and start_position > self.start_position_sell:
                start_position = self.start_position_buy

        return math.toNearest(start_position * (1 + settings.INTERVAL * self.interval_factor) ** index, self.instrument['tickSize'])

    ###
    # Orders
    ###

    def place_orders(self):
        """Create order items for use in convergence."""

        buy_orders = []
        sell_orders = []

        # Create orders from the outside in. This is intentional - let's say the inner order gets taken;
        # then we match orders from the outside in, ensuring the fewest number of orders are amended and only
        # a new order is created in the inside. If we did it inside-out, all orders would be amended
        # down and a new order would be created at the outside.
        for i in reversed(range(1, settings.ORDER_PAIRS + 1)):
            if not self.long_position_limit_exceeded():
                buy_orders.append(self.prepare_order(-i))
            if not self.short_position_limit_exceeded():
                sell_orders.append(self.prepare_order(i))

        # I add some code here to place an additional order

        return self.converge_orders(buy_orders, sell_orders)

    def prepare_order(self, index):
        """Create an order object."""

        if settings.RANDOM_ORDER_SIZE is True:
            quantity = random.randint(settings.MIN_ORDER_SIZE, settings.MAX_ORDER_SIZE)
        else:
            if index < 0:
                quantity = self.buy_order_start_size + ((abs(index) - 1) * settings.ORDER_STEP_SIZE)
            else:
                quantity = self.sell_order_start_size + ((abs(index) - 1) * settings.ORDER_STEP_SIZE)

        price = self.get_price_offset(index)

        return {'price': price, 'orderQty': quantity, 'side': "Buy" if index < 0 else "Sell"}

    def converge_orders(self, buy_orders, sell_orders):
        """Converge the orders we currently have in the book with what we want to be in the book.
           This involves amending any open orders and creating new ones if any have filled completely.
           We start from the closest orders outward."""

    ###
    # Sanity
    ##

    def sanity_check(self):
        """Perform checks before placing orders."""

        # Check if OB is empty - if so, can't quote.
        self.exchange.check_if_orderbook_empty()

        # Ensure market is still open.
        self.exchange.check_market_open()

        # Get ticker, which sets price offsets and prints some debugging info.
        ticker = self.get_ticker()

        # Sanity check:
        if self.get_price_offset(-1) >= ticker["sell"] or self.get_price_offset(1) <= ticker["buy"]:
            logger.error("Buy: %s, Sell: %s" % (self.start_position_buy, self.start_position_sell))
            logger.error("First buy position: %s\nBitMEX Best Ask: %s\nFirst sell position: %s\nBitMEX Best Bid: %s" %
                         (self.get_price_offset(-1), ticker["sell"], self.get_price_offset(1), ticker["buy"]))
            logger.error("Sanity check failed, exchange data is inconsistent")
            self.exit()

        # Messaging if the position limits are reached
        if self.long_position_limit_exceeded():
            logger.info("Long delta limit exceeded")
            logger.info("Current Position: %.f, Maximum Position: %.f" %
                        (self.exchange.get_delta(), settings.MAX_POSITION))

        if self.short_position_limit_exceeded():
            logger.info("Short delta limit exceeded")
            logger.info("Current Position: %.f, Minimum Position: %.f" %
                        (self.exchange.get_delta(), settings.MIN_POSITION))

    ###
    # Running
    ###


    def exit(self):
        logger.info("Shutting down. All open orders will be cancelled.")
        try:
            self.exchange.cancel_all_orders()
            self.exchange.bitmex.exit()
        except errors.AuthenticationError as e:
            logger.info("Was not authenticated; could not cancel orders.")
        except Exception as e:
            logger.info("Unable to cancel orders: %s" % e)
        sys.exit()

    def run_loop(self):
        while True:
            sys.stdout.write("-----\n")
            sys.stdout.flush()

            self.check_file_change()
            sleep(settings.LOOP_INTERVAL)

            # This will restart on very short downtime, but if it's longer,
            # the MM will crash entirely as it is unable to connect to the WS on boot.
            if not self.check_connection():
                logger.error("Realtime data connection unexpectedly closed, restarting.")
                self.restart()

            self.sanity_check()  # Ensures health of mm - several cut-out points here
            self.print_status()  # Print skew, delta, etc
            self.place_orders()  # Creates desired orders and converges to existing orders

    def restart(self):
        logger.info("Restarting the market maker...")
        os.execv(sys.executable, [sys.executable] + sys.argv)


# The entrance function
def run():
    logger.info('BitMEX Market Maker Version: %s\n' % constants.VERSION)

    om = OrderManager()
    # Try/except just keeps ctrl-c from printing an ugly stacktrace
    try:
        om.run_loop()
    except (KeyboardInterrupt, SystemExit):
        sys.exit()

下面我们来看看这个脚本是怎么执行网格做市的策略的:

我们在外部启动market_maker.run()函数,然后程序进入OrderManagerrun_loop函数。该函数每五秒依次执行三个函数:

  1. sanity_check:设置起始的买卖价格并确保它们是正常的(不会出现想买的价格比想卖的价格高)。
  2. print_status:把一些价格订单信息输出到屏幕上,方便调试。我在这里加了一些代码把这些信息记录到了数据库中。
  3. place_orders:创建或调整订单,进行网格做市。

place_orders这个函数是程序的核心,它将创建一个买入订单队列和卖出订单队列。每个订单都包含价格、数量、方向三个信息,那程序是怎么确定它们的呢?

很简单,它会通过get_ticker函数确定一个买卖的起始价格(比如下图这个价格在6200美元附近),在这个起始价格的基础上,get_price_offset这个函数会根据settings.py中设置的网格间隔INTERVAL确定订单价格(比如下图中我设置的间隔是0.6%)。至于数量则根据设置的参数进行递增(下图中起始数量为22,递增值为1)。

网格做市机器人实时订单的截图。

但由于起始价格会随着比特币的价格波动,我们做市单的“网格”也会随之而动。这样的话除非比特币价格在秒量级上巨幅波动,否则我们的订单永远无法成交。好在程序中带有RELIST_INTERVAL这个参数。当place_orders确定好希望的做市单时,它将交给converge_orders做最后的执行,该函数会比较期望买(卖)单与现存买(卖)单的区别,只有价格差的比例大于RELIST_INTERVAL才会调整订单。

当出现单边行情时该程序又是如何应对呢?程序中有MIN_POSITION和MAX_POSITION两个参数以控制仓位。当持有仓位超过设置值时,程序就会停止布置对应方向上的做市单。结果就是它会在价格回调时慢慢平仓。(样例程序通过设置仓位的最大(小)值这一参数,从而能量化地“二选一”:仓位没有超过设置的最值那我就继续承担风险,若超过就平仓止损。)

以上就是样例程序的网格做市的机制,设置好参数的话其效率比我以前手动做市高好几倍。

对样例程序的改进

BitMEX平台是“币本位”的机制,一切交易都是通过比特币结算。比如我在测试bot的时候BitMEX上有0.05个比特币,比特币单价在6000美元附近时我的资产价值大概是300美元。如果我什么都不做,我所持有的比特币是不变的,但从法币的角度资产价值是不断变化的。而我如果只做一个300美元的空单,那就相当于锁定了资产的法币价值,但比特币的数量会变化。

因此单纯地运行样例程序并对称地将MAX_POSITION和MIN_POSITION设置为±A是一种试图“赚币”的策略。对应地,如果将MAX_POSITION和MIN_POSITION设置为-300±A就和很多以法币结算的平台一样,是一种试图“赚美元”的策略。

以上两种策略都是自带杠杆存在爆仓风险的,最稳妥的方法是将MAX_POSITION和MIN_POSITION设置为0与-300。在这一范围内的操作等价于现货交易,是没有杠杆的。

作为做市商,我希望我的做市策略风险越小越好,自然地就要把仓位控制在“一半一半”的位置(对应上述例子中的-150)。但我也没必要追求“零风险”,在测试时我把MAX_POSITION和MIN_POSITION设置为150与-450以获取较高的利润率。

我做的第一个改进就是及时把仓位赶到“一半一半”的位置。当仓位偏离中间位置时,我会额外挂单修正这一偏差,其价格我选择在使总资产在比特币和美元的角度都不会缩水的一个位置。从而在理想状态下每一次仓位回归时比特币和美元总有一个增加,并且另一个不会减少。

而非理想状态自然就是单边行情,尽管程序做了量化的处理,但当其判断需要平仓时我就会有损失。我自然希望在维持低风险的情况下尽量地加大仓位的浮动范围,降低被止损的概率。

因此我做的第二个改进就是动态调整INTERVAL参数:随着仓位接近设置的最大(小)值,参数INTERVAL也对应增大,从而网格随之变大。于是原本波动10%就停止做市通过动态增大INTERVAL参数可以使比特币在20%的波动下仍然正常工作。

经过这一改进,相同浮动范围下量化机器人对抗波动性的能力更强了。代价就是当仓位偏离中间位置过多时,应对同样的波动幅度收益会减小。因为我比较贪,就会把这些有风险的头寸挪到其它季度合约和永续合约中。通过风险转移,量化机器人应对波动的收益率就恢复了,而其它位置的风险头寸不大的话也可以耐心等待时机处理掉。

RED b1.0 Bot交易结果分析

网格做市机器人第一阶段的测试已经完成了,试运行9天后的详细结果可以点击此链接查看:BitMEX Bot - XBTH20交易报告。下图展示了机器人在9天的整体表现。

整体看来运行得比较平稳,下面分析一下各幅图:

  • 首先是比特币一季度合约XBTH20的价格图。在这一周多的时间里前后各有一个比较大的波动行情,然后中间有个单边行情,比特币的价格上涨了1000美元左右。而我们的收益表现正如上一节分析的那样在波动行情中能收割很多利润,在单边行情中机器人止损的操作会带来不少损失(见第三幅图)。
  • 第二幅图是仓位图。可以看到机器人会根据价格不断地转换比特币和美元。当二者的仓位都大于0时实际上可以视为现货操作,是没有风险的。从图中可以看出大多数时间仓位在中间位置小幅振荡,风险很低,符合设计要求。
  • 第三幅图是总资产按比特币和美元计价的走势图。我们发现在最后几天美元曲线十分平稳,而比特币的曲线大幅波动上升,这是第一个改进的结果。

上图中有一个比较突兀的地方:3月20号在比特币快速单边上涨的过程中机器人突然平掉了一大笔多头(按设计是慢慢平仓的)。这是由于程序的一个Bug导致的:Bot在失去与服务器的连接时会自动重启,重新连接。该测试版中的bot重启后会“失忆”,认为平仓是不亏的,于是我就被迫“止损”了。

在新的版本中我解决了这个“失忆”Bug,仓位的变动也变得更有规律,期待其在二季度的表现。


5月15日更新:

原本计划让这个bot运行满一季度的,但在四月底的时候比特币开启减半行情,彻底突破了我的网格(涨幅超过了20%),手中的比特币损失了超过10%,所以暂停了这一交易策略。(但从美元的角度看是赚了10%,40天这个涨幅其实很可以了。)事实上这种剧烈波动行情是十分有利于测试量化交易策略的,详细结果可以点击此链接查看:BitMEX Bot - XBTM20交易报告

现在让我们再一次分析一下各幅图:

  • 首先是比特币二季度合约XBTM20的价格图,它记录了比特币从3月底到4月底40天左右的价格变化。其中的分界点是4月23日,在这一天之前比特币价格有来有回,在7千美元上下来回震荡。这里震荡的幅度基本都在10%之内,正好在网格所包住。因此每一次比特币震荡回7千美元的价格区间后,比特币和美元均有增加。4月23日那天比特币大涨500美元后我就一直等待其回到7000美元的价格区间,但最终还是在一周后再涨1000刀,彻底告别了7字头的价格。
  • 第二幅图是仓位图。但在这里没有太大的意义,因为当价格偏离均值比较多的时候我会转移一部分风险头寸到其它合约中,而这在图上没有显示出来。值得注意的是,网格做市的理想状态是仓位紧随价格变化,不难看出满足这一走势的时间段收益都很不错。
  • 第三幅图是总资产按比特币和美元计价的走势图。不难看出当价格较高时美元资产会维持一个比较平稳的曲线增长;相对地价格较低时,比特币资产会维持一个比较平稳的曲线增长。这时因为当价格较高时,趋势网格卖掉了大多数比特币,所以此阶段相当于空仓赚美元;反之亦然。

根据上面的讨论,只要我们把握好一个中间价格,那么网格做市地策略就能维持一个很高的收益率。但单边上涨行情到来的时候,我们以为是较高的一个位置却变为了低位,越涨越买的策略在和大趋势对抗的过程中完全溃败下来。

在知友 @yangshixin525 的建议下,我下一阶段采用了趋势网格,目前已经测试了一周多了,详细的结果我估计6月会在新的一篇文章中更新。大家如果有任何建议的话欢迎私信或在评论区下评论。


广告:使用这个链接bitmex.com/register/NZt注册交易前6个月手续费有优惠,我也有佣金。(在中国该域名受到DNS污染,更改hosts即可访问。)

编辑于 05-15