Bot CEX 開発ログ

🛠️開発記録#470(2026/3/3)「multi_market_probe_v1 再設計 ― WS神経系+保存最小化プロファイルへ」

これまでの multi_market_probe_v1 は、「見えるものはすべて保存する」という思想で設計してきました。
板も約定も、複数市場を同時に、可能な限り高頻度で記録する。
観測機としては正しいアプローチに思えました。

しかし、7時間の稼働でストレージが約10GB増加し、さらに Raw logger の queue fulldropped が発生。
“高精度で取っているはずのデータ” に欠損が混じっているという、設計上の矛盾が露呈しました。

今回の再設計では、観測を二層に分離します。

  • WS神経系:リアルタイム性を担保する層(signal・health監視)
  • 保存最小化プロファイル:欠損ゼロを優先する検証骨格層

すべてを保存するのではなく、
目的に対して十分な情報だけを、安定して残す。

multi_market_probe_v1 はここから、
「全部取る観測機」から「壊れない観測機」へ進化します。

Ⅰ. 7時間で+10GB ― 何が起きたのか

今回の観測ランでは、multi_market_probe_v1 を全市場・全チャネル保存モードで約7時間稼働させました。
結果はシンプルです。

ストレージ +10GB
Raw logger queue full 発生
dropped 累積 50,000件超(最終 50,825件)

数字だけ見れば「保存しすぎた」で済みます。
しかし実際に起きていたことは、もう少し構造的でした。


1. 保存構成:何を“全部”取っていたのか

当時の構成では、以下を常時保存していました。

  • bf_fx: ticker + executions + board + board_snapshot
  • bf_spot: ticker
  • binance_perp: ticker + board + executions(WS)
  • snapshot_stream(market/spread)
  • event_stream(SIGNAL/FEED/STATE)

raw_stream は以下のような設定でした。

raw_stream:
enabled: true
queue_maxsize: 50000
batch_size: 200
flush_interval_sec: 0.25
partition_by_market_dir: true
partition_by_kind_dir: true
channel_split: true

つまり、

  • 市場別
  • 種別別(ticker/executions/board/board_snapshot)
  • さらにWS生データそのまま保存

という完全保存プロファイルでした。


2. 何が最も流量を生んでいたか

実測では、流量最大は以下でした。

  • binance_perp の bookTicker(WS)
  • bf_fx の board + executions
  • snapshot_stream(0.2秒間隔 emit)

特に Binance の WS は流量制御がなく、価格変動が荒れる局面では瞬間的にスパイクします。

このとき発生したのが、

Raw logger queue full: dropped=50000 queue_size=50000

です。

ここで重要なのは、

  • queue_size=50000 は現在のキュー占有数
  • dropped=50000捨てた件数の累積

という点です。

実装上、asyncio.Queue.put_nowait()QueueFull を投げると、

except asyncio.QueueFull:
self._stats["dropped"] += 1

という処理でデータは破棄されます。

つまり、

“保存しているつもり” のデータの一部は
実際には保存されていなかった

という状態でした。


3. 何がボトルネックだったのか

構造は単純です。

  • 生産(WS入力) > 消費(ディスク書き込み)
  • それが継続
  • queue_maxsize 到達
  • drop開始

raw_stream の書き込み設定は

batch_size=200
flush_interval_sec=0.25

でした。

これは低レイテンシ寄りの設定です。
しかし観測ログに求められるのは「即時性」より「欠損しないこと」です。

この時点で、

  • 高頻度入力
  • 細かいflush
  • 複数市場
  • partition_by_kind_dirでファイル分割

が重なり、I/O が追いつかなくなっていました。


4. さらに悪いのは“気づきにくい”こと

dropが起きても、

  • ファイルは増え続ける
  • Grafanaも更新される
  • サービスは落ちない

つまり、

表面上は正常稼働に見える

しかし実際は、

  • 板の連続性が欠損
  • 約定の一部が消失
  • その上に作るメトリクスが歪む

という、静かなデータ破壊が起きていました。


5. ストレージの内訳

当日観測時点のディレクトリ容量は以下。

  • data/mmarb_raw: 534MB(圧縮後)
  • data/mmarb_snapshot: 4.7GB
  • data/mmarb_logs: 4.5GB
  • data/mmarb_signal: 112MB

