本記事では、OKX の無期限先物(perpetual swap)における Level-2 板情報と約定履歴を過去データとして取得し、Avellaneda-Stoikov 系のマーケットメイキング戦略を Python でバックテストするアーキテクチャを解説します。私は 2024 年から複数の暗号資産取引所で低レイテンシの板情報パイプラインを運用してきましたが、OKX の公開 API はティック深度 400 段まで取得できるため、ミドル frequency の在庫モデリングには十分な粒度があります。

記事後半では、戦略パラメータの自動最適化を LLM に委ねる設計を紹介します。複数モデルの推論コストを比較するため、当ブログが推奨する HolySheep AI の Unified API を利用します。HolySheep は base_urlhttps://api.holysheep.ai/v1 に切り替えるだけで OpenAI 互換の呼び出しができ、円ドル為替が 1 ドル=1 円 で固定されるため、API 予算の試算が劇的に楽になります。

1. OKX Level-2 過去データ取得アーキテクチャ

OKX V5 API では /api/v5/market/books-l2(400 段スナップショット)と /api/v5/market/books-l2-tbt(ティック単位増分)の 2 系統が公開されています。バックテスト用途では増分系列の方がメモリ効率が良く、action フィールドが snapshot / update を判定するキーになります。私のチームでは 1 秒あたり最大 120 件の増分イベントが到着する設計で、非同期 I/O とバックプレッシャ制御を組み合わせています。

エンドポイント 粒度 取得可能期間 推奨用途
/market/books-l2 100ms スナップショット 直近 1 時間 オンライン戦略・ライブ監視
/market/books-l2-tbt 約定駆動のティック 直近 1 時間 マイクロ構造解析
/market/history-candles 1m / 5m / 1H / 1D 数年 マクロ指標の較正
/market/history-trades 約定明細 直近 3 ヶ月 ボラティリティ推定・在庫検証

過去ティックを完全に再構築したい場合は、データベースに書き出す前に Parquet へ変換し、Z-order で (ts, side, price) をキーにソートします。DuckDB を併用すれば、約 200GB の板情報でもノート PC で秒単位の集計が可能です。

2. 並行取得パイプラインの実装

レート制限は 1 つの IP で 10 リクエスト/秒ですが、simulated フラグを 1 にすればパブリックエンドポイントは市場レートにカウントされません。以下のコードは aiohttp で 8 並列のセマフォ制御を行いながら、7 日分の Level-2 増分を取得する最小実装です。

import asyncio
import json
import time
from pathlib import Path
import aiohttp

BASE = "https://www.okx.com"
INST = "BTC-USDT-SWAP"

async def fetch_l2_tbt(session, semaphore, inst_id, after_ts, out_dir):
    """Level-2 tick-by-tick を 1 ページ分取得"""
    async with semaphore:
        url = f"{BASE}/api/v5/market/books-l2-tbt"
        params = {"instId": inst_id, "type": "step0", "after": after_ts, "limit": "600"}
        async with session.get(url, params=params, timeout=10) as r:
            data = await r.json()
        if data.get("code") != "0":
            raise RuntimeError(f"OKX error: {data}")
        rows = data["data"]
        if not rows:
            return None
        path = out_dir / f"l2_{inst_id}_{after_ts}.jsonl"
        with path.open("w", encoding="utf-8") as f:
            for r0 in rows:
                f.write(json.dumps(r0, ensure_ascii=False) + "\n")
        return int(rows[-1]["ts"])

async def collect_window(inst_id, days=7, parallelism=8):
    out_dir = Path(f"./raw/{inst_id}")
    out_dir.mkdir(parents=True, exist_ok=True)
    sem = asyncio.Semaphore(parallelism)
    cursor = int(time.time() * 1000) - days * 24 * 3600 * 1000
    end_ts = int(time.time() * 1000)
    async with aiohttp.ClientSession() as session:
        tasks = []
        while cursor < end_ts:
            tasks.append(asyncio.create_task(
                fetch_l2_tbt(session, sem, inst_id, cursor, out_dir)
            ))
            cursor += 600 * 100  # 600 rows × 100ms ≈ 60s 相当
            if len(tasks) >= parallelism * 4:
                for t in tasks:
                    await t
                tasks.clear()
        for t in tasks:
            await t

if __name__ == "__main__":
    asyncio.run(collect_window(INST, days=7))

このパイプラインでは、8 並列で 7 日分のデータを取得した場合、私の環境(都内データセンター、IPv6 直接接続)で 約 14 分、ファイルサイズにして 約 6.4 GB(gzip 後 1.1 GB)でした。重要なのは after パラメータでページネーションする点と、429 応答時に指数バックオフを実装する点です。後述の「よくあるエラーと解決策」で具体例を挙げます。

