ディープラーニングで指値位置を決める
ディープラーニングを用いた自動取引botにおいてはシャープ損失やリスク回避型効用関数が提案されており、投資家にとって重要な指標をend-to-endで最適化できるようになっている。しかし指値位置の調整に関しては、指値が約定するかどうかの境界において目的関数が不連続になってしまうため、勾配法による学習が困難であるという問題点があった。本稿ではこの課題を解決するために、不連続なリターンを連続関数によって置き換えるスムージング(平滑化)を提案する。
指値位置決定の問題設定
今回は以下のシンプルな問題設定で指値位置をディープラーニングで選択するための課題を検討する。
- 始値、安値、高値、終値の四本値のデータを使用する
- 始値時点で買い指値を入れる(ショートはしない)
- ディープラーニングのモデルで始値から指値までの距離を決定
- レバレッジは1倍で複利運用(指値サイズ=証拠金)1
- 指値 ≧ 安値なら買い指値がすべて約定(部分約定はしない)
- 約定したら終値で決済(成行注文)
- 取引コストや金利は考慮しない
課題:指値注文の不連続なリターン
上記の問題設定において、指値価格$\text{bid}$を変えた場合のリターン$\text{Return}(\text{bid})$について考えよう。まず指値=安値において不連続であり、指値価格を少し動かしただけでリターンが大きく変わってしまうことがわかる。また、指値<安値においては勾配がゼロになってしまい、指値価格を少し動かしてもリターンが全く変わらなくなってしまう、という課題がある。
このようなリターンを勾配法によって最大化しようとすると、運良く指値>安値の領域に初期化されたしても、指値が約定しない可能性を考えずにどんどん指値を小さくしていって、いざ指値が約定しなくなったらいきなり無気力になって何もしなくなる、という現象が起こり、学習すればするほど性能が劣化してしまう。
\text{Return}(\text{bid}) = \begin{cases}
\frac{\text{close} - \text{bid}}{\text{bid}} & \text{if} \space \text{bid} \geq \text{low} \\
0 & \text{otherwise}
\end{cases}
解決策:連続関数によるスムージング
そこで指値注文のリターンを連続関数でスムージングし、指値が約定しそうな場合に部分点を与えたり、逆に約定しなくなりそうな場合にペナルティを与えたりすることを考える。具体的には約定するかどうかを{0,1}のステップ関数ではなく、シグモイド関数を用いて便宜上の約定確率を表し、約定した場合のリターンにかけ合わせる。このようにすれば、指値が約定しなくなる直前で目的関数が最大となって学習が止まるため、勾配法の利用が可能となる。
$$ \text{Return}(\text{bid}) = \frac{\text{close} - \text{bid}}{\text{bid}} \cdot \text{sigmoid}\left(\frac{\text{bid} - \text{low}} {\text{temperature}}\right) $$
ここで$\text{temperature}$は近似の強さを調整する温度パラメータで、小さいほど元の不連続なリターンに近づき、大きいほど滑らかな関数になり学習しやすくなる。
目的関数:ケリー損失
- 今回の目的関数は、学習期間中の累積対数リターンの最大化とする
- この目的関数はリスク回避型効用関数の一種である対数効用とみなすことができる
- 解釈が容易でパラメータもないため、筆者は好んで多用している
- 実装にあたっては、ディープラーニングの慣例に従ってマイナスを取って損失関数とする
- この損失関数をケリー損失と呼ぶことにする
L_{\text{kelly}}=-\sum_{t=1}^T \ln(1 + \text{Return}_t)
バックテスト
バックテストはmlbot_tutorialを参考に実施し、ベースラインを上回る性能を確認した。
- 取引所はGMOコイン、銘柄はBTC/JPY、ローソク足は15分足のデータを使用
- 期間は2018-09-06から2024-11-03までで、学習とテストに半分ずつ分割
- モデルはサイズ4のELUを使用し、始値から指値までの相対価格をバッチ学習
- 特徴量としてATRとSTDDEVを採用
- 温度は50、学習率は0.01、学習エポックは10000
- ベースラインとして現物ホールド、固定位置(-0.5%)、ATR/2を比較
テスト期間における累積リターンの倍率(初期資産に対し何倍になったか)は以下の通りである。
まとめ
本稿ではディープラーニングによる指値位置決定を検討し、以下の点を示した。
- 目的関数が不連続で、勾配がゼロになる問題を指摘
- シグモイド関数を用いたスムージングによる解決策の提案2
- 学習期間中の累積対数リターンを最大化するケリー損失の提案
- 実データを用いたバックテストにおいてベースラインを上回る性能を確認
今回は指値が刺さったら終値で成行決済する取引戦略を扱ったが、決済も指値で行う戦略に関してはさらなる検討が必要である。特に在庫管理を行う戦略の場合、在庫を特徴量として含めることが重要となるが、過去の約定履歴に依存するため学習を並列化できないという未解決問題がある。
仮想通貨botter Advent Calendar 2024
付録:コード
import talib
import numpy as np
import pandas as pd
import torch
import torch.nn as nn
from sklearn.model_selection import train_test_split
import matplotlib.pyplot as plt
import japanize_matplotlib
# 乱数シードを固定して結果の再現性を確保
np.random.seed(42)
torch.manual_seed(42)
# グラフのサイズを設定
plt.rcParams['figure.figsize'] = [12, 6]
# GmoFetcherを用いて取得したGMOコインの15分足のOHLCVデータを読み込む
ohlcv = pd.read_pickle('df_ohlcv_all.pkl')
# 特徴量(ATRとSTDDEV)を計算し、1つ前のデータにシフト
features = pd.DataFrame({
'ATR': talib.ATR(ohlcv.hi, ohlcv.lo, ohlcv.cl),
'STDDEV': talib.STDDEV(ohlcv.cl),
}).shift(1)
# ターゲットデータ(open, low, close)を準備
target_data = pd.DataFrame({
'open': ohlcv.op,
'low': ohlcv.lo,
'close': ohlcv.cl
})
# 欠損値を除去してデータを整える
features = features.dropna()
target_data = target_data.loc[features.index]
# データを学習用とテスト用に分割
features_train, features_test, target_train, target_test = train_test_split(features, target_data, test_size=0.5, shuffle=False)
# データをPyTorchのテンソル形式に変換
features_train_tensor = torch.FloatTensor(features_train.values)
target_train_tensor = torch.FloatTensor(target_train.values)
features_test_tensor = torch.FloatTensor(features_test.values)
target_test_tensor = torch.FloatTensor(target_test.values)
# モデルを定義(入力層、隠れ層、出力層)
hidden_layer_size = 4
neural_network_model = nn.Sequential(
nn.Linear(features_train_tensor.shape[1], hidden_layer_size),
nn.ELU(),
nn.Linear(hidden_layer_size, 1)
)
optimizer = torch.optim.Adam(neural_network_model.parameters(), lr=0.01)
# トレーニングループを設定
num_epochs = 10000
for epoch in range(num_epochs):
neural_network_model.train()
optimizer.zero_grad()
# 順伝播で予測値を計算
predicted_values = neural_network_model(features_train_tensor)
# 買い指値を計算(bid = open - pred)
bid_prices = target_train_tensor[:, 0] - predicted_values.squeeze()
# シグモイド関数を使って約定確率を計算
price_difference = bid_prices - target_train_tensor[:, 1] # bid - low
execution_probability = torch.sigmoid(price_difference / 50)
# 期待リターンを計算
potential_returns = (target_train_tensor[:, 2] - bid_prices) / bid_prices # (close - bid) / bid
expected_returns = execution_probability * potential_returns
# 対数和を計算してケリー損失を求める
log_returns = torch.log(1 + expected_returns)
cumulative_log_returns = torch.sum(log_returns)
kelly_loss = -cumulative_log_returns # リターンを最大化したいので負の値を使用
# 逆伝播でモデルを更新
kelly_loss.backward()
optimizer.step()
# 実際のリターンを計算
executed_mask = price_difference >= 0 # bid価格がlow以上の場合に約定
actual_returns = torch.zeros_like(potential_returns)
actual_returns[executed_mask] = potential_returns[executed_mask]
actual_cumulative = torch.prod(1 + actual_returns)
print(f'Epoch [{epoch+1}/{num_epochs}], Loss: {kelly_loss.item():.4f}, Return: {actual_cumulative.item():.4f}', end='\r')
# テストデータでモデルを評価
neural_network_model.eval()
with torch.no_grad():
predicted_values = neural_network_model(features_test_tensor)
bid_prices = target_test_tensor[:, 0] - predicted_values.squeeze() # open - pred
# バックテストを実施
test_ohlcv = ohlcv[-len(bid_prices):]
bid_prices = pd.Series(bid_prices.numpy(), index=test_ohlcv.index, name='bid')
strategy_returns = ((test_ohlcv.cl - bid_prices) / bid_prices).where(test_ohlcv.lo < bid_prices, 0)
strategy_cumulative = (1 + strategy_returns).cumprod()
print(f"Cumulative Return: {strategy_cumulative.iloc[-1]:.4f}")
# ベースライン戦略と比較
fixed_bid_prices = test_ohlcv.op * (1 - 0.005)
fixed_returns = ((test_ohlcv.cl - fixed_bid_prices) / fixed_bid_prices).where(test_ohlcv.lo < fixed_bid_prices, 0)
fixed_cumulative = (1 + fixed_returns).cumprod()
# ATR/2に指値する戦略
atr = talib.ATR(test_ohlcv.hi.values, test_ohlcv.lo.values, test_ohlcv.cl.values)
atr_bid_prices = test_ohlcv.op - atr/2
atr_returns = ((test_ohlcv.cl - atr_bid_prices) / atr_bid_prices).where(test_ohlcv.lo < atr_bid_prices, 0)
atr_cumulative = (1 + atr_returns).cumprod()
hodl_cumulative = test_ohlcv.cl / test_ohlcv.cl.iloc[0]
# 累積リターン倍率をプロット
plt.plot(strategy_cumulative.asfreq('D'), label='ディープラーニング')
plt.plot(atr_cumulative.asfreq('D'), label='ATR/2')
plt.plot(fixed_cumulative.asfreq('D'), label='固定位置(-0.5%)')
plt.plot(hodl_cumulative.asfreq('D'), label='BTC現物ホールド')
plt.title('ディープラーニングを用いた指値位置決定のバックテスト(テスト期間)')
plt.ylabel('累積リターン倍率')
plt.yscale('log')
plt.legend()
plt.show()