GBDTモデルによる機械学習で、暗号資産トレーディング収益を上げる入門スクリプト

# はじめに

みなさん、こんにちは。ホタテわらびです。

この記事では、GBDTモデルによる機械学習で、暗号資産のシステムトレーディングを行うBOTを実装し、安定した収益を上げるための最初の雛形スクリプトを公開してみようと思います。

エンジニアにとっては、簡単に動かすことができて、なんかそれっぽいものが動いた！となると、興味が湧いて取り組みやすいと思うので、１枚の .py ファイルにギュン！と詰め込んでみました。
必要なライブラリを pip install して、python コマンドを叩くだけで動くと思います。

![img_4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2910920/20d0da33-97b3-a5d2-1285-2a75317b6e86.png)

機械学習やトレードについての言葉も多く登場しますが、Pythonの最低限の知識があれば、読める内容になっていると思います。


# ゴール設定

「**過去データによる学習とバックテストから統計的優位に立って損益が安定してプラスになる**」ことを目指す考え方になっています。

簡単に言うと、１回きりの買ったり売ったりの試行では、運次第のコイントスみたいなものになってしまいますが、
数百回、数千回の試行をしたときに着実にプラスになる法則のようなものを機械学習によって見つけようとしています。
（この法則のようなものをアルファとかエッジとか言ったりします。）

たまに、「機械学習で株価の予測ができるか？」=>「できなかった」といった結論を出している記事を見かけますが、
ある未来時刻での価格をピンポイントで予測するのは、ほぼ不可能であり、ある意味ナンセンスであるとも捉えられます。

