🦀

Rustで作る!Market Maker Bot (mmbot)

2022/12/25に公開

TL;DR

  • Rust言語でMarket Maker Bot向けフレームワークを設計・実装してみた
  • 取引戦略と取引所APIクライアントの実装例も作成した
  • 実際に取引所テストネットに接続して動作確認を実施した

はじめに

こんにちは。magitoと申します。

https://twitter.com/regolith1223

筆者は普段、DeFi/DEXのドメインでTrading Bot[1]を開発・運用しています。Botの実装言語としては、以前はPythonやTypeScript/node.jsを使用することが多かったのですが、最近はRustにほぼ一本化しています。主な理由は以下です。

  1. 実行パフォーマンス:取引執行に高速性が要求されるオンチェーン戦略[2]を実装するのに適している
  2. 高生産性:大規模なシステムでも効率的かつ安全に開発を進めることができる
  3. ブロックチェーンとの相性:Rustで実装されているチェーンの場合、Botとブロックチェーンノードの統合が容易であったり、Bot開発にも直接利用できるSDKやライブラリが豊富である

上記の1,2については従来のトレーディングドメイン(主にMarket Makingなどの高頻度取引)においても魅力的な言語の特徴だと思っているのですが、ネットをざっと調べた限りでは「RustによるBot開発」というトピックに関する情報は(PythonやJSなどの他言語に比べると)極めて少ないように感じました。

そこで、今回は自分で実際にRustを用いてMarket Maker Bot向けフレームワークを設計・実装してみることにしました。

ソースコードは以下のリポジトリに置いてあります。

https://github.com/mag1to/market-maker-rs

本稿では作成したシステムについて紹介しますが、あまり設計や実装の詳細に踏み込みすぎるとドキュメントのようになってしまい読む側も書く側も大変ですので、主要な部分をピックアップする程度にとどめ、詳しくは直接リポジトリを参照してもらうという形にしたいと思います。

なお、筆者は非ソフトウェアエンジニアということもあり、今回紹介する設計や実装の中には技術的に拙い部分があるかと思います。もし本稿の中で間違っている記述などありましたらご指摘いただけると助かります。

本稿がこれからRustでBot開発を始めようとしている方や、既に開発を進めている方にとって少しでも参考になれば幸いです。

設計

アーキテクチャ

今回は、システム全体を大きく4つのレイヤに分けてみました。

後述するように、それぞれのレイヤに対応するインタフェースをトレイトとしていくつか定義しています。実際のBot運用に向けては、関心のある「取引所」や「取引戦略」に対してこれらのトレイトを実装していくことになります。

exchange

取引所サーバとのコミュニケーションを担うレイヤです。各取引所が(取引所APIを通じて)提供すべき共通のふるまいをMarketStatusBrokerという3つのトレイトに定義しています。これらのトレイトを関心のある取引所の仕様に応じて実装することで、その取引所に対してBot取引が可能になります。

strategy

取引戦略にしたがって次のアクション(注文リクエスト)を決定するレイヤです。後述するように、実行したい取引戦略のロジックをPolicyトレイトの実装としてコード化することで、そのロジックをBotで実行することができるようになります。

database(未実装)

exchangeから受信したリアルタイムデータを蓄積し、時系列データ等の形式で提供します。板情報や約定履歴などの生データだけでなく、ローソクデータやインジケータなどの二次加工データを利用する際もここの蓄積データから作成することになると思います。今回は時間の都合上、未実装です。

bot

上述の3つを総合的に操作して、Bot全体としての機能を実現します。

大まかな処理の流れは以下の通り。

  1. MarketStatusからデータをリアルタイム受信
  2. 受信データに応じて最新の観測状態を表すObservation値を逐次アップデート
  3. 2をトリガーにObservation値をPolicyに入力し、実行アクションを決定
  4. 実行アクションが存在する場合、BrokerOrderServiceを介して実行

メッセージング

MarketStatusのリアルタイムデータは、PubSub方式で配信するようにしました。詳細については割愛しますが、Publisher側でPubSub<T>を作成してメッセージを送信、Subscriber側はSubscription<T>を通じてT型のメッセージを受信します。

以下はString型のメッセージを配信する例です。

Publisher側

// Create pubsub value
let pubsub: PubSub<String> = PubSub::new();

