2026-04-06

自動売買への道 (2026-04-06)

楽天証券の口座でデイトレの自動売買に挑戦しようと、Windows / Excel 上で動作する マーケットスピード II RSS を利用した Python アプリ (Kabuto) を開発しています。未だ安心して自動売買できるレベルにまで到達していませんが、経験値を上げるため、セミオートでデイトレードを始めています。

今日の日経平均株価

現在値 53,413.68 +290.19 +0.55% 15:45
前日終値 53,123.49 04/03 高値 54,039.34 11:24
始値 53,205.93 09:00 安値 53,205.93 09:00

※ 右の 15 分足チャートは Yahoo! Finance のデータを yfinance で取得して作成しました。

【関連ニュース】

デイトレ用自作アプリ

以下は株価・取引に関連する情報の流れを示しています。

株価データ・取引の流れ(Windows 11)

楽天証券では、Python からネットワーク越しに直接取引できるような API が提供されていないので、マーケットスピード II RSS を介して Python アプリから取引をする構成を取っています。

強化学習へ沼りに行く

週末に再開した強化学習モデルの開発に熱中してしまって週末だけでは時間が足りませんでした。確認したいことが残っていたので、今日は強化学習モデルの開発を続けることにして、デイトレ用アプリの Kabuto をティックデータ収集目的で稼働させました。

ただし、稼働中にチャートを表示する銘柄を 3 銘柄に増やして負荷の確認をしました。

大引け後のアプリの株価トレンド画面

Google Gemini とのやりとり

今日のデイトレを止めてまで強化学習で確認したかったことは、下記のやり取り(抜粋)に起因します。

Stable Baselines3 の VecNormalize ラッパー利用を前提に、観測値空間を下記のように設定することはアリですか?なお、観測値空間の次元は前回より増えています。MA1 - VWAP の乖離率を追加しました。

self.observation_space = spaces.Box(
    low=np.array([
        -np.float32('inf'),
        -np.float32('inf'),
        -np.float32('inf'),
    ]),
    high=np.array([
        np.float32('inf'),
        np.float32('inf'),
        np.float32('inf'),
    ]),
    shape=(3,),
    dtype=np.float32
)

Stable Baselines3(SB3)の VecNormalize ラッパーを利用することを前提とするならば、その設定は非常に理にかなっており、「大いにアリ」な設計です。

技術的な観点から、なぜその設定が適切なのか、そして注意すべき点について解説します。

1. なぜ inf(無限大)設定で問題ないのか

VecNormalize を使用する場合、環境から出力された生の値(Raw data)は、ラッパー内部で計算される「移動平均」と「標準偏差」に基づいて、リアルタイムに正規化(標準化)されます。

  • 空間定義の役割: spaces.Box の low や high は、多くの場合「その範囲外の値をクリップ(制限)するかどうか」や、一部のアルゴリズムでの初期化に使われます。
  • VecNormalize との相性: VecNormalize は入力されるデータの分布を統計的に処理するため、元の空間が -inf から inf であっても、最終的にニューラルネットワークに渡される値は平均0、分散1程度の扱いやすい範囲に収まります。したがって、具体的な境界値が決めにくい株価や指標において、無理に値を決めるよりも inf にしておくのは標準的なアプローチです。

結論

VecNormalize にスケーリングを任せることで、生データの桁数(株価の4桁、乖離率の0.0x桁など)の差を気にせずに学習させることが可能になります。観測値空間を inf で定義し、正規化をラッパーに委ねる手法は、柔軟性が高く実戦的なアプローチと言えます。

 

去年、強化学習モデルの開発に取り組んでいた時、観測値の標準化(スケーリング)あるいはクリッピング処理に大いに悩まされました。当時、スケーリングをラッパーに委ねようとした時に VecNormalize が候補に上がったのですが、使い方がいまひとつ判りませんでした。

自分の場合、複数の環境をベクトル化して学習させることではないと、ベクトル化するラッパーの利用を敬遠して、特徴量毎に自力でスケーリングの調整をしようとしました。これはなかなか骨が折れる作業で、あれこれ試行錯誤しているうちに何をやっているのかがあやふやになった上に、モデルの学習が進まなくなってしまいました。

