Bot トレードロジック

仮想通貨botの開発記録#64(2024/3/12)「MMBotの改良(Rustで書き直す)」

2024年3月12日

前回の記事に引き続き、今回も仮想通貨botの開発状況をまとめていきます。

今回は、Claudを使ってMMbotをRustで書き直してみました。

このような試みは言語の理解プロセスとして非常に実践的であり、私の好きな学習スタイルです。

関連記事
仮想通貨botの開発を本格的に始めてみる#31(2023/11/15)「MMBotの構造解析①」

続きを見る

MMbotのコード(プロトタイプ)

use std::collections::HashMap;
use std::thread;
use std::time::Duration;

use chrono::Utc;
use log::{error, info};

use ccxt::market_maker;
use ccxt::bitflyer::{Bitflyer, BitflyerCredentials};

const COIN: &str = "BTC";
const PAIR: &str = "BTCJPY28SEP2018";
const LOT: f64 = 0.002;
const AMOUNT_MIN: f64 = 0.001;
const SPREAD_ENTRY: f64 = 0.0005;
const SPREAD_CANCEL: f64 = 0.0003;
const AMOUNT_THRU: f64 = 0.01;
const DELTA: f64 = 1.0;

fn main() {
   env_logger::init();

   let credentials = BitflyerCredentials {
       api_key: "YOUR_API_KEY".to_string(),
       secret: "YOUR_SECRET_KEY".to_string(),
   };

   let mut bitflyer = BitflyerMarketMaker::new(credentials);

   let asset = bitflyer.get_asset();
   let colla = bitflyer.get_collateral();

   info!(
       "BOT TYPE      : MarketMaker @ bitFlyer\n
        SYMBOL        : {}\n
        LOT           : {} {}\n
        SPREAD ENTRY  : {}%\n
        SPREAD CANCEL : {}%\n
        ASSET         : {}\n
        COLLATERAL    : {}\n
        TOTAL         : {}",
       PAIR,
       LOT,
       COIN,
       SPREAD_ENTRY * 100.0,
       SPREAD_CANCEL * 100.0,
       asset,
       colla,
       asset + colla
   );

   let mut remaining_ask = 0.0;
   let mut remaining_bid = 0.0;
   let mut trade_ask = TradeOrder::default();
   let mut trade_bid = TradeOrder::default();
   let mut pos = Position::None;

   loop {
       match pos {
           Position::None => {
               let tick = bitflyer.get_effective_tick(AMOUNT_THRU, 0.0, 0.0, 0.0, 0.0);
               let ask = tick.ask;
               let bid = tick.bid;
               let spread = (ask - bid) / bid;

               info!(
                   "ask: {:.2}, bid: {:.2}, spread: {:.4}%",
                   ask, bid, spread * 10000.0
               );

               if spread > SPREAD_ENTRY {
                   let amount_ask = LOT + remaining_bid;
                   let amount_bid = LOT + remaining_ask;

                   trade_ask = bitflyer.limit_order("sell", amount_ask, ask - DELTA);
                   trade_bid = bitflyer.limit_order("buy", amount_bid, bid + DELTA);

                   pos = Position::Entry;
                   info!("entry");
                   thread::sleep(Duration::from_secs(5));
               }
           }
           Position::Entry => {
               if trade_ask.status != OrderStatus::Closed {
                   trade_ask = bitflyer.get_order_status(&trade_ask.id);
               }
               if trade_bid.status != OrderStatus::Closed {
                   trade_bid = bitflyer.get_order_status(&trade_bid.id);
               }

               let tick = bitflyer.get_effective_tick(
                   AMOUNT_THRU,
                   trade_ask.price,
                   trade_ask.amount,
                   trade_bid.price,
                   trade_bid.amount,
               );
               let ask = tick.ask;
               let bid = tick.bid;
               let spread = (ask - bid) / bid;

               info!(
                   "ask: {:.2}, bid: {:.2}, spread: {:.4}%\n
                    ask status: {:?}, filled: {}/{}, price: {:.2}\n
                    bid status: {:?}, filled: {}/{}, price: {:.2}",
                   ask,
                   bid,
                   spread * 10000.0,
                   trade_ask.status,
                   trade_ask.filled,
                   trade_ask.amount,
                   trade_ask.price,
                   trade_bid.status,
                   trade_bid.filled,
                   trade_bid.amount,
                   trade_bid.price
               );

               if trade_ask.status == OrderStatus::Open && trade_ask.remaining <= AMOUNT_MIN {
                   bitflyer.cancel_order(&trade_ask.id);
                   trade_ask.status = OrderStatus::Closed;
                   remaining_ask = trade_ask.remaining;
                   info!("ask almost filled.");
               }

               if trade_bid.status == OrderStatus::Open && trade_bid.remaining <= AMOUNT_MIN {
                   bitflyer.cancel_order(&trade_bid.id);
                   trade_bid.status = OrderStatus::Closed;
                   remaining_bid = trade_bid.remaining;
                   info!("bid almost filled.");
               }

               if spread > SPREAD_CANCEL {
                   if trade_ask.status == OrderStatus::Open && trade_ask.price != ask - DELTA {
                       bitflyer.cancel_order(&trade_ask.id);
                       if trade_ask.remaining >= AMOUNT_MIN {
                           trade_ask = bitflyer.limit_order("sell", trade_ask.remaining, ask - DELTA);
                       } else if trade_ask.remaining > 0.0 {
                           trade_ask.status = OrderStatus::Closed;
                           remaining_ask = trade_ask.remaining;
                       } else {
                           trade_ask.status = OrderStatus::Closed;
                       }
                   }

                   if trade_bid.status == OrderStatus::Open && trade_bid.price != bid + DELTA {
                       bitflyer.cancel_order(&trade_bid.id);
                       if trade_bid.remaining >= AMOUNT_MIN {
                           trade_bid = bitflyer.limit_order("buy", trade_bid.remaining, bid + DELTA);
                       } else if trade_bid.remaining > 0.0 {
                           trade_bid.status = OrderStatus::Closed;
                           remaining_bid = trade_bid.remaining;
                       } else {
                           trade_bid.status = OrderStatus::Closed;
                       }
                   }
               }

               if trade_ask.status == OrderStatus::Closed && trade_bid.status == OrderStatus::Closed {
                   pos = Position::None;
                   info!("completed.");
               }
           }
       }

       thread::sleep(Duration::from_secs(5));
   }
}