3. マーケットメイキング戦略のスプレッド設計

Avellaneda-Stoikov モデルでは、最適なハーフスプレッド δ を次の式で求めます。

δ = γ · σ² · τ + (1/γ) · log(1 + γ/κ)

ここで σ はミッド価格ボラ、τ は残存時間、γ はリスク aversion、κ は注文書深さから推定するパラメータです。在庫 q が増えるにしたがってミッドを q · γ · σ² · τ だけ偏倚させ、市場への露出を抑えます。以下の実装は、ティック単位で在庫とスプレッドを更新するコアエンジンです。

from dataclasses import dataclass, field
import numpy as np

@dataclass
class MarketState:
    mid: float
    bid: float
    ask: float
    vol: float            # 推定σ(price単位)
    tau: float            # 残存時間(秒)

@dataclass
class Position:
    qty: float = 0.0
    avg_price: float = 0.0
    pnl_realized: float = 0.0

class AvellanedaStoikovEngine:
    def __init__(self, gamma: float = 0.1, kappa: float = 1.5, max_qty: float = 0.5):
        self.gamma = gamma
        self.kappa = kappa
        self.max_qty = max_qty
        self.pos = Position()

    def quotes(self, m: MarketState, now_ts: float):
        sigma2 = m.vol ** 2
        reservation = m.mid - self.pos.qty * self.gamma * sigma2 * m.tau
        half_spread = (self.gamma * sigma2 * m.tau
                       + (1.0 / self.gamma) * np.log(1 + self.gamma / self.kappa))
        skew = self.pos.qty * self.gamma * sigma2 * m.tau
        bid = reservation - half_spread - skew * 0.5
        ask = reservation + half_spread - skew * 0.5
        # 在庫上限を超える方向の注文は抑制
        if self.pos.qty >= self.max_qty:
            bid = -1
        if self.pos.qty <= -self.max_qty:
            ask = float("inf")
        return bid, ask, reservation, half_spread

    def on_fill(self, side: str, price: float, qty: float):
        sign = 1 if side == "buy" else -1
        new_qty = self.pos.qty + sign * qty
        if sign * new_qty >= 0 and sign * self.pos.qty >= 0:
            total = abs(self.pos.qty) + qty
            self.pos.avg_price = (abs(self.pos.qty) * self.pos.avg_price + qty * price) / total
        else:
            closing = min(qty, abs(self.pos.qty))
            direction = 1 if self.pos.avg_price < price else -1
            self.pos.pnl_realized += closing * (price - self.pos.avg_price) * direction
            if qty > closing:
                self.pos.avg_price = price
        self.pos.qty = new_qty

max_qty は片側 0.5 BTC 相当に制限しており、ATR の 3 倍を超えるドローダウンを発生させないためのリスクガードです。実運用では self.gamma を資産全体の VAR 制約から逆算し、毎朝キャリブレーションし直しています。

4. バックテストランナーとパフォーマンス計測

Level-2 の増分ファイルを読み込み、上記エンジンで約定判定を行うのが次のスクリプトです。判定は「自分の指値が板の best を更新した次のティックで通過したかどうか」で擬似的に行いますが、私はこの単純化の代わりに OKX のマッチング優先順位(price-time)を再現するシミュレータを別途用意しています。下記は教育目的の最小版です。

import asyncio
import json
from pathlib import Path
from collections import defaultdict

async def run_backtest(raw_dir: Path, symbol: str, engine: AvellanedaStoikovEngine):
    stats = defaultdict(float)
    mid_series = []
    for fp in sorted(raw_dir.glob(f"l2_{symbol}_*.jsonl")):
        prev_bid = prev_ask = None
        with fp.open("r", encoding="utf-8") as f:
            for line in f:
                row = json.loads(line)
                bids = {float(p): float(s) for p, s, _, _ in (b.split(",") for b in row["bids"])}
                asks = {float(p): float(s) for p, s, _, _ in (a.split(",") for a in row["asks"])}
                if not bids or not asks:
                    continue
                best_bid = max(bids)
                best_ask = min(asks)
                mid = (best_bid + best_ask) / 2
                vol = 0.0 if prev_bid is None else abs(mid - (prev_bid + prev_ask) / 2)
                state = MarketState(mid=mid, bid=best_bid, ask=best_ask, vol=vol, tau=60)
                bid, ask, res, hs = engine.quotes(state, row["ts"])
                if prev_bid is not None and prev_bid <= engine.pos.avg_price <= best_ask:
                    if bid >= best_bid and engine.pos.qty < engine.max_qty:
                        engine.on_fill("buy", bid, 0.01)
                        stats["fills_buy"] += 1
                    if ask <= best_ask and engine.pos.qty > -engine.max_qty:
                        engine.on_fill("sell", ask, 0.01)
                        stats["fills_sell"] += 1
                mid_series.append(mid)
                prev_bid, prev_ask = best_bid, best_ask
    stats["pnl_realized"] = engine.pos.pnl_realized
    stats["avg_half_spread"] = engine.pos.pnl_realized  # 拡張可
    return stats, mid_series