今回の再開では、同じようにスケーリングに悩むことがないように注意をしていましたが、調べるうちに複数の環境をベクトル化するラッパー群に行き着いてしまいました。どうやらスケーリングの手間から開放されたければ VecNormalize ラッパーの利用が近道のようです。英文を面倒くさがらずにじっくり読み込んで、使いこなせるようになることを目指します。

標準化がベストかどうかの議論は残しておくにしても、ラッパー側で一貫した標準化処理をしてくれるのであれば、特徴量を開発する負荷をかなり減らせます。

問題

特徴量として乖離率 (MA1 - VWAP) / VWAP を状態に追加して、エントリ時にこの値を報酬あるいはペナルティに反映するようにしました。

問題
  • 報酬の最大化
  • 報酬と収益が概ね比例するように考慮
状態 [Observation]
  1. Price(株価)
  2. Profit(含み損益)
  3. Diff(乖離率 : (MA1 - VWAP) / VWAP)
行動 [Action]
  1. HOLD(何もしない)
  2. BUY(買建または返済)
  3. SELL(売建または返済)
返済ロジックは環境側で制御
ナンピン禁止は行動マスクで処理
報酬 [Reward]
  • 建玉時、含み損益の一定割合を付与
  • 買建時、-Diff を付与
  • 売建時、+Diff を付与
  • 返済時、直前の含み損益を付与
  • 約定コスト : 建玉、返済時いずれも固定の約定コストを引く
終了条件
  1. terminated
    • なし
  2. truncated
    • ティックデータが最終行に達した時
    • 終了時、建玉があれば強制返済。報酬条件、約定コストは同じ。

結果

メインのアプリ Kabuto では、短周期の移動平均線 MA1 と、出来高加重平均価格 VWAP を情報に取り入れているので、クロス・シグナルではなく MA1 - VWAP の乖離率をエントリ時の報酬(ペナルティ)に加えてみました。

コードはますます長くなったので、最後にまとめることにして。結果をまとめました。

学習曲線

100 回程度のエピソードになるようにして、固定銘柄のある日のティックデータについて学習したときの報酬トレンド = 学習曲線を確認しました。

100 エピソード程度の学習時の報酬トレンド(学習曲線)

モデルの報酬がプラスになれば収益もプラスになるようには設計していないので、相対的な変化を見るべきです。とは言え、学習時の収益を取得して報酬との相関関係を評価していないので、今のうちに評価ができるようにしておきたいです。

損益(シミュレーション)

スリッページを考慮せず、現在値で売買が成立すると仮定したときの、売買シミュレーションの損益結果です。

    注文番号                          注文日時 銘柄コード  売買    約定単価  約定数量    損益    備考
0      1 2026-04-01 09:10:15.457420111  9984  売建  3836.0     1   NaN      
1      2 2026-04-01 09:10:35.461060047  9984  買埋  3807.0     1  29.0      
2      3 2026-04-01 09:10:37.456360102  9984  買建  3806.0     1   NaN      
3      4 2026-04-01 09:10:39.466429949  9984  売埋  3807.0     1   1.0      
4      5 2026-04-01 09:10:41.459969997  9984  売建  3805.0     1   NaN      
5      6 2026-04-01 09:11:29.545860052  9984  買埋  3789.0     1  16.0      
6      7 2026-04-01 09:11:31.538379908  9984  売建  3785.0     1   NaN      
7      8 2026-04-01 09:12:01.601809978  9984  買埋  3771.0     1  14.0      
8      9 2026-04-01 09:12:29.618880033  9984  売建  3789.0     1   NaN      
9     10 2026-04-01 09:13:59.748509884  9984  買埋  3779.0     1  10.0      
10    11 2026-04-01 09:14:05.747400045  9984  売建  3774.0     1   NaN      
11    12 2026-04-01 09:15:09.858330011  9984  買埋  3759.0     1  15.0      
12    13 2026-04-01 09:15:11.847739935  9984  売建  3763.0     1   NaN      
13    14 2026-04-01 10:06:51.597110033  9984  買埋  3755.0     1   8.0      
14    15 2026-04-01 10:06:53.616209984  9984  買建  3755.0     1   NaN      
15    16 2026-04-01 10:07:05.628619909  9984  売埋  3762.0     1   7.0      
16    17 2026-04-01 10:07:07.639659882  9984  売建  3762.0     1   NaN      
17    18 2026-04-01 10:07:55.723779917  9984  買埋  3756.0     1   6.0      
18    19 2026-04-01 10:11:49.972150087  9984  買建  3739.0     1   NaN      
19    20 2026-04-01 10:13:14.020529985  9984  売埋  3747.0     1   8.0      
20    21 2026-04-01 10:13:16.033050060  9984  買建  3748.0     1   NaN      
21    22 2026-04-01 15:02:48.341360092  9984  売埋  3749.0     1   1.0      
22    23 2026-04-01 15:02:56.334330082  9984  売建  3748.0     1   NaN      
23    24 2026-04-01 15:03:12.370599985  9984  買埋  3744.0     1   4.0      
24    25 2026-04-01 15:03:14.370419979  9984  売建  3745.0     1   NaN      
25    26 2026-04-01 15:09:51.175129890  9984  買埋  3742.0     1   3.0      
26    27 2026-04-01 15:14:03.464440107  9984  売建  3748.0     1   NaN      
27    28 2026-04-01 15:24:48.622090101  9984  買埋  3759.0     1 -11.0  強制返済
モデル報酬 : -1623.94482421875,
損益 : 111.0 円, 約定係数 : 28 回