struct BitflyerMarketMaker {
   exchange: BitflyerClient,
}

impl BitflyerMarketMaker {
   fn new(credentials: BitflyerCredentials) -> Self {
       let exchange = BitflyerClient::new(credentials);
       BitflyerMarketMaker { exchange }
   }

   fn get_asset(&self) -> f64 {
       self.exchange
           .fetch_balance()
           .map(|resp| resp.total.get("JPY").cloned().unwrap_or_default())
           .unwrap_or_default()
   }

   fn get_collateral(&self) -> f64 {
       self.exchange
           .private_get_getcollateral()
           .map(|resp| resp.collateral)
           .unwrap_or_default()
   }

   fn get_effective_tick(
       &self,
       size_thru: f64,
       rate_ask: f64,
       size_ask: f64,
       rate_bid: f64,
       size_bid: f64,
   ) -> Tick {
       let orderbook = self.exchange.fetch_order_book(PAIR.to_string());
       let mut bid_iter = orderbook.bids.iter().peekable();
       let mut ask_iter = orderbook.asks.iter().peekable();

       let mut remaining = size_thru;
       let mut effective_bid = 0.0;
       while remaining > 0.0 {
           if let Some((price, size)) = bid_iter.peek() {
               if *price == rate_bid {
                   let size_bid_remaining = size_bid - remaining;
                   if size_bid_remaining > 0.0 {
                       remaining -= size_bid_remaining;
                       effective_bid = *price;
                   } else {
                       remaining -= *size;
                   }
               } else {
                   remaining -= *size;
               }
               bid_iter.next();
           } else {
               break;
           }
       }

       let mut remaining = size_thru;
       let mut effective_ask = 0.0;
       while remaining > 0.0 {
           if let Some((price, size)) = ask_iter.peek() {
               if *price == rate_ask {
                   let size_ask_remaining = size_ask - remaining;
                   if size_ask_remaining > 0.0 {
                       remaining -= size_ask_remaining;
                       effective_ask = *price;
                   } else {
                       remaining -= *size;
                   }
               } else {
                   remaining -= *size;
               }
               ask_iter.next();
           } else {
               break;
           }
       }

       thread::sleep(Duration::from_millis(500));
       Tick {
           bid: effective_bid,
           ask: effective_ask,
       }
   }

   fn limit_order(&self, side: &str, size: f64, price: f64) -> TradeOrder {
       let order = self
           .exchange
           .create_order(PAIR.to_string(), side, "limit", size, price)
           .unwrap();

       TradeOrder {
           id: order.id,
           status: OrderStatus::Open,
           filled: 0.0,
           remaining: size,
           amount: size,
           price,
       }
   }

   fn cancel_order(&self, id: &str) {
       if let Err(err) = self.exchange.cancel_order(PAIR.to_string(), id) {
           error!("Error cancelling order: {}", err);
           let order_status = self.get_order_status(id);
           if order_status.status == OrderStatus::Closed {
               info!("Order already filled: {}", id);
           }
       }
   }

