Yahoo Financeのデータ（S&P500）を使って、DDQNにトレーディングを学習させてみた

# はじめに
今回は、「ChatGPTにハンズオンを作らせてみた」の第10弾で、DDQN（Double Deep Q Network）を用いて、S&P500のデータを使ってアルゴリズムトレーディングを実装しました。

第9弾はこちら↓

https://qiita.com/UKI_datascience/items/bb3236e38ba888765ea9

# DDQN（Double Deep Q Network）
Q値の更新を「次の行動を選択するためのネットワーク（現在のネットワーク）」と「その行動のQ値を評価するためのネットワーク（ターゲットネットワーク）」の2つを用いることで、DQNの価値を過大評価してしまう欠点を防ぐために改良された手法。

# 使用コード・分析結果
```python
import numpy as np
import pandas as pd
import yfinance as yf
import gym
import matplotlib.pyplot as plt
from collections import deque
import random

import tensorflow as tf
from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import Dense
from tensorflow.keras.optimizers import Adam
```

Yahoo FinanceからS&P500の価格データ（終値）を取得します。2018年から2022年までの期間を学習データ、2023年を検証データとして分割します。また、終値に加えて、10日移動平均（SMA_10）と50日移動平均（SMA_50）も変数として追加しています。

```python
import yfinance as yf
import pandas as pd

# 取得する銘柄
ticker = "SPY"

# 期間設定（2024年1月1日まで）
start_date = "2018-01-01"
end_date = "2024-01-01"

# データ取得
data = yf.download(ticker, start=start_date, end=end_date)

# 必要なカラムのみ
data = data[['Close']]

# 移動平均線（SMA）の特徴量を追加
data['SMA_10'] = data['Close'].rolling(window=10).mean()
data['SMA_50'] = data['Close'].rolling(window=50).mean()

# NaNがある行を削除（移動平均の計算後）
data.dropna(inplace=True)

# インデックスを日付に設定
data.index = pd.to_datetime(data.index)

# データを分割
train_data = data[:'2022-12-31']  # 2022年までを学習データ
test_data = data['2023-01-01':]  # 2023年を検証データ

# データの確認
print("学習データ (Train) の範囲:", train_data.index.min(), "〜", train_data.index.max())
print("検証データ (Test) の範囲:", test_data.index.min(), "〜", test_data.index.max())
```

取引環境を作成し、DDQNエージェントが学習できるようにします。

```python
import gym
import numpy as np
import pandas as pd

class StockTradingEnv(gym.Env):
    def __init__(self, df):
        super(StockTradingEnv, self).__init__()

        self.df = df.reset_index()
        self.current_step = 0
        self.cash = 10000  # 初期資金
        self.shares_held = 0  # 持ち株数
        self.total_reward = 0  # 累積リワード
        self.history = []  # 資産履歴を保存するリスト

        self.action_space = gym.spaces.Discrete(3)
        self.observation_space = gym.spaces.Box(
            low=0, high=np.inf, shape=(3,), dtype=np.float32
        )

    def reset(self):
        self.current_step = 0
        self.cash = 10000
        self.shares_held = 0
        self.total_reward = 0
        self.history = []  # 履歴をリセット
        return self._next_observation()

    def _next_observation(self):
        obs = np.array([
            float(self.df.iloc[self.current_step]['Close']),
            float(self.df.iloc[self.current_step]['SMA_10']),
            float(self.df.iloc[self.current_step]['SMA_50'])
        ], dtype=np.float32)
        return obs

    def step(self, action):
        current_price = float(self.df.iloc[self.current_step]['Close'])
        self.current_step += 1

        # 売買ロジック
        if action == 1 and self.cash >= current_price:  # 買い
            self.shares_held += 1
            self.cash -= current_price
        elif action == 2 and self.shares_held > 0:  # 売り
            self.shares_held -= 1
            self.cash += current_price

        # 総資産計算
        total_value = self.cash + (self.shares_held * current_price)
        reward = total_value - 10000
        self.total_reward += reward

        # 資産履歴を保存
        self.history.append(total_value)

        done = self.current_step >= len(self.df) - 1
        return self._next_observation(), reward, done, {}

    def get_equity_curve(self):
        """ 累積リターンを取得 """
        return self.history
```

DDQNエージェントを作成し、学習を行います。

```python
class DDQNAgent:
    def __init__(self, state_size, action_size):
        self.state_size = state_size
        self.action_size = action_size
        self.memory = deque(maxlen=1000)
        self.gamma = 0.95
        self.epsilon = 1.0
        self.epsilon_min = 0.01
        self.epsilon_decay = 0.995
        self.learning_rate = 0.001
        self.model = self._build_model()
        self.target_model = self._build_model()
        self.update_target_model()

    def _build_model(self):
        model = Sequential([
            Dense(24, input_dim=self.state_size, activation="relu"),
            Dense(24, activation="relu"),
            Dense(self.action_size, activation="linear")
        ])
        model.compile(loss="mse", optimizer=Adam(learning_rate=self.learning_rate))
        return model

    def update_target_model(self):
        self.target_model.set_weights(self.model.get_weights())

    def act(self, state):
        if np.random.rand() <= self.epsilon:
            return random.randrange(self.action_size)
        q_values = self.model.predict(state)
        return np.argmax(q_values[0])

    def train(self, batch_size=32):
        minibatch = random.sample(self.memory, batch_size)
        for state, action, reward, next_state, done in minibatch:
            target = reward
            if not done:
                target = reward + self.gamma * np.amax(self.target_model.predict(next_state)[0])
            target_f = self.model.predict(state)
            target_f[0][action] = target
            self.model.fit(state, target_f, epochs=1, verbose=0)
        if self.epsilon > self.epsilon_min:
            self.epsilon *= self.epsilon_decay
```

