暗号資産取引所 API は署名とレート制限で分かれる── 取引所を増やしても壊れない構成にする
ブロックチェーン

暗号資産取引所 API は署名とレート制限で分かれる── 取引所を増やしても壊れない構成にする

FTL編集部

暗号資産取引所の API は、どこも似た形をしています。Public と Private に分かれた REST があり、板や約定はリアルタイム配信のために WebSocket で流れてくる。1 社つないだ経験があれば、2 社目も同じように書けそうに見えます。

実際に詰まるのはその先です。署名の作り方とレート制限の数え方は取引所ごとに違い、しかも違い方に規則性がありません。ここを吸収する層を持たずに書き始めると、取引所を 1 社増やすたびに業務ロジックへ条件分岐が入り込みます。本稿では bitFlyer と Binance を例に、どこが揃っていないのかを具体的に確認し、差異をどこへ閉じ込めるかを設計として整理します。仕様の出典は末尾に挙げています。

共通しているのは構成だけ

取引所 API の構成は、おおむね次の 3 つに分かれます。認証の要らない Public REST(板・歩み値・銘柄一覧)、認証の要る Private REST(発注・残高・注文照会)、そしてリアルタイム配信の WebSocket です。この分け方はほぼ共通しているため、取引所非依存のインタフェースを定義すること自体は無理なく成り立ちます。

揃っていないのは、その内側です。認証で何を署名するのか、レート制限を何の単位で数えるのか、上限を超えたときに何が起きるのか。この 3 点は取引所ごとに設計思想から違っており、共通の抽象で覆い隠せるのはインタフェースの形までです。中身は取引所ごとに書くことになります。

取引所 API を扱う構成を 3 層で示した図。業務ロジックは共通インタフェースだけを見て、その下のアダプタ層が取引所ごとの署名・レート制限・WebSocket 購読の差異を吸収し、さらに下に各取引所の API がある
差異はアダプタ層に閉じ込め、業務ロジックからは見えないようにする

署名は「何を連結するか」が違う

bitFlyer Lightning API の Private API は、ACCESS-KEYACCESS-TIMESTAMPACCESS-SIGN の 3 つのヘッダを使います。署名は、タイムスタンプ・HTTP メソッド・リクエストのパス・リクエストボディを文字列として連結し、API シークレットで HMAC-SHA256 したものです。

import hashlib
import hmac
import time

def bitflyer_headers(api_key: str, api_secret: str, method: str,
                     path: str, body: str = "") -> dict[str, str]:
    """タイムスタンプ + メソッド + パス + ボディ を連結して署名する。"""
    timestamp = str(int(time.time()))
    message = timestamp + method + path + body
    sign = hmac.new(
        api_secret.encode("utf-8"),
        message.encode("utf-8"),
        hashlib.sha256,
    ).hexdigest()
    return {
        "ACCESS-KEY": api_key,
        "ACCESS-TIMESTAMP": timestamp,
        "ACCESS-SIGN": sign,
        "Content-Type": "application/json",
    }

一方 Binance の Spot API は、API キーを X-MBX-APIKEY ヘッダで送り、署名は signature というパラメータとして渡します。署名対象は query string と HTTP body を区切りなく連結したもので、アルゴリズムは同じ HMAC-SHA256 です。非 ASCII 文字はパーセントエンコードが必須とされ、署名は大文字小文字を区別しません。

import hashlib
import hmac
import time
from urllib.parse import urlencode

def binance_signed_query(api_secret: str, params: dict[str, str]) -> str:
    """query string を組み立て、その文字列そのものを署名して末尾に足す。"""
    # recvWindow はリクエストが有効な期間(ミリ秒)。既定 5000、最大 60000。
    params = {**params, "timestamp": int(time.time() * 1000), "recvWindow": 5000}
    query = urlencode(params)
    signature = hmac.new(
        api_secret.encode("utf-8"),
        query.encode("utf-8"),
        hashlib.sha256,
    ).hexdigest()
    return f"{query}&signature={signature}"

並べると、揃っていない点がはっきりします。署名をヘッダで渡すかパラメータで渡すかが違い、署名対象にメソッドとパスを含めるかどうかが違い、タイムスタンプの単位が秒とミリ秒で違います。さらに Binance には recvWindow という有効期間の概念があり、bitFlyer にはありません。

ここから導かれる設計上の要件は 1 つです。署名処理は「リクエストを組み立てる処理」と一体にしておくことです。パスとボディが確定した後でなければ署名できない取引所があるため、共通層で URL を組み立て、署名だけをアダプタに委ねる作りにすると破綻します。リクエスト全体の生成をアダプタの責務にします。

レート制限は「何を数えるか」が違う

bitFlyer の制限は回数で数えます。同一 IP アドレスからのアクセスは 5 分間で 500 回、Private API も 5 分間で 500 回です。さらに sendchildordersendparentordercancelallchildorders の 3 つは合計で 5 分間に 300 回という別枠が設けられています。加えて数量 0.1 以下の注文は 1 分間で 100 回までで、1 時間の制限を受けた後は 10 回まで下がります。

