DQNでトレードの売買判断を行うAIの試作 〜データ準備編〜

:::note warn
本記事で紹介している内容は、DQN（ディープQネットワーク）を用いた日経平均トレードの技術的な解説およびシミュレーション事例であり、特定の投資行動や金融商品の購入・売却を勧誘するものではありません。
また、記載された運用成績や利回りは過去のバックテストまたはシミュレーション結果に基づいており、将来の成果を保証するものではありません。
投資には元本割れや損失が発生するリスクがあり、最終的な投資判断はご自身の責任でお願いいたします。
本記事の内容を参考にして生じたいかなる損失についても、一切の責任を負いかねます。
投資にあたっては、必ずご自身で十分な調査・ご判断のうえ、必要に応じて専門家等にご相談ください。
:::

## 関連記事一覧

1.（本記事）DQNでトレードの売買判断を行うAIの試作 〜データ準備編〜
[2. DQNでトレードの売買判断を行うAIの試作 〜環境・トレーニング編〜](https://qiita.com/ssc-yshikeda/items/4469ef1272fe380b17a3)
[3. DQNでトレードの売買判断を行うAIの試作 〜性能測定編〜](https://qiita.com/ssc-yshikeda/items/e2b4919e1f5548523028)

## はじめに

「株価の動きが予測できたら...」

そんな夢を持ったことはありませんか？しかし、人間の目で判断するのは難しく、感情に左右されがちです。そこでAIを使って株価の動きを予測してみることにしました。特に強化学習は、不確実な市場環境で「経験を積みながら学習する」という点で、株式投資と相性が良さそうです。

この記事では、Deep Q-Network (DQN)という強化学習手法を使って、日経平均の売買判断を行うAIモデルを作る過程を、特にデータ準備の観点からお話しします。

## 強化学習とDQNって何？

強化学習は、簡単に言うと「試行錯誤しながら最適な行動を学ぶ」方法です。例えば、子供が自転車の乗り方を覚えるとき、何度も転びながらバランスの取り方を学んでいきますよね。それと同じです。

DQNは強化学習の一種で、ディープラーニングの力を借りて、より複雑な状況でも最適な行動を学習できるようにしたものです。株式市場のような複雑な環境では、特に以下の点で役立ちます：

1. 株価や指標など、たくさんの数値データを同時に扱える
2. 株価の動きに潜む複雑なパターンを見つけられる
3. 時間とともに変化するデータの特徴を捉えられる

## なぜデータ準備が大切なの？

「機械学習の成功の8割はデータの質で決まる」とよく言われます。特に株式市場のような複雑な環境では、AIに「何を見せるか」が重要です。

人間の投資家が株価チャートだけでなく、様々な経済指標や企業情報を参考にするように、AIにも多角的な情報を与える必要があります。また、生のデータをそのまま与えるのではなく、AIが理解しやすい形に整形することも大切です。

「どんなデータをどのように準備すれば、AIが株価の動きを理解しやすくなるか」を考えながら実装しましょう。

## データ収集：何を見せる？どこから取る？

AIトレーダーを作るには、まず「何を見せるか」を決める必要があります。人間のトレーダーが見ているものを考えてみましょう：

- 株価のチャート（高値、安値、始値、終値）
- 移動平均線やRSIなどのテクニカル指標
- 金利や為替などの経済指標
- 市場のセンチメント（恐怖指数など）

これらをAIに提供できれば、人間に近い判断ができるかもしれません。

まずはライブラリのインポートをしましょう。
```
pip install pandas_datareader yfinance pandas numpy
```

`data.py`では、これらのデータを収集・加工する処理を実装していきます。コードの最初はこんな感じです：

```python
import pandas_datareader.data as web
import yfinance as yf
import pandas as pd
import numpy as np

def generate_env_data(start, end, ticker="^N225", manual_data=None):
    # 株価データの取得
    test_data = yf.download(ticker, start=start, end=end)
    test_data.columns = test_data.columns.get_level_values(0)
    
    # 以下、各種データの取得と結合、テクニカル指標の計算...
```

### 1. 株価データの取得：Yahoo Financeの活用

まず基本となる株価データですが、無料で使えるYahoo Finance APIを利用しました。このAPIは世界中の株価指数や個別株、為替レートなどを簡単に取得できる便利なツールです。

```python
test_data = yf.download(ticker, start=start, end=end)
```

ここで`ticker`というパラメータに注目してください。これは取得したい銘柄のシンボルを指定するもので、例えば：
- `^N225`：日経平均株価
- `^GSPC`：S&P500指数
- `JPY=X`：米ドル/円の為替レート

と指定できます。様々な市場でテストできるよう、柔軟に銘柄を変更できる設計にしました。

### 2. 経済指標データの取得：金利とVIX

株価だけでは不十分です。マクロ経済の状況も株価に大きな影響を与えるからです。特に重要なのが金利と市場のセンチメントです。

#### 金利データ：FREDからの取得
金利は株式市場に大きな影響を与えます。金利が上がると企業の借入コストが増加し、株価が下がる傾向があります。また、日米の金利差は為替レートにも影響します。

そこで、pandas_datareader というPythonライブラリのAPIを使い、米国と日本の金利データを取得しました。

```python
# 米国金利
us_rate = web.DataReader("FEDFUNDS", "fred", start, end)
us_rate.rename(columns={"FEDFUNDS": "US_10Y_Rate"}, inplace=True)
date_range = pd.date_range(start=start, end=end, freq="D")
us_rate = us_rate.reindex(date_range).ffill()

# 日本金利
jp_rate = web.DataReader("IRLTLT01JPM156N", "fred", start, end)
jp_rate.rename(columns={"IRLTLT01JPM156N": "Japan_10Y_Rate"}, inplace=True)
jp_rate = jp_rate.reindex(date_range).ffill()
```

ここで注目してほしいのは`ffill()`という処理です。金利データは毎日更新されるわけではないので、データが欠損している日は直前の値で埋める（Forward Fill）処理をしています。これにより、AIに与えるデータに穴がなくなります。

#### 恐怖指数（VIX）：市場心理の数値化
VIXは「恐怖指数」とも呼ばれ、市場の不安感を数値化したものです。VIXが高いときは市場が不安定で、低いときは安定していることを示します。

```python
vix_data = yf.download("^VIX", start=start, end=end)[["Close"]]
vix_data.rename(columns={"Close": "VIX"}, inplace=True)
```

VIXを取り入れることで、AIに「今、市場はどれくらい不安定なのか」という情報を与えることができます。相場が荒れているときは慎重に、安定しているときは積極的に投資するといった判断ができるようになるかもしれません。

### 3. テクニカル指標の計算：AIのための「チャート分析力」

人間のトレーダーがチャートを見るとき、様々なテクニカル指標を参考にします。AIにもそれらの指標を「見せる」ことで、より高度な分析ができるようになるでしょう。

#### 移動平均線（SMA）：トレンドを捉える
移動平均線は、株価のノイズを除去してトレンドを見やすくする基本的な指標です。短期、中期、長期の3種類の移動平均線を計算することで、異なる時間軸でのトレンドを捉えられるようにしました：

```python
test_data["SMA_5"] = test_data["Open"].rolling(window=5).mean()
test_data["SMA_25"] = test_data["Open"].rolling(window=25).mean()
test_data["SMA_75"] = test_data["Open"].rolling(window=75).mean()
```

短期（5日）の移動平均線が中期（25日）の線を上から下に抜けると「デッドクロス」、下から上に抜けると「ゴールデンクロス」と呼ばれ、売買シグナルとして使われます。AIにもこれらのパターンを学習してほしいと考えました。

#### ボリンジャーバンド：価格変動の「異常さ」を測る
ボリンジャーバンドは、移動平均線を中心に標準偏差の倍数で上下のバンドを設定し、価格がどれだけ普段と違ったな動きをしているかを視覚化する指標です：

```python
test_data["STD_25"] = test_data["Open"].rolling(window=25).std()
test_data["Upper_3σ"] = test_data["SMA_25"] + 3 * test_data["STD_25"]
test_data["Lower_3σ"] = test_data["SMA_25"] - 3 * test_data["STD_25"]
# 2σ、1σのバンドも同様に計算
```

株価が上側のバンド（Upper_3σ）に達すると「買われすぎ」、下側のバンド（Lower_3σ）に達すると「売られすぎ」と判断できます。1σ、2σ、3σと複数のバンドを用意することで、買われすぎ・売られすぎ度合いをより細かく判断できるようにしました。

#### 偏差値：相対的な位置を数値化
偏差値は、現在の株価が過去の分布の中でどの位置にあるかを示す指標です。学校のテストの偏差値と同じ考え方ですね：

```python
test_data["偏差値25"] = 50 + 10 * (
    (test_data["Open"] - test_data["SMA_25"]) / test_data["STD_25"]
)
```

偏差値が70を超えると「高すぎる（買われすぎ）」、30を下回ると「低すぎる（売られすぎ）」と判断できます。AIにこの感覚を身につけてほしいと考えました。

#### RSI（相対力指数）：買われすぎ・売られすぎを判断
RSIは、一定期間における値上がりの大きさと値下がりの大きさを比較して、現在の価格が買われすぎか売られすぎかを判断する指標です：

```python
def calc_rsi(series, period):
    delta = series.diff()  # 前日との差分
    gain = delta.where(delta > 0, 0.0)  # 上昇分
    loss = -delta.where(delta < 0, 0.0)  # 下落分（正の値に変換）
    avg_gain = gain.rolling(window=period, min_periods=period).mean()  # 平均上昇
    avg_loss = loss.rolling(window=period, min_periods=period).mean()  # 平均下落
    rs = avg_gain / avg_loss  # 相対力指数
    return 100 - (100 / (1 + rs))  # RSI値の計算

test_data["RSI_14"] = calc_rsi(test_data["Open"], 14)
test_data["RSI_22"] = calc_rsi(test_data["Open"], 22)
```

RSIは0〜100の値を取り、一般的に70以上で「買われすぎ」、30以下で「売られすぎ」と判断します。14日と22日の2種類のRSIを計算することで、短期と中期の両方の視点を持たせました。

#### MACD（移動平均収束拡散）：トレンドの転換点を捉える
MACDは、短期と長期の指数移動平均（EMA）の差を取ることで、トレンドの転換点を捉える指標です：

```python
test_data["EMA12"] = test_data["Open"].ewm(span=12, adjust=False).mean()
test_data["EMA26"] = test_data["Open"].ewm(span=26, adjust=False).mean()
test_data["MACD"] = test_data["EMA12"] - test_data["EMA26"]
test_data["MACD_signal"] = test_data["MACD"].ewm(span=9, adjust=False).mean()
```

MACDがシグナル線を下から上に抜けると買いシグナル、上から下に抜けると売りシグナルとされます。単純な移動平均線よりも早くトレンド転換を捉えられる特徴があります。

#### RCI（ランク相関指数）：価格の順位変化を捉える
RCIは、価格の順位変化を統計的に分析する指標です。価格の動きが時間に対してどれだけ一貫しているかを示すため、トレンドの強さを捉えるのに役立ちます：

```python
def calc_rci(series, period):
    def rci_calc(arr):
        N = len(arr)
        order = np.arange(1, N + 1)  # 時間順の順位（1,2,3...）
        rank_ = pd.Series(arr).rank(method="first").values  # 価格の順位
        d = order - rank_  # 順位の差
        # スピアマンの順位相関係数の計算式
        return (1 - 6 * np.sum(d**2) / (N * (N**2 - 1))) * 100

    return series.rolling(window=period).apply(rci_calc, raw=True)

test_data["RCI_9"] = calc_rci(test_data["Open"], 9)
test_data["RCI_26"] = calc_rci(test_data["Open"], 26)
```

RCIは-100〜+100の値を取り、+80以上で「買われすぎ」、-80以下で「売られすぎ」と判断できます。9日と26日の2種類のRCIを計算することで、短期と中期の両方の視点を持たせました。

#### ATR（Average True Range）：ボラティリティを測る
ATRは、価格の変動幅（ボラティリティ）を測る指標です。相場の荒さを数値化することで、リスク管理やポジションサイズの決定に役立ちます：

```python
# 前日のOpenを取得
test_data["Previous_Open"] = test_data["Open"].shift(1)
# True Range (TR) の各構成要素を計算
tr1 = test_data["High"] - test_data["Low"]  # 当日の高値と安値の差
tr2 = (test_data["High"] - test_data["Previous_Open"]).abs()  # 当日高値と前日始値の差
tr3 = (test_data["Low"] - test_data["Previous_Open"]).abs()  # 当日安値と前日始値の差
# 各日のTRは3要素の中で最大の値
test_data["TR"] = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1)
# ATRはTRの単純移動平均値
test_data["ATR_5"] = test_data["TR"].rolling(window=5).mean()
test_data["ATR_25"] = test_data["TR"].rolling(window=25).mean()
```

ATRが大きいほど相場が荒く、小さいほど落ち着いていることを示します。相場が荒いときは慎重に、落ち着いているときはより積極的に取引するといった判断ができるようになります。

### data.pyの全体
それでは今まであげたコードをまとめたものを見せます。
```python
import pandas_datareader.data as web
import yfinance as yf
import pandas as pd
import numpy as np

def generate_env_data(start, end, ticker="^N225", manual_data=None):
    # ^N225の取得
    test_data = yf.download(ticker, start=start, end=end)
    # ダウンロード直後にカラムをフラット化する
    test_data.columns = test_data.columns.get_level_values(0)

    # -------------------------
    # 金利データ(FRED)の取得
    # -------------------------
    us_rate = web.DataReader("FEDFUNDS", "fred", start, end)
    us_rate.rename(columns={"FEDFUNDS": "US_10Y_Rate"}, inplace=True)
    us_rate.index.name = "Date"
    date_range = pd.date_range(start=start, end=end, freq="D")
    us_rate = us_rate.reindex(date_range).ffill()

    jp_rate = web.DataReader("IRLTLT01JPM156N", "fred", start, end)
    jp_rate.rename(columns={"IRLTLT01JPM156N": "Japan_10Y_Rate"}, inplace=True)
    jp_rate.index.name = "Date"
    date_range = pd.date_range(start=start, end=end, freq="D")
    jp_rate = jp_rate.reindex(date_range).ffill()

    # 3. アメリカの恐怖指数 VIX のデータを取得（終値を使用）
    vix_data = yf.download("^VIX", start=start, end=end)[["Close"]]
    vix_data.rename(columns={"Close": "VIX"}, inplace=True)
    vix_data.columns = vix_data.columns.get_level_values(0)
    test_data = test_data.join(vix_data, how="left")

    rate_data = pd.merge(
        jp_rate, us_rate, left_index=True, right_index=True, how="left"
    )
    test_data = pd.merge(
        test_data, rate_data, left_index=True, right_index=True, how="left"
    )
    # 手動データがある場合は追加
    if manual_data is not None:
        manual_data = pd.DataFrame(manual_data)
        manual_data.index = pd.to_datetime(manual_data.index)  # 日付データを適切に変換
        test_data = pd.concat([test_data, manual_data])

    # テクニカル指標の計算（例ではOpenを使用）
    test_data["SMA_5"] = test_data["Open"].rolling(window=5).mean()
    test_data["SMA_25"] = test_data["Open"].rolling(window=25).mean()
    test_data["SMA_75"] = test_data["Open"].rolling(window=75).mean()
    test_data["STD_25"] = test_data["Open"].rolling(window=25).std()
    test_data["Upper_3σ"] = test_data["SMA_25"] + 3 * test_data["STD_25"]
    test_data["Lower_3σ"] = test_data["SMA_25"] - 3 * test_data["STD_25"]
    test_data["Upper_2σ"] = test_data["SMA_25"] + 2 * test_data["STD_25"]
    test_data["Lower_2σ"] = test_data["SMA_25"] - 2 * test_data["STD_25"]
    test_data["Upper_1σ"] = test_data["SMA_25"] + 1 * test_data["STD_25"]
    test_data["Lower_1σ"] = test_data["SMA_25"] - 1 * test_data["STD_25"]
    test_data["偏差値25"] = 50 + 10 * (
        (test_data["Open"] - test_data["SMA_25"]) / test_data["STD_25"]
    )

    test_data["STD_75"] = test_data["Open"].rolling(window=75).std()
    test_data["Upper2_3σ"] = test_data["SMA_75"] + 3 * test_data["STD_75"]
    test_data["Lower2_3σ"] = test_data["SMA_75"] - 3 * test_data["STD_75"]
    test_data["Upper2_2σ"] = test_data["SMA_75"] + 2 * test_data["STD_75"]
    test_data["Lower2_2σ"] = test_data["SMA_75"] - 2 * test_data["STD_75"]
    test_data["Upper2_1σ"] = test_data["SMA_75"] + 1 * test_data["STD_75"]
    test_data["Lower2_1σ"] = test_data["SMA_75"] - 1 * test_data["STD_75"]
    test_data["偏差値75"] = 50 + 10 * (
        (test_data["Open"] - test_data["SMA_75"]) / test_data["STD_75"]
    )

    # -------------------------
    # RSIの計算
    # -------------------------
    def calc_rsi(series, period):
        delta = series.diff()
        gain = delta.where(delta > 0, 0.0)
        loss = -delta.where(delta < 0, 0.0)
        avg_gain = gain.rolling(window=period, min_periods=period).mean()
        avg_loss = loss.rolling(window=period, min_periods=period).mean()
        rs = avg_gain / avg_loss
        return 100 - (100 / (1 + rs))

    test_data["RSI_14"] = calc_rsi(test_data["Open"], 14)
    test_data["RSI_22"] = calc_rsi(test_data["Open"], 22)

    # -------------------------
    # MACDの計算
    # -------------------------
    test_data["EMA12"] = test_data["Open"].ewm(span=12, adjust=False).mean()
    test_data["EMA26"] = test_data["Open"].ewm(span=26, adjust=False).mean()
    test_data["MACD"] = test_data["EMA12"] - test_data["EMA26"]
    test_data["MACD_signal"] = test_data["MACD"].ewm(span=9, adjust=False).mean()

    # -------------------------
    # RCIの計算 (9日と26日)
    # -------------------------
    def calc_rci(series, period):
        def rci_calc(arr):
            N = len(arr)
            order = np.arange(1, N + 1)
            rank_ = pd.Series(arr).rank(method="first").values
            d = order - rank_
            return (1 - 6 * np.sum(d**2) / (N * (N**2 - 1))) * 100

        return series.rolling(window=period).apply(rci_calc, raw=True)

    test_data["RCI_9"] = calc_rci(test_data["Open"], 9)
    test_data["RCI_26"] = calc_rci(test_data["Open"], 26)

    # ATR（Average True Range）の計算 (5日と25日)
    # 前日のOpenを取得
    test_data["Previous_Open"] = test_data["Open"].shift(1)
    # True Range (TR) の各構成要素を計算
    tr1 = test_data["High"] - test_data["Low"]
    tr2 = (test_data["High"] - test_data["Previous_Open"]).abs()
    tr3 = (test_data["Low"] - test_data["Previous_Open"]).abs()
    # 各日のTRは3要素の中で最大の値
    test_data["TR"] = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1)
    # ATRはTRの単純移動平均値
    test_data["ATR_5"] = test_data["TR"].rolling(window=5).mean()
    test_data["ATR_25"] = test_data["TR"].rolling(window=25).mean()
    # 途中計算用のカラム（例: Previous_Open）は削除
    test_data.drop(columns=["Previous_Open"], inplace=True)

    print(test_data)
    test_data.to_csv("test_data.csv")
    return test_data
```

## AIトレーダーの環境づくり：強化学習の世界

さて、ここまでで様々なデータを集めてきましたが、これらをどのようにAIに「見せる」のでしょうか？強化学習では、「環境」を作ることが重要です。詳しくは次回話しますが、ここではかいつまんで紹介します。

`NikkeiEnv`クラスは、株式市場をシミュレートする環境を提供します。AIはこの環境の中で取引を行い、その結果に応じて報酬を得ながら学習していきます。

```python
class NikkeiEnv(gym.Env):
    def __init__(
        self,
        df,
        window_size=30,
        transaction_cost=0.001,
        risk_limit=0.5,
        trade_penalty=0.002,
    ):
        # ...
        self.feature_cols = [
            "Open", "SMA_5", "SMA_25", "SMA_75",
            "Upper_3σ", "Upper_2σ", "Upper_1σ",
            "Lower_3σ", "Lower_2σ", "Lower_1σ",
            "偏差値25", "Upper2_3σ", "Upper2_2σ", "Upper2_1σ",
            "Lower2_3σ", "Lower2_2σ", "Lower2_1σ",
            "偏差値75", "RSI_14", "RSI_22",
            "MACD", "MACD_signal", "Japan_10Y_Rate", "US_10Y_Rate",
            "ATR_5", "ATR_25", "RCI_9", "RCI_26", "VIX",
        ]
        # ...
```

この環境には3つの重要な要素があります：

1. **観測空間（状態）**: AIが「見る」情報。直近130日間の各種指標データ
2. **行動空間**: AIが取れる行動。0（買い＝ロング）、1（様子見＝フラット）、2（売り＝ショート）の3種類
3. **報酬**: AIの行動に対するフィードバック。ここでは1日分の利益率（対数リターン）

特に注目してほしいのは、取引コスト（transaction_cost）とリスク制限（risk_limit）のパラメータです。現実の取引では手数料がかかりますし、資産が一定以上減ると取引を続けられなくなります。これらの要素を環境に組み込むことで、より現実的な学習ができるようになります。

## データの正規化：AIに理解しやすい形に

人間は「日経平均が30,000円」と聞けば「高い水準」だと理解できますが、AIにとっては単なる数値です。そこで、データを0〜1の範囲に正規化する必要があります。

このモデルでは、MinMax法という手法を使って、各特徴量をウィンドウ内（例：直近130日間）で相対的に正規化しています：

```python
def _get_observation(self):
    # MIXMAX法
    obs = []
    for col in self.feature_cols:
        # 現在のウィンドウの値を取得
        window = self.data[col][
            self.current_step - self.window_size : self.current_step
        ]
        # ウィンドウ内の最小値・最大値を計算：　MinMax法
        min_val = np.min(window)
        max_val = np.max(window)
        # ゼロ除算を防ぐため、最大値と最小値が等しい場合の処理
        if max_val - min_val == 0:
            norm = np.zeros_like(window)
        else:
            norm = (window - min_val) / (max_val - min_val)
        # shape (window_size, 1) に整形してリストに追加
        obs.append(norm.reshape(-1, 1))
    # 各特徴量ごとの正規化済みデータを連結
    observation = np.concatenate(obs, axis=1).astype(np.float32)
    return observation
```

この正規化には重要な意味があります。例えば、日経平均が20,000円から20,040円に上昇した場合と、RSIが30から70に上昇した場合では、後者の方が投資判断としては重要かもしれません。正規化することで、異なるスケールの指標を公平に扱えるようになります。

また、ウィンドウ内での相対的な正規化を行うことで、「最近の相場環境における相対的な位置」を捉えられるようになります。例えば、絶対値では高くても、最近の相場環境では「普通」かもしれないという判断ができるようになります。

## まとめ：データ準備の重要性

今回は、DQNを用いた株価テクニカル分析AIモデルのデータ準備について解説しました。ポイントをまとめると：

1. **多角的なデータ収集**: 株価だけでなく、金利やVIXなどの経済指標も含める
2. **テクニカル指標の計算**: 移動平均線、RSI、MACDなど、人間のトレーダーが使う指標をAIにも提供
3. **データの正規化**: AIが理解しやすいよう、データを0〜1の範囲に正規化
4. **現実的な環境設計**: 取引コストやリスク制限を考慮した環境を作成

これらの工夫により、AIがより効果的に株価の動きを学習できるようになります。データ準備は地味な作業に思えるかもしれませんが、AIの性能を大きく左右する重要なステップです。

次回は、ResNetを用いた特徴抽出器の実装について詳しく解説する予定です。AIがどのようにしてこれらのデータから取引戦略を学習していくのか、その仕組みに迫ります。