rawよりも snapshot/log の方が大きいのも重要な発見でした。
(mmarb_snapshot: 4.7GBもmmarb_logs: 4.5GBも後に圧縮ローテに編入しました)

つまり問題は

「WSが重い」だけではない
「保存レイヤー全体が重い」

ということ。


6. 本質的に何が間違っていたか

当初の思想はこうでした。

見えるものは全部保存すれば後から検証できる

しかし実際は、

  • 保存しきれない量を取る
  • dropで穴あきになる
  • 精度が落ちる

という逆転が起きました。

ここで気づいたのは、

高解像度 ≠ 高精度

ということです。

10ms単位で取れていても、
途中で欠損すればそれは「精密」ではありません。


7. ここから得た結論

7時間 +10GB は失敗ではありませんでした。

これは、

  • 保存戦略の限界点
  • queue設計の限界
  • WS流量の現実

を数値で確認した実験でした。

そしてこの出来事が、今回の再設計

「WS神経系 + 保存最小化プロファイル」

へと繋がります。

次章では、
なぜWSは万能ではないのかを掘り下げます。


Ⅱ. dropped=50000 の意味

ログに出ていた一行。

Raw logger queue full: dropped=50000 queue_size=50000

これを「キューが満杯になった」程度に読むのは危険です。
この数字が意味しているのは、もっと重い事実です。


1. dropped は「現在のサイズ」ではない

まず誤解しやすい点から。

  • queue_size=50000 はその瞬間のキュー占有数
  • dropped=50000捨てた件数の累積

実装上は、asyncio.Queue.put_nowait()QueueFull を投げたときに、

except asyncio.QueueFull:
self._stats["dropped"] += 1
return False

という処理が走ります。

つまり、

50000件、ログとして記録されなかった

という意味です。

これは「遅れた」ではありません。
消えたです。


2. 何が消えていたのか

消えていたのは raw ログです。

raw には

  • WSの板更新
  • 約定情報
  • ticker payload
  • 市場別生データ

が含まれます。

つまり、

  • 板の更新の一部
  • 約定の一部
  • 価格変動の瞬間

が保存されていない可能性がある。

そしてこの raw の上に、

  • snapshot
  • spread算出
  • premium状態判定
  • signal生成

が乗っています。

基礎データが欠けると、上位レイヤーは静かに歪みます


3. なぜ queue full が起きるのか

構造は単純です。

入力(WS受信) > 出力(ディスク書き込み)

この状態が続くと、キューは必ず満杯になります。

今回の設定は以下でした。

queue_maxsize: 50000
batch_size: 200
flush_interval_sec: 0.25

つまり、

  • 200件ずつ
  • 0.25秒間隔で
  • ディスクへ書き出す

という設計です。

しかし入力は、

  • binance_perp WS
  • bf_fx board
  • bf_fx executions
  • snapshot_stream(0.2秒間隔)

という多層構造。

スパイク局面では入力が急増し、
出力が追いつかなくなります。

そして queue が溢れた瞬間から、
データは無言で破棄され始めます。


4. dropped が意味する3つの危険

(1) “事実ログ”の欠損

板の連続性が壊れると、

  • 価格が飛んだように見える
  • 流動性が急減したように見える

が、実際は「保存されなかっただけ」かもしれない。

(2) メトリクスの歪み

lead/lagやpremium持続時間の計算は、

  • 連続したtick列
  • 欠損のないmid系列

を前提としています。

欠損が入ると、

  • persistence が短く見える
  • slope が急に変化したように見える
  • regime判定が乱れる

原因が市場ではなくロガーになります。

(3) 気づきにくい

最も厄介なのはこれです。

  • サービスは落ちない
  • Grafanaは更新される
  • ファイルは増える

つまり、

正常に動いているように見える

しかし内部では欠損が進行している。

これが一番危険です。


5. 高解像度データのパラドックス

今回の観測は「高精度に取ろう」とした結果、

  • 高頻度
  • 全量保存
  • 全市場同時

を選択しました。

しかし drop が発生した瞬間、

高解像度 ≠ 高精度

になります。

10ms単位で受信しても、
途中で抜ければそれは精密ではありません。

むしろ、

  • 500msサンプリングでも欠損ゼロ

の方が、統計的には信頼できます。


6. dropped=50000 が示したもの

この数字は単なる警告ではありません。

