シンプルな数行のコードが、Sharpe比 +1.2 という数字をはじき出した。IS期間もOOS期間も両方プラス。何度見直してもロジックは正しい。これは本当に機能する戦略ではないか——そう興奮した翌日、ある根本的な問題に気づいた。
戦略の概要
ロジックは単純だ。
- 今日の始値が前日の高値を上回っていたら買い(ギャップアップ継続)
- 今日の始値が前日の安値を下回っていたら売り(ギャップダウン継続)
- 当日の寄り引けで決済
import pandas as pd
import numpy as np
# yfinanceでダウンロードした日経225の日次OHLC
close = pd.read_csv("cache/idx_Close.csv", index_col=0, parse_dates=True)
open_ = pd.read_csv("cache/idx_Open.csv", index_col=0, parse_dates=True)
high = pd.read_csv("cache/idx_High.csv", index_col=0, parse_dates=True)
low = pd.read_csv("cache/idx_Low.csv", index_col=0, parse_dates=True)
col = "JP_NK225"
c = close[col].dropna()
o = open_[col].dropna()
h = high[col].dropna()
l = low[col].dropna()
prev_high = h.shift(1)
prev_low = l.shift(1)
gap_up = o > prev_high
gap_down = o < prev_low
sig = pd.Series(0.0, index=c.index)
sig[gap_up] = 1.0 # ロング
sig[gap_down] = -1.0 # ショート
COST = 0.0003 # 往復3bps(楽観的な想定)
daily_ret = (c - o) / o # 寄り引けリターン
pnl_daily = sig * daily_ret - sig.abs() * COST
pnl_wk = pnl_daily.resample("W-FRI").sum()
def sharpe(s):
s = s.dropna()
return s.mean() / s.std() * np.sqrt(52)
バックテスト結果
期間: 2005-01-04 → 2025-12-30
シグナル発生: 2722日 / 5138日中 (53.0%)
全体 Sharpe: +1.203
IS Sharpe: +0.931 (〜2017年)
OOS Sharpe: +1.777 (2018年〜)
数字が良い。IS・OOSともにプラスで、しかもOOSの方が良い。普通の過学習なら
OOSで悪化するはずなのに、逆転している。
コストへの感度も確認した。
cost=0.00% 全体=+1.504 IS=+1.213 OOS=+2.124
cost=0.03% 全体=+1.203 IS=+0.931 OOS=+1.777 ← 楽観
cost=0.10% 全体=+0.501 IS=+0.275 OOS=+0.969 ← 現実的?
cost=0.30% 全体=−1.469 IS=−1.565 OOS=−1.298 ← 保守
コストに非常に敏感だ。しかしコスト0.03%(3bps)なら十分現実的に見える。興奮が高まった。
最初の違和感
冷静になると、いくつかの数字が引っかかった。
1. OOS > IS は普通はおかしい
過去データをフィットさせた戦略はOOSで悪化するのが普通だ。ISよりOOSの方が良いということは、もしかしてデータに何か問題があるのではないか。
2. シグナルが53%の日に発生している
前日の高値・安値を上下にブレイクする日が全体の半分以上ある。よく考えると、株価は毎日のように前日レンジを多少なりとも越えて始まることが多い。このシグナルは「ほぼ常にポジションを持つ戦略」になっている。
3. (close - open) / open という計算
openで入ってcloseで出る。つまりバックテストは、「その日の始値でちょうど約定できた」前提になっている。本当にその価格で入れるのか?
問題の核心:日経225は「理論値」である
ここで根本的な問いが浮かぶ。
yfinanceの
^N225のデータで言う「始値」とは何か?
日経225は価格加重型の指数であり、225銘柄の株価を一定の計算式で合成した「数値」だ。それ自体は売買できない。yfinanceの ^N225 の "Open" は、225銘柄のうち9:00以降に取引が成立した銘柄の初値を集計して計算した理論上の数値であって、ある瞬間に「38,000円」で買える注文を出せる市場は存在しない。
日経225を実際に取引するには別の商品が必要だ。
| 商品 | 取引所 | 特徴 |
|---|---|---|
| 日経225先物(ラージ/ミニ) | 大阪取引所 | 最も流動性が高い。ただし夜間取引あり、始値は現物とズレる |
| 日経225連動ETF(1321.T等) | 東京証券取引所 | ETF自体が9:00に独立した価格形成をする |
| CFD(各ブローカー) | OTC | スプレッドが乗る。IG等のCFDはブローカー独自の価格 |
つまり、バックテストで使っている「指数の始値」と、実際に約定できる「先物・ETFの始値」は別物だ。
実際に何が起きているか
具体的な場面を想像してほしい。
ある日の夜、米国市場が大幅上昇した。翌朝9:00 JST、日経225先物は昨日の現物高値(38,000円)を大きく超えた38,500円付近で取引されている。これは前夜の先物取引(夕場)や早朝のシンガポールのSGX先物で価格発見がすでに起きているからだ。
9:00になると各銘柄の寄り付き競売が始まる。銘柄によって30秒〜2分後に次々と始値が決まっていく。「日経225指数の始値」は、この225銘柄の初値が出揃い始めた時点で計算されるが、まだ寄り付いていない銘柄は前日終値で代用される。したがって指数の"Open"は、厳密には「全銘柄の寄り付き後」まで確定しない。
バックテストは「指数の始値でちょうど買えた」と仮定しているが、実際には:
- その価格で買える商品(先物・ETF)の始値は、指数の始値とズレている
- 先物は夜間取引で已然に動いており、「指数上昇幅」の大半がすでに折り込まれている
「指数が前日高値を超えた」時点で、先物は既に高値から更に上にいる。指数ベースでは絶好のエントリーポイントに見えても、先物・ETFで実際に買うと「高値掴み」になりやすい。
yfinanceの指数データを使う際の注意点
yfinanceで取得できる主要指数(^N225, ^GSPC, ^DJI など)は、すべて計算上の理論値だ。
import yfinance as yf
# これらは「理論値」— 直接は売買不可
nk225 = yf.download("^N225", start="2020-01-01") # 日経225
sp500 = yf.download("^GSPC", start="2020-01-01") # S&P500
dji = yf.download("^DJI", start="2020-01-01") # ダウ
# こちらは「実際に売買可能な商品」
nkm = yf.download("NKM=F", start="2020-01-01") # 日経225先物(CME)
spy = yf.download("SPY", start="2020-01-01") # S&P500 ETF
インデックスの^付きシンボルで取得したOHLCデータは、可視化や大まかなトレンド確認には使えるが、売買の正確な価格として使うバックテストには向かない。特に「始値」は各銘柄の競売タイミングのズレや計算方法の都合上、実際に取引できる商品(先物・ETF)の始値と必ずしも一致しない。
ギャップ系の戦略は「始値」の精度に非常に敏感だ。わずかな価格のズレが、エッジを消すどころか逆に働くことがある。
正しいアプローチ
日経225のギャップ戦略を本気で検証したいなら、実際の取引対象のOHLCデータを使う必要がある。IG証券のCFD 1分足データから日次OHLCを再構築して、まったく同じ戦略ロジックを走らせてみた。
# IG CFD 1分足 → 日次OHLC再構築
df1m = pd.read_parquet('cache/JPXJPY_2010_2025.parquet')
df1m.index = df1m.index.tz_convert('Asia/Tokyo')
mn = df1m.index.hour * 60 + df1m.index.minute
session = df1m[(mn >= 540) & (mn <= 915)] # 9:00〜15:15 JST
daily = session.groupby(session.index.normalize()).agg(
Open=('Open','first'), High=('High','max'),
Low=('Low','min'), Close=('Close','last')
)
# 以下はyfinanceと完全に同じ戦略コード
sig = pd.Series(0.0, index=daily.index)
sig[daily['Open'] > daily['High'].shift(1)] = 1.0
sig[daily['Open'] < daily['Low'].shift(1)] = -1.0
結果はこうなった。
期間: 2010-11-15 〜 2025-12-31
シグナル発生: 2149日 / 3895日 (55.2%)
全体 Sharpe: −0.697
IS Sharpe: −1.122 (〜2017年)
OOS Sharpe: −0.197 (2018年〜)
MaxDD: −75.6%
コスト感度(同一期間):
cost=0.00% 全体=−0.305 IS=−0.772 OOS=+0.261
cost=0.03% 全体=−0.697 IS=−1.122 OOS=−0.197
Sharpe +1.20 が −0.70 に反転した。コストゼロでも −0.31 だ。「戦略自体がダメ」なのであって、コストの問題ではない。
そしてエクイティカーブを2010年以降で並べると差は一目瞭然だ。
yfinanceはIS・OOS両方でプラス(OOSの方が高い)。IG CFDはIS期間から既に大きく下落している。これが本来の姿だ。OOSの方が成績が良いという逆転は「何かがおかしい」サインだったのだ。
まとめ:学んだこと
| 項目 | 内容 |
|---|---|
| 使用データ | yfinanceの ^N225 日次OHLC |
| 戦略 | 始値 > 前日高値 → 当日寄り引き |
| 見かけの結果 | Sharpe +1.2(IS/OOS両方プラス) |
| 幻の原因 | 日経225指数は理論値であり実売買価格ではない |
| 教訓 |
^付きインデックスのOHLCは参考値。売買コスト計算以前に「執行可能かどうか」を先に確認する |
シンプルな戦略ほど、データの性質を疑わなくなりやすい。コードのロジックは正しいのに、その下にある「この数値は本当に取引できる価格か?」という問いを見逃すと、完璧に見えるバックテストが幻になる。
本稿はFX×日経225の合成ブック研究(2026年)の過程で発見した事例をもとに書いた。最終的な日経225戦略の実装には1分足のIG CFDデータを使用している。