// Publish
pubsub.publish(String::from("hello,"));
pubsub.publish(String::from("botter"));

Subscriber側

// Subscribe
let subscription: Subscription<String> = pubsub.subscribe();

// Received!
assert_eq!(subscription.recv().unwrap(), String::from("hello,"));
assert_eq!(subscription.recv().unwrap(), String::from("botter"));

今回のシステムでは、exchangeがPublisher側、botがSubscriber側となります。

インタフェース

Market

src/interfaces/exchange.rs
pub trait Market {
    fn info(&self) -> MarketInfo;
    fn orderbook(&self) -> Subscription<Orderbook>;
    fn execution(&self) -> Subscription<Execution>;
}

Marketトレイトを実装する型は、Orderbook(板)やExecution(約定)といった価格や出来高に関するリアルタイムデータを配信する役割を持ちます。

実際の取引所のリアルタイムAPI(WebSocketなどで受信)では、板情報の更新に関する差分データ(create/update/delete)を受け取りローカルでスナップショットを更新していく形式が一般的ですが、今回の設計ではMarketから配信されるOrderbook型はある時刻における板情報のスナップショットであり、前述の差分更新などの処理はMarket実装側の責務としています。

Status

src/interfaces/exchange.rs
pub trait Status {
    fn inventory(&self) -> Subscription<Inventory>;
    fn open_orders(&self) -> Subscription<OpenOrders>;
}

Statusトレイトを実装する型は、Inventory(ポジションや現物残高などの在庫情報)とOpenOrders(アクティブな指値注文のリスト)のリアルタイムデータを配信する役割を持ちます。

「取引所データの配信」という意味ではMarketトレイトと役割が似ていますが、こちらは自己アカウントに関するプライベートデータ(通常apikeyによるauthenticationを要するようなもの)を担当します。

OpenOrdersについては、前述のOrderbookと同じくスナップショットです。

Broker

src/interfaces/exchange.rs
#[async_trait]
pub trait Broker {
    async fn submit(&self, order: Order) -> OrderResponse;
}

Brokerトレイトを実装する型は、注文リクエストを取引所サーバに送信する役割を持ちます。

Broker::submitメソッドは、引数として受け取ったOrder値に応じて注文リクエストを作成して取引所に送信し、その結果(注文リクエストが成功したか失敗したか)を返します。

MarketStatusとの通信は一方通行(データ受信のみ)であるのに対し、Broker::submitはリクエスト後に取引所からのレスポンスを待ち受ける必要があり、それにより(少なくない)待機時間が発生します。これをtokioなどの非同期ランタイム下で処理するためにasync fnとして定義しています。

実際にBrokerに注文リクエストを投げる際は、OrderServiceを介して行います。OrderServiceBroker経由で注文リクエストを送信するとともに、それらのリクエストを追跡し、まだレスポンスを受け取っていないリクエストをVec<PendingOrder>として管理します。botからはget_pending_ordersメソッドでVec<PendingOrder>を取得し、Policyによるアクション決定に使用します。

Policy

src/interfaces/strategy.rs
pub trait Policy {
    fn evaluate(&self, observation: impl Observation) -> Vec<Order>;
}

Policyトレイトを実装する型は、現在の観測情報から次の実行アクションを決定する役割を持ちます。

Policy::evaluateメソッドは、Observationトレイトを実装した型から、現在の観測情報に関するデータを読み取り、それらに基づいて次にどのようなアクションを起こすか(新規注文する?キャンセルする?何もしない?)を計算します。決定した一連のアクションはVec<Order>として出力されます。

Observationのトレイト定義は以下のとおり全てがgetterメソッドであり、これらの値が観測情報として提供されます。

src/interfaces/strategy.rs
pub trait Observation {
    fn info(&self) -> &MarketInfo;
    fn executions(&self) -> &[Execution];
    fn orderbook(&self) -> &Orderbook;
    fn inventory(&self) -> &Inventory;
    fn open_orders(&self) -> &OpenOrders;
    fn pending_orders(&self) -> &[Order];
}

基本型

高頻度Botトレーディングのドメインにおける基本的な概念を定義しています。ここで定義された型は、exchangeとやりとりする各種データの共通フォーマットとなる他、Policy実装にてアクション決定のための計算に使用されます。

Decimal, Price, Amount

src/types/values.rs
use rust_decimal::prelude::*;

