見出し画像

AKAGAMI式 トレードシリーズ mmbot神速汎用バックテストプログラム「MMBT Type-C」 (まだまだバックテストで消耗してるの?bitFlyerFX約定履歴をCSVファイルから読み込み、mmbotのバックテストを100000倍の速度で実行するC言語プログラム&Python3スクリプト)

目次

はじめに
利用上の留意点
オリジナルMMBTとの違い
MMBT Type-Cの構成
プログラム実行方法
プログラム実行結果サンプル・実稼働損益との比較
オリジナルMMBTとの速度比較
Python3ソースコード (backtestC)
C言語ソースコード (mmbot_backtest.c)
・約定履歴データ・注文データの構造体定義
・backtest関数定義とmallocによるメモリ確保
・各種変数定義と約定履歴データCSVファイル読み込み
・sscanfでのデータParse・タイムスタンプの計算
・// ここにご自身の売買ロジックを加えること
・直近データから古いデータへ遡りループ処理
・注文が約定も期限切れもしていない場合・部分約定考慮
・発注時刻からMTE(分)だけ経過していれば注文を無効化
・損益・ポジション等を出力
性能を改善するためのコンパイルオプション
(おまけ)バックテストの意義

はじめに

mmbotを含め、高頻度に指値を入れるトレードbotでは、約定履歴を用いた精緻なバックテストが不可欠です。高速なトレードにおいては、OHLCV情報だけでは正確なバックテストができるわけもなく、約定履歴ベースの検証は避けて通れません。ただし、bitFlyerFXの1日あたり約300万件の約定履歴を使ってのバックテストは、何も工夫をしないと数時間かかります。

以前のnote(以下、オリジナルMMBT)では、Python3を使い、約定履歴データをインプットとしたバックテストを行うためのソースコードを公開しました。

本noteでは、上のMMBTをさらにブラッシュアップさせ、飛躍的な高速化を実現したプログラムを提供します。C言語で実装をしているため、オリジナルMMBTに比べ、10倍以上の高速化が成されます。

本noteでは、高速なmmbot用バックテストプログラムのC言語ソースコードを掲載しています。

また、オリジナルMMBTと同様、mmbot用を謳っていますが、mmbotに限らず、約定履歴をインプットとしたトレードBot全般に汎用的に利用できるものです。

利用上の留意点

▶︎ Jupyter Notebook (or Jupyter Lab, Google Colab)上での実行を想定
▶︎ mmbot自体の売買ロジックは含めていない
(ただし、最終価格上下に指値を入れる単純なものをサンプルとして掲載)
▶︎ 板情報キューの概念は取り入れていない(部分約定は考慮済み)
▶︎ 事前に約定履歴をCSVファイルへ保存していることを想定
(こちらのnote「bitFlyerの約定履歴をリアルタイムAPI(JSON-RPC)を用いて日付ごとにCSVファイルで自動保存するPython3スクリプト」にてやり方を解説しています)

本noteに掲載の内容を利用していただくにあたり、オリジナルMMBTを購入していただいていることを前提とはしていません。ただし、前作で語っていたことをもう一度繰り返すような冗長な記述はしません。あくまでもポイントを簡潔に絞った記述をしています。

C言語はPythonに比べるとやや取っ付きにくいため、Python版であるオリジナルMMBTを理解しているとC言語ソースコードが読み解きやすくなります。Python3で書かれたオリジナルMMBTのコードと今回のType-Cを見比べていただくことで、プログラム内容に対する理解はより一層に深まるはずです。

また、C言語での実装は高速ではありますが、取り回し・小回りの良さを考えると、Python3のみで完結したバージョンの存在意義も一定程度あると考えています。

mmbotのロジックを構築し、バックテストを行う際は、まずPython3で書かれたバックテストを行い、優位性が確認できたら、C言語を用いた高速化を行う、といった使い方が良いでしょう。始めからC言語を使うのは生産性が落ちます。パラメータの最適化が必要で、何度かバックテストを行う必要が出てきた場面で高速化を図るのが、トータルでの時間効率を上げるコツです。

本Type-CはPython版のオリジナルMMBTをC言語へと移植・改善したものであり、上述の通り、主に差分について言及します。基本的なバックテストロジックの考え方についてはオリジナルMMBTを参照ください。

オリジナルMMBTとType-Cとの違い

▶︎ コア部分をPython3ではなくC言語で実装することで高速化(10倍以上)
▶︎ オプションパラメータFREQ間隔での発注頻度の表現
▶︎ 部分約定を考慮し実装

MMBT Type-Cの構成

MMBT Type-Cバックテストツールの構成は下のようになっています。