ゴールは、損益がプラスになることであり、ある時刻の価格が予測できたことではないので、執行の時刻や価格を厳密に決めなくても損益の期待値がプラスになればよい、という考え方を含んでいるところがあります。
このあたりの考え方は、[UKI様のこちらの記事](https://qiita.com/blog_UKI/items/f782fb86747e0bae89a9)がとても参考になると思います。

# 個人開発者の実績

実際に個人レベルでBOT運用を行って、成果を出し続けている人はかなり限られてるように思います。

筆者は、2,3年ほど前にbitFlyerやBitMEXなどの取引所でBOTを運用して一定の成果を上げていましたが、本業が忙しくなりすぎたため、しばらく触っていませんでした。
ここ数ヶ月では、ひさびさに機械学習を取り入れて再起してみたら、毎日コツコツと収益が出てきていて、さらに深堀りしたり横展開していこうと思っています。

以下の図は、最近作って実運用しているBOTの損益履歴を時系列で示していますが、グラフの形状を見ても成果が出てきている手応えがあります。

![img_5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2910920/1bbaa08b-4613-f22c-347f-a1251489353e.png)

ちなみに、暗号資産のトレードBOTを作っている人をBotterと呼ぶ文化がありまして、[UKI様のこちらの記事](https://qiita.com/blog_UKI/items/eb50a668e0a906b3d8dc)に集まってたりするので、フォローするとヒントがあるかもしれません。
また、個人の趣味レベルで成果が出せている人がいるのは勇気づけられるところもあります。
筆者の旧アカウントもこっそりインしていました。


# 入門スクリプト

前置きが長くなったかもしれませんが、以下の必要な lib を pip で導入して、すぐに実行することができます。
なお、動作確認は Python 3.10.7 で行っています。

```shell
$ pip install -r requirements.txt
$ python quickstart.py
```

実行すると、matplotlibによる図が3つ出力されます。

```requirements.txt
numpy==1.23.2
pandas==1.4.4
requests==2.28.1
matplotlib==3.5.3
wheel==0.37.1
scikit-learn==1.1.2
lightgbm==3.3.2
```

実行環境によっては、lightgbmがそのまま入らないかもしれません。
その場合は、lightgbm公式のInstallation Guideをご確認ください。

```quickstart.py
import math

import requests
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.metrics import mean_squared_error
import lightgbm as lgb


def get_bybit_klines(symbol, interval, from_ts, limit=200):
    # https://bybit-exchange.github.io/docs/futuresV2/linear/#t-querykline
    api_url = 'https://api.bybit.com/public/linear/kline'
    res = requests.get(api_url, params={
        'symbol': symbol,
        'interval': interval,
        'from': from_ts,
        'limit': limit,
    })
    return res.json()


def fetch_klines(symbol, from_ts, limit=200, page=1):
    interval = '30'
    interval_sec = 30 * 60
    klines = []
    for _ in range(page):
        res = get_bybit_klines(symbol, interval, from_ts, limit=limit)
        klines += res['result']
        from_ts = klines[-1]['start_at'] + interval_sec
    ohlcv_list = [
        [k['open'], k['high'], k['low'], k['close'], k['volume'], k['start_at']] for k in klines
    ]
    return pd.DataFrame(ohlcv_list, columns=['open', 'high', 'low', 'close', 'volume', 'start_at'])


def RCI(x):
    n = len(x)
    d = ((np.arange(1, n+1) - np.array(pd.Series(x).rank())) ** 2).sum()
    rci = 1 - 6 * d / (n * (n ** 2 - 1))
    return rci*100


def make_features_and_y(ohlcv_df):
    f_df = ohlcv_df

    # add features
    f_df['close_diff'] = f_df['close'].diff(1)
    f_df['volume_diff'] = f_df['volume'].diff(1)
    f_df['volume_pct_change'] = f_df['volume'].pct_change(1) * 100

    f_df['hl'] = f_df['high'] - f_df['low']
    f_df['co'] = f_df['close'] - f_df['open']

    f_df['RCI5'] = f_df['close'].rolling(5).apply(RCI)

    # set y
    f_df['y'] = f_df['close'].diff(1).shift(-1)

    f_df = f_df.dropna()

    return f_df


def train_and_predict(train_df):
    X = train_df.drop('y', axis=1)
    y = train_df['y']

    train_size = int(len(train_df) * 0.70)
    X_train = X[:train_size]
    y_train = y[:train_size]
    X_test = X[train_size:]
    y_test = y[train_size:]

    lgb_train = lgb.Dataset(X_train, y_train)
    lgb_test = lgb.Dataset(X_test, y_test, reference=lgb_train)

    # LightGBM parameters
    params = {
        'task': 'train',
        'boosting_type': 'gbdt',
        'objective': 'regression',
        'metric': 'rmse',
        'learning_rate': 0.01,
        'min_data_in_leaf': 1,
        'num_iteration': 100,
        'verbose': -1, # 0
    }

    model = lgb.train(
        params,
        train_set=lgb_train,
        valid_sets=[lgb_train, lgb_test],
        valid_names=['Train', 'Test'],
        # early_stopping_rounds=50,
        verbose_eval=100,
    )

    y_pred = model.predict(X_test)

    mse = mean_squared_error(y_test, y_pred)
    print('RMSE:', math.sqrt(mse))

    plt.figure(figsize=(8, 8))
    plt.scatter(y_test, y_pred, marker='o', s=5, alpha=0.50)
    plt.plot([y_test.min(), y_test.max()], [y_test.min(), y_test.max()], alpha=0.5, color='gray')
    plt.xlabel('y_test')
    plt.ylabel('y_pred')
    plt.grid()

    # regression line
    a, b = np.polyfit(y_test, y_pred, 1)
    y2 = a * np.array(y_test) + b
    plt.plot(y_test, y2, alpha=0.5, color='blue')
    print('regression line', a, b)

    importance = model.feature_importance(importance_type='gain')
    df_imp = pd.Series(importance, index=X_train.columns).sort_values(ascending=True)

    plt.figure(figsize=(12, 8))
    plt.barh(df_imp.index, df_imp)

    predicted_df = X_test[['close', 'start_at']]
    predicted_df['ts'] = pd.to_datetime(predicted_df['start_at'], unit='s', utc=True)
    predicted_df['y'] = y_pred

    return predicted_df


def backtest(predicted_df):
    position_price = 0.0
    has_long = False

    pl_series = []
    for i, row in predicted_df.iterrows():
        close, ts, y = row['close'], row['ts'], row['y']
        print(close, ts, y)

        if position_price == 0.0:
            if y > 0.0:
                has_long = True
                position_price = close
            elif y < 0.0:
                has_long = False
                position_price = close
        else:
            if y > 0.0 and not has_long:
                pl = close - position_price
                pl_series.append([ts, pl])
                has_long = True
                position_price = close
            elif y < 0.0 and has_long:
                pl = position_price - close
                pl_series.append([ts, pl])
                has_long = False
                position_price = close

    pl_df = pd.DataFrame(pl_series, columns=['ts', 'pl'])
    pl_df['pl_sum'] = pl_df['pl'].cumsum()

    _, ax = plt.subplots()
    ax.plot(pl_df['ts'], pl_df['pl_sum'])

    return pl_df


if __name__ == '__main__':
    print('# Loading ohlcv data from 2022-07-01...')
    ohlcv_df = fetch_klines('BTCUSDT', 1656633600, limit=200, page=20)
    print('# Loaded ohlcv data (%d)' % (len(ohlcv_df)))
    print(ohlcv_df)

    f_df = make_features_and_y(ohlcv_df)
    print('# Features & y dataframe.')
    print(f_df)

    predicted_df = train_and_predict(f_df)
    print('# Trained & predicted.')
    print(predicted_df)

    pl_df = backtest(predicted_df)
    print(pl_df)

    plt.show()
```
※ ライセンス: CC0


出力画像 a
![img_1.png "キャプションテキスト"](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2910920/47796729-9fe1-29e9-c1e3-17f02c84d774.png)

出力画像 b
![img_2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2910920/43fe4974-3b7c-be3c-c6c3-af69b58dea5c.png)

出力画像 c
![img_3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2910920/5029be81-ecfe-7d54-c468-127986b66a33.png)


# スクリプトの説明

たった200行に満たないコードですが、以下のことを順に行っています。

・学習元データの取得
・特徴量の作成
・目的変数yの設定
・GBDTモデルの作成
・モデルによる学習
・モデルによる予測
・予測値を使ったバックテスト

また、実行結果として以下３つをプロットしています。

・目的変数を予測した散布図
・特徴量の重要度を示す棒グラフ
・予測値を使ってバックテストを行った時系列の損益グラフ

それぞれ実行されている順に簡単な説明をしていきます。
上記の `quickstart.py` のコードをエディタなどにコピーして、説明箇所を見合わせると読みやすいと思います。

## 1. 学習元データの取得

最初の２つのメソッド `get_bybit_klines` `fetch_klines` では、学習に使うデータとしてBybitという取引所のAPIを使って、klineデータを取得しています。
klineとは、一般にローソク足などと呼ばれる、始値、終値、高値、安値（とボリューム）をある時間間隔で区切ったデータ形式です。
それぞれの英表記の頭文字をとって、データ構造としてohlcvともよく呼ばれています。

Bybitを選んだのは、APIから手頃に取得できてサンプルにしやすかったからで、実際には取引対象を決めた後に取得先を変えたり、DBに永続化しておくなど適宜工夫が必要な部分になります。

ohlcvを学習データに使うかどうかも解釈の１つであり、マーケットに影響を与えているデータを色々と試してみる価値があります。

なお、BybitのAPIでは１度に200件までの取得になっているので、ページングしてそれなりの件数を集めた後に、pandasのDataFrameにするメソッドを用意しました。

このスクリプトでは、`2022-07-01 00:00` のunixtime指定で、4000件のohlcvデータを引用しています。


## 2. 特徴量の作成と目的変数 y の設定

`make_features_and_y` メソッドでは、取得したohlcvデータを使って特徴量の作成を行っています。
この工程は、特徴量エンジニアリングとよく呼ばれます。

ここでは入門サンプルなのであまり意味を考えずに、シリーズデータの前項からの差を入れたり、シリーズデータ間の差などを入れています。
例えば、 `close_diff` ですと、直前の足から見た終値の差ということになります。

特徴量の作り方は極めて重要です。
作成元のデータが同じでも、差や積を取ったり、平均や標準偏差を使ってみるなどの加工をすることで学習、予測に効くことがあります。
そのため、pandasの文法に慣れておくと試行錯誤しやすいです。

また、トレードの世界では、テクニカル指標というものがよく用いられますが、これが特徴量の作成に有益である可能性があります。
こちらもサンプルとして、RCIというテクニカル指標を実装しています。

Pythonでは、TA-Libというライブラリのラッパーを使うと多くの指標を簡単に計算できますが、環境によってインストールに詰まることがありそうだったので、今回は動作確認の敷居を下げるために外しておきました。

このメソッドでは、目的変数 y の設定も同時に行っています。
目的変数とは、機械学習モデルが予測を試みる対象の変数で、単に y と呼ばれることもあります。

システムトレードに機械学習を応用する場合は、未来の地点の価格や、その価格に付随する何かを目的変数にすることになると思います。
今回は、実直に１つ未来の足の終値の差を設定しています。

後のモデルの説明でも触れますが、y の設定は、学習モデルがどのタスク（予測の種類）を採用してるかによって前処理が必要なこともあります。
たとえば、１つ未来の足の終値が上がったか下がったかだけを予測する場合（２値分類タスク）は、価格差の正負で1か0にラベリングするといった例が考えられます。

## 3. GBDTモデルの作成、学習、予測

`train_and_predict` メソッドでは、GBDTモデルの作成、学習、予測の一連の処理を含んでいます。

まずはじめに、使用する特徴量と目的変数yを分離し、さらに学習に使うデータとテストに使うデータを分離しています。
ここでは、入力されたデータ数の70%を学習に、残りの30%をテストに使っています。

次に、LightGBMで回帰タスクを指定し、評価指標にはRMSEを指定して、モデルを作成するためのパラメータ設定をしています。
こちらもサンプルとしての設定になっており、予測対象によってはタスクの種類を変えたり、評価指標を変えたり、カスタム評価指標のための独自関数を定義したり、ハイパーパラメータを変えてみる、など様々な改善が考えられる部分です。

このパラメータと、学習用のデータを使って、`lgb.train(...` によって学習したモデルを作成し、 `model.predict(...` では、yの予測を行っています。
さらにここでは、学習・予測結果の図を2つプロットしていますが次の節で後述します。

今回は、予測結果をバックテストに使いたかったため、 `predicted_df` というデータを用意し、終値、タイムスタンプ、予測結果をシリーズデータとして返却しています。

## 4. バックテスト

`backtest` メソッドでは、学習モデルが予測した結果を使って、その期間の損益を出力しています。

具体的には、予測された y が正数なら買い、負数なら売り、往復トレードごとにその損益をシリーズデータにまとめています。

ここでは、ショート（空売り）ができる前提のドテン式で簡単に書いています。
ドテン式とは、ロングを持っているときに、その決済とショートを持つのを同時に行う方式のことです。ショートでもその逆で同じです。

ロングとショートが入れ替わるタイミングで、その損益とタイムスタンプを記録し、最後に時刻ごとの累計損益も算出しています。
さらに、時系列の損益を図に出力していますが次の節で後述します。

一般的にバックテストを厳密に行うことは難しいですが、実運用する際にどのように注文を出すかを決めておいて、バックテストの実装にもそのコストを反映しておくことが望ましいです。
このコストは執行コストなどと呼ばれ、取引手数料、スプレッド、スリッページ、約定確率などの影響を受けますが、実践経験がないと伝わりにくい部分なので割愛しています。

採用する戦略や注文単位での数量、取引所、通貨ペア、などによって、執行コストを気にする割合は大きく変化します。


# 出力図の説明

## a. 目的変数を予測した散布図

![img_1.png "キャプションテキスト"](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2910920/47796729-9fe1-29e9-c1e3-17f02c84d774.png)

出力画像 a では、テストデータのy値と、学習モデルが予測したy値による散布図を示しています。
散布図は、予測された値がどのような分布になっていたか確認するのに便利です。

また、この分布に対して、回帰直線を青い線で書き加えています。
この図では、傾きが 1.0 に近いほど予想性能が高いことを示しますが、残念ながらこの図を見るとその傾きはほぼ 0 に近い値を取っています。

このサンプルでは、特に有意となる特徴量を与えられていないので当然の結果なのですが、そもそも未来の価格を予測すること自体が難しいことなので、
入力データや特徴量エンジニアリングを試行錯誤して、少しでも傾き（予測性能）を上げることが必要になります。

一般的に、遠い未来の予測よりも近い未来の予測の方が簡単なので、入力データの時間の間隔を短くして特徴量エンジニアリングのコツを掴むのが近道かもしれません。

## b. 特徴量の重要度を示す棒グラフ

![img_2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2910920/43fe4974-3b7c-be3c-c6c3-af69b58dea5c.png)

出力画像 b では、学習モデルが出力した特徴量の重要度を示しています。

スクリプトで指定している `importance_type='gain'` の引数は重要で、「学習データの誤差を小さくできた程度」を示しています。
デフォルトは、`importance_type='split'` となっており、「学習に特徴量が使用された回数」となってしまうため注意が必要です。

この図の見方ですが、出力画像 a でも見たように、特に予測力を持っていない状態なので、上位の特徴量を見ても学習には効いてない可能性が高いです。
まずは、有意と思われる特徴量をできるだけ多く試して、散布図にプラスの変化が現れた後、この図を見て特徴量の重要度を分析すると効率がよいと考えられます。

有意を思われる特徴量が見つかった場合は、学習/テストの期間を変えたり、入力データの時間間隔を変えたりすることで、未来の時刻でも予測がうまくいきそうかの確度（汎化性能と言ったりします）を高めることができます。
また、学習に効いていないと捉えられる特徴量がある場合は、それを省いておくことでも予測精度の向上、安定が期待できます。

## c. 予測値を使ってバックテストを行った時系列の損益グラフ

![img_3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2910920/5029be81-ecfe-7d54-c468-127986b66a33.png)

出力画像 c では、学習モデルの予測結果を元にバックテストを行った損益の変化を示しています。

累計で見ると、$2000幅のプラスになっていることがわかります。
また、グラフに山と谷が大きく描かれており、不安定な損益の変化になっていることもわかります。

このグラフの見方ですが、このサンプルでは有意な特徴量が与えられていないため、実際には期待値がプラスとなる予測は行えておらず、
たまたまプラスになっただけ、となってしまっています。
この学習モデルでは、対象の期間を変えたりするとマイナスになったり非常に不安定な動きをするのが確認できます。

逆に有意な特徴量が発見されると、次第にグラフが綺麗な右肩上がりに近づき、安定して収益がプラスになることを確認できるようになります。

この3つの図を出力することで、予測の分布、特徴量の重要度、バックテストによる損益、の変化を見ることができますので、
次の節で示すような改善ポイントを、実際にコード上でいじって実行してみるととてもおもしろいと思います。

# 但し書き・改善ポイントなど

この記事では、データを取得するところから始めて、GBDTモデルによる学習・予測、バックテストといった一連の流れをすぐに体験できるように用意したもので、マーケットに実戦投入して期待値を取れるような実装にはなっていません。

しかし、各箇所で実装の改善を加えたり、学習やテスト期間を変えたりすると、学習が予測に効いているか？バックテストによる損益の期待値は取れているか？を確認できる雛形にはなっていますので、
それぞれの実装を改変して、期待値がプラスになるか探求してみるととても面白いと思います。

具体的にどんな改善ポイントがあるかというと、主に以下のようなものがあります。

・対象の取引所、通貨ペアを変えてみる
・データを見る区間を変えてみる（本サンプルでは30分）
・特徴量の作り方を試行錯誤する
・学習データの入力元を増やしてみる
・目的変数を変えてみる
・GBDTを回帰タスクではなく、分類タスクにしてみる
・学習パラメータをチューニングしてみる
・モデルから予測値を受け取ってからの執行戦略を工夫してみる
・バックテストで執行コストを計算し、現実的な損益を確認する

筆者の経験上は、それぞれしっかり実装・検証した方がいいのですが、すべてを行うのはなかなか大変だったりもします。

結果を出す観点で言うと、効いている特徴量が１つ２つできれば、他は雑に扱っても収益が出ることもあるので、どこをいじると結果に影響を与えやすいか当たりをつけて取り組むかがコツのように思います。

ちなみに、BOTの実装時に注文を発行したりする部分については、今回のスクリプトでは取り扱いませんでしたが、暗号資産の取引所では簡単に行えるAPIが提供されているので、すぐに実装できます。取引所ごとにライブラリも転がっていたりします。

また、筆者は未経験ですが株式にも応用できると思いますので、やってみると面白いと思います。

なお、当然ながら実運用で発生する損益は自己責任であり、いきなり想定通りの期待値を実現するのは難しいと思うので、もし実運用を決めた際も少額での実現性テストを挟むことを強くオススメします。

# おわりに

今回は絶賛取り組んでいる、GBDTモデル機械学習によるBOT開発の流れをスクリプト１つで簡単に紹介してみました。

エンジニアにとっては、個人レベルで勝負ができる面白い取り組みの１つだと思います。

ファイナンス分野への機械学習の応用は、勘所やTips的なものがとても多く、実運用してみないとわからない落とし穴のようなものもありますが、個人でどこまで成果を出せるか引き続き研究してみようと思っています。

またどこかの区切りで情報発信できたらと考えていますので、面白かったらコメント・いいねなどリアクションいただけると嬉しいです。

#### その他関連ワード

ML、システムトレード、自動売買、仮想通貨、BTC、ビットコイン、価格予測