損益は 1 株当たりになっています。当日のティックデータで学習・推論なので、収益があるのは当然かもしれません。しかし、そうならないケースがあるので、最初の結果としてはまずまずだと思います。

スリッページの無い売買シミュレーション結果であっても、収益とモデル報酬との関係を調べたいので、どこまでできるか調べています。

成果

今回の成果は、Stable Baselines3(SB3)の VecNormalize ラッパーを利用することで、特徴量のスケーリングを環境のラッパー・プログラムへ委ねられるようになったことです。

コード

今回使用した Python コードの概略を示しました。

from typing import Any

import gymnasium as gym
import pandas as pd
from gymnasium import spaces
import numpy as np

from modules.posman import PositionManager
from modules.technical import MovingAverage, VWAP
from structs.app_enum import ActionType, PositionType


class TrainingEnv(gym.Env):
    # metadata defines render modes and framerate
    metadata = {"render_modes": ["human", "rgb_array"], "render_fps": 30}

    def __init__(self, code: str, df: pd.DataFrame, render_mode=None) -> None:
        super().__init__()
        self.df: pd.DataFrame = df
        self.render_mode = render_mode

        # 報酬関連
        self.pnl_total = 0
        self.ratio_profit_hold = 0.01  # HOLD 時の含み損益からの報酬比率
        self.cost_contract = 1  # 約定手数料(スリッページ相当)

        # インスタンス変数の初期化
        self.code: str = code
        self.row: int = 0
        self.position: PositionType = PositionType.NONE
        self.profit: float = 0.0

        # ポジション・マネージャ
        self.posman = posman = PositionManager()
        posman.initPosition([self.code])

        # Define action_space(行動空間)
        n_action_space = len(ActionType)
        self.action_space = spaces.Discrete(n_action_space)

        # 必要な観測値を追加
        ma1 = MovingAverage(window_size=30)
        df["MA1"] = [ma1.update(p) for p in df["Price"]]
        vwap = VWAP()
        df["VWAP"] = [vwap.update(p, v) for p, v in zip(df["Price"], df["Volume"])]
        df["Diff"] = (df["MA1"] - df["VWAP"]) / df["VWAP"] * 100

        print(df.tail())

        # Define observation_space(観測値空間)
        """
        【観測値】
        1. Price(株価)
        2. Profit(含み損益)
        3. Diff(乖離率 - (MA1 - VWAP) / VWAP)
        """
        self.observation_space = spaces.Box(
            low=np.array([
                -np.float32('inf'),
                -np.float32('inf'),
                -np.float32('inf'),
            ]),
            high=np.array([
                np.float32('inf'),
                np.float32('inf'),
                np.float32('inf'),
            ]),
            shape=(3,),
            dtype=np.float32
        )

    def action_masks(self) -> np.ndarray:
        """
        行動マスク
        【マスク】
        - ナンピン取引の禁止
        :return:
        """
        if self.position == PositionType.NONE:
            # 建玉なし → 取りうるアクション: HOLD, BUY, SELL
            return np.array([1, 1, 1], dtype=np.int8)
        elif self.position == PositionType.LONG:
            # 建玉あり LONG → 取りうるアクション: HOLD, SELL
            return np.array([1, 0, 1], dtype=np.int8)
        elif self.position == PositionType.SHORT:
            # 建玉あり SHORT → 取りうるアクション: HOLD, BUY
            return np.array([1, 1, 0], dtype=np.int8)
        else:
            raise TypeError(f"Unknown PositionType: {self.position}")

    def get_data(self, row: int) -> tuple:
        """
        ティックデータから一行抽出
        :param row:
        :return:
        """
        return self.df.iloc[row][["Time", "Price", "Diff"]]

    def get_transaction_result(self) -> pd.DataFrame:
        """
        取引結果
        :return:
        """
        return self.posman.getTransactionResult()

    def init_status(self) -> None:
        """
        初期化処理
        :return:
        """
        self.row = 0
        self.position = PositionType.NONE
        self.profit: float = 0.0
        self.pnl_total: float = 0.0
        # ポジション・マネージャのリセットと初期化
        self.posman.reset()
        self.posman.initPosition([self.code])

    def reset(self, seed=None, options=None) -> tuple[np.ndarray, dict[str, Any]]:
        """
        環境のリセット処理
        :param seed:
        :param options:
        :return:
        """
        # Mandatory: seed the random number generator
        super().reset(seed=seed)

        # Initialize your state
        _, price, diff = self.get_data(0)
        profit = 0
        observation = np.array([price, diff, profit], dtype=np.float32)
        info = {}  # Additional debug info
        self.init_status()
        return observation, info

    def step(self, action) -> tuple[np.ndarray, float, bool, bool, dict[str, Any]]:
        """
        ステップ処理
        :param action:
        :return:
        """
        # データを一行分取得
        ts, price, diff = self.get_data(self.row)

        # 含み損益
        profit = self.posman.getProfit(self.code, price)

        # 観測値
        observation = np.array([price, diff, profit], dtype=np.float32)

        # 報酬
        reward = 0

        # 建玉管理
        action_type = ActionType(action)
        if action_type == ActionType.BUY:
            if self.position == PositionType.NONE:
                # 【買建】建玉がなければ買建
                self.posman.openPosition(self.code, ts, price, action_type)
                self.position = PositionType.LONG  # ポジションを更新
                reward -= self.cost_contract  # 約定コスト
                # 買建用 VWAP 判定
                reward -= diff # diff が負の時に買建すれば報酬
            elif self.position == PositionType.SHORT:
                # 【返済】売建(ショート)であれば(買って)返済
                self.posman.closePosition(self.code, ts, price)
                self.position = PositionType.NONE  # ポジションを更新
                reward -= self.cost_contract  # 約定コスト
                reward += profit  # 含み損益分そっくり報酬
            else:
                raise "trade rule violation!"
        elif action_type == ActionType.SELL:
            if self.position == PositionType.NONE:
                # 【売建】建玉がなければ売建
                self.posman.openPosition(self.code, ts, price, action_type)
                self.position = PositionType.SHORT  # ポジションを更新
                reward -= self.cost_contract  # 約定コスト
                # 売建用 VWAP 判定
                reward += diff # diff が正の時に売建すれば報酬
            elif self.position == PositionType.LONG:
                # 【返済】買建(ロング)であれば(売って)返済
                self.posman.closePosition(self.code, ts, price)
                self.position = PositionType.NONE  # ポジションを更新
                reward -= self.cost_contract  # 約定コスト
                reward += profit  # 含み損益分そっくり報酬
            else:
                raise "trade rule violation!"
        elif action_type == ActionType.HOLD:
            if self.position != PositionType.NONE:
                # 含み益があれば幾分かを報酬に
                reward += profit * self.ratio_profit_hold
        else:
            raise f"unknown action type {action_type}!"

        # エピソード終了判定
        terminated = False  # Task finished (e.g., goal reached)
        truncated = False  # Time limit reached
        info = {}
        if len(self.df) - 1 <= self.row:
            if self.posman.hasPosition(self.code):
                reward -= self.cost_contract  # 約定コスト
                reward += profit * (1 - self.ratio_profit_hold)  # 残りの含み損益分
                self.posman.closePosition(self.code, ts, price, "強制返済")
                self.position = PositionType.NONE  # ポジションを更新

            truncated = True  # ← ステップ数上限による終了
            info["done_reason"] = "truncated: last_tick"
            info["transaction"] = self.get_transaction_result()

        self.row += 1
        return observation, reward, terminated, truncated, info

    def render(self) -> None:
        # Implement visualization logic based on self.render_mode
        pass

    def close(self) -> None:
        # Cleanup resources (e.g., close windows)
        pass