上記で定義したエージェントを実際に学習させて、評価します。

```python
agent = DDQNAgent(state_size=3, action_size=3)
episodes = 100

for e in range(episodes):
    state = env.reset().reshape(1, 3)
    done = False
    total_reward = 0

    while not done:
        action = agent.act(state)
        next_state, reward, done, _ = env.step(action)
        next_state = next_state.reshape(1, 3)
        agent.memory.append((state, action, reward, next_state, done))
        state = next_state
        total_reward += reward

    agent.train()
    agent.update_target_model()
    print(f"Episode {e+1}/{episodes}, Total Reward: {total_reward}")
```

エージェントの学習には時間がかかるので、学習させたモデルを保存しておきます。

```python
# keras 形式で保存
agent.model.save("ddqn_trading_model.keras")
```

エージェントの資産推移をグラフ化するための関数を定義します。

```python
import matplotlib.pyplot as plt

def plot_equity_curve(env):
    equity_curve = env.get_equity_curve()

    plt.figure(figsize=(10, 5))
    plt.plot(equity_curve, label="Total Asset ($)")
    plt.axhline(y=10000, color='r', linestyle='--', label="Initial Capital ($10,000)")
    plt.xlabel("Time Steps")
    plt.ylabel("Total Asset ($)")
    plt.title("Cumulative Return (Equity Curve)")
    plt.legend()
    plt.grid()
    plt.show()
```

学習済みモデルを読み込み、検証用データ（2023年）で検証していきます。

```python
# 1. 学習後のモデルを保存（.keras 形式）
agent.model.save("ddqn_trading_model.keras")

# 2. 検証データを使った評価
from tensorflow.keras.models import load_model

# 学習済みモデルをロード
trained_model = load_model("ddqn_trading_model.keras")

# 検証用のエージェント（同じネットワーク構造）
test_agent = DDQNAgent(state_size=3, action_size=3)
test_agent.model = trained_model  # モデルを置き換え

# 検証用の環境を作成
test_env = StockTradingEnv(test_data)

# 検証開始
state = test_env.reset().reshape(1, 3)
done = False

while not done:
    action = test_agent.act(state)  # エージェントの予測
    next_state, _, done, _ = test_env.step(action)
    state = next_state.reshape(1, 3)

# 検証後に累積リターンをプロット
plot_equity_curve(test_env)

```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3457746/59f3f465-e730-4caf-9046-bae533a7206d.png)


元の価格の推移も可視化しておきます。

```python
plt.figure(figsize=(10, 5))
plt.plot(test_data['Close'], label="Close Price")
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3457746/42e63065-d764-4c14-bb0f-eabb27fb218e.png)

検証データでのパフォーマンスを定量的に評価するために、各指標を計算します。

```python
import numpy as np

# 累積リターンの取得
equity_curve = np.array(test_env.get_equity_curve())

# ① リターンの計算
returns = np.diff(equity_curve) / equity_curve[:-1]  # 日次リターン

# ② 平均リターン
mean_return = np.mean(returns)

# ③ 標準偏差（リスク）
std_return = np.std(returns)

# ④ シャープレシオ（リスク調整後のリターン）
risk_free_rate = 0.02 / 252  # 年率2%を1日あたりに換算
sharpe_ratio = (mean_return - risk_free_rate) / std_return

# ⑤ 最大ドローダウン
cumulative_returns = (equity_curve - 10000) / 10000  # 累積リターン
peak = np.maximum.accumulate(cumulative_returns)
drawdown = (peak - cumulative_returns)
max_drawdown = np.max(drawdown)

# ⑥ 勝率（トレードの成功率）
winning_trades = np.sum(returns > 0)
total_trades = len(returns)
win_rate = winning_trades / total_trades

# 結果を表示
print(f"✅ 平均リターン: {mean_return:.5f}")
print(f"✅ 標準偏差（リスク）: {std_return:.5f}")
print(f"✅ シャープレシオ: {sharpe_ratio:.2f}")
print(f"✅ 最大ドローダウン: {max_drawdown:.2f}")
print(f"✅ 勝率: {win_rate:.2%}")
```

```
✅ 平均リターン: 0.00026
✅ 標準偏差（リスク）: 0.00235
✅ シャープレシオ: 0.08
✅ 最大ドローダウン: 0.04
✅ 勝率: 50.40%
```

# 考察
一つ目のグラフを見てみると、最終的にはそれなりに利益が出ているように見えますが、元の価格推移と比較すると、めちゃくちゃ相関しているように見えます。

各指標を見てみても、

- 平均リターンが**低い**：利益は出ているが、小さい
- 標準偏差が**低い**：リスクが低い
- シャープレシオが**低い**：リスクに見合ったリターンが得られていない
- 最大ドローダウンが**約4%**：許容範囲内だが、改善の余地がある
- 勝率が**約50%**：ほぼランダム

という結果になり、まだ学習が足りていないような気がしますね。

# おわりに
まだまだ改善の余地がありそうなので、もう少し改善してみようと思います