楽天証券の口座でデイトレの自動売買に挑戦しようと、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 で取得して作成しました。
【関連ニュース】
- 商船三井の船舶がホルムズ海峡を通過、日本関連で初めて | ロイター [2026-04-03]
- 米雇用者数17.8万人増加、24年末以来の大幅増-失業率は予想外に低下 - Bloomberg [2026-04-03]
- 米3月雇用者数17.8万人増、過去15カ月で最多 イラン戦争がリスク | ロイター [2026-04-03]
- 米サービス業PMI、2023年以来の縮小圏-イラン戦争で新規受注が低下 - Bloomberg [2026-04-04]
- NY市場サマリー(3日)利回り上昇、株式は休場 | ロイター [2026-04-04]
- 【米国市況】国債が下落、雇用統計堅調で利下げ観測後退-159円台後半 - Bloomberg [2026-04-04]
- 「AIウォッシング」が隠す深刻な危機-未来の労働力、空洞化の恐れ - Bloomberg [2026-04-05]
- 日経平均は続伸、米イラン停戦思惑で一時5万4000円回復 懐疑的見方も | ロイター [2026-04-06]
- 【日本市況】長期金利27年ぶり高水準、中東の緊張続く-日経平均続伸 - Bloomberg [2026-04-06]
デイトレ用自作アプリ
以下は株価・取引に関連する情報の流れを示しています。
楽天証券では、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]
- Price(株価)
- Profit(含み損益)
- Diff(乖離率 : (MA1 - VWAP) / VWAP)
- 行動 [Action]
- HOLD(何もしない)
- BUY(買建または返済)
- SELL(売建または返済)
- 返済ロジックは環境側で制御
- ナンピン禁止は行動マスクで処理
- 報酬 [Reward]
- 建玉時、含み損益の一定割合を付与
- 買建時、-Diff を付与
- 売建時、+Diff を付与
- 返済時、直前の含み損益を付与
- 約定コスト : 建玉、返済時いずれも固定の約定コストを引く
- 終了条件
- terminated
- なし
- truncated
- ティックデータが最終行に達した時
- 終了時、建玉があれば強制返済。報酬条件、約定コストは同じ。
結果
メインのアプリ Kabuto では、短周期の移動平均線 MA1 と、出来高加重平均価格 VWAP を情報に取り入れているので、クロス・シグナルではなく MA1 - VWAP の乖離率をエントリ時の報酬(ペナルティ)に加えてみました。
コードはますます長くなったので、最後にまとめることにして。結果をまとめました。
学習曲線
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()
参考サイト
- マーケットスピード II RSS | 楽天証券のトレーディングツール
- マーケットスピード II RSS 関数マニュアル
- 注文 | マーケットスピード II RSS オンラインヘルプ | 楽天証券のトレーディングツール
- PythonでGUIを設計 | Qtの公式Pythonバインディング
- Python in Excel alternative: Open. Self-hosted. No limits.
- Book - xlwings Documentation




0 件のコメント:
コメントを投稿