見出し画像

Python3 MarketMaker(MM)BOTのサンプルロジックとソースコード

こんにちは。magito(@magimagi1223)です。はたまた久しぶりのnote投稿です。

今、暗号通貨BOT界隈はマーケットメイク(MarketMaking:MM)ブームですね。

UKIさん(@blog_uki)の1万円チャレンジや天下一BOT会での高頻度BOTの活躍、そしてINOさん(@vs_ino)の板読みnoteの公開により、盛り上がりにさらに拍車がかかっているようです。

本noteでは、この流行りに乗っかりまして、「板情報を利用したMMBOTのサンプルロジックとソースコード」をご紹介したいと思います。
(僕が初めて作ったBOTはMM型(と裁定型)なので、その思い入れの深さゆえ便乗せずにはいられませんでしたw)

ご紹介するのは、自分が以前に使用していたロジックを、便宜上ビットフライヤー用に移植して手直しをしたものです(実際はBTCFXではなく、某取引所にて現物アルトで動かしていました)。半年前になりますが、今回と同じロジックのBOTを使用して1ヶ月で原資10万→120万を達成しています。いきなり高収益な戦略を見つけることができたのはきっとビギナーズラックですが、トレードを始めたばかりの頃の自分にはそれなりに衝撃的な出来事でした。

このようにMM型のBOTは、うまくいけば高頻度取引×複利運用により短期間で絶大な利益率を叩き出せるのが魅力です。しかし、システムトレードで一般的に用いられているディレクショナル戦略(価格チャート等から得られる情報を元に将来の価格を予測するタイプの戦略)とは性質が大きく異なるため、馴染みのない方にはとっつきにくく、難しいイメージがあるかもしれません(実際にロジックもスクリプトも複雑なものが多いです)。

本noteは、そのような方々を対象に、「過去にワークしていたMMBOTのロジックを知ってもらい、オリジナルMMBOTづくりの参考にしてもらう」ことを目的として書きました。ソースコードも載せているので、MMBOTづくりの足がかりになれば幸いです。

<注意事項>
本noteに掲載するBOTはあくまで「過去にワークした実績がある」というだけのものです。MM競争が激化している今、このようなシンプルな戦略の優位性はすでに消失していると考えられます。つまり、今このBOTをそのまま稼働させても利益は見込めないのでくれぐれも注意してください。ご使用は完全自己責任でお願いします。

--------------------------------------------------------

目次

1.  マーケットメイクとは

2. MMBOTのサンプルロジック

3. MMBOTのサンプルコード

--------------------------------------------------------


1.  マーケットメイクとは

マーケットメイクについて簡単に説明しておきます。Wikipediaさんの力を借ります。

マーケットメイクとは、金融商品市場において、マーケットメイカーが常時売り買い両方の気配(価格と上限個数)を示し、投資家の注文に約定を保証することである。
(出典:フリー百科事典『ウィキペディア(Wikipedia)』)

この説明をトレード戦略という視点で読み換えると、「マーケットメイク戦略とは売り気配(Ask)と買い気配(Bid)に指値を供給し、約定した際の売買差額を利益とする戦略」という感じになるかと思います。

MM戦略の理論についてはUKIさんのブログで解説されていますので、詳しく知りたい方はそちらを参照いただくことをお勧めします。


2. MMBOTのサンプルロジック

僕が運用していたロジックは、「スプレッドが大きいとき、その両端の少し内側に指値をおく」というものです。MM戦略の基本形とも言える、非常にシンプルな戦略です。以下のようなイメージです。

この図の①、②、③を繰り返すだけです。このロジックがワークすると、成行テイカーの執行コストを徴収する形で利益を上げることができます。スプレッドが大きい市場で有効な手法といえます。

ただ、むやみに指値を置くのではなく、少しだけ工夫をしました。例えば、以下のような板の状況を考えてみます。