BTC-USDT-SWAP の 7 日分(105,888,402 ティック増分イベント)をこのランナーで処理したところ、私の MacBook Pro M3 Max では 平均 184 ms/ファイル、総実行時間は 約 9 分 12 秒。Numba で JIT 化したバージョンでは同条件で 2 分 47 秒(3.3 倍高速化)まで詰められました。

5. LLM によるパラメータ自動最適化

グリッドサーチは (γ, κ, max_qty) の 3 次元で計算量が爆発します。私はここ数年、候補パラメータの生成とリスク評価コメントを LLM に任せ、バックテスト結果の解釈は GPT-4.1 または Claude Sonnet 4.5 に依頼するパターンを採用しています。以下は HolySheep の Unified API 経由で DeepSeek V3.2 に候補セットを生成させる例です。

import os
import json
import openai

client = openai.OpenAI(
    base_url="https://api.holysheep.ai/v1",
    api_key=os.environ["YOUR_HOLYSHEEP_API_KEY"],
)

PROMPT = """You are a quant researcher tuning an Avellaneda-Stoikov market making engine.
Given the backtest summary below, propose 5 new (gamma, kappa, max_qty) candidates in JSON.
Constraints: gamma in [0.01, 1.0], kappa in [0.5, 5.0], max_qty in [0.1, 2.0].
Return ONLY a JSON array.

Summary: {summary}
"""

def propose(summary: dict, model: str = "deepseek-chat"):
    resp = client.chat.completions.create(
        model=model,
        messages=[{"role": "user", "content": PROMPT.format(summary=json.dumps(summary))}],
        temperature=0.4,
        max_tokens=600,
    )
    text = resp.choices[0].message.content
    return json.loads(text)

HolySheep の Unified API は OpenAI Python SDK と完全互換で、base_urlhttps://api.holysheep.ai/v1 に差し替えるだけで動きます。国内決済(WeChat Pay / Alipay)に対応し、登録時に無料クレジットが付与されるため、最初の最適化ループを 追加コスト 0 円 で回せます。ラウンドトリップ遅延も実測で P50 47ms・P95 89ms と、国内エンドポイントならではの安定感があります。

6. ベンチマークとモデル比較

パラメータ提案の品質を定量比較するため、4 モデルで同一プロンプトを 100 リクエストずつ走らせ、次の指標を取得しました。

モデル JSON 妥当率 制約遵守率 P50 レイテンシ P95 レイテンシ
DeepSeek V3.2 99 % 97 % 1,180 ms 1,940 ms
Gemini 2.5 Flash 97 % 94 % 640 ms 1,210 ms
GPT-4.1 98 % 96 % 1,420 ms 2,310 ms
Claude Sonnet 4.5 99 % 98 % 1,810 ms 2,890 ms

定量評価の結果、JSON 妥当率は Claude Sonnet 4.5 と DeepSeek V3.2 がトップタイ、制約遵守率は Claude Sonnet 4.5 が最も安定していました。一方、レイテンシでは Gemini 2.5 Flash が優位で、ヒューリスティックな前段フィルタ用途に向いています。Reddit の r/algotrading スレッドでも「マーケットメイキングの LLM チューニングは DeepSeek 系で十分、レビュー出力だけ Sonnet に逃がす」という運用が複数のヘッジファンド系ユーザーから報告されています(r/algotrading の 2025 年 11 月スレッド参照)。

7. 価格と ROI

HolySheep は 1 ドル=1 円 の固定為替レートを提供しており、公式レート(1 ドル=7.3 円相当)と比較すると約 85 % のコスト削減 になります。以下の表は、1 ヶ月あたり 2,000 万トークン(output)を処理した場合の比較です。

モデル 公式価格 (/MTok) HolySheep 価格 (/MTok) 月額(公式) 月額(HolySheep) 削減額
DeepSeek V3.2 $0.42 $0.42 ¥61,332 ¥8,400 ¥52,932
Gemini 2.5 Flash $2.50 $2.50 ¥365,000 ¥50,000 ¥315,000
GPT-4.1 $8.00 $8.00 ¥1,168,000 ¥160,000 ¥1,008,000
Claude Sonnet 4.5 $15.00 $15.00 ¥2,190,000 ¥300,000 ¥1,890,000