mmbot_backtest.c (C言語ソースコード)
↓ gccを用いてコンパイル
mmbtc (MMBT Type-C 実行ファイル)
↓ Python3関数backtestCから呼び出し
output.csv (バックテスト結果出力CSVファイル)
↓ Python3でプロット
バックテスト損益グラフ

コア部分はC言語で実装し、高速化を図っています。C言語プログラムで処理した結果を、output.csvという中間ファイルに出力しています。その後、そのoutput.csvをPython3側で読み込み、損益グラフをプロットしています。

プログラム実行方法

dft = backtestC('exec_01-30.csv', LOT=0.01, MTE=1, INTV=10, FREQ=5, plot=True)

のように実行します。また、パラメータがデフォルト設定で良いならば、単純にこうも書けます。

dft = backtestC('exec_01-30.csv')

プログラム実行結果サンプル・実稼働損益との比較

グラフを3つ掲載します。それぞれ上側がBot実稼働での損益結果、下側がMMBTによる実行結果です。実稼働結果の損益と比べて、極めて結果の整合性・信頼性が高いことがわかります(これより優秀なバックテストツールがあれば教えてください)。

2019/02/21

2019/02/22

2019/02/23

Python3ソースコード (backtestC)
C言語ソースコード (mmbot_backtest.c)

(具体的なソースコードは有料パートに掲載(Python3は30行ほど、C言語は200行ほどのコードです))

オリジナルMMBTとの速度比較

Python3版であるオリジナルMMBTと、C言語版であるMMBT Type-Cを比較した結果です。出力結果にほぼ相違がないこと・速度が19.7秒=>1.76秒へと大幅に短縮されていることがわかります。

工夫無し:約数時間
オリジナルMMBT:約20秒
MMBT Type-C:約1.8秒

数時間かかるバックテストはもはや論外です。あなたがもしそういったプログラムを組んでいるならば、今すぐに卒業しましょう。MMBT Type-Cを活用して、20秒待たなければならなかったところを1〜2秒クラスの時間で処理を終わらせることができれば、もはや「作業を中断して待つ」といった行為がほぼ不要になるレベルかと思います。

これがC言語による高速化の威力であり、恩恵です。あなたの作業効率が劇的に改善することは間違いありません。

バックテストで何万倍もの高速化ができれば、実質的にパラメータの組み合わせを追加で何万通りも試すことができるため、極めて直接的な形でbot最適化作業の生産性の向上に結びつきます。

また、バックテストから一歩進んだトレード戦略の堅牢性を検証するウォークフォワード分析等では、単体のバックテストをかなりの回数実行する必要が出てくるため、このような高速化は必須です。

さらに踏み込んだ生産性向上を追求するのであれば、AWSのハイスペックEC2インスタンスを借りる等、環境面でのアプローチを取ることになります。その点についても機会があればまとめたいと思っています。

無料パートはここまでです。以下、有料パートとなります。note売上は良質な情報を発信している方のnote購入・サポートに充てさせていただきます。

本noteの内容があなたのbot最適化作業を効率化・高速化させ、少しでも多くのトレード収益の伸びを実現できれば幸いです。是非、C言語による高速化の恩恵を味わってみてください。

ここからは有料パートです。まずはご購入いただき、どうもありがとうございます。今後、的を絞った、より良い情報を発信していくための励みになります。引き続きよろしくお願いします。

Python3ソースコード (backtestC)

import numpy as np
import pandas as pd
import os

def backtestC(filename, COMMAND='mmbtc', LOT=0.01, MTE=1, INTV=10, FREQ=5, plot=True):
    command = './' + COMMAND + ' '
    command += './' + filename
    command += ' ' + str(LOT)
    command += ' ' + str(MTE)
    command += ' ' + str(INTV)
    command += ' ' + str(FREQ)
    command += ' > ./output.csv'
    os.system(command)
    dft = pd.read_csv('./output.csv', names=('index','exec_date', 'pnl', 'pos', 'n_cross') ) # CSVファイル読み込み
    dft = dft.set_index('index')
    dft['profitMax'] = dft['pnl'].rolling(window=50000000, min_periods=0).max().replace(np.nan, 0)
    dft['DD'] = dft['pnl'] - dft['profitMax']
    dft['MDD'] = dft['DD'].rolling(window=50000000, min_periods=0).min().replace(np.nan, 0)
    mdd = dft['DD'].min()
    maxpos = dft.pos.max()
    minpos = dft.pos.min()
    bigpos = max(maxpos, -minpos) + 0.01
    n_cross = dft.n_cross.replace(np.nan, 0).values[-1]
    pnl    = dft.pnl.replace(np.nan, 0).values[-1]
    npnl   = int(pnl/bigpos)
    dft['npnl'] = dft['pnl'] / bigpos
    nmdd   = int(mdd/bigpos)
    nmdd   = min(nmdd, -1)
    rdr = round(npnl/-nmdd, 2)

    if plot:
        print('LOT:', LOT, '/ MTE:', MTE, '/ RDR:', rdr, ', PnL:', round(dft.pnl.values[-1], 2), ', MDD:', int(mdd), ', nPnL:', int(dft.pnl.values[-1] / bigpos), ', nMDD:', int(mdd / bigpos), ', MaxPos:', round(maxpos, 2), ', MinPos:', round(minpos, 2))
        dft.pnl.plot()

    return dft

