botter記(22-03-30) ポジションずれ問題考察
日本ローカライズ版がとうとう動き出したFTXですが、そんなさなか同取引所にて、私は重度のポジションずれ問題で日利-B級を何日か叩き出して白目を剥いていました。-Aじゃなくて良かった。良かった…
今後別の取引所でも役に立つかもしれないので、その際に考えていたことを言語化しました。
途中から取引所の内部について私が考えていたことが登場しますが、当方そういった世界で働いたことはまったくないので全て想像であるということはご注意願います。
ポジションずれとは
私の造語というか勝手にそう呼んでいるだけなのですが、botterとして活動されている方は少なくとも一度は経験があるのではないかと思います。取引所からポジションのデータがエラーで取得できなかったりといった理由で遅延して送られてくることを指しています。たとえばネットワークが不調であるとか、サーバーが一時的に落ちている、などの原因が考えられます。
今回最も被害の大きかったボットはそれほどたくさん取引するボットでもなかったため、REST APIでポジションを取得していたのですが、ログを振り返ったところ1分から3分ほどの遅延、もっとも長いもので5分近くポジションが更新されず、いったんは復旧したものの再度遅延…といった動作を複数回確認しました。その結果、想定以上のポジションを積んでしまい損失を出した、というのが今回のあらましです。
今回に限った諸事情
この問題は実は自分自身で傷口を広げてしまった部分があります。今回、このポジションずれ多発期間に突入する以前に、Cloudflareから大量のエラーが返ってくるという事態に頭を悩ませていました。このサービスは要するにあるウェブサイトがボディーガードを雇いたいときに使うものなのですが、そこから実質Rate limitのような制限を食らってしまいました。
といってもこちらのリクエスト数自体は大したものではなかったのですが、おそらく一つのサブアカウントで通貨別にポジション取得していたのが悪く、同じタイミングで全く同じAPIを2、3投げているという趣旨であったと理解しています。
FTXのポジション取得APIはアカウント単位でまとめて返してくれる仕様のため、私がDDoS系の攻撃を企てているかもしれない、と自動で判定したものと思われます。
ボットを止めることは大きな機会損失であったため、私はここで非同期でAPIを投げ続けるループ処理を書きました。通貨別の取得を止め、一定時間でそのアカウントが持つ現在のポジションを取得し、シンプルなオブジェクトに格納しておくわけです。
一度目の損失はこの処理を施す前に発生していたため、このリミットが原因でポジションが取得できず、こういった問題が起こったのだろう、と私は深く考えずに手早くコードを書いてテストし、ボットを再稼働しました。
これにてRate limitは回避できてめでたしめでたし…とはもちろんなりませんでした。これは完全に自分のミスで、ボットを早く再稼働させたかったがゆえに考慮が足りず、ポジションずれ問題の被害を悪化させる原因になりました。
どういう問題が起こったか
そもそもですが、私はポジションが取得出来なかった場合は一時的にメインの処理をロックする機構を設けていました。今回も非同期ループ内でawaitしてフラグを立てて、処理自体は止めておくという手はずでした。
これは一つのフローの中ではそれなりにうまくいきます。ポジション取得エラーが返ってきたなら次のAPI successまで待つ…というだけの、流れに身を任せるロジックです。
しかし今回非同期にしたことで、実はタイムアウトに非常に弱くなってしまいました。APIがエラーを即座に返してくれる場合は問題ないのですが、タイムアウトまで30秒かかってそこからエラーを返してしまうような場合、指標がONになっているとその30秒間は最大ポジションを気にせずボットが発注し続けてしまいます。
そんなわけで私は再び損失を被り、二度目の改修工事を余儀なくされました。非同期で最新の情報を待つ場合は、このように時間差問題がつきまとうことを常に気にかけておかねばなりませんね…。
ことの真相
仕切り直しとなった私は、別の箇所でも無駄にAPIを投げてしまっている事に気づき、そちらもスリム化しました。するとこちらが功を奏したのか単に危険リストから外されたのか、Rate limit自体をくらう頻度が大幅に下がりました。
そこで私はいったんポジション取得ロジックは元に戻し(つまり非同期ループはお蔵入りし)ボットを再稼働することにしました。
当初の問題と考えていたRate limitは概ね回避できているし、非同期ループのヒューマンエラーも排除したのでこれまで何ヶ月も動いていた形に戻ったわけで、ひとまず心配無いだろうと考えていました。
そんな私の楽観はあえなくぺちゃんこになりました。夕方になって収支を確認すると、これまで以上の損失を計上しているではありませんか。これは一体どういうことなのか?
もう一度私はログを順番に確認していきました。念の為に前回の改修時にもう少し詳細なログ取りを設置していたので、それを順番にたどっていきました。すると、ここにてようやく今回の問題の元凶にたどり着きました。
ポジション取得APIはエラーを返すこともなく、(仮に直前に売買を行っていた場合)淡々と更新前のポジションを私に返していたのです。ここでエラーを返してくれるなら、上述の通り問題なく処理を休ませることができますが、エラーがないまま正しいものとして情報が送られてきてしまうとこちらの監視の目から漏れてしまいます。
FTXのAPIは比較的安定している印象で、{ data: 欲しい情報, success: TrueあるいはFalse }といった形で情報を返してくれます。これまで私はこのsuccessがTrueではあるが、dataは適切ではない、という問題がある事自体に気づいていなかったということです。
おそらくこれまでもあったのだと思いますが、今回のように数分の遅延が連続して起こったというような事態には遭遇しておらず、これほど明らかな損失を出さなかったため今回に至るまでひっそりと微損を出し続けていた…ということではないかと思います。
最後の改修
ようやく問題の本質に行き着いた私は、fillsをソケットから取得して、自分でポジションを計算しておくことにしました。また、以前作っておいた非同期ループをここで再利用して、適宜APIから返ってくる値とずれないかチェックしていく形にしました。
仮にずれが直らない場合は計算がおかしいかFTXのサーバーがbusy状態なため、いずれにせよ望ましい状態ではないとして、そこでしばらくポーズして再取得、確認、とする流れにしました。
fillsが存在しない取引所ではorder idなどを自分で管理しておき、約定情報と合わせて検証するなどの方法が考えられます。fillsはprivateな約定のようなものなので、大枠としては同様の処理形態であると思います。
この実装以降は安定して動いてくれており、ひとまず今回の問題自体はクローズとなりました。
ポジションずれはなぜ起きるのか
さて、今回の事態を機に、この問題は一体何なのかということを考えていました。ここから書く内容は私の想像が多分に含まれており、実際のところはどうなのかということはわかっていません。ですが、前述の最後の改修を積極的に進めた動機となります。
まず、仮に私が取引所を作る側だった場合、一番優先して守りたい情報はなんだろうかと考えました。取引所が守るべきものは信用であり、つまりユーザーの財産を保証あるいは証明することが先決となります。
すると、情報には優先度が存在するということになります。ある情報から得られる二次的な情報はそれ自体をDBに格納する必要はなく、一番上の情報を確実に守り、なおかつ検証していく体制を作りたいと考えるはずです。
そうなると取引所が確実に残したいデータは何か。それはおそらくユーザーとユーザーに紐付いた取引履歴ではないかと私は思います。
極端な話、ユーザーIDと取引履歴さえあれば一番古い情報から計算していくことで現在の資産額やポジションは再構築できます。ですから、このデータの保全に細心の注意を払い、これをベースにして他のデータを保証していくという構造を作ることを考えます。
ではたとえば現在のポジションはどうすればよいでしょうか。おそらく全ての取引履歴から計算はしないでしょうから、これはある一定のところまでFinalizeするような仕組み、つまりは検証する別のプロセスを構築しておくことで深さを制限して取引履歴をDBから取り出して、動的に計算した上でユーザーに返す、というような仕組みが考えられます。
これは別のパターンも考えられて、取引が走るたびに確定したポジションをDBにて保存し、返す、というものです。
ただしそうなるとpositionsというソケットのチャンネルがあってもおかしくないですし、大量の取引が短時間で行われたときにDBへの書き込みが重なりすぎて負荷が大きくなることが懸念されます。DBのI/Oというのはサーバーにとって負荷がかかる処理で、できるだけ贅肉の無い作りにしたい部分ですから、確定したポジション以降の取引履歴(と新たにInsertされた最新の取引履歴)から動的に計算して返しているのではないか、というのが私の推測となります。
このような仮定をベースとすると、successがTrueにも関わらず古いポジションが数分間に渡って戻ってきてしまう原因がなんとなく想像できます。つまりFTXのサーバー内で最新の取引履歴は流れているにもかかわらず、DBにInsertする段で遅延が発生しており新たな値を加えた計算が滞っている、ということです。その上で、こちらのリクエストに対しては新しい値が含まれていない状態で計算したものが「正常に」返されてしまった、という次第です。
これは負荷対策のために複数のサーバーを利用して、DBの同期等といった動作に何らかの問題が発生したとすると瞬間的に起こりうるのではないかと思います。
これにて、ポジション取得問題は、FTXのサーバーがbusy状態に陥る等の問題で、DBからの読み出しは走っているが書き込みが制限されているためリアルタイム情報に遅れが生じている、という問題に置き換わりました。
もしこれが実情に近いとすれば、取引履歴は確実に守られるべきものですので、DBに差し込まれるというキューには乗っているが、実行はされていない、あるいは複数DBの同期がなされていない、といった状態かと思います。その場合は挿入されることは確定しているのでtradesやfillsといった情報はソケットから流れてきます。一方で現在の保有通貨量やポジションといった(取引所の処理体系の中で)副次的な情報は更新されません。
これはUI上での経験となりますが、FTXで以前ポジションが全く更新されないにもかかわらず約定は流れている、という事態に遭遇したことがありました。これも原因としては似たようなものだったかもしれません。
これらの仮定のもとソケットとその情報を確かめていく処理を基本線として実装しました。いわゆるポジションの自炊というやつで、結論としてはあっさりしているのですが、これまでは低頻度ならRESTで毎回リセットしていくやり方でも十分安全なのではないかと思っていたところ、今回の問題を機に考えを改めたという感じです。
動的な計算の部分で読み出しが原因で古い情報が処理されているなら、その動的な部分はこちらで請け負ってしまおう、ということですね。実装後のログも毎日確認していますが、やはりポジションの取得遅延自体は何度か起きていました。しかし約定確定時間とソケット配信とのずれは非常に小さく、現状問題は再発していません。
もう一歩すすめるなら
FTXはソケットの遅延が少ない取引所なのではないかと思いますが、ソケットからの情報も遅延することが頻繁にある取引所の場合はどうでしょうか。
ここでは仮にRESTとソケットがだいたい同じ程度遅延するなら、これは思い切ってRESTの返答が遅れている段階で全て無視して処理を休止させるのが簡単でよいかと思います。
そうではなく、ソケットはやや遅れる、RESTは酷く遅れる、のようにここでも大きな時間差があるとしたらどうでしょうか。
この場合はおそらくですが、publicな約定のソケット配信が急激に減速していることが想像されます。ですから、ある程度この配信頻度をRateとして計算しておき、この値が直近の値から著しく落ち始めたら一度様子を見る、といった実装が良いのではないかと思います。
RESTが遅れていると、場合によっては注文が遅れて処理されるというようなことも考えられますし、そこで抱えてしまった不利なポジションを捌けないという事態もありえます。
こういったサーバー由来の問題は急変時などに多い印象で、そういった鉄火場を利益の源泉とするボットにとっては難しい問題ではありますが、ボットに安定感を要求する場合はこういったウェブアプリケーションの強度を追求する、という視点で堅牢性を求めるのもよいのではないかと思います。
おわりに
今回は実体験とその際に考えていたことを文字にしました。重ね重ねになりますが、取引所内部の仕様に関しては全くの推測で、そこで働いている人から話を伺ったことも一切ないので見当外れかもしれない、ということはもう一度述べさせていただきます。
とりあえず少しはbotterらしい記事になったかな…?
それでは良いbotterライフを!