この記事は個人で作っている自動売買システムの障害の記録です。特定の銘柄や売買手法をすすめるものではありません。
はじめに
Raspberry Pi で日本株の自動売買ボットを作って運用しています。前回は「売買ボットの安全装置どうしがぶつかって、実弾の注文が届いていなかった話」を書きました。今回はペーパートレード(実際には注文を出さず、約定したことにして記録だけ残す運用)のほうで起きた話です。
このボットでは、新しい銘柄はまずペーパーで動かし、成績が良ければ実弾に昇格させるルールにしていました。ところが、**昇格候補として様子を見ていた銘柄の好成績が、丸ごと「幻」**でした。
構成(Raspberry Pi+Windows 中継サーバー+kabuステーションAPI)の説明は前回の記事にあります。現在も運用している銘柄は「銘柄B」のように伏せています。
この記事のまとめ
- ペーパーの建値(買った値段)に、実際の株価とかけ離れた「¥847」が記録されていた
- 原因は、板情報 API から返ってきた現在値をそのまま信じて、ペーパーの約定価格にしていたこと
- そのせいで、昇格候補だった 6966 の +¥24,000、別の銘柄Bの +¥4,860 が、どちらも幻だった
- 対策として、現在値が前日高値から ±30% 以上離れていたら「異常値」として捨てるチェックを入れた
- 汚れたペーパーの記録は、あとでバックテストの答え合わせにも使えなくなった。ペーパーの成績は、入力データが正しいという前提の上にしか立たない
何が起きたか
きっかけは、バックテストで出る数字と、ペーパーで記録された利益が、どうも合わないと感じたことでした。
そこで 6966 のペーパーの記録を1件ずつ見直すと、7/6 と 7/10 の2回、建値が ¥847 になっていました。6966 は、6月中旬に1,285円で発注した記録が残っている銘柄です。その3週間後に847円で買えたとは考えにくい値段です。
ペーパーでは「この値段で買えた」ことにして記録するので、実際よりずっと安く買ったことになります。そのあと普通の値段で決済すれば、当然大きな利益が出ます。こうして記録されたのが、6966 の +¥24,000 でした。同じ時期、銘柄Bの +¥4,860 も同じ原因で汚れていたことがわかりました。
6966 は、このとき実弾への昇格を判定する候補に入っていました。気づくのが遅ければ、幻の成績を根拠に実弾に上げていたかもしれません。
なお、実弾のトレードには影響がありませんでした。実弾の損益は、証券会社から返ってくる実際の約定価格で計算しているからです。
なぜ起きたか
エントリーの判定は、「現在値が前日の高値を上に抜けたら買う」というものです。修正前のコードはこうなっていました。
# 修正前(抜粋)
try:
board = self._get_board(sym) # 板情報 API(/board/{symbol}@1)
cp = float(board.get("CurrentPrice") or entry_price)
except Exception:
cp = entry_price
# ... cp をそのまま約定価格として使う(ペーパーなら即約定扱い)
問題は2つあります。
1. 返ってきた現在値を、まったく疑っていなかった。
板情報 API が異常な値を返したときに、それを弾く仕組みがありませんでした。当時の調査メモには「板情報 API が別の銘柄の価格を返した」と書いていますが、これは推測で、実際に確かめたわけではありません。なぜ ¥847 が返ってきたのかは、わからないままです。
2. 取れなかったときに、別の値で黙って代用していた。
or entry_price と except の部分です。現在値が取れなかったら、前日高値(entry_price)を現在値の代わりに使って、そのまま先に進みます。今回の ¥847 の原因はこちらではありませんが、「取れなかった値を、それらしい値で黙って埋める」という同じ種類の危うさがありました。
修正:おかしな値は捨てる
# 修正後(抜粋)
try:
board = self._get_board(sym)
cp = float(board.get("CurrentPrice") or 0)
except Exception:
cp = 0
# 価格の妥当性チェック:取れなかった、または前日高値から ±30% 以上離れていたら異常値
if cp <= 0 or abs(cp - entry_price) / entry_price > 0.30:
self.logger.system(
f"GRIP {sym} 現値異常 cp={cp} vs prev_high={entry_price} → スキップ")
return
変えたのは2点です。
- 取れなかったときは 0 にして、代用せずにエントリーを見送る
- 取れた値でも、前日高値から ±30% 以上離れていたら異常値として見送る
「前日高値を抜けたら買う」という戦略なので、エントリーするときの現在値は、ふつう前日高値のすぐ近くにあるはずです。そこから3割も離れた値は、相場の動きより、データの異常を疑うほうが自然です。
このチェックの限界
±30% のチェックは、あくまで「大きく外れた値」を捨てるためのものです。正直に書くと、穴はまだあります。
- 30% 以内の異常値は通ってしまう。 たとえば、別の銘柄の価格がたまたま近い値段だったら、見分けられません
- チェックしているのはエントリーのときだけ。 保有中の値動きの監視(利確や損切りの判定)や、ティックログへの記録では、同じチェックをしていません。監視中に異常な安値が1回来たら、損切りの判定に引っかかる可能性があります
- 本当に3割以上動いた日は、見送りになる。 ただ、これは「買わない」側に倒れるだけなので、損はしません
1つめへの対策としては、板情報 API の応答に含まれる銘柄コード(Symbol)が、問い合わせた銘柄と一致しているかを照合する方法が考えられます。まだ入れていないので、今後の課題です。
被害はペーパーの成績だけでは済まなかった
この話にはもう少し続きがあります。
9月に、ティック単位のログから売買を再現するバックテストを作り、その答え合わせとして、過去のペーパーの成績35件と突き合わせてみました。結果は、一致1件、軽いずれ1件、残り33件は比べられない、でした。比べられなかった理由の1つが、この ¥847 で汚れた記録です(ほかは、ログが欠けている日や、途中で止めた銘柄の記録でした)。
汚れたペーパーの記録は、あとから「正解データ」として使うこともできなくなっていました。
最終的に、ペーパーの建玉をボットの中で持つのはやめました(理由のもう半分は前回の記事に書いています)。ペーパー銘柄の成績は、ティックログからあとでバックテストし直して評価します。そのうえで、本当に汚れていない答え合わせの材料は、実弾のトレード(実際の約定価格)だけだと考えるようにしています。件数は少なくても、信用できるのはそこだけです。
教訓
- ペーパーの成績は、入力データが正しいという前提の上にしか立たない。 ペーパーでは約定しないので、おかしな値が来ても誰にも止められません
- 外から取ってきた値は、使う前に「ありえる範囲か」を確かめる。 戦略から考えて「ここにあるはずだ」と言える範囲があるなら、それがそのまま検査になります
- 取れなかった値を、それらしい値で黙って埋めない。 取れなかったなら、その回は見送るほうが安全です
- 良すぎる成績は、まず疑う。 今回はバックテストとのずれで気づけましたが、本来は昇格を決める前に、1件ずつ建値と決済値を実際の株価と見比べるべきでした
書いた人
会社員のかたわら、Raspberry Pi で日本株の自動売買システムを作って運用しています。検証や実装で失敗した話を中心に書いています。Zenn では「perkin」として、バックテストと検証の話を書いています。
ほかに、暮らしのお金を計算するサイト「いくらかかるの研究所」も個人開発しています。エアコンの年間の電気代を、機種・部屋・地域・住宅の性能から計算するシミュレーターなどを置いています(Astro + Cloudflare 製)。