osライブラリを用いてMMBT Type-Cの実行ファイル(mmbtc)をコマンドライン引数を付けて実行しています。標準出力をoutput.csvへと保存しています。その後、output.csvの内容をpandasへと読み込み、最大ドローダウンやリターン・最大ドローダウンレシオ(RDR)等を計算し、損益グラフをプロットしています。

返り値として、output.csvを読み込んだ結果であるdft(Temp DataFrame)を返しています。後続の処理内容・必要に応じて損益やRDRを返すのも一つでしょう。

C言語ソースコード (mmbot_backtest.c)

 #include  <stdio.h> #include  <stdlib.h> #include  <string.h>

typedef struct // 約定履歴データ(INTV間隔)用の構造体を定義
{
    char exec_date[27];
    int timestamp;
    char side[5];
    double price;
    double size;
    int id;
} exec_struct;

typedef struct // 注文データ用の構造体を定義
{
    char *exec_date;
    int timestamp;
    char *side;
    double price;
    double size;
} order_struct;

int backtest(char *fname, float LOT, int MTE, int INTV, int FREQ){
    FILE *fp;
    int END = 500000000; // 処理する最大行(bFFXであれば通常1日分は約300万件)
    const int LENGTH = END / INTV; // ENDをひとまず5億としてINTV間隔でLENGTHを決定

    exec_struct *dft; // LENGTH分だけ約定履歴データ用のメモリを確保
    dft = (exec_struct *)malloc(sizeof(exec_struct) * LENGTH);

    order_struct *order; // LENGTH分だけ注文データ用のメモリを確保
    order = (order_struct *)malloc(sizeof(order_struct) * LENGTH * 2);

    int *status; // 注文ステータス用のメモリを確保
    status = (int *)malloc(sizeof(int) * LENGTH * 2);

    double pos = 0; // ポジション
    double prepos = 0; // 1時刻前のポジション
    double exec_price = 0; // 取得価格

    char temp[3]; // タイムスタンプ計算用の一時変数

    if ((fp = fopen(fname, "r")) == NULL) // 約定履歴CSVファイルを開く
    {
        printf("%sファイルが開けません\n", fname);
        return -1;
    }

    int i = 0; // 約定履歴データ(INTV間隔)用のカウンタ
    int ii = 0; // 注文データ用のカウンタ
    int k = 0; // 約定履歴データ(非INTV間隔)用のカウンタ
    int n_cross = 0; // ポジション0クロス回数
    double pnl; // 損益
    double nopos_pnl; // ポジション0クロス時の損益
    char *f;
    while (k < END) // ENDまでループを継続
    {
        char readline[256] = {'\0'}; // 約定履歴1行分のデータ
        while (1)
        {
            f = fgets(readline, 256, fp); // 約定履歴CSVファイルから1行分読み込み
            if (readline[0] == '2' && readline[27] != ',') // 正常なデータであれば次の処理に進む
            {
                break;
            }
            if (f == NULL) // ファイル終端に来たら終了処理
            {
                fclose(fp);
                free(dft);
                free(order);
                return pnl; //出力はpnl
            }
        }
        if (k % INTV == 0) // INTV間隔にて処理を実行
        {

            sscanf(readline, "%[^,],%[^,],%lf,%lf,", dft[i].exec_date, dft[i].side, &dft[i].price, &dft[i].size); // 約定履歴1行分のデータから値をParse
            char *exec_date = dft[i].exec_date;
            double last = dft[i].price; // 最終約定価格

            strncpy(temp, exec_date + 11, 2);
            int hour = atoi(temp); // 最終時刻の時
            strncpy(temp, exec_date + 14, 2);
            int minute = atoi(temp); // 最終時刻の分
            strncpy(temp, exec_date + 17, 2);
            int second = atoi(temp); // 最終時刻の秒
            int timestamp = hour * 60 * 60 + minute * 60 + second; // 約定履歴データのタイムスタンプ
            dft[i].timestamp = timestamp;

            // ここにご自身の売買ロジックを加えること
            double sell_price = last + 10; // Sell価格
            double buy_price  = last - 10;  // Buy価格
            // ここにご自身の売買ロジックを加えること

            if (i % FREQ == 0){ // INTV * FREQ おきに発注
                order[ii].timestamp = timestamp;
                order[ii].side = "BUY";
                order[ii].price = buy_price;
                order[ii].size = LOT;
                status[ii] = 0;
                ii++;
                order[ii].timestamp = timestamp;
                order[ii].side = "SELL";
                order[ii].price = sell_price;
                order[ii].size = LOT;
                status[ii] = 0;
                ii++;
            }
            
            for(int j = ii - 1; j >= 0; j--) // 直近データから古いデータへ遡りループ処理
            {
                if (status[j] == 0) // 注文が約定も期限切れもしていない場合
                {
                    char *side = order[j].side;
                    double price = order[j].price;
                    double size;
                    if (side[0] == 'B' && last < price) // Buy注文が約定した場合
                    {
                        if (order[j].size > dft[i].size){ // 部分約定を考慮
                            size = dft[i].size;
                            order[j].size -= dft[i].size;
                        } else {
                            size = order[j].size;
                            order[j].size = 0;
                        }
                        pos += size;                // ポジションをsize分だけ増やす
                        exec_price -= (double)price * size; // 取得価格の更新
                    }
                    else if (side[0] == 'S' && last > price) // Sell注文が約定した場合
                    {
                        if (order[j].size > dft[i].size){ // 部分約定を考慮
                            size = dft[i].size;
                            order[j].size -= dft[i].size;
                        } else {
                            size = order[j].size;
                            order[j].size = 0;
                        }
                        pos -= size;                // ポジションをsize分だけ減らす
                        exec_price += (double)price * size; // 取得価格の更新
                    }
                    else
                    {
                        int time_diff = dft[i].timestamp - order[j].timestamp;
                        if (time_diff > MTE * 60 || time_diff < 0) // 発注時刻からMTE(分)だけ経過していれば注文を無効化
                        {
                            status[j] = -1; // -1は期限切れを表す
                        }
                    }
                }
                else if (status[j] == -1) // 期限切れした注文に到達したらそこでループ終了
                {
                    break;
                }
            }
            
            pnl = ((double)exec_price + (double)pos * (double)last); // 損益を更新

            if (pos == 0 && pos != prepos){ // ポジションが0クロスしたとき
                n_cross++;
            }
            prepos = pos;
            
            printf("%d,%s,%.0f,%.2f,%d\n", k, exec_date, pnl, (double)pos, n_cross); // 損益・ポジション等を出力
            i++; // カウンタiを増やし、次の約定履歴データ(INTV間隔)へと進む
        }
        k++; // カウンタkを増やし、次の約定履歴データへと進む
    }
    fclose(fp);
    free(dft);
    free(order);
    return 0;
}

