バックテストとは何か
バックテストは、過去の相場に自分の売買ルールをそのまま当てはめる作業だ。いつ買っていつ売るかをあらかじめ決めておき、過去の数か月あるいは数年を一日ずつ進めながら、そのとおり売買していたら今いくらだったかを計算する。実際の資金を入れる前に、ルールが筋の通ったものかを確かめる手続きである。
わざわざやる理由は、勘で立てたルールの大半が数字の前で崩れるからだ。頭の中ではもっともらしいルールも、実際の相場に当ててみると信号が遅すぎたり、多すぎたり、肝心の局面で逆に動いたりする。バックテストはそれを、授業料を払う前に教えてくれる。
同時にバックテストは未来を当てる道具ではない。過去に通用したことは、これからも通用する保証にはならない。バックテストが本当にふるい落とすのは明らかに悪いルールであり、残るのは悪くはなかったルールにすぎない。この違いを最初に受け入れないと、数字に寄りかかりすぎることになる。
以前はこれをやるのに、相場データをどこかから入手して表に整え、繰り返し処理のコードを自分で書く必要があった。今は二つの部品で足りる。相場を返す API と、自分の PC でコードを書いて実行する AI エージェントだ。
まずデータ — ローソク足取得で日足 OHLC を受け取る
用語を一つだけ整理しておく。ローソク足は一日(または 1 分)の値動きを四つの数字に圧縮したものだ。始値、高値、安値、終値、頭文字を取って OHLC と呼ぶ。ローソク足一本が一日なら日足で、その連なりがバックテストに必要な最小限の材料になる。
トス証券 Open API はローソク足取得のエンドポイントでこのデータを返す。前編で発行したアクセストークンさえあれば呼べて、口座ヘッダーは要らない。相場はすべての利用者に同一に提供される客観データだからだ。
実際に使うと引っかかる点が二つある。第一に、一回の呼び出しで最大 200 本までしか返らない。1 年分の日足は約 250 本なので一度では足りず、レスポンスに付いてくるカーソルを渡して過去方向へつないでいく必要がある。第二に、調整後株価で受け取ること。配当や株式分割があった銘柄を元の価格のままにすると、分割当日に価格が半分になり、バックテストがその日を暴落と誤認する。
受け取ったローソク足は日付の昇順に並べ、重複した日付は一つにまとめる。ページをつないでいくと境界で同じ日が二度入ることがあり、そのままにするとその一日が二度計算されて収益率が静かに水増しされる。
TOKEN=eyJ...
curl -s 'https://openapi.tossinvest.com/api/v1/candles?symbol=KORU&interval=1d&count=200&adjusted=true' \
-H "Authorization: Bearer $TOKEN"
# レスポンス(抜粋)
# {"result":{
# "candles":[{"timestamp":"2026-07-27T00:00:00",
# "openPrice":18.02,"highPrice":18.9,
# "lowPrice":17.71,"closePrice":18.45,"volume":...}],
# "nextBefore":"..."}}import os, requests
BASE = "https://openapi.tossinvest.com/api/v1/candles"
H = {"Authorization": f"Bearer {os.environ['TOSS_TOKEN']}"}
def daily_ohlc(symbol, need=400):
rows, before = {}, None
while len(rows) < need:
p = {"symbol": symbol, "interval": "1d",
"count": 200, "adjusted": "true"}
if before:
p["before"] = before
r = requests.get(BASE, params=p, headers=H, timeout=10).json()["result"]
if not r.get("candles"):
break
for c in r["candles"]: # 日付をキーに重複を除去
d = c["timestamp"][:10]
rows[d] = {"d": d, "o": float(c["openPrice"]),
"h": float(c["highPrice"]), "l": float(c["lowPrice"]),
"c": float(c["closePrice"])}
before = r.get("nextBefore")
if not before:
break
return [rows[d] for d in sorted(rows)] # 古い順から最新へエージェントにルールを渡すとき必ず固定すべき四つ
ここからが本題だ。ルールを説明すればスクリプトは数分で出てくる。問題は、一文のルールには必ず空欄が残ること、そしてエージェントがその空欄について聞き返さないことだ。最もそれらしい値で静かに埋めたうえで、実にそれらしい収益率の表を出してくる。
空欄はおおむね四か所ある。ルールと一緒にこの四つを書いておくと、結果の振れ幅は大きく縮む。
第一に判定の時点。条件を一日のいつ確認するのか。終値で確認するのか、ザラ場でいつでも条件に触れた瞬間なのかで、まったく別の戦略になる。とくに買いの信号をザラ場基準にすると、その日の終値では崩れていた偽の突破まですべて買ってしまう。
第二に約定価格。条件が満たされたとき、どの価格で売買したことにするのか。その日の終値か、条件線そのものか、翌日の始値か。後で見るように、この一行が結果を最も大きく動かす。
第三に取引コスト。手数料と税金、そして気配が開くことで生じる損を往復何パーセントとして見込むかを決める。ここを 0 にすると、頻繁に売買する戦略が実際よりずっと良く見える。
第四にデータ区間。いつからいつまでを使うのか。開始日を一つずらすだけで順位がひっくり返ることは珍しくない。だから一つの区間だけで勝ったなら、それはまだ結論ではない。
| 項目 | 決めないと | こう書いておく |
|---|---|---|
| 判定の時点 | ザラ場の信号まで拾い、実際には買えなかった取引が混ざる | 買い条件は終値のみで判定。ザラ場の一時的な突破は無視 |
| 約定価格 | 最も有利な価格で埋められ収益率が水増しされる | 決済は条件線の価格で約定、窓を空けたらその日の始値で約定 |
| 取引コスト | 0 で計算され、頻繁売買の戦略が過大評価される | 片道 0.05 パーセント、往復 0.1 パーセントを毎取引で控除 |
| データ区間 | 最も良く出る区間が選ばれ結論がひっくり返る | 直近 1 年・3 年・全期間をそれぞれ別に出力 |
次のルールを、このローソク足データでバックテストする Python スクリプトを書いてください。
[ルール]
- 区間の開始時点で全額保有の状態から始める
- 保有中、高値から 20% 下落したら全量売却する(高値はザラ場高値で更新)
- 未保有時、直近 20 営業日の高値を終値で上抜けたら全量買う
- 保有中、RSI(14, Wilder) が 80 を超えたら全量売却する
[前提 — 必ずこのとおりに]
- 買いと RSI の判定は終値基準、トレール決済はザラ場のタッチ基準
- トレール約定価格 = 高値 x 0.8。始値がそれより低く窓を空けたら始値で約定
- 取引コスト往復 0.1% を毎取引で控除
- 区間は直近 1 年 / 3 年 / 全期間をそれぞれ別に出力
[出力]
- 売買履歴(日付、方向、約定価格、理由)
- 区間ごとの収益率、最大ドローダウン、単純保有との比較同じルールが +15 パーセントと +95 パーセントに分かれた理由
言葉だけでは伝わらないので、実際に回してみた。対象は KORU、韓国市場を 3 倍で追う米国上場のレバレッジ ETF だ。ルールは上に書いたそのままで、高値から 20 パーセント下落で全量売却、20 日高値を終値で上抜けたら全量買い、RSI 80 超で過熱利確。区間は 2026 年 2 月 26 日から 7 月 22 日までの約 5 か月である。
同じルール、同じローソク足で二度回した。変えたのは一つだけ、決済の約定前提だ。一度目は条件が満たされた日の終値で売ったことにし、二度目は価格が条件線に触れたその瞬間、その価格で売れたことにした。
終値約定は 15 パーセント、タッチ約定は 95 パーセントになった。同じ期間ただ持っていればマイナス 33 パーセントだ。ルールは一文字も変わっていないのに、結果は 80 ポイント開いた。
差はたった一日から生まれた。6 月初めに天井を打ったあと、この銘柄は数日で半値になった。条件線に触れた時点で売れたことにすれば 49 ドル近辺で抜けられるが、その日の終値まで待って売ったことにすると 30 ドルで抜けることになる。3 倍商品ゆえ一日の下げ幅が大きく、だからこの前提一つが成果全体を支配した。
どちらが正しいかは、自分のシステムが実際にどう動くかで決まる。証券会社のサーバーに条件注文を置いておけば、価格が監視線に触れた瞬間に注文が出るのでタッチ約定に近い。逆に一日一度終値を見て人が判断し翌日発注するなら、終値約定よりさらに不利だ。つまりこれは好みではなく、自分の運用のしかたをそのまま書き写すべき値である。
そしてどちらを選んでも、実際の約定はそれより悪い。急落のさなかに望んだ価格ですべて売れるとは限らず、成行で投げれば気配が開いた分だけさらに滑る。バックテストの数字をそのまま期待収益として読んではいけない理由がここにある。
# A. 終値約定 - 条件を終値で確認し、その終値で売ったことにする
if close[i] <= high * 0.8:
fill = close[i]
# B. タッチ約定 - ザラ場安値が条件線に届いたらその線で売れたことにする
level = high * 0.8
if low[i] <= level:
fill = min(level, open[i]) # 始値がより低く窓を空けたら始値で約定
# 同じルール・同じデータ・2026-02-26 〜 07-22
# A -> +15% B -> +95% 単純保有 -> -33%KORU で実際に検証した変種
前提を固定したら、そこからが本当の実験だ。同じ骨格の上で一度に一つだけ変えて回す。3 倍レバレッジ ETF で回した変種は、おおむね次のように分かれた。
最も頻繁に出てきた発想は、再エントリーを容易にする方向だった。20 日高値を待つのが遅すぎるように見えたからだ。そこで待つ窓を 10 日に縮め、高値にわずかに届かなくても買えるようしきい値を削り、RSI 30 や移動平均のクロスといった反発シグナルに差し替えてみた。方向の異なるこれらの試みは、すべて元のルールに及ばなかった。
逆に改善したのは二つだけだった。待つ窓を 30 日へさらに伸ばす方向と、RSI が 80 を超える過熱局面でトレール決済を待たずに先に利確する方向である。つまりこの戦略の力は、賢いエントリー信号ではなく、高値が確認されるまで待つ忍耐から来ていた。
決済比率を分けて置く発想、いわゆるポケット分割も試した。同じ銘柄を二つに分け、一方を 15 パーセント、他方を 20 パーセントで売ればドローダウンが和らぐように思えたが、そうはならなかった。同じ銘柄なので二つの塊は同じ暴落を一緒に受け、決済のタイミングが数日ずれるだけで最大ドローダウンはほとんど変わらない。代わりに常に劣るほうのパラメータを混ぜた分だけ収益が削られた。相関が 1 の資産を分けることは分散ではない。
別の市場でうまくいっていたロジックを持ち込む試みも行った。分単位の暗号資産取引で使っていた複数指標の z スコア合算方式だが、日足 ETF に載せると取引が数千件発生した。コストを 0 にすればそれらしいが、片道 0.05 パーセントを入れただけで成果が崩れた。ロジックの品質の問題ではなく、時間軸とコスト構造が合っていなかったのだ。
もう一つ。2 倍レバレッジで最もよく効いた 200 日移動平均フィルターは、3 倍ではむしろ悪かった。変動が大きい分、移動平均の近辺で売り買いを繰り返して損だけが積み上がった。同じ系列の商品でも、倍率が違えば合うフィルターも違う。
| 変えたもの | 意図 | 結果 |
|---|---|---|
| 再エントリーの窓 20 日 → 10 日 | より早く再エントリー | 大きく劣後 |
| エントリーしきい値を 10 パーセント割引 | 高値の手前で先に買う | 単調に悪化 |
| 反発シグナルで再エントリー(10 種) | 底で拾う | すべて劣後 |
| 再エントリーの窓 20 日 → 30 日 | より長く確認 | 改善 |
| RSI 80 過熱利確を追加 | 天井付近で先に決済 | 改善 |
| トレール比率の分割(15+20、20+25) | ドローダウンの緩和 | 三区間すべて劣後 |
| 分単位 z スコア合算の移植 | 他市場のロジックを再利用 | コストを入れると崩壊 |
| 200 日移動平均フィルター | トレンド割れで決済 | 2 倍は最適、3 倍は不適 |
バックテストが静かに嘘をつく五か所
結果が良く出たとき、真っ先に疑うべき場所だ。五つとも エラーを出さずにただ良い数字を返すので、目で探すしかない。
第一は先に見た約定前提だ。条件線でぴったり売れたことにして計算すると、急落局面で現実よりはるかに良く出る。スリッページを少し乗せて回し直し、結論が保たれるかを確かめる。
第二は未来の情報が漏れ込んだ場合だ。その日の終値が分かって初めて計算できる値で、その日のザラ場に売買するよう組まれていれば、現実には不可能な取引が成果に入り込む。指標の計算に現在の足を含めてしまうのが、よくある事故の場所だ。
第三はコスト 0 だ。まず取引回数を見て、往復コストを掛け、その分を引いても残るかを計算する。残らなければ、その戦略はこれ以上バックテストを回す必要がない。
第四は過剰最適化だ。パラメータを少しずつ変えながら最高成績を探すと、その値でだけ良い結果にたどり着く。先に採用した RSI 80 も、78 から 82 の狭い区間でしか良く出なかった。狭い峰はたいてい偶然だ。パラメータを上下に揺らしても緩やかに保たれる値を選ぶべきである。
第五はデータと再現性だ。調整後株価を使わなければ配当落ちと株式分割が暴落として記録され、いま上場している銘柄だけで検証すれば消えた銘柄が抜けて結果が良く見える。そしてバックテストのスクリプトを一時フォルダに置けば、結論だけが残って再現できなくなる。実際に私もこの罠を踏み、数か月前に回したスクリプトが消えていて今回は一から書き直した。ルールと前提、スクリプトは結果と一緒にリポジトリへ入れておく。
検証したルールを実際の口座に移すとき
バックテストを通ったからといって、すぐ武装はしない。あいだの隔たりを埋める順序がある。
まず、バックテストが前提とした判定の時点と、実システムの判定の時点を合わせる。終値確認を前提に検証したなら、実取引でも引け間際にだけ注文が出るようにする。これ一つを合わせないと、ザラ場の偽の突破で発注し続け、バックテストとは別の戦略になる。
次に、計算が要る判定と単純な監視を分ける。トレールのように基準線が毎日動くもの、RSI のように計算が要る条件は自分のスクリプトが担い、単純な価格到達の監視は証券会社の条件注文に渡す。自分の PC が落ちていても証券会社のサーバーが見張ってくれるからだ。突然の窓開け下落に備える安全網もここに置く。
そして実注文のゲートは、前編で作ったまま維持する。既定はドライラン、実注文は実行フラグと銘柄確認を同時に要求、金額上限を超えたら拒否、冪等キーで重複を防止。バックテストが良く出るほどこのゲートを外したくなるが、良く出たバックテストほど検証すべき前提は多い。
最後に規模だ。検証したルールでも、はじめは失っても構わない金額で始める。レバレッジ商品ならなおさらである。自動売買は、ルールが間違っていればその間違ったルールを非常に速く忠実に繰り返す。この記事の数字は特定の銘柄と特定の区間から出たもので、投資勧誘ではない。判断と責任は本人にある。