import os
import sys

import numpy as np
import pandas as pd
from sb3_contrib import MaskablePPO
from stable_baselines3.common.monitor import Monitor
from stable_baselines3.common.vec_env import DummyVecEnv, VecNormalize

from env import TrainingEnv
from funcs.io import get_sample_data
from funcs.plot import learning_curve

if __name__ == "__main__":
    # 使用するティックデータ
    file_csv: str = "20260401_9984.csv"

    # 銘柄コードとティックデータのデータフレームを取得
    code, df = get_sample_data(file_csv)

    # ログフォルダの準備
    dir_log = "./logs/"
    os.makedirs(dir_log, exist_ok=True)
    file_log = os.path.join(dir_log, "monitor.csv")

    # VecNormalizeの内部状態の保存用
    file_pkl = "vecnormalize.pkl"

    # 学習用ステップ数の設定
    # timesteps = 100_000
    timesteps = 1_000_000


    def make_env():
        # 1. Gymnasium 継承の環境クラスのインスタンス
        env_gym = TrainingEnv(code, df)
        # 2. Monitor Wrapper
        env_mon = Monitor(env_gym, dir_log)
        return env_mon


    # ====== 学習用環境の準備 ======

    # 3. DummyVecEnv Wrapper
    env_dummy = DummyVecEnv([make_env])

    # 4. VecNormalize Wrapper
    env_train = VecNormalize(env_dummy, norm_obs=True, norm_reward=True)

    # sys.exit()

    # モデルの準備
    model = MaskablePPO("MlpPolicy", env_train, verbose=1)

    # ====== 学習実施 ======
    print("Begin training...")
    model.learn(total_timesteps=timesteps)

    # 推論時に利用できるように VecNormalize の内部状態を保存
    env_train.save(file_pkl)

    # ====== 報酬トレンド/学習曲線 ======

    # 最初の行の読み込みを除外
    df_reward = pd.read_csv(file_log, skiprows=[0])
    learning_curve(df_reward, file_csv)

    # ====== 推論用環境の準備 ======

    # 3. DummyVecEnv Wrapper
    env_inf = DummyVecEnv([make_env])

    # 4. VecNormalize Wrapper
    env_inf = VecNormalize.load(file_pkl, env_inf) # 学習情報を読み込む
    env_inf.training = False
    env_inf.norm_reward = False  # 推論時は報酬正規化を無効化

    # 特定環境を指定するインデックス
    idx = 0  # 環境は 1 つのみなので、インデックスは常に 0

    # 環境のリセット
    obs = env_inf.reset()
    print(f"Initial observation: {obs}")
    episode_over = False
    total_reward = 0

    # ====== 推論実施 ======
    print("Begin inference...")
    info = []
    while not episode_over:
        # VecEnv では action_masks を env_method で取得する
        action_masks = env_inf.env_method("action_masks")[idx]
        # マスク情報付きで推論
        action, _states = model.predict(obs, action_masks=action_masks)
        action = np.array([action])  # VecEnv では複数環境分の配列
        obs, reward, done, info = env_inf.step(action)
        total_reward += reward[idx]
        episode_over = done[idx]
    else:
        dict_info = info[idx]
        # 取引結果を出力
        if "transaction" in dict_info:
            df = dict_info["transaction"]
            print(df)
            print(
                f"モデル報酬 : {total_reward},\n"
                f"損益 : {df['損益'].sum()} 円, 約定係数 : {len(df)} 回"
            )

    # 環境の終了処理
    env_inf.close()

参考サイト

  1. マーケットスピード II RSS | 楽天証券のトレーディングツール
  2. マーケットスピード II RSS 関数マニュアル
  3. 注文 | マーケットスピード II RSS オンラインヘルプ | 楽天証券のトレーディングツール
  4. PythonでGUIを設計 | Qtの公式Pythonバインディング
  5. Python in Excel alternative: Open. Self-hosted. No limits.
  6. Book - xlwings Documentation
にほんブログ村 株ブログ 株日記へ
PVアクセスランキング にほんブログ村

0 件のコメント:

コメントを投稿