Binance はウェイトで数えます。エンドポイントごとに重みが割り当てられており、重い処理ほど 1 回のリクエストで多くのウェイトを消費します。消費量はレスポンスヘッダ X-MBX-USED-WEIGHT-(intervalNum)(intervalLetter) で返ります。これとは別に未約定注文数の制限があり、注文が成功するたびに X-MBX-ORDER-COUNT-(intervalNum)(intervalLetter) が返ります。制限は API キーではなく IP に対して適用されます。

超過時の挙動も違います。bitFlyer では一時的にアクセスが制限され、その後の上限も引き下げられます。Binance では上限を超えると HTTP 429 が返り、429 を受け取った後も送り続けると自動的に IP が BAN され、HTTP 418 が返るようになります。BAN の期間は繰り返すほど延び、2 分から 3 日まで伸びます。429 と 418 のいずれにも Retry-After ヘッダが付き、429 では BAN を避けるために待つべき秒数、418 では BAN が明けるまでの秒数を示します。

つまり、共通化できるのは「送信を絞る」という動作だけで、絞る根拠になる数え方は取引所ごとに実装するしかありません。アダプタには、リクエスト前に消費を申告し、レスポンスから実測を取り込む口を持たせます。

import time
from dataclasses import dataclass, field

@dataclass
class WindowLimiter:
    """一定時間あたりの消費量で送信を絞る。回数制もウェイト制もこれで表せる。

    bitFlyer の「5 分 500 回」は capacity=500, window=300, cost=1。
    Binance の「1 分あたりのウェイト」は cost にエンドポイントの重みを渡す。
    """

    capacity: int
    window: float
    used: list[tuple[float, int]] = field(default_factory=list)

    def acquire(self, cost: int = 1) -> float:
        """送信してよいなら 0、待つべきなら待機秒数を返す。"""
        now = time.monotonic()
        self.used = [(t, c) for t, c in self.used if now - t < self.window]
        spent = sum(c for _, c in self.used)
        if spent + cost <= self.capacity:
            self.used.append((now, cost))
            return 0.0
        oldest = self.used[0][0]
        return max(0.0, self.window - (now - oldest))

    def sync(self, reported: int) -> None:
        """取引所が返した実測値で上書きする。自前の計算より実測を信じる。"""
        now = time.monotonic()
        self.used = [(now, reported)]

注文系に別枠がある取引所では、この制限器を用途ごとに分けて持ちます。一般的な参照系と同じキューに注文を載せると、相場が動いて参照が増えた場面で、いちばん通したい注文が待たされます。

WebSocket は切れる前提で組む

リアルタイム配信は、取引所ごとに購読の作法が違います。bitFlyer の Realtime API は WebSocket 上の JSON-RPC 2.0 で提供されます。購読するチャンネル名を指定する方式は共通していても、認証の要否や再購読の手順は揃いません。

ここで共通して必要になるのは、切断からの復帰です。再接続したら、購読をやり直したうえでスナップショットを取り直します。差分だけを積み上げる実装は、1 度切れた時点で板の状態が実際とずれ、そのずれは次に全件を取り直すまで残ります。切断は異常ではなく、日常的に起きる前提で設計します。

残高と建玉についても同じ考え方を適用します。WebSocket で受けた更新だけを信じるのではなく、REST で定期的に突き合わせる経路を持たせます。突合で差が出たときにどちらを正とするかは、取引所側の値を正とするのが原則です。

鍵は権限を落として持つ

最後に運用の話をします。自動売買に必要な権限は、発注と参照です。出金権限を持つ鍵を常時稼働のシステムに置かないのは、実装の巧拙とは無関係に効く対策です。鍵が漏れたときの被害の上限が、権限の設定でそのまま決まります。

Binance のように制限が IP 単位で適用される取引所では、送信元 IP を固定しておくと制限の管理が読めるようになります。IP アドレスによるアクセス制限を鍵に設定できる取引所であれば、それも併せて有効にします。鍵そのものはコードにもリポジトリにも置かず、秘密情報の管理サービスから実行時に読み込む形にします。

まとめ

取引所 API は、Public REST・Private REST・WebSocket という構成こそ共通していますが、署名の連結順序、署名の渡し方、タイムスタンプの単位、レート制限の数え方、超過時の挙動は揃っていません。共通のインタフェースを定義したうえで、リクエストの生成と制限の管理をアダプタの責務にすると、取引所を増やしても業務ロジックへ差異が波及しません。制限器は用途ごとに分け、取引所が返す実測値で補正します。WebSocket は切れる前提で、再接続時にスナップショットから作り直します。鍵は出金権限を落とし、可能なら IP でも縛ります。

金融テクノロジー総合研究所では、複数取引所に接続する自動売買基盤の設計・実装を受託しています。既存システムの取引所追加や、レート制限に起因する障害の切り分けからもご相談いただけます。暗号資産取引所 API 開発の詳細もあわせてご覧ください。ご相談はお問い合わせよりご連絡ください。

参考資料

記事一覧に戻る