それは、

  • 保存設計の限界点
  • 流量制御の不在
  • レイヤー分離の必要性

を示すシグナルでした。

multi_market_probe_v1 は

「全部取る観測機」から
「壊れない観測機」へ

移行する必要がある。

dropped=50000 は、その転換点でした。


Ⅲ. WSは万能ではない

これまでの自分の前提は、ほぼ無意識にこうなっていました。

リアルタイム観測なら WebSocket が最適解
低レイテンシ=高精度

実際、multi_market_probe_v1 も WS 前提で設計されています。
transport: websocket を指定すれば、各市場ごとに WS アダプタが立ち上がります。

例えば bitFlyer の場合は、

async def _run_bitflyer_ws_stream(...)

Binance なら、

async def _run_binance_ws_stream(...)

といった具合に、WS ストリームを常時監視する実装になっています。 multi_market_probe_v1

設計としては正しい。
しかし今回の実験で分かったのは、

WSは「速い」が、「壊れない」わけではない

ということでした。


1. WSの強み:神経系としては最適

WebSocket の強みは明確です。

  • 低レイテンシ
  • push型
  • イベント駆動
  • 板や約定の瞬間を取り逃さない

リアルタイムシグナル生成や health 監視には最適です。

multi_market_probe_v1 の内部でも、

  • MARKET_TICK
  • FEED_STATE
  • SIGNAL

などは、WSからの即時入力を前提にしています。 multi_market_probe_service

このレイヤーは、まさに「神経系」です。


2. しかし、WSには“流量制御”がない

WSの弱点はここです。

  • 入力レートをこちらで制御できない
  • スパイクがそのまま飛び込んでくる
  • 市場の荒れ=そのまま流量増加

今回 queue full が起きたのは、

入力(WS) > 出力(ディスク)

が継続したためでした。

WSは止まりません。
しかしロガーのキューには上限があります。

そして上限に達した瞬間から、

低レイテンシのはずのデータが「捨てられる」

という逆転現象が起きます。


3. 高解像度の罠

WSで10ms単位の更新を受け取っても、

  • 途中で500件落ちる
  • 板の一部が保存されない
  • 約定の一部が抜ける

と、それはもう「精密」ではありません。

むしろ、

  • 200ms間隔で
  • 欠損ゼロで
  • 長時間安定

の方が、状態量分析には向いている。

WSは解像度を上げますが、
完全性を保証しません。


4. pollingは“劣化”ではない

ここで polling を選ぶと、

精度が下がるのでは?

という直感が働きます。

しかし、精度には二種類あります。

種類WSpolling
時間解像度
欠損耐性

今回必要だったのは、後者でした。

雑スクリーニング段階では、

  • premium の持続時間
  • regime 分類
  • 比較軸作成

が目的です。

そのために必要なのは、

10ms刻みの板ではなく
欠損のない mid 系列

でした。


5. WSをやめるのではない

重要なのはここです。

結論は、

WSを捨てる

ではありません。

正しくは、

WSを“全量保存しない”

です。

WSは神経系として維持する。

しかし保存は、

  • サンプリング
  • 集約
  • polling併用

で流量を制御する。

multi_market_probe_v1 の再設計は、

WS=リアルタイム層
保存=検証骨格層

という分離へ向かいます。


6. 今回の教訓

WSは速い。
しかし速さは万能ではない。

  • 保存できなければ意味がない
  • 欠損が出れば信頼性が崩れる
  • 高解像度はコストを伴う

今回の queue full は、

WSが強すぎた瞬間

でした。

そしてその経験が、

WS神経系+保存最小化プロファイル

という設計へと繋がっています。

WSは万能ではない。
だが、正しく使えば最強である。

次章では、その“正しい使い方”としての
二層構造設計について書きます。


Ⅳ. 観測を“2レーン”に分ける

queue full と dropped=50000 を経て分かったことは単純でした。

「観測」は一枚岩ではない。

リアルタイムに反応するための観測と、
長時間比較・検証のための観測は、
求める特性がまったく違います。

そこで multi_market_probe_v1 を、
2つのレーンに分離するという設計に切り替えました。


レーン1:WS神経系(リアルタイム層)

これは即時性を最優先する層です。

役割

  • SIGNAL生成
  • FEED_STATE監視
  • latency/stale検知
  • distortion判定

