WebSocketで米国株ティックデータを受けるとRESTポーリングで消えていた約定が見える
目次
DEV Communityの「Is Your Real-Time Feed Lying to You? Streaming US Stock Tick Data with WebSockets」を読んだ。
売買判断の話ではなく、ダッシュボードやバックテストで「保存したはずのデータに何が残っていないか」を整理する視点として読める。
自分のかなチャットのWebSocket実装と比較しながら、パイプライン設計と接続安定性について考えた。
REST APIのポーリングで消えるもの
REST APIで数百ミリ秒〜1秒おきに価格を取りに行く構成だと、得られるのは「その時点の集計結果」になる。
典型がOHLCVと呼ばれるバー形式だ。
一定時間(1秒、1分、5分など)の中で最初についた価格がOpen(始値)、最も高い約定がHigh(高値)、最も低い約定がLow(安値)、最後の約定がClose(終値)、その間の総出来高がVolume。
この5つで1本のバーになる。
OHLCVバーは「その期間中に何が起きたか」の要約であって、個々の約定は入っていない。
たとえばバーの高値と安値が同じだったとしても、最初に大口の売りが出てから小口の買い戻しが続いたのか、細かい約定のあと最後にだけ大口が飛んだのかは区別がつかない。
約定1件ごとのサイズ、取引所側のミリ秒タイムスタンプ、板に当たった方向(aggressor side)は集計の時点で捨てられている。
価格表示だけならOHLCVで足りる。
注文フロー分析、出来高プロファイル、異常約定の検知、あるいはバックテスト用の正確な約定ログが欲しいときに「もう取り返せないデータ」が出てくる。
WebSocketで約定イベントを1件ずつ受ける
WebSocketはコネクションを張りっぱなしにして、サーバーからイベントが発生するたびにメッセージが飛んでくる。
RESTポーリングのように毎回リクエストを投げてレスポンスを待つ形ではないので、約定が起きた瞬間にデータが届く。
低レイテンシ・リアルタイム同期の記事で整理した「双方向・低遅延」の典型的な使い方だ。
受信してからどう処理するかが問題になる。
受信コールバックの中でDB書き込みやUIの描画を直接やると、そこが詰まった瞬間にWebSocketのメッセージ受信自体が止まる。
パイプラインは受信→キュー→後段処理に分ける。
graph LR
A["市場データAPI<br/>WebSocket"] --> B["受信プロセス"]
B --> C["キュー"]
C --> D["永続化<br/>ティック単位で全件保存"]
C --> E["集計・アラート"]
E --> F["UI更新<br/>秒4〜5回にthrottle"]
受信コールバックはJSONをパースして最低限の検証をしたらキューに入れて終わり。
保存、集計、アラート、画面更新はすべて後段で非同期に回す。
画面の描画とデータの保存は別のスロットルにする
活発な銘柄だと1秒に数十〜数百件の約定が届く。
届くたびにDOMを書き換えていたらブラウザがフレーム落ちするので、画面更新は秒4〜5回程度にthrottleする。
フロントに見せる値は最新価格と直近出来高、短い移動平均くらいで十分だ。
ここで大事なのは、画面更新を絞ったからといって保存まで一緒に絞らないこと。
永続化する側では受信した約定を1件ずつ、受信時刻・配信元時刻・シンボル・価格・サイズ・メッセージIDをそのまま書き込む。
Cloudflare Workersのリアルタイム分析記事でも、ライブ表示はKVのカウンターで秒単位に丸め、永続化先のPostgreSQLにはイベント単位で入れていた。
画面更新のthrottleと保存のthrottleを混同すると、あとでバックテストしたときに「この秒の約定が5件しかないが、本当は40件あった」みたいなことになる。
UIの都合で保存データの粒度を落としてはいけない。
WebSocketの接続切断とQUIC(HTTP/3)
WebSocketはTCP上の長時間接続だから、ネットワーク経路が変わるとコネクションが切れる。
Wi-Fiの切り替え、モバイル回線のハンドオーバー、ISPの経路変更。
クライアント側で切断を検知し、再接続して購読を張り直し、抜けた区間をREST APIでバックフィルする処理が必ず要る。
高頻度取引の世界ではここのレイテンシを極限まで詰めるために取引所の隣にサーバーを置く(コロケーション)が、ダッシュボードや日次バックテスト用のフィードならそこまでは要らない。
問題は「切れたことに気づく速さ」と「復帰の速さ」だ。
QUIC(HTTP/3のトランスポート)はここで効く可能性がある。
QUICはUDP上で動き、コネクションIDでセッションを管理するため、IPアドレスが変わっても接続が維持される。
TCPだとIPが変わった時点でコネクションは死ぬが、QUICなら同じセッションを継続できる。
0-RTT再接続もあるので、仮に切れても復帰が速い。
現状、市場データプロバイダーの大半はWebSocket(TCP)で配信していて、QUIC上のストリーミングを正式提供している例はまだ少ない。
ただしSSE(Server-Sent Events)をHTTP/3で受ける構成や、gRPCストリーミングをQUIC上に載せる実装は出始めている。
プロバイダーが対応したら切り替える準備だけしておく、くらいの距離感で十分だ。
価格が合わないときに疑う場所
WebSocketに切り替えただけで「取引画面と保存ログの値が一致しない」問題が消えるわけではない。
ズレの原因は段階的にある。
配信元のデータ種別がまず怪しい。
「リアルタイム」と書かれていても、最良気配値なのか直近約定なのか集約バーなのか15分遅延なのか。
米国株は取引所、ATS(代替取引システム)、SIP(統合テープ)、ベンダー独自の正規化が混在していて、ソースによって見える値が変わる。
タイムスタンプの意味も切り分けが要る。
取引所で約定した時刻、ベンダーが配信した時刻、自分のアプリが受信した時刻。
ネットワーク遅延で後着になったメッセージを受信時刻だけで並べると、市場での約定順序とずれる。
自分のパイプライン内部にも原因がある。
再接続中の欠損、キューのオーバーフロー、DBのユニーク制約違反、バッチ書き込みの失敗。
受信直後の生ログと正規化後の保存ログを分けて持っていれば、どの段階で消えたか追える。
AllTickの最小構成コード
原典のコードはAllTickのWebSocketエンドポイントへ接続し、AAPL・MSFT・GOOGLを購読してsymbol、price、volume、timeを取り出す数十行の例だった。
動作確認には十分だが、本番で使うには足りないものが多い。
再接続時の購読復元、ping/pongによるハートビート監視、シーケンス番号のギャップ検出、保存失敗時のリトライキュー、APIキーのローテーション。
データ契約としても、カバーしている取引所、含まれる約定の種類(プレマーケット・アフターアワーズを含むか)、遅延と欠落のSLAを確認しないと「なぜこの約定がない」を追えない。
かなチャットとの構造比較
かなチャット v1のFastAPIバックエンドもWebSocketでLLMの応答トークンをブラウザへ流している。
thinking → stream → done のイベント単位で送り、UIがトークンを逐次表示する。
v2で再接続後の購読復元やキャンセル通知を追加した。
構造だけ見ると市場ティックもチャットストリーミングも同じだ。
サーバーがイベントを生成し、クライアントが受けて後段に流す。受信コールバックを軽く保つのも共通。
差が出るのは取りこぼしの深刻度だった。
チャットでトークンが1つ抜けても前後の文脈で読めるし、最悪もう一度生成すればいい。
ティックデータで約定が1件抜けるとOHLCVの計算結果が変わり、バックテストの再現性が壊れる。再送を頼める相手がいないから、その穴は二度と埋まらない。
かなチャットのストリーミング実装でWebSocketの接続管理やイベント分離の勘所を掴んでいたので、ティックフィードのパイプラインで受信→キュー→後段の骨格は迷わず引けた。
逆にティック側の「1件も落とさない」設計を考えたことで、チャット側の再接続時にメッセージIDの重複チェックを雑に済ませていたのが気になっている。
画面更新はデータ配信に追いつけるのか
前のセクションで「秒4〜5回にthrottle」と書いた。
そもそもデータがほぼリアルタイムで届くとして、画面描画がボトルネックにならないのか。
ブラウザの描画ループはrequestAnimationFrameでディスプレイのリフレッシュレートに同期していて、60Hzのモニタなら1フレーム16.6ms。
この中でJavaScript実行、DOM更新、レイアウト計算、ペイントまで終わらないとフレーム落ちする。
ただし16.6msはアニメーションの滑らかさの上限であって、数値表示のティッカーやテーブルを秒60回書き換える意味はない。
人間が数字の変化を読み取れるのは秒3〜4回がせいぜいで、それ以上はチカチカするだけだ。
画面に全ティックを通す必要はなく、秒数回のスナップショットで足りる。
ボトルネックになるのはデータの到着速度ではなくDOM操作のコストだ。
テーブルの行を大量に更新するとブラウザはレイアウト再計算(リフロー)を走らせる。
innerTextを1箇所書き換えただけでも、親要素を遡ってレイアウトツリーを再構築することがある。
活発な銘柄で毎秒100件の約定イベントが届くとき、全件をDOMに反映しようとすると16.6msの予算は一瞬で消える。
実用的なのはrequestAnimationFrameの中で最新のステートだけを描画する方法だ。
受信のたびにstateオブジェクトを上書きし、rAFコールバックの中で前回描画との差分がある部分だけDOMを触る。
Reactの仮想DOMも原理は同じだが、高頻度更新のティッカーでは仮想DOMのdiff計算自体がオーバーヘッドになるので、素のDOM操作のほうが速いケースが多い。
リアルタイムチャートやヒートマップならCanvas(WebGL含む)で描画してDOMのレイアウトエンジンをバイパスする手がある。
TradingViewのチャートがCanvasベースなのはこのためだ。
SVGで折れ線グラフを描くとノード数に比例してレイアウト計算が走るが、Canvasならピクセルバッファへの直接描画なので負荷が安定する。
ティックログを画面に流し続けたいなら仮想スクロールも要る。
DOMに載せる行数を画面に見えている範囲とバッファだけに限定し、スクロール位置に応じて表示行を差し替える。
1万行のログをそのままDOMに突っ込むとスクロール操作すら重くなるが、仮想スクロールならDOMノードは常に数十行で済む。