   fn get_order_status(&self, id: &str) -> TradeOrder {
       let status = self
           .exchange
           .fetch_order(PAIR.to_string(), id)
           .unwrap_or_else(|err| {
               error!("Error fetching order status: {}", err);
               OrderStatus::Unknown
           });

       let filled = status.filled.unwrap_or_default();
       let remaining = status.remaining.unwrap_or_default();
       let amount = status.amount.unwrap_or_default();
       let price = status.price.unwrap_or_default();

       TradeOrder {
           id: id.to_string(),
           status,
           filled,
           remaining,
           amount,
           price,
       }
   }
}

#[derive(Debug, Default)]
struct Tick {
   bid: f64,
   ask: f64,
}

#[derive(Debug, Default)]
struct TradeOrder {
   id: String,
   status: OrderStatus,
   filled: f64,
   remaining: f64,
   amount: f64,
   price: f64,
}

#[derive(Debug, PartialEq)]
enum OrderStatus {
   Open,
   Closed,
   Unknown,
}

impl Default for OrderStatus {
   fn default() -> Self {
       OrderStatus::Unknown
   }
}

#[derive(Debug, PartialEq)]
enum Position {
   None,
   Entry,
}

impl Default for Position {
   fn default() -> Self {
       Position::None
   }
}

主な変更点

Rustでマーケットメイキングプログラムを書く際に行った、主な変更点について解説したいと思います。

まず、プログラムの中で BitflyerMarketMaker という構造体を作成しました。この構造体は、取引所(Exchange)との通信を管理する役割を持っています。複雑なプログラムでは、このように機能ごとに構造体を作ることで、コードをきれいに整理することができます。

次に、TradeOrder という構造体を作りました。この構造体は、注文情報(注文ID、注文状態、注文量など)を格納するための入れ物となります。構造体を使うと、関連するデータをまとめて扱えるので便利です。

さらに、OrderStatusPosition という2つの列挙型(enum)を作りました。列挙型は、特定の値のみを取ることができる型です。OrderStatus は注文の状態(オープン、クローズド、不明)を表し、Position はポジションの状態(なし、エントリー済み)を表します。列挙型を使うことで、不正な値が入ることを防ぐことができます。

また、ログ出力のために env_logger というライブラリを使いました。ログを出力することで、プログラムの動作を追跡しやすくなります。

メインのループでは、match という制御文を使って、現在のポジションに応じて処理を分岐させています。match は、値に応じて処理を分岐させる際に使う便利な構文です。

最後に、関数の抽出とコードの整理を行いました。長いコードを関数に分けることで、可読性が上がり、保守性も向上します。

バックテスト用のコード(プロトタイプ)

use chrono::{DateTime, Utc};
use csv;
use std::error::Error;

struct HistoricalData {
    timestamp: DateTime<Utc>,
    open: f64,
    high: f64,
    low: f64,
    close: f64,
    volume: f64,
}

fn load_historical_data(file_path: &str) -> Result<Vec<HistoricalData>, Box<dyn Error>> {
    let mut reader = csv::Reader::from_path(file_path)?;
    let mut data = Vec::new();

    for result in reader.deserialize() {
        let record: HistoricalData = result?;
        data.push(record);
    }

    Ok(data)
}

fn backtest(data: &[HistoricalData]) {
    let mut position = Position::None;
    let mut equity = 1_000_000.0; // 初期資金
    let mut trade_ask = TradeOrder::default();
    let mut trade_bid = TradeOrder::default();

    for bar in data {
        // ストラテジーのロジックを実装
        match position {
            Position::None => {
                // エントリーロジック
            }
            Position::Entry => {
                // ポジション管理ロジック
            }
        }

        // 結果を記録
        record_results(&bar, equity, &trade_ask, &trade_bid);
    }

    // 結果を分析
    analyze_results();
}

fn main() {
    let historical_data = load_historical_data("path/to/data.csv").unwrap();
    backtest(&historical_data);
}

まとめ

Claudは、ChatGPT-4よりもアウトプットのスピードとコードの正確性で上回っているような感触です。

ネット上でも、さまざまな使い方が試みられているようなので、それらも参考にしながらBot開発に取り入れていきたいです。

関連情報(論文や市場データ)を読み込ませて課題分析と修正も合わせて行うと良いかもしれませんが、過程を理解していないものを使うのは気持ち悪いので、まずは小さく試してみます。

Claudも、生成AIであることは変わりないので、あくまで開発の補助ツールとして活用していきます。

今後もこの調子で開発を学習を進めていきます。

-Bot, トレードロジック