pub type Price = Decimal;
pub type Amount = Decimal;

Priceは「価格」の値を表し、Amountは「数量」の値を表す型です。

便宜上わかりやすい名前を与えていますが、これらはともにrust_decimalクレートのDecimal型のエイリアスです。

今回のシステム内での価格と数量に関する数値計算は基本的にDecimal型で行うことになります。

Execution

src/types/execution.rs
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct TradeId(String);

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Execution {
    timestamp: u64,
    id: TradeId,
    maker_side: Side,
    price: Price,
    amount: Amount,
}

Executionは、ある時点で発生した約定イベントを表します。

TradeIdは各Execution値を識別するIDです。これは取引所側で発行されるIDで、Bot側でもそのままの値を使用しています(つまり、一意性については取引所をfull trustしています)。
ちなみに、このIDの実体は単なるString型ですが、そのまま使用すると後述する他のID(OrderIdOfferIdなど)と混同する危険があるため、TradeId型にラップして静的に区別しています[3]

Orderbook

src/types/orderbook.rs
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct OfferId(String);

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Offer {
    pub(crate) id: OfferId,
    pub(crate) price: Price,
    pub(crate) amount: Amount,
}

#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Orderbook {
    pub(crate) timestamp: u64,
    pub(crate) asks: Vec<Offer>,
    pub(crate) bids: Vec<Offer>,
}

OfferOrderbookの上に存在する1つの指値注文(もしくはそれらを価格ごとに束ねたもの)を表します。

OfferIdは各Offer値を(あるOrderbook値の内部で)識別するためのIDです。
TradeIdと同じく、取引所側で発行されたIDを直接使用します。
取引所によってはOfferIdは後述するOrderIdと概念的に同一である場合もありますが、そうでない取引所も存在するので、今回は別々の型で表現することにしました。

Orderbookはその名のとおり、ある時点での板情報のスナップショットを表す型です。内部ではasksとbidsがそれぞれVec<Offer>として保持されていて、それぞれbest側のOfferから順に配置されています。

Orderbookに対しては、Market実装での差分更新処理の際にmutableな操作が必要になりますが、それらの差分更新ロジックはexchange側の知識であるため、Orderbook本体にsetterを生やすのではなくOrderbookWriterというラッパー型に切り分けて定義しています(各メンバのvisがpub(crate)となっているのはこのため)。

src/implements/writers/orderbook_writer.rs
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum OrderbookWriteOp {
    Snapshot(Orderbook),
    Create(CreateOp),
    Update(UpdateOp),
    Delete(DeleteOp),
}

pub struct OrderbookWriter<'a> {
    inner: &'a mut Orderbook,
}

impl<'a> OrderbookWriter<'a> {
    pub fn apply_snapshot(&mut self, orderbook: Orderbook) -> OrderbookWriterResult<()> {
    ...
    }
    
    pub fn apply_create(&mut self, op: CreateOp) -> OrderbookWriterResult<()> {
    ...
    }
    
    pub fn apply_update(&mut self, op: UpdateOp) -> OrderbookWriterResult<()> {
    ...
    }
    
    pub fn apply_delete(&mut self, op: DeleteOp) -> OrderbookWriterResult<()> {
    ...
    }
    
    ...
}

Inventory

src/types/inventory.rs
use super::values::Amount;

#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Inventory {
    Position(Amount),
    Balances(Balances),
}

impl Inventory {
    pub fn position(&self) -> Amount {
        match self {
            Self::Position(position) => *position,
            Self::Balances(balances) => balances.base_amount(),
        }
    }
}

#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Balances {
    ba: Amount,
    qa: Amount,
}

impl Balances {
    pub fn new(ba: Amount, qa: Amount) -> Self {
        Self { ba, qa }
    }

    pub fn base_amount(&self) -> Amount {
        self.ba
    }

    pub fn quote_amount(&self) -> Amount {
        self.qa
    }
}

Inventoryは、あるマーケット(取引ペア)における自己アカウントの在庫状況を表す型です。この型はPositionBalancesからなるenumであり、信用取引の場合はPosition、現物取引の場合はBalances(base/quoteの現物残高)となります。現物ペアの場合はInventory::positionメソッドでbase残高をポジションとみなしてその値を返します。

Order