このような場合、素直にスプレッド両端に指値をおいてしまうのは賢明ではありません。なぜなら、スプレッド両端の指値にある数量は微々たるものであるため、これらよりも後ろに自分の指値を置いたとしても約定機会にほとんど影響がないと考えられるからです。約定機会が変わらないなら、1セットのトレードで出来る限りの値幅を取るべきだと考えます。

このことを考慮し、「スプレッドの両端から数量X(例えば0.001BTCなどの微小量)だけ指値をスルーして、その下の最良位置に自分の指値をおく」というルールを加えました。数量Xによって決定される「実効スプレッド」を計算し、その両端に指値を置くということです。こうすることで、約定機会を損なわずに、より大きな値幅を稼ぐことができます。

この戦略において、設定する数量Xを大きくすると、1サイクルで取れる値幅は大きくなりますが、約定頻度は減少します(逆も同様の関係)。そして、Xがどちらに偏り過ぎた場合には、相場変動により片方の指値が置き去りになるリスクが高まります。このように「1サイクルの値幅」と「約定のしやすさ」はトレードオフの関係にあるため、この戦略では「X」というパラメータの調整がパフォーマンスの決め手になることが分かります。ちなみに自分は約定履歴を参照し、一度の成行注文の数量を考慮して数量Xを決めていました。

また、上で述べたようなパラメータ調整だけでなく、「どの市場、取引所、銘柄の板で運用するか」によっても戦略のパフォーマンスが大きく左右されます。スプレッドの狭い市場(=一度に取れる値幅が小さい)や出来高の小さい市場(=約定機会が少ない)では、相場変動リスクが大きくなってしまうからです。つまり、MM戦略で継続的に利益を出すためには、ロジック自体の改良を続けていくことはもちろん、「市場を選ぶこと」も同じくらい(かそれ以上に)重要であると考えられます。

さて、ここまでで説明したロジックをシステム化(BOT化)するために、フローチャートを用いて実行手順を書き下すと以下のようになります。

スプレッド(AskとBidの差)が開いているときだけ、両端にそれぞれ同じ数量の指値を置き、両方とも約定したら最初に戻る、というサイクルを繰り返します。実際のBOTのコードは例外処理等を多く含むため、上記のフローよりも処理手順は複雑になります。



3. MMBOTのサンプルコード

2章で説明したロジックのPython3ソースコードは以下のようになります。このコードでは、BTCJPY先物をターゲットにしています(先物は0.001BTCという小ロットでテストできるため)。

BOTの細かい仕様については、文章による説明は省略します。コード中にコメントを所々入れているので、それらを参考に読み解いていただけたらと思います。

#!/usr/bin/python3
# coding: utf-8

import datetime
import time

import ccxt
bitflyer = ccxt.bitflyer({
'apiKey': 'APIKEYを入力',
'secret': 'SECRETKEYを入力',
})

# 取引する通貨、シンボルを設定
COIN = 'BTC'
PAIR = 'BTCJPY28SEP2018'

# ロット(単位はBTC)
LOT = 0.002

# 最小注文数(取引所の仕様に応じて設定)
AMOUNT_MIN = 0.001

# スプレッド閾値
SPREAD_ENTRY = 0.0005  # 実効スプレッド(100%=1,1%=0.01)がこの値を上回ったらエントリー
SPREAD_CANCEL = 0.0003 # 実効スプレッド(100%=1,1%=0.01)がこの値を下回ったら指値更新を停止

# 数量X(この数量よりも下に指値をおく)
AMOUNT_THRU = 0.01

# 実効Ask/BidからDELTA離れた位置に指値をおく
DELTA = 1

#------------------------------------------------------------------------------#
#log設定
import logging
logger = logging.getLogger('LoggingTest')
logger.setLevel(10)
fh = logging.FileHandler('log_mm_bf_' + datetime.datetime.now().strftime('%Y%m%d') + '_' + datetime.datetime.now().strftime('%H%M%S') + '.log')
logger.addHandler(fh)
sh = logging.StreamHandler()
logger.addHandler(sh)
formatter = logging.Formatter('%(asctime)s: %(message)s', datefmt="%Y-%m-%d %H:%M:%S")
fh.setFormatter(formatter)
sh.setFormatter(formatter)