int main(int argc, char *argv[]) // メイン関数
{
    float LOT;
    int MTE;
    int INTV;
    int FREQ;
    char *fname = argv[1];
    argc > 2 ? (LOT = atof(argv[2])) : (LOT = 0.01);
    argc > 3 ? (MTE = atoi(argv[3])) : (MTE = 1);
    argc > 4 ? (INTV = atoi(argv[4])) : (INTV = 10);
    argc > 5 ? (FREQ = atoi(argv[5])) : (FREQ = 5);
    int pnl = backtest(fname, LOT, MTE, INTV, FREQ);
    return 0;
}

処理内容はオリジナルMMBTとほぼ変わりません。できるだけ丁寧にコメントを記載しましたが、続けて処理内容を順を追って説明します。

約定履歴データ・注文データの構造体定義

Python3でいうところのpandas DataFrameのデータ構造を模擬すべく、C言語の構造体を定義しています。保有する情報は

▶︎ 約定時刻
▶︎ 約定時刻を変換したタイムスタンプ
▶︎ BUY/SELLサイド
▶︎ 約定価格
▶︎ 約定サイズ
▶︎ 約定ID

です。約定IDは処理の中では実際には利用しないのですが、一応データ構造として定義してあります。また、注文データのstatusは、少しでも高速化を行うため、専用の配列を用意しています。構造体の配列よりも、単純な一次元のint型配列のほうが、ループ内でのアクセスが速くなると期待できるためです。statusは0がACTIVE、-1が期限切れを表します。

backtest関数定義とmallocによるメモリ確保