src/types/order.rs
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct OrderId(String);

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum Side {
    Ask,
    Bid,
}

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum OrderType {
    Limit,
    Market,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Order {
    New(NewOrder),
    Cancel(CancelOrder),
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct NewOrder {
    order_type: OrderType,
    order_side: Side,
    price: Price,
    amount: Amount,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CancelOrder {
    id: OrderId,
}

数が多くて大変です。SideOrderTypeは自明なので省きましょう。

OrderIdはリクエスト済みの注文に対して取引所側から付与されるIDで、注文のキャンセルなどで注文を指定する必要がある場面で使います。

Orderは、注文リクエストを表す型で、New(新規注文)かCancel(キャンセル)のどちらかをとります。Broker実装ではこの値を解釈して取引所向けのリクエストを作成します。

OpenOrders

src/types/order.rs
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OpenOrders {
    pub(crate) timestamp: u64,
    pub(crate) orders: Vec<OrderState>,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OrderState {
    pub(crate) id: OrderId,
    pub(crate) side: Side,
    pub(crate) price: Price,
    pub(crate) amount: Amount,
}

OpenOrdersは、自己アカウントのアクティブな指値注文のリストを保持します。指値注文はOrderStateとして格納されています。OrderStateamountは「約定済み」ではなく「未約定」の数量であることに注意してください。

なお、OpenOrdersOrderbookと同様に、exchange側での差分更新処理が必要であるため、mutable操作を許可するOpenOrdersWriterというラッパーを別途用意してあります。

実装

一応、ここまででフレームワーク部分は形になりました。疲れました。画面の右上にチラチラ見える「公開する」ボタンを押してしまいたいです。

が、ここで終わってしまうと

このシステム、本当に使えるんけ????????

という疑問が(筆者本人も含めて)残り続けてしまうので、strategyexchangeについてそれぞれ一つずつ試しに実装してみようと思います。

sample strategy

過度に利益志向な内容にならないように」というBotterアドカレの厳粛なる教義にしたがい、儲かる取引戦略を載せるのは遠慮しておきましょう。残念ですが仕方ない、、

とはいえ、「儲からない取引戦略を適当に考えろ」というのも結構難しいのですが、、幸運なことにその昔、以下のような記事を書いたことを思い出しました!

https://note.com/magimagi1223/n/n5fba7501dcfd

おお、これはまさに我々がほしかった(今はたぶん)儲からない取引戦略[4]です!!!!

あらためて見るとかなりプレーンなMarketMakingですが、サンプルとしては好都合です。このロジックを今回作ったシステムに移植してみましょう。

src/strategies/dbo.rs
#[derive(Debug)]
pub struct DepthBasedOffering {
    max_exposure: Amount,
    target_depth: Amount,
}

impl DepthBasedOffering {
    pub fn new(max_exposure: Amount, target_depth: Amount) -> Self {
        Self {
            max_exposure,
            target_depth,
        }
    }

    pub fn max_exposure(&self) -> Amount {
        self.max_exposure
    }

    pub fn target_depth(&self) -> Amount {
        self.target_depth
    }
}

取引戦略の実行に必要なパラメータを格納しただけのシンプルな構造体DepthBasedOfferingを定義[5]しました。

パラメータは以下の2つ。
max_exposure:保有ポジション(の絶対値)の上限
target_depth:Best Ask/Bidから積算してどれだけのサイズ(depth)の位置に指値を置くか
(詳しい取引ロジックは上記noteを参照ください)

さて、あとやることは一つだけ、Policyトレイトの実装です!

src/strategies/dbo.rs
impl Policy for DepthBasedOffering {
    fn evaluate(&self, observation: impl Observation) -> Vec<Order> {
        if !observation.pending_orders().is_empty() {
            return Vec::new();
        }

        let mut orders = Vec::new();
	
	// some logic

        orders
    }
}

長ったらしいのでロジックの中身は省略しました。

最初のガード節でpending_ordersが存在しないことを確認しています。これは、直前に出力済み(かつ送信済み)のVec<Order>と同一結果を再出力しないようにするためです。
(この部分、やり方によってはobservationpending_ordersから信念状態のようなものを導出してそれに基づいてアクション決定するみたいなこともできそうですが、今回のはシンプルにフェイルセーフな感じにしています。)

で、そのあと、ロジックに沿ってordersの中にOrder値を適宜pushしていって、、最後にそれらをリターンしています。

ソースファイルの後方ではこの取引戦略の実装の正しさを確認するテストをいくつか書いています。(Bot本体から分離されているのでユニットテストも簡単に書けますね!)

sample exchange

今日はクリスマスです。

つまり、あと一週間で今年が終わるということです。

今年が終わるとどうなる?

しらんのか
確定申告が始まる

、、、

何が言いたいかというと、儲かりもしない高頻度Botなんか動かして取引履歴をむやみやたらに汚したくありません!!!!

でも今回作ったシステムは試してみたい、、なにかいい方法はないでしょうか?

あります。取引所のテストネットAPI(いわゆるデモ口座的なやつ)を使うことにしましょう。

でも国内にはありません[6]。仕方ないので海外取引所にしましょう。

というわけで、今回は(まさかの)BitMEXという取引所のテストネットAPIを利用します。

テストネットAPI

前半に説明したとおり、ある取引所に今回のシステムを適合させるためには、その取引所についてMarketStatusBrokerをそれぞれ実装しなければなりません。

当然ではありますが、そのための作業の中には「取引所サーバに接続して」「必要なデータを収集して」「正しい形式にパースする」というような、具象と技術的詳細にまみれた、煩わしくて、面倒臭くて、吐き気を催すコーディングも含まれます。

でもよう、そんなことするためにBotterになったわけじゃあねえだろう

まあ落ち着いて聞いてください。これも非常に幸運なことですが、今日はクリスマスです。しかも2022年の。

そう、この時代にはもう既にアレが存在しているのでした。頼りましょう!

わあしゅごい。

ちなみに、このあと調教質問を続けていったところ、どうやらbitmex-rsというクレートを使うともっと高水準な感じでBitMEX APIを叩けそうなことを教えてくれました。Cargo.tomlに追加しましょう。

bitmex = "0.2.2"

関連するクレートもまとめて追加しておきます。

tokio = { version = "1", features = ["full"] }
futures = "0.3"

ここで重大なことに気づいたのですが、このbitmex-rsというクレート結構古いっぽく(エラーハンドリングにfailureクレートが使われてたりとか)、なんとtokioの依存バージョンが0.2であるために最新の1.*のランタイムでは動きませんでした、、

と思いきや、crates.ioではなく、gitリポジトリを直接指定したら動きました!

bitmex = { version = "0.2.2", git = "https://github.com/dovahcrow/bitmex-rs.git" }

...

...

というわけで、紆余曲折を経ましたが、どうにか三種の神器であるBitMEXMarketBitMEXStatusBitMEXBrokerを用意することができました!!

このへんに突っ込んどいたよ↓

.
├── Cargo.lock
├── Cargo.toml
├── src
│   ├── implements
│   │   ├── exchanges
│   │   │   ├── bitmex
│   │   │   │   ├── broker.rs
│   │   │   │   ├── market.rs
│   │   │   │   ├── mod.rs
│   │   │   │   ├── parser.rs
│   │   │   │   └── status.rs

はあ〜もうしにそうです。でもあとは動かすだけや、、

main関数

ああ、そうだった。動かすためにはmainを書かなくちゃ。

examples/run_dbo_bitmex.rs
extern crate market_maker;

use rust_decimal_macros::dec;

use market_maker::apikey::ApiKey;
use market_maker::bot::{Bot, Config};
use market_maker::implements::exchanges::bitmex::{BitMEXBroker, BitMEXMarket, BitMEXStatus};
use market_maker::logger;
use market_maker::strategies::dbo::DepthBasedOffering;

fn main() {
    logger::setup_with("info");

    // exchange
    let apikey = ApiKey::read_json("./keys/bitmex.json").expect("apikey not found");
    let market = BitMEXMarket::connect();
    let status = BitMEXStatus::connect(&apikey);
    let broker = BitMEXBroker::connect(&apikey);

    // strategy
    let policy = DepthBasedOffering::new(dec!(200), dec!(1000));

    // bot
    let config = Config {
        num_iteration: usize::MAX,
        test: true,
    };
    let mut bot = Bot::new(config, market, status, broker, policy);
    bot.run().unwrap();
}

とってもシンプルですね。これまで定義してきたトレイトを実装する各型をインスタンス化して、それらをまとめてBotのコンストラクタにぶちこんでいます。取引所や取引戦略を別のにすげ替える場合も、具体型が変わるだけなのでmainの中身はほぼ同じ見た目になると思います。

ConfigBot自体の設定データで、test: trueとすると(PolicyからVec<Order>は出力されますが)注文リクエストは送信されなくなります。

なお、BitMEXテストネットに接続するためのAPIキーはJSONファイルに格納しておいて実行時に読み取るようにしています(適切なオーナーシップ設定を忘れずに!)。

keys/bitmex.json
{
    "key": "YOUR_KEY",
    "secret": "YOUR_SECRET"
}

メインループはBot::runの中にあります。

src/bot.rs
impl<M, S, B, P> Bot<M, S, B, P>
where
    M: Market,
    S: Status,
    B: Broker + Send + Sync + 'static,
    P: Policy,
{
    pub fn run(&mut self) -> Result<()> {
        info!("Start running!");
        info!("\n{:#?}", self.config);

        let info = self.market.info();
        info!("\n{:#?}", info);

        let execution = self.market.execution();
        let orderbook = self.market.orderbook();
        let inventory = self.status.inventory();
        let open_orders = self.status.open_orders();

        info!("Warmingup observation..");
        let mut observation = Observation::warmup(
            info,
            execution.as_receiver(),
            orderbook.as_receiver(),
            inventory.as_receiver(),
            open_orders.as_receiver(),
        )?;

        for i in 0..self.config.num_iteration {
            let mut target = false;
            select! {
                recv(execution.as_receiver()) -> msg => {
                    info!("iteration[{i}] receive execution!");
                    observation.insert_execution(msg?);
                },
                recv(orderbook.as_receiver()) -> msg => {
                    info!("iteration[{i}] receive orderbook!");
                    observation.update_orderbook(msg?);
                    target = true;
                },
                recv(inventory.as_receiver()) -> msg => {
                    info!("iteration[{i}] receive inventory!");
                    observation.update_inventory(msg?);
                },
                recv(open_orders.as_receiver()) -> msg => {
                    info!("iteration[{i}] receive orders!");
                    observation.update_open_orders(msg?);
                },
            }

            if target {
                let pending_orders = self
                    .order_service
                    .get_pending_orders()
                    .into_iter()
                    .map(|po| po.into_inner())
                    .collect();
                observation.update_pending_orders(pending_orders);

                info!("orderbook:\n{}", observation.orderbook());
                info!("open_orders:\n{}", observation.open_orders());
                info!("inventory:\n{:?}", observation.inventory());
                info!("pending_orders:\n{:?}", observation.pending_orders());

                info!("iteration[{i}] evaluating..");
                let orders = self.policy.evaluate(&observation);
                info!("output:\n{:#?}", orders);

                if !orders.is_empty() && !self.config.test {
                    for order in orders {
                        self.order_service.submit(order);
                    }
                }
            }
        }

        Ok(())
    }
}

Subscriptionを開始した後、Observation初期値を構築し、ループに入ります。

各イテレーションでは最初にselect!マクロ[7]で全てのreceiverをまとめて待ち受け、最初に受信したメッセージに対してObservation値の更新処理を実行します。

現在の実装ではupdate_orderbookのタイミングでPolicy計算を開始するようにしています。

Run!!!!

パラメータは以下の設定です。

DepthBasedOffering {
    max_exposure: dec!(200),
    target_depth: dec!(1000),
}

test: falseで注文機能をオン。

let config = Config {
    num_iteration: usize::MAX,
    test: false,
};

さあ、いよいよです。--releaseフラグをつけてBotを起動しましょう!

cargo run --release --example run_dbo_bitmex

わくわく。

、、、

あっ!早速、最初の指値注文が出力されました。

[
    New(
        NewOrder {
            order_type: Limit,
            order_side: Ask,
            price: 16949.0,
            amount: 200,
        },
    ),
    New(
        NewOrder {
            order_type: Limit,
            order_side: Bid,
            price: 16816.5,
            amount: 200,
        },
    ),
]

UIで確認してみましょう。しっかりと注文が反映されています!!

パラメータに設定したとおり、板のトータルサイズが1000となる価格位置(16949.5/16816.0)の手前にそれぞれ200ずつの指値が置かれています。

指値位置の変更(CancelNew)も問題なく実行されています。

おやおや、買い指値の一部が約定し、保有ポジションが+100になりましたね。

今回の設定ではポジション絶対値の上限は200なので、買い指値は100、売り指値は300に調整されています。これも期待通り!!!!

、、、

、、、

ふう、、、、

楽しかったですね。

願わくばこのまま一日中眺めていたいところですが、さすがにもう体力の限界です

基本的な動作は一通り確認できたことですし、今回のテスト運用はこのへんにしておきましょう!!

課題

めでたく動きました。

しかし、本稿を執筆するためだけに生み出された存在である本システムは言うまでもなく荒削りのプロトタイプであり、おそらく改善点やバグも数えきれないほど残されていることでしょう。

いくつかは本文で触れましたが、それ以外ですぐに思いつくものを以下に挙げておきます。

  • Observationのデータ整合性
    現在の設計では、MarketStatusからの各データは、データの発生順ではなく到着順で受信され、Observationに順次適用されています。そのため、タイミングによってはObservationの値全体になんらかの不整合(例えば、OpenOrdersは最新であるのにOrderbookにはそれらに対応するOfferが反映されていない、など)が発生する可能性があります。

  • Observationに保持されるExecutionサイズが無制限
    Botの連続稼働時間やデータの蓄積速度によってはメモリを圧迫する可能性があるので、キャパシティを設定する必要があります。

  • レートリミット
    取引所のレートリミット(単位時間あたりの最大リクエスト数)を考慮して注文リクエストを制限する機構をどこかに設置する必要があります。

さいごに

当初の想定をはるかに超える長旅となってしまいましたが、なんとかタイトルのとおり「RustでMarket Maker Botを作ったよ!」と胸を張って言えるくらいのところまでは辿り着けたのではないでしょうか。

まあ、今回提示した「フレームワーク」というスタイルはあくまでもBot取引を実現する手段の一つに過ぎないので、各自の状況に合ったスタイルを選ぶのが良きかなと思います。フレームワークに括り出せる部分が少なかったり、処理の共通化にともなうオーバーヘッドが気になるというのであれば、それぞれ独立したプログラムに分ける、といった風に。

あとあと、今回は「Bot取引」という大きなトピックの中でも特に技術的な側面にフォーカスした内容になりましたが、実際にBot取引で利益を生み出すためには、プログラムの実行速度や開発効率の他にも重要な要素が山ほどあります。さらにドメイン同士を比較してみても(CeFi vs DeFiとか、arb vs directionalとか)それらの重みが異なっていたりもするので、(技術面にだけ盲目的に傾倒するのではなく)状況に応じてバランスをうまくとりながら取り組んでいけたらいいですね!

、、、さてついに意識が途切れ途切れになりつつあるので、今回はこのあたりで終わりにしたいと思います。

最後まで読んでいただきありがとうございました。

それでは良いお年を!

追記

投稿直前に知ったのですが、Botterアドカレにて素晴らしい記事とクレートが紹介されておりました!
https://qiita.com/negi_grass/items/dc67d0af0d7b8d1b5d78
https://github.com/negi-grass/crypto-botters

本稿でも一番苦労したのがAPIクライアントの実装まわりだったので、このあたりを受け持ってくれるRustクレートの登場は嬉しいですね。次回からありがたく活用させていただきます!

脚注
  1. Bot: 本稿では「暗号資産の取引を自動実行するプログラム」を指します。 ↩︎

  2. 裁定取引(arbitrage)や清算(liquidation)などが有名です。 ↩︎

  3. NewTypeパターンといいます。参考:https://doc.rust-jp.rs/book-ja/ch19-04-advanced-types.html ↩︎

  4. 実は記事公開後もめちゃくちゃ儲かったという噂があります。 ↩︎

  5. 取引戦略の名前は筆者が勝手につけました。正式名称かもっとかっこいい名前があれば教えてください。 ↩︎

  6. APIの仕様を調べるためだけに実弾使うの結構しんどかった思い出があります。これあるとBotter's UX全然違うと思います。人気出るのでは。導入いかがでしょう?bitbankさんとか。 ↩︎

  7. select!マクロはtokioクレートのものが非同期コードでよく使われていますが、実はcrossbeamクレートにも生スレッド用に同様の機能を提供するマクロがあり、今回はそれを使っています。 ↩︎

Discussion