実装上は、

_run_bitflyer_ws_stream()
_run_binance_ws_stream()
_run_bybit_ws_stream()

といった WebSocket アダプタが担います。 multi_market_probe_v1

この層の特徴は:

  • レイテンシが重要
  • 最新状態が重要
  • 過去全量保存は不要

ここで必要なのは「今の状態」です。

多少過去が抜けても、
“今LIVEかどうか”が分かれば役割は果たせます。

だからこのレーンでは、

  • WSは維持
  • raw保存は最小化
  • health重視

という思想になります。


レーン2:検証骨格(保存層)

こちらは逆です。

役割

  • premium分布作成
  • persistence分析
  • regime比較
  • lead/lag検証
  • 将来リターン検証

ここで重要なのは、

  • 欠損ゼロ
  • 比較可能性
  • 流量上限管理

時間解像度はある程度落としても構いません。

例えば:

  • 200msサンプリング
  • 500ms集約
  • 1秒リサンプル

でも、統計的な比較には十分です。

むしろ重要なのは、

同じ条件で、長時間、安定して残ること。


なぜ1レーンではダメだったのか

これまでの設計は、

WSで受けたものを全量保存

という単一レーンでした。

しかしこの設計だと、

  • WSの流量が保存層を圧迫
  • queue full発生
  • 欠損
  • 信頼性低下

という構造的な矛盾が起きます。

リアルタイム性と保存完全性は、
同じレイヤーで両立しにくい。

だから分ける。


実際の切り替え

今回のA+軽いBの反映では、

  • bf_fx board_snapshot 常時OFF
  • binance_perp を polling に切替
  • raw_stream batch/flush 緩和

という第一段階を実施しました。

次段階では、

  • raw_stream.enabled=false(全量raw保存停止)
  • snapshot_stream / event_stream のみ最小限維持
  • WS tickerを内部リサンプルして保存

へ進みます。

つまり、

WSは動かす
保存は制御する

という構造です。


2レーン設計の本質

この分離は、単なる負荷対策ではありません。

それは観測思想の転換です。

  • 神経系は速く
  • 骨格は強く

速さは神経の役割。
安定は骨格の役割。

multi_market_probe_v1 はここから、

全部取る観測機
から
構造を分けた観測機

へ進化します。

そしてこの分離が、
メトリクス生成の段階化へと繋がっていきます。


Ⅴ. 壊れない観測機への第一歩

queue full と dropped の発生を受けて、
今回まず実施したのが A(入力削減)+軽いB(書き出し強化) です。

これは設計思想の大転換というより、
「まず壊れない状態に戻す」ための安定化パッチです。


A:入力削減(最優先)

まずやったのは、流量そのものを減らすことです。

1. bf_fx の board_snapshot 常時OFF

bf_fx:
raw_ws_channels:
executions: true
board_snapshot: false
board: true

board_snapshot は情報量が大きく、
かつスクリーニング段階では常時不要。

短期検証時のみONにする方針に変更しました。


2. binance_perp を websocket → polling に切替

binance_perp:
transport: polling
poll_interval_sec: 0.75

ここが最も効きました。

観測結果では、

binance lines/min ≈ 80

まで流量が低下。

WSでは市場スパイクがそのまま流量増加に直結しますが、
pollingはアプリ側で流量上限を強制できる

今回のフェーズ(雑スクリーニング)では、
この方が適切でした。


軽いB:書き出し強化(補助)

入力を絞った上で、書き出し側も少し強化。

raw_stream:
batch_size: 500
flush_interval_sec: 0.5

変更前:

  • batch_size=200
  • flush=0.25秒

変更後:

  • batch_size=500
  • flush=0.5秒

これは「ディスクI/O回数を減らして、
一回あたりの吐き出し量を増やす」調整です。

低レイテンシよりも欠損ゼロ優先の設定に寄せました。


結果

再起動後の観測では、

  • Raw logger queue full 未発生
  • dropped 増加なし
  • board_snapshot 2分間 +0 lines
  • binance流量安定
  • Prometheus更新継続

つまり、

観測機は壊れなくなった

という状態まで戻せました。


重要なのは“まだ途中”ということ

今回のA+軽いBは、あくまで安定化です。

まだ、

  • WS神経系の最適化
  • raw保存の完全停止プロファイル
  • 内部リサンプル層の実装

