資産2倍バグのその後 — 再起動が暴いた5つの不具合を直す
再起動が暴いた連鎖不具合を、発注ガードと起動完全性とタイムゾーンの三方向から直す
前回のあらすじ
前回、自動売買システムが「資産ほぼ2倍(+93.8%)」という嘘を表示しました。サーバー再起動時に DB が起動しきる前へ売買プロセスが先行し、状態復元の例外を握りつぶして初期値の現金 $1,500 のまま走り出した。現金は「買う前」、保有銘柄は「買ったあと」という別の時刻から拾われ、二重計上で水増しされた、というインシデントです(詳細は 再起動したら資産が2倍に増えてた — システムのよくある罠にハマった話)。
前回は「壊れた現金では起動しない」を 4 対策で直し、続けて 3 改善(発注のハードキャップ・物理口座の買付余力チェック・保有銘柄の購読リスト再投入)を予告しました。
この記事はその予告を完了させた続報です。1 回の再起動が炙り出した不具合は 1 個ではなく、最初に直した 1 つの裏に性質の違う不具合が 5 つ残っていました。
残っていた5つの不具合
前回直したことで保証されたのは「壊れた現金で起動しない」(起動完全性)だけです。起動を通り抜けたあとの売買サイクルや、再起動で取りこぼした周辺データには別の不具合が残っていました。今回直した 5 つを、A〜E で並べます。
| 符号 | 不具合 | 性質 |
|---|---|---|
| A | 発注を止める防壁が in-memory の現金頼み | 発注ガード |
| B | その現金自体が壊れうる | 発注ガード |
| C | 物理口座は共有プールで枯渇しうる | 物理整合 |
| D | 保有銘柄の現在値が更新されない | 起動完全性 |
| E | 資産推移グラフから 1 日が消える | データ整合 |
最初の A が、今回いちばん大きな教訓です。
A 発注ガードを置く場所を間違えかけた
前回直したのは「壊れた現金では起動しない」を保証しただけで、起動後に枠を超える注文を止める防壁は in-memory の現金残高頼みのまま。その現金は今回まさに壊れた値です。これを定数基準の防壁に作り替えるのが Phase 4 の主題でした。
問題は、防壁を どこに足すか です。Chillarin Trading Lab(CTL)のリスク判定には RiskManager.check_signal という、いかにも発注の入口らしい名前の関数があり、最初はここにガードを足すつもりでした。ところが本番のコードを追うと、ライブの実取引はそこを通っていなかった。CTL の 2 戦略 — 中長期保有の Fund A と機械的テクニカルルールの Fund B — は、弱気相場での発注ゲートや自動ストップ注入といった戦略に合わない処理を避けるため、check_signal を意図的にバイパスし、_hold_size_order という別メソッドで注文サイズを計算していました。実際にお金を動かすチョークポイントはこちらです。
つまり check_signal にどれだけ立派なガードを足しても、ライブには 1 ミリも効かない。ユニットテストは通り、レビューでも一見正しく見える。本番でその関数を一度も通らないなら、防壁としては存在しないのと同じです。今回いちばんの勘所はここでした。
そこで実チョークポイント _hold_size_order に論理枠キャップを設置し、check_signal 側にも同じガードをバックアップで二重配置しました。
教訓は明快です。コードを直す前に、本番でその関数が本当に通る経路かを確かめる。 テストが通っても本番経路が違えば意味がない。「絶対に効いてほしい」ガードほど、置く場所を実行経路から逆算する必要があります。
B 壊れない防壁 — 定数基準のハードキャップ
場所が決まったら、次は防壁の中身です。in-memory の現金(cash)は再起動のレース一つで偽の $1,500 にリセットされる。その壊れうる値を発注ガードの基準にするのは、土台が崩れる場所に防壁を建てるようなものです。
そこで基準を、壊れうる cash から 定数 initial_cash($1,500)を起点にしたハードキャップ に変えました。不変条件はこうです。
Σ(保有銘柄の簿価) + 新規発注額 ≤ initial_cash
initial_cash は再起動でも例外でも書き換わらない定数なので、cash や資産額がどう壊れてもキャップの位置はびくともしません。実装は、論理枠の残りを返すヘルパーに集約しました。
def logical_headroom(self) -> float:
# 論理枠の残り = initial_cash − Σ簿価。in-memory cash に依存しない。
return self.initial_cash - sum(p.cost_basis for p in self.positions.values())これを実チョークポイント _hold_size_order で、注文サイズの上限として掛けます。
# 既存の現金残ガードに加え、initial_cash 基準のハードキャップを掛ける。
# cash が偽の初期値にリセットされても initial_cash(定数) 基準なので影響を受けない。
headroom = fund.portfolio.logical_headroom() # = initial_cash − Σ簿価
order_usd = min(order_usd, headroom)
if order_usd < cfg.min_order_usd:
return 0 # 枠が無ければ 0 株一点こだわったのは、保有銘柄の評価に時価でなく 簿価(取得単価 × 株数)を使う ことです。時価で測ると株価が上がるだけで枠が縮み、下がれば枠が広がり、健全な値動きでキャップの位置がぶれて正常な売買を誤検知します。簿価で見れば値動きはノイズにならず、「買った分だけ枠を消費したか」という保存則だけを純粋に守れます(前回の _assert_state_consistent と同じ思想です)。
検算します。事故時点で Fund A の保有簿価は約 $1,442、論理枠の残り(headroom)は約 $58。一方、自動売買システムが偽の現金 $1,500 を信じて出そうとした GOOG の注文は $726。このキャップがあれば min(726, 58) = 58、最小発注額に満たず、誤発注の GOOG は即 0 株に落ちていました。起動時のチェック(前回)と発注時のキャップ(今回)で二段に守る形になりました。
C 失敗したとき、止めるか通すか
論理枠ハードキャップ(B)で DB 上の枠は守れますが、それだけでは守れない弱点がもう 1 つ。物理口座 です。CTL の証券口座は実は 1 つで、2 戦略が現金プールを共有します。DB 上は「Fund A の枠 $1,500」「Fund B の枠 $1,500」と論理分割していても、実際にお金が出ていく財布は 1 つ。Fund A の論理枠に余裕があっても、物理現金が Fund B 側で使い切られていれば発注は通りません。
そこで発注直前に買付余力を照会し「発注額 ≤ 余力 × 0.5」を強制するチェックを足しました。問題は、この余力照会そのものが失敗したとき にどうするか。証券会社の API は一時的に落ちますし、タイムアウトもします。照会が返らないとき、発注を 止める(fail-fast)か通す(fail-open)か。前回は迷わず fail-fast を選びましたが、今回はあえて fail-open を選びました。
def _check_buying_power(self, order_usd: float) -> bool:
ret, data = self._session.ctx.accinfo_query(trd_env=TrdEnv.REAL, currency="USD")
if ret != RET_OK or data is None or data.empty:
# 照会失敗 = fail-open。論理枠キャップが主防壁なので売買は止めない。
# ただし握りつぶさず skip をメトリクスで可視化する。
REAL_BUYING_POWER_CHECK_SKIPPED.labels(fund=self.fund).inc()
return True
avail = float(data.iloc[0]["power"]) # 買付余力
return order_usd <= avail * MAX_ORDER_CASH_RATIO # 0.5理由は防壁の 役割の違い です。主防壁は定数基準で壊れない論理枠ハードキャップ(B)。物理余力チェック(C)は、それをすり抜けた稀なケースを拾う 補助防壁 にすぎません。補助防壁の照会が一時的な API 障害で返らなかっただけで正常な売買まで全部止めるのは過剰反応です。
ただし fail-open は、前回いちばん批判した「例外の握りつぶし」と同じ罠の裏返しでもあります。黙って通せばそれこそ静かに壊れる。なので照会をスキップした回数を Prometheus メトリクス(real_buying_power_check_skipped_total)に記録し、ダッシュボードから観察できる ようにしました。通すけれど、通したことは見える。
整理がついたのは、常に fail-fast が正しいわけではない ということです。壊れた状態で走り出すと取り返しがつかない場面(起動完全性)は fail-fast、主防壁が別にあり一時障害で全部止めるほうが損な場面(補助防壁)は fail-open。その防壁が主か補助かで、fail の方針を選び分けます。
D 値が動かなくなった銘柄
ここからは、再起動で取りこぼしていた周辺データの話です。発注ガード(A〜C)とは性質が違います。
修正後、1 銘柄だけ損益が +0.00% から動かないことに気づきました。Fund B が買った、デフォルト監視 28 銘柄の 外 にある銘柄です。原因は「現在値が取れていない」ではなく「そもそも取りに行っていなかった」でした。現在値の出所である 1 分足テーブルに書くのは監視銘柄リスト self.symbols のポーリングだけで、このリストは起動時にデフォルト 28 銘柄で初期化され、保有建玉を再投入していなかった。28 銘柄外の保有銘柄は再起動のたびに購読から脱落し、1 分足が記録されず取得単価のフォールバックのまま固まります。
修正は、起動時に全戦略の保有銘柄を購読リストへ union する処理を、real-trader の起動(初期化)フェーズに入れただけです。
def _resubscribe_held_positions(self) -> None:
held = set()
for fund in self.funds.values():
held |= set(fund.portfolio.positions.keys())
added = [s for s in sorted(held) if s not in self.symbols]
if added:
self.symbols.extend(added) # 28 → 30 銘柄
log.info("[startup] Re-subscribed %d held symbol(s): %s", len(added), ", ".join(added))起動ログで監視銘柄が 28 → 30 に増え、脱落していた 2 銘柄が購読に戻ったことを確認しました。表示の不在は、取得の失敗とは限らない。今回は取りに行く対象リストから漏れていただけ、という引っかかりやすい罠でした。
E グラフから消えた1日
もう 1 つ、資産推移グラフから 6 月 3 日が丸ごと欠落していた 問題がありました。原因が 2 つ重なり、片方だけ見ても気づけない構造でした。
silent failure は 1 箇所直しても同型が他にいる
1 つめは、前回直したものと 同じ型のバグ でした。6/3 00:46 の再起動で日次の資産スナップショットを記録するバッチが動いたものの、DB がまだ起動しきっていないタイミングに当たり、Connection refused で失敗し、通知もなく静かに終わっていました。前回直したのは状態の 復元側(real-trader) の silent failure でしたが、スナップショットを保存するバッチ側にも同型が残っていた わけです。
ここに今回いちばん汎用的な教訓があります。silent failure は 1 箇所直しても、同じ型が他のコンポーネントに潜んでいる。 バッチ側にも接続リトライと失敗通知を足し、状態保存の失敗も portfolio_state_save_failed_total で可視化しています。
タイムゾーン基準は、系列を重ねて初めてズレる
2 つめは、もっと見つけにくいものでした。6/3 22:35 UTC の正常実行分は走っていましたが、そのスナップショットは JST 基準の日付ロジック で日付を決めており、22:35 UTC は JST で翌朝なので date=6/4 として保存されていたのです。一方、集計バッチは米国の取引日(ET 基準)で動く。スナップショットは JST 基準、集計は ET 基準 で、2 つのテーブルの日付軸が食い違っていました。
その結果、6/3 のデータは「中身は 6/3 クローズの実データなのにラベルは 6/4」という形で DB に入り、ET 基準で並べる集計バッチからは 6/3 が空白に見えていた。グラフから 1 日が消えた正体はこれです。
修正は、スナップショットの日付決定を JST から 米国 ET 取引日基準 に統一し、集計バッチと揃えることです。
# 旧: JST の今日 → 22:35 UTC 実行分が翌日ラベルになりグラフから当日が欠落
# snap_date = datetime.now(ZoneInfo("Asia/Tokyo")).date()
# 新: 米国 ET 取引日。22:35 UTC = ET 当日夕方なので、その取引日(クローズ済)になる
snap_date = datetime.now(ZoneInfo("America/New_York")).date()連鎖して、ベンチマーク(SPY)系列の日次集計も JST から ET に揃え直しました。fund 系列が ET・SPY 系列が JST のままだと、グラフ上で比較線が 1 日ずれて重なるためです。
さらにデータ補正。すでに date=6/4 で入っていた行(中身は 6/3 クローズの実データ)を date=6/3 に付け替えました(主キー衝突を防ぐガード付きトランザクション)。数字そのものは一切作っていません。 ラベル(日付)の貼り替えだけで、資産額の中身は実データのまま。間違った棚に置かれていたデータを正しい棚に戻した作業です。
厄介なのは、タイムゾーン基準の不一致は片方だけ見ても気づけない ことでした。スナップショット側だけ見れば「6/4 にちゃんと記録がある」、集計側だけ見れば「6/3 が無い」。どちらも単体では正しく動いて見え、2 つの系列を重ねた瞬間に初めて 1 日ずれていることが分かります。系列をまたぐ日付軸は重ねて検算する必要がある、という教訓です。
補正後の資産推移は、論理枠 $3,000 を起点に緩やかにマイナスで推移する「あるべき姿」に戻りました。
| 日付(ET) | 合計 equity | リターン |
|---|---|---|
| 6/1 | $3,000.00 | 0% |
| 6/2 | $2,988.97 | −0.37% |
| 6/3 | $2,921.35 | −2.62% |
最後にもう一手、不揃いを過去データで揃えました。D の修正後も、当該銘柄(BG)の 1 分足だけ取りこぼした分が空白で、他銘柄が 6/3 クローズ値で揃うなか 1 銘柄だけゼロでした。ここで使えたのが、moomoo のデータ取得 API が 過去の直近バーを遡って取得できる ことです。他銘柄と同一時刻(6/3 19:59 UTC のクローズ値)の BG を遡って取得し 1 分足に記録すると、全保有銘柄が同一の基準時刻で揃い、BG の損益も +0.00% から +$8.68(+1.68%) に解消、合計資産は $2,930.03 になりました。リアルタイムで取り損ねても、データソースが遡れるなら後から揃えられる、という運用 Tips です。
検証と本番反映
新規テストを Phase 4(発注ガード)17 件、Phase 5(起動完全性・データ整合)12 件(うち日付基準 3 件)追加し、既存 35 件と合わせ 全 61 件 pass、リグレッションなし を確認しました。
本番反映はデプロイスクリプトを使わず、変更ファイルだけをピンポイント配置(バックアップ + md5 一致 + 本番 venv py_compile 確認)。real-trader の停止は Phase 4 と Phase 5 で 1 回にまとめ、市場がクローズしている窓を選びました。実弾を動かしている以上、止める回数は最小に抑える方針です。
起動後の論理枠は、両ファンドとも保存則を満たしていることを起動ログで確認しました。
| Fund | cash | invested | 合計 | initial | drift | 判定 |
|---|---|---|---|---|---|---|
| A | $53.28 | $1,442.50 | $1,495.78 | $1,500 | $4.22 | OK |
| B | $24.70 | $1,468.97 | $1,493.67 | $1,500 | $6.33 | OK |
両ファンドとも drift は許容範囲($75)に収まり、前回新設した起動時の保存則チェックを通過しています。
教訓
- コードを直す前に、本番でその関数が本当に通る経路かを確かめる。 発注ガードを
check_signalに足しても、ライブがそこを通っていなければ防壁として存在しないのと同じでした(A)。 - fail-fast 一辺倒が正義ではない。 起動完全性のように壊れた状態で走り出すと致命的な場面は fail-fast、主防壁が別にある補助チェックの一時障害は fail-open。fail-open に倒した分は必ず観察可能にする(C)。
- silent failure は 1 箇所直しても、同じ型が他のコンポーネントに潜んでいる。 前回直した復元側だけでなく、スナップショット保存側にも残っていました。1 つ見つけたら同型を横展開で探す(E)。
- タイムゾーン基準の不一致は、系列を重ねて初めてズレが見える。 スナップショット側も集計側も単体では正しく見え、重ねた瞬間に 1 日ずれが分かりました(E)。
前回が「気づいて直した」話だとすれば、今回は「構造的に二度と起きないようにし、前回見えていなかった周辺の不具合まで片付けた」話でした。お金が絡む実験では、この「端から直す」しつこさが効いてきます。次に何か起きたら、また続報を書きます。
関連記事
- 前回のインシデント(本記事の前提) — 再起動したら資産が2倍に増えてた — システムのよくある罠にハマった話。今回直した不具合のうち 1 つめを、気づいてから直すまでの一次記録です。
- 運用を始めた経緯 — 米国株 $3,000 を AI とテクニカルルールで動かす — Chillarin Trading Lab を始めた。
- システムの仕組み — Chillarin Trading Lab の仕組み — AI 自動売買の全体像。
- 売買シグナルの定義 — Chillarin Trading Lab 売買シグナル解説。
- 日々の成績 — 現在の保有や資産の推移は Chillarin Trading Lab のダッシュボード で見られます。
注記
- この記事は投資助言ではありません。個人が自分の少額資金で行っている実験の、トラブル対応の記録です。
- 記事内の $ 値は、論理枠(戦略ごとに割り当てた $1,500)の上での金額です。物理的な証券口座の残高そのものではありません。
- 元手は Fund A・Fund B あわせて $3,000($1,500 × 2)。失っても生活に影響しない範囲に意図的に抑えています。
- データの補正は、間違った日付ラベルの貼り替えと、過去バーのバックフィルに限られます。資産額の数字そのものは作っていません。
- 売買とシステム運用の最終的な判断と結果の責任は、すべて運用している私自身(ちらりんの飼い主)にあります。読まれる方ご自身の投資判断は、ご自身の責任で行ってください。
コメント