ディープラーニングで指値位置を決める

# ディープラーニングで指値位置を決める
ディープラーニングを用いた自動取引botにおいては[シャープ損失](https://arxiv.org/abs/1904.04912)や[リスク回避型効用関数](https://www.jstage.jst.go.jp/article/jsaisigtwo/2024/FIN-033/2024_169/_article/-char/ja)が提案されており、投資家にとって重要な指標を**end-to-endで最適化**できるようになっている。しかし指値位置の調整に関しては、指値が約定するかどうかの境界において**目的関数が不連続**になってしまうため、勾配法による学習が困難であるという問題点があった。本稿ではこの課題を解決するために、不連続なリターンを連続関数によって置き換える**スムージング**（平滑化）を提案する。

# 指値位置決定の問題設定
今回は以下のシンプルな問題設定で指値位置をディープラーニングで選択するための課題を検討する。

- 始値、安値、高値、終値の四本値のデータを使用する
- 始値時点で買い指値を入れる（ショートはしない）
- ディープラーニングのモデルで始値から指値までの距離を決定
- レバレッジは1倍で複利運用（指値サイズ＝証拠金）[^size]
- 指値 ≧ 安値なら買い指値がすべて約定（部分約定はしない）
- 約定したら終値で決済（成行注文）
- 取引コストや金利は考慮しない
[^size]:指値サイズの学習を行う場合、ロスカットしない範囲では目的関数は連続になる。

# 課題：指値注文の不連続なリターン
上記の問題設定において、指値価格$\text{bid}$を変えた場合のリターン$\text{Return}(\text{bid})$について考えよう。まず指値=安値において不連続であり、指値価格を少し動かしただけでリターンが大きく変わってしまうことがわかる。また、指値<安値においては勾配がゼロになってしまい、指値価格を少し動かしてもリターンが全く変わらなくなってしまう、という課題がある。

このようなリターンを勾配法によって最大化しようとすると、運良く指値>安値の領域に初期化されたしても、指値が約定しない可能性を考えずにどんどん指値を小さくしていって、いざ指値が約定しなくなったらいきなり無気力になって何もしなくなる、という現象が起こり、**学習すればするほど性能が劣化**してしまう。

```math
\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}
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3658116/a8425c7e-5113-1a60-aded-ffbb4e1463b9.png)


# 解決策：連続関数によるスムージング
そこで指値注文のリターンを連続関数でスムージングし、指値が約定しそうな場合に部分点を与えたり、逆に約定しなくなりそうな場合にペナルティを与えたりすることを考える。具体的には約定するかどうかを{0,1}のステップ関数ではなく、[シグモイド関数](https://ja.wikipedia.org/wiki/%E3%82%B7%E3%82%B0%E3%83%A2%E3%82%A4%E3%83%89%E9%96%A2%E6%95%B0)を用いて便宜上の約定確率を表し、約定した場合のリターンにかけ合わせる。このようにすれば、**指値が約定しなくなる直前で目的関数が最大となって学習が止まる**ため、勾配法の利用が可能となる。

$$ \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}$は近似の強さを調整する温度パラメータで、小さいほど元の不連続なリターンに近づき、大きいほど滑らかな関数になり学習しやすくなる。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3658116/ecb8025f-0096-3183-bc6d-b4ab2a64adc0.png)

# 目的関数：ケリー損失
- 今回の目的関数は、学習期間中の累積対数リターンの最大化とする
- この目的関数はリスク回避型効用関数の一種である[対数効用](https://imoz.jp/note/finance.html)とみなすことができる
- 解釈が容易でパラメータもないため、筆者は好んで多用している
- 実装にあたっては、ディープラーニングの慣例に従ってマイナスを取って損失関数とする
- この損失関数をケリー損失と呼ぶことにする

```math
L_{\text{kelly}}=-\sum_{t=1}^T \ln(1 + \text{Return}_t)
```

# バックテスト
バックテストは[mlbot_tutorial](https://github.com/yohok/mlbot_tutorial)を参考に実施し、ベースラインを上回る性能を確認した。
- 取引所はGMOコイン、銘柄はBTC/JPY、ローソク足は15分足のデータを使用
- 期間は2018-09-06から2024-11-03までで、学習とテストに半分ずつ分割
- モデルはサイズ4のELUを使用し、始値から指値までの相対価格をバッチ学習
- 特徴量としてATRとSTDDEVを採用
- 温度は50、学習率は0.01、学習エポックは10000
- ベースラインとして現物ホールド、固定位置（-0.5%）、ATR/2を比較

テスト期間における累積リターンの倍率（初期資産に対し何倍になったか）は以下の通りである。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3658116/2e2a2059-d3da-1bd3-9edf-ff3bca3a7a33.png)

# まとめ
本稿ではディープラーニングによる指値位置決定を検討し、以下の点を示した。
- **目的関数が不連続**で、**勾配がゼロ**になる問題を指摘
- シグモイド関数を用いた**スムージング**による解決策の提案[^solution]
- 学習期間中の累積対数リターンを最大化する**ケリー損失**の提案
- 実データを用いたバックテストにおいてベースラインを上回る性能を確認
[^solution]:不連続な目的関数を最適化する方法には[方策勾配法など](https://arxiv.org/abs/1308.3432)もあるが、今回はスムージングで十分と判断した。

今回は指値が刺さったら終値で成行決済する取引戦略を扱ったが、決済も指値で行う戦略に関してはさらなる検討が必要である。特に[在庫管理を行う戦略](https://math.nyu.edu/~avellane/HighFrequencyTrading.pdf)の場合、在庫を特徴量として含めることが重要となるが、過去の約定履歴に依存するため学習を並列化できないという未解決問題がある。

[仮想通貨botter Advent Calendar 2024](https://qiita.com/advent-calendar/2024/botter)

# 付録：コード
```python
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()
```