は未着手。

しかし、

queue full が出ない
dropped が増えない

という状態を確保できたことで、
ようやく次フェーズ(メトリクス段階化)に進めます。


今回の学び

  • 入力削減が最も効く
  • 出力強化は補助
  • 流量を制御できないWSは危険
  • 保存は“全部”でなく“必要十分”

A+軽いBは地味ですが、
観測機の再設計における重要な土台です。

ここから先は、

WS神経系 + 保存最小化プロファイル

という本丸に入ります。


Ⅵ. 明日やること

A+軽いBで「壊れない状態」には戻りました。
しかしこれはあくまで安定化です。

明日やることは、multi_market_probe_v1 を本来の構造へ切り替えることです。


1. WS神経系へ戻す

今回、流量抑制のために binance_perp を polling に落としました。

しかし最終的な構造は、

WS=神経系
保存=最小化

です。

したがって明日は:

binance_perp:
transport: websocket

へ戻します。

目的は、リアルタイム性の回復

  • distortion検知
  • lead/lag観測
  • premium状態変化

これらはWS前提の方が自然です。

ただし今回は、全量保存しません。


2. raw_stream を停止する

これが最大の変更です。

raw_stream:
enabled: false

今回の検証で分かったことは、

生データ全量保存はスクリーニング段階では不要

という事実でした。

rawは短期調査時のみONにします。

これで、

  • queue圧迫ゼロ
  • droppedゼロ
  • I/O負荷低下
  • ストレージ増加抑制

が同時に達成できます。


3. snapshot / event を“必要最小限”にする

snapshot_stream と event_stream は維持します。

しかし、

  • 出力頻度
  • 保存対象
  • ローテーション間隔

を再確認します。

目的は、

「観測可能」ではなく
「比較可能」にすること。

メトリクス生成に必要な粒度だけを残します。


4. 再起動後の即時検証

明日のチェックポイントは明確です。

  • Prometheusメトリクスが継続更新していること
  • Raw logger started が出ないこと(raw無効確認)
  • Grafanaの主要パネル(market/spread/signal)がリアルタイム更新すること
  • queue full / dropped が出ないこと

ここがクリアできれば、

WS神経系+保存最小プロファイル

への移行成功です。


5. 余力があれば:検証骨格層の設計

次のステップとして考えているのは、

  • WS ticker を内部で 200ms / 500ms / 1s にリサンプル
  • それだけを保存
  • board/executions は短期フラグでON

という構造。

これは、

リアルタイム神経
統計骨格

の明確な分離です。


6. 明日の本質

明日の作業は単なる設定変更ではありません。

それは、

「全部取る」から
「目的に十分なものだけ残す」

への転換です。

multi_market_probe_v1 はここから、

観測装置ではなく、
構造化された観測機になります。

明日はその第一歩です。


Ⅶ. 今日の結論

今日はコードを書いたというより、
観測の前提を書き換えた日でした。

7時間で+10GB。
Raw logger queue full。
dropped=50000超。

最初は単なる負荷問題に見えました。

しかし本質は違いました。


高解像度は高精度ではない

WSで全量を取れば精密になる。

そう思っていました。

しかし実際には、

  • 保存しきれない
  • キューが溢れる
  • データが欠損する

結果として、

速いが穴あき

という状態になっていました。

それは高精度ではありません。


観測は一枚岩ではない

今日一番大きな気づきはこれです。

  • リアルタイム監視と
  • 長時間検証

は同じではない。

WSは神経系としては最適。
しかし保存骨格としては強すぎる。

だから分ける。


A+軽いBは安定化にすぎない

  • 入力削減
  • 書き出し強化
  • queue full解消

これは土台の修復です。

本丸はこれからです。


multi_market_probe_v1 は次の段階へ

ここからは、

WS神経系 + 保存最小化プロファイル

という構造へ移行します。

全部を保存する観測機ではなく、

  • 壊れず
  • 欠損せず
  • 比較可能で
  • 構造を抽出できる

観測機へ。


今日の一文

観測可能 ≠ 保存可能
保存可能 ≠ 比較可能

今日の10GBは無駄ではありません。

それは、

観測設計の限界を
数字で確認できた証拠でした。

multi_market_probe_v1 は、
ここから本当に“使える観測機”になります。

-Bot, CEX, 開発ログ