#------------------------------------------------------------------------------#

# JPY残高を参照する関数
def get_asset():

    while True:
        try:
            value = bitflyer.fetch_balance()
            break
        except Exception as e:
            logger.info(e)
            time.sleep(1)
    return value

# JPY証拠金を参照する関数
def get_colla():

    while True:
        try:
            value = bitflyer.privateGetGetcollateral()
            break
        except Exception as e:
            logger.info(e)
            time.sleep(1)
    return value

# 板情報から実効Ask/Bid(=指値を入れる基準値)を計算する関数
def get_effective_tick(size_thru, rate_ask, size_ask, rate_bid, size_bid):

    while True:
        try:
            value = bitflyer.fetchOrderBook(PAIR)
            break
        except Exception as e:
            logger.info(e)
            time.sleep(2)

    i = 0
    s = 0
    while s <= size_thru:
        if value['bids'][i][0] == rate_bid:
            s += value['bids'][i][1] - size_bid
        else:
            s += value['bids'][i][1]
        i += 1

    j = 0
    t = 0
    while t <= size_thru:
        if value['asks'][j][0] == rate_ask:
            t += value['asks'][j][1] - size_ask
        else:
            t += value['asks'][j][1]
        j += 1

    time.sleep(0.5)
    return {'bid': value['bids'][i-1][0], 'ask': value['asks'][j-1][0]}

# 成行注文する関数
def market(side, size):

    while True:
        try:
            value = bitflyer.create_order(PAIR, type = 'market', side = side, amount = size)
            break
        except Exception as e:
            logger.info(e)
            time.sleep(2)

    time.sleep(0.5)
    return value

# 指値注文する関数
def limit(side, size, price):

    while True:
        try:
            value = bitflyer.create_order(PAIR, type = 'limit', side = side, amount = size, price = price)
            break
        except Exception as e:
            logger.info(e)
            time.sleep(2)

    time.sleep(0.5)
    return value

# 注文をキャンセルする関数
def cancel(id):

    try:
        value = bitflyer.cancelOrder(symbol = PAIR, id = id)
    except Exception as e:
        logger.info(e)

        # 指値が約定していた(=キャンセルが通らなかった)場合、
        # 注文情報を更新(約定済み)して返す
        value = get_status(id)

    time.sleep(0.5)
    return value

# 指定した注文idのステータスを参照する関数
def get_status(id):

    if PAIR == 'BTC/JPY':
        PRODUCT = 'BTC_JPY'
    else:
        PRODUCT = PAIR

    while True:
        try:
            value = bitflyer.private_get_getchildorders(params = {'product_code': PRODUCT, 'child_order_acceptance_id': id})[0]
            break
        except Exception as e:
            logger.info(e)
            time.sleep(2)

    # APIで受け取った値を読み換える
    if value['child_order_state'] == 'ACTIVE':
        status = 'open'
    elif value['child_order_state'] == 'COMPLETED':
        status = 'closed'
    else:
        status = value['child_order_state']

    # 未約定量を計算する
    remaining = float(value['size']) - float(value['executed_size'])

    time.sleep(0.1)
    return {'id': value['child_order_acceptance_id'], 'status': status, 'filled': value['executed_size'], 'remaining': remaining, 'amount': value['size'], 'price': value['price']}

#------------------------------------------------------------------------------#

# 未約定量が存在することを示すフラグ
remaining_ask_flag = 0
remaining_bid_flag = 0

# 指値の有無を示す変数
pos = 'none'

#------------------------------------------------------------------------------#