構造体定義の後にはbacktest関数を定義し、処理する最大行ENDを元にしてLENGTHを決定しています。その後、LENGTH分だけ、mallocにより約定履歴データ配列・注文データ配列・status配列のメモリ確保を行っています。

各種変数定義と約定履歴データCSVファイル読み込み

ポジション・損益・カウンタ等の変数を定義し、約定履歴データCSVファイルをオープンし、1行単位でreadlineへと読み込みを行っています。ファイル終端に来たら終了処理を行います。終端でない正常データが読み込まれたらINTV間隔でのメインループ処理に突入します。

sscanfでのデータParse・タイムスタンプの計算

fgetsで読み取った1行の約定履歴データをsscanfにてカンマごとにParse分解し、処理に利用しています。また、約定時刻文字列から秒レベルでのタイムスタンプを計算しています。これはMTE(Minute to Expire)による期限切れ処理に用います。

// ここにご自身の売買ロジックを加えること

ソースコード上では単純に10円上下に指値を入れるロジックを掲載しています。こちらにご自身のmmbotに売買ロジックを加えるようお願いします。また、その後、FREQ間隔で注文を両面指ししています。

例えば、bitFlyerの場合、1日の約定履歴データ件数は300万件程度です。およそ1秒あたり30件です。INTVに30を指定すると1秒ごとの情報を用いてバックテストを行うことになります。

FREQは発注の頻度です。INTV=30, FREQ=5であれば、150約定ごと(約5秒ごと)に注文を入れることになります。INTV=10, FREQ=15の場合も同様の頻度です。ただし、INTVが小さいほど、バックテストは精緻になります。ただし、反比例して処理時間は長くかかるようになります。私はINTVにはよく10〜20の値を使っています。

直近データから古いデータへ遡りループ処理

for(int j = ii - 1; j >= 0; j--)で注文データを走査する部分では、直近のデータからstatusを見て行き、期限切れのものに到達した時点でループをbreakしています。これにより不要なループを避けることができるため、高速化を実現しています。

注文が約定も期限切れもしていない場合・部分約定考慮

order[j].sizeとdft[i].sizeを比べ、小さいほうを約定したサイズとしています。このサイズを元にして、ポジションと取得価格を更新しています。

発注時刻からMTE(分)だけ経過していれば注文を無効化

dft[i].timestampとorder[j].timestampを比較し、その差がMTE * 60以上あれば、それは期限切れしたものとみなし、statusを-1へと更新しています。

損益・ポジション等を出力

カウンタk・時刻・損益・ポジション・ポジション0クロス回数を出力しています。Python3側でこちらをCSVファイル(output.csv)に保存し、読み取ってプロットすることとなります。

性能を改善するためのコンパイルオプション

C言語ソースコードのコンパイルには下のコマンドを用います。

gcc -O3 -mtune=native -march=native -std=c99 -o mmbtc mmbot_backtest.c

「-O3 -mtune=native -march=native」の部分はコンパイラによる最適化を施すためのオプションです。この指定により10〜20%の速度改善が見込めます。

また、GCCのバージョンは新しくしておくと良いでしょう。新しいバージョンのGCCによりコンパイルされた実行ファイルのほうが速度がやや優れているという実感です。

バックテストの意義

ソースコードの説明は以上になります。これまでバックテスト用のプログラムについて記述をしてきました。バックテストはやらないよりやったほうが遥かにマシです。ただし、もちろん過去のデータに対する検証ですので、万能ではありません。バックテスト結果だけでロジック実運用の判断を下すのには慎重になったほうが良いです。

下のブログの記述にあるように、さらに一歩進んだ検証を経る必要があると思います。ウォークフォワード分析などはその一例です。時間ができたら、そういった分析に関する私の今までの知見をnoteにまとめたいと思います。

バックテストは、既に起こった過去の指標データから、"偶然または必然"的要因によって、良好な結果を導き出したパターンを抽出する行為、以上のものではありません。バックテストの結果が良好なのは、あなた自ら意図して良好になるようなパターンを選び出したのであって、はじめから良好な結果を導くだけの原理が用意されていたわけではない、つまりバックテストの結果が良好だからと言ってそのシステムが優秀だと即断するのは、原因から結果を導いているのでなく自分で引っ張ってきた結果からその原因を導いている、因果律を逆転させた誤判断ということです。

以上となります。では、良いトレードbot作成ライフをお送りください!

【宣伝・告知】
AKAGAMI3年半ぶりの新トレード教材。
年間6000万円を稼いだ自動売買手法とは?
「シン・アービトラージ」公開中。

最後まで読んでいただき、どうもありがとうございます。頂いたサポートは、良質な情報を発信している方のnote購入・サポートに充てさせていただきます。