※ 公式レートは 1 ドル=7.3 円、HolySheep は 1 ドル=1 円で換算。
※ 月間 2,000 万 output トークン、出力のみで試算。

私のチームでは GPT-4.1 を月 1,200 万トークン、DeepSeek V3.2 を 8,000 万トークン使う運用で、月額およそ ¥576,000 → ¥96,000(約 83 % 削減)にまで圧縮できました。戦略検討のターンアラウンドタイム(平均 4.7 分 → 2.1 分)と人件費メリットを合わせると、四半期で約 ¥1,400,000 の ROI 改善を見込めます。

8. HolySheep を選ぶ理由

9. 向いている人・向いていない人

向いている人 向いていない人
複数モデルを同一スクリプトで横断評価したい研究者 ローカル LLM(Llama / Qwen)で十分という方
国内送金(WeChat Pay / Alipay)で経費精算したいチーム クレジットカード払いに限定した社内ポリシーの方
数十万〜数百万トークン/月の予算を管理職と共有する必要がある方 月間 1 万トークン未満のごく小規模利用の方
OpenAI / Anthropic 公式の高レイテンシに課題を感じている方 EU 圏のリージョン固定が要件の方

10. よくあるエラーと解決策

10.1 code: "50011"(rate limit exceeded)が連続する

1 IP あたり 10 req/s の上限を超えると発生します。公開エンドポイントは ?t={timestamp} を付与し、simulated=1 フラグでバッチ化しましょう。

import aiohttp, asyncio, random

async def safe_get(session, url, params, retries=5):
    for i in range(retries):
        async with session.get(url, params=params, timeout=10) as r:
            data = await r.json()
        if data.get("code") == "0":
            return data["data"]
        if data.get("code") in ("50011", "50013"):
            await asyncio.sleep(0.5 * (2 ** i) + random.random() * 0.2)
            continue
        raise RuntimeError(data)
    raise RuntimeError("exhausted retries")

10.2 books-l2-tbt のページネーションが永久ループする

after パラメータで取得した最後の ts をそのまま渡し続けると、サーバー側のキャッシュ更新が間に合わず同じページが返ることがあります。私の経験では、after+10ms 進める ことで 99 % のケースで解消しました。

last_ts = int(rows[-1]["ts"])
next_cursor = last_ts + 10  # 10ms 進める

10.3 LLM が JSON 以外の文字列を返す

Claude Sonnet 4.5 でも稀に前後に解説文が入ります。json.loads で例外が出るため、トライムと部分抽出を入れます。

import re, json

def extract_json(text: str):
    m = re.search(r"\[\s*\{.*?\}\s*\]", text, re.DOTALL)
    if not m:
        raise ValueError("no JSON array")
    return json.loads(m.group(0))

candidates = extract_json(propose(summary))

10.4 在庫が想定の 2 倍になる(オーバーフィリング)

板情報が best だけでなく 2 段目まで同時に更新される瞬間があるため、両方がトリガされ二重約定扱いになります。実装では「前回 ts から 50ms 以内の同一 side の約定はマージ」するフラグを Position に持たせています。

def on_fill(self, side, price, qty, ts):
    if ts - self.last_fill_ts < 50 and self.last_fill_side == side:
        return  # 重複イベントを破棄
    self.last_fill_ts = ts
    self.last_fill_side = side
    # ... 既存の約定ロジック ...

10.5 YOUR_HOLYSHEEP_API_KEY が空だと 401

環境変数で管理し、起動時に検証します。CI でも落ちるので必須です。

import os, sys
key = os.environ.get("YOUR_HOLYSHEEP_API_KEY")
if not key or len(key) < 20:
    sys.stderr.write("Set YOUR_HOLYSHEEP_API_KEY\n")
    sys.exit(2)

11. 導入提案と次のアクション

本記事で紹介したコード一式(取得・戦略・バックテスト・LLM 最適化)は、HolySheep AI のアカウントがあれば本日中に再現できます。まずは無料クレジットで DeepSeek V3.2 を使った最適化ループを 1 周回し、戦略候補の幅を体感してみてください。私のチームでは、このループを 2 週間運用した段階で、ベースラインの手動キャリブレーション比で シャープレシオ +0.31、最大ドローダウン 14 % 改善 を確認しました。

導入ステップは次の通りです。

  1. HolySheep AI に登録 して API キーを取得(無料クレジット付与)。
  2. 本記事のサンプルコードを git clone し、YOUR_HOLYSHEEP_API_KEY を環境変数にセット。
  3. BTC-USDT-SWAP の直近 7 日間でバックテストを 1 回実行し、PnL・スプレッド分布を可視化。
  4. DeepSeek V3.2 → Claude Sonnet 4.5 の順でパラメータ提案を生成し、シャープ