logger.info('--------TradeStart--------')
logger.info('BOT TYPE      : MarketMaker @ bitFlyer')
logger.info('SYMBOL        : {0}'.format(PAIR))
logger.info('LOT           : {0} {1}'.format(LOT, COIN))
logger.info('SPREAD ENTRY  : {0} %'.format(SPREAD_ENTRY * 100))
logger.info('SPREAD CANCEL : {0} %'.format(SPREAD_CANCEL * 100))

# 残高取得
asset = float(get_asset()['info'][0]['amount'])
colla = float(get_colla()['collateral'])
logger.info('--------------------------')
logger.info('ASSET         : {0}'.format(int(asset)))
logger.info('COLLATERAL    : {0}'.format(int(colla)))
logger.info('TOTAL         : {0}'.format(int(asset + colla)))

# メインループ
while True:

    # 未約定量の繰越がなければリセット
    if remaining_ask_flag == 0:
        remaining_ask = 0
    if remaining_bid_flag == 0:
        remaining_bid = 0

    # フラグリセット
    remaining_ask_flag = 0
    remaining_bid_flag = 0

    # 自分の指値が存在しないとき実行する
    if pos == 'none':

        # 板情報を取得、実効ask/bid(指値を入れる基準値)を決定する
        tick = get_effective_tick(size_thru=AMOUNT_THRU, rate_ask=0, size_ask=0, rate_bid=0, size_bid=0)
        ask = float(tick['ask'])
        bid = float(tick['bid'])
        # 実効スプレッドを計算する
        spread = (ask - bid) / bid

        logger.info('--------------------------')
        logger.info('ask:{0}, bid:{1}, spread:{2}%'.format(int(ask * 100) / 100, int(bid * 100) / 100, int(spread * 10000) / 100))

        # 実効スプレッドが閾値を超えた場合に実行する
        if spread > SPREAD_ENTRY:

            # 前回のサイクルにて未約定量が存在すれば今回の注文数に加える
            amount_int_ask = LOT + remaining_bid
            amount_int_bid = LOT + remaining_ask

            # 実効Ask/Bidからdelta離れた位置に指値を入れる
            trade_ask = limit('sell', amount_int_ask, ask - DELTA)
            trade_bid = limit('buy', amount_int_bid, bid + DELTA)
            trade_ask['status'] = 'open'
            trade_bid['status'] = 'open'
            pos = 'entry'

            logger.info('--------------------------')
            logger.info('entry')

            time.sleep(5)

    # 自分の指値が存在するとき実行する
    if pos == 'entry':

        # 注文ステータス取得
        if trade_ask['status'] != 'closed':
            trade_ask = get_status(trade_ask['id'])
        if trade_bid['status'] != 'closed':
            trade_bid = get_status(trade_bid['id'])

        # 板情報を取得、実効Ask/Bid(指値を入れる基準値)を決定する
        tick = get_effective_tick(size_thru=AMOUNT_THRU, rate_ask=float(trade_ask['price']), size_ask=float(trade_ask['amount']), rate_bid=float(trade_bid['price']), size_bid=float(trade_bid['amount']))
        ask = float(tick['ask'])
        bid = float(tick['bid'])
        spread = (ask - bid) / bid

        logger.info('--------------------------')
        logger.info('ask:{0}, bid:{1}, spread:{2}%'.format(int(ask * 100) / 100, int(bid * 100) / 100, int(spread * 10000) / 100))
        logger.info('ask status:{0}, filled:{1}/{2}, price:{3}'.format(trade_ask['status'], trade_ask['filled'], trade_ask['amount'], trade_ask['price']))
        logger.info('bid status:{0}, filled:{1}/{2}, price:{3}'.format(trade_bid['status'], trade_bid['filled'], trade_bid['amount'], trade_bid['price']))

        # Ask未約定量が最小注文量を下回るとき実行
        if trade_ask['status'] == 'open' and trade_ask['remaining'] <= AMOUNT_MIN:

            # 注文をキャンセル
            cancel_ask = cancel(trade_ask['id'])

            # ステータスをCLOSEDに書き換える
            trade_ask['status'] = 'closed'

            # 未約定量を記録、次サイクルで未約定量を加えるフラグを立てる
            remaining_ask = float(trade_ask['remaining'])
            remaining_ask_flag = 1

            logger.info('--------------------------')
            logger.info('ask almost filled.')

        # Bid未約定量が最小注文量を下回るとき実行
        if trade_bid['status'] == 'open' and trade_bid['remaining'] <= AMOUNT_MIN:

            # 注文をキャンセル
            cancel_bid = cancel(trade_bid['id'])

            # ステータスをCLOSEDに書き換える
            trade_bid['status'] = 'closed'

            # 未約定量を記録、次サイクルで未約定量を加えるフラグを立てる
            remaining_bid = float(trade_bid['remaining'])
            remaining_bid_flag = 1

            logger.info('--------------------------')
            logger.info('bid almost filled.')

        #スプレッドが閾値以上のときに実行する
        if spread > SPREAD_CANCEL:

            # Ask指値が最良位置に存在しないとき、指値を更新する
            if trade_ask['status'] == 'open' and trade_ask['price'] != ask - DELTA:

                # 指値を一旦キャンセル
                cancel_ask = cancel(trade_ask['id'])

                # 注文数が最小注文数より大きいとき、指値を更新する
                if trade_ask['remaining'] >= AMOUNT_MIN:
                    trade_ask = limit('sell', trade_ask['remaining'], ask - DELTA)
                    trade_ask['status'] = 'open'
                # 注文数が最小注文数より小さく0でないとき、未約定量を記録してCLOSEDとする
                elif AMOUNT_MIN > trade_ask['remaining'] > 0:
                    trade_ask['status'] = 'closed'
                    remaining_ask = float(trade_ask['remaining'])
                    remaining_ask_flag = 1
                # 注文数が最小注文数より小さく0のとき、CLOSEDとする
                else:
                    trade_ask['status'] = 'closed'

            # Bid指値が最良位置に存在しないとき、指値を更新する
            if trade_bid['status'] == 'open' and trade_bid['price'] != bid + DELTA:

                # 指値を一旦キャンセル
                cancel_bid = cancel(trade_bid['id'])

                # 注文数が最小注文数より大きいとき、指値を更新する
                if trade_bid['remaining'] >= AMOUNT_MIN:
                    trade_bid = limit('buy', trade_bid['remaining'], bid + DELTA)
                    trade_bid['status'] = 'open'
                # 注文数が最小注文数より小さく0でないとき、未約定量を記録してCLOSEDとする
                elif AMOUNT_MIN > trade_bid['remaining'] > 0:
                    trade_bid['status'] = 'closed'
                    remaining_bid = float(trade_bid['remaining'])
                    remaining_bid_flag = 1
                # 注文数が最小注文数より小さく0のとき、CLOSEDとする
                else:
                    trade_bid['status'] = 'closed'

        # Ask/Bid両方の指値が約定したとき、1サイクル終了、最初の処理に戻る
        if trade_ask['status'] == 'closed' and trade_bid['status'] == 'closed':
            pos = 'none'

            logger.info('--------------------------')
            logger.info('completed.')

    time.sleep(5)



さいごに

いかがでしたでしょうか。MM戦略をまったく知らなかった方にも、「指値ベースでトレードするBOT」のイメージは掴んでいただけたのではないかと思います。

また、繰り返しになりますが、このようなシンプルなロジックは半年前には十分ワークしていたものの、市場や参加者の変遷によって現在はエッジが消失していると考えられます。今これをそのまま動かしても無限に損するだけでしょう。しかし、MM戦略すべてが無用化したというわけではないので、アイディア次第では、こういう基本形を魔改造することで凄いBOTが作れるかもしれませんね。


以上になります。
最後まで読んでいただき、ありがとうございました!