取引所ごとに「同じ意味の値」の名前も型も違う── 正規化レイヤに何を持たせるか
ブロックチェーン

取引所ごとに「同じ意味の値」の名前も型も違う── 正規化レイヤに何を持たせるか

FTL編集部

複数の取引所をつなぐとき、署名とレート制限の違いはアダプタで吸収できます。厄介なのはその先で、返ってくるデータの形が揃っていないことです。価格も数量も時刻も、名前が違い、型が違い、単位が違います。

ここを「とりあえず動く形」で個別に変換していくと、取引所が増えるたびに変換処理が散らばり、どこかで単位を取り違えます。データの正規化は、アダプタの中でも独立した層として設計するべき部分です。本稿では bitFlyer と Binance の実際のフィールドを並べ、正規化レイヤに何を持たせるかを具体的に決めていきます。仕様の出典は末尾に挙げています。

並べると、揃っている項目のほうが少ない

まず同じ情報を指しているフィールドを比べます。左が bitFlyer Lightning API、右が Binance Spot API です。

  • 銘柄product_codeBTC_JPYFX_BTC_JPY)/ symbolBNBUSDTBNBBTC
  • 時刻timestampexec_date(ISO 8601 の文字列)/ timeopenTimecloseTime(ミリ秒の整数)
  • 数量 — sizeqty
  • 板 — bids / askspricesize を持つ要素の配列 / bids / asks に価格と数量の 2 要素の組
  • 売買方向side"BUY" / "SELL" / 空文字列)/ isBuyerMaker(真偽値)
  • 出来高 — volumevolume_by_productvolume(base 資産)・quoteVolume(quote 資産)

同じ名前が別の意味を持つ例もあります。volume は両方に存在しますが、Binance では base 資産建ての出来高で、quote 資産建ては quoteVolume という別のフィールドです。名前が一致しているぶん、取り違えても気づきにくくなります。

取引所ごとのフィールドの違いを正規化レイヤで吸収する構成を示した図。左に bitFlyer と Binance それぞれの生のフィールド名と型を並べ、中央の正規化レイヤで銘柄・時刻・価格と数量・売買方向を共通の型へ変換し、右の業務ロジックへ渡すことを表している
変換は 1 か所に集める。ここを通っていない値を業務ロジックへ入れない

文字列で返ってくる数値を float にしない

Binance は価格も数量も文字列で返します。priceqtyvolume も、型は string です。これは実装の都合ではなく、精度を落とさずに渡すための設計です。

受け取った側が float() で変換した時点で、その意図は失われます。二進浮動小数点では十進の小数を正確に表せないため、桁数の多い数量では丸めが入ります。少額の誤差でも、注文数量として送り返せば拒否される可能性があり、残高の突合では毎回わずかにずれ続けます。

正規化レイヤでは十進の固定小数点として扱います。Python なら Decimal、その際に文字列から直接構築するのが要点です。

from decimal import Decimal

# 正しい。文字列のまま Decimal へ渡す
qty = Decimal("0.00123456")

# 誤り。float を経由した時点で誤差が入る
qty = Decimal(float("0.00123456"))   # Decimal('0.001234560000000000...')

bitFlyer のように数値型で返ってくる取引所でも、扱いは揃えます。JSON をパースした時点で float になっているため、文字列へ落としてから Decimal を作るか、パーサに parse_float=Decimal を渡して最初から Decimal で受けます。片方の取引所だけ float のまま扱うと、比較や集計で型が混ざります。

売買方向は「向き」と「メイカーかどうか」が混ざる

約定履歴の売買方向は、取引所によって表し方が根本的に違います。bitFlyer の side"BUY" または "SELL" で、注文の向きをそのまま示します。ただし空文字列になる場合があります。

一方 Binance の isBuyerMaker は真偽値で、買い手がメイカーだったかどうかを示します。これは売買の向きそのものではありません。テイカー側の向きに直すと、isBuyerMaker が真のときテイカーは売り、偽のときテイカーは買いになります。

「どちらが仕掛けたか」を知りたい分析でこれを取り違えると、売り圧力と買い圧力が反転します。符号が逆になるだけの誤りは、動作が止まらないので発見が遅れます。正規化レイヤでは、向きを表す型を 1 つに決めて、取引所ごとの表現をそこへ寄せます。

from dataclasses import dataclass
from datetime import datetime, timezone
from decimal import Decimal
from enum import Enum


class Side(Enum):
    BUY = "buy"
    SELL = "sell"


@dataclass(frozen=True)
class Trade:
    """業務ロジックが見るのはこの形だけ。生のフィールド名は外へ出さない。"""

    symbol: str          # 正規化した表記に統一する(例 "BTC/JPY")
    price: Decimal
    size: Decimal        # base 資産建て。quote 建ては持たせない
    side: Side | None    # テイカー側の向き。判定できないときは None
    at: datetime         # tz 付き。naive な datetime は入れない


def from_binance(row: dict) -> Trade:
    return Trade(
        symbol="BTC/USDT",
        price=Decimal(row["price"]),          # 文字列のまま渡す
        size=Decimal(row["qty"]),
        # isBuyerMaker は「買い手がメイカーか」。テイカーの向きは逆になる。
        side=Side.SELL if row["isBuyerMaker"] else Side.BUY,
        at=datetime.fromtimestamp(row["time"] / 1000, tz=timezone.utc),
    )


def from_bitflyer(row: dict) -> Trade:
    raw = row.get("side") or ""               # 空文字列で返ることがある
    return Trade(
        symbol="BTC/JPY",
        price=Decimal(str(row["price"])),
        size=Decimal(str(row["size"])),
        side=Side(raw.lower()) if raw else None,
        at=datetime.fromisoformat(row["exec_date"]).replace(tzinfo=timezone.utc),
    )

時刻は必ずタイムゾーン付きで持つ

時刻の表現も揃っていません。bitFlyer は ISO 8601 の文字列(2015-07-08T02:43:34.823 のような形式)で、Binance はミリ秒の整数です。

ここで注意が要るのは、ISO 8601 の文字列にタイムゾーンの指定が含まれていない場合です。パースするとタイムゾーンを持たない値になり、そのまま比較や差分の計算に使うと、実行環境のタイムゾーンに依存した結果になります。開発機と本番のタイムゾーンが違えば、そこで結果が変わります。

正規化レイヤを出る時点で、すべての時刻を UTC のタイムゾーン付きに揃えます。表示のときだけ現地時間へ変換します。ミリ秒とマイクロ秒の取り違えも同じ場所で吸収しておくと、後段で単位を意識せずに済みます。

銘柄の表記は変換表を 1 か所に置く

銘柄の表記は取引所ごとにばらばらです。bitFlyer は BTC_JPY のようにアンダースコアで区切り、証拠金取引には FX_BTC_JPY という別の表記を使います。Binance は BNBUSDT のように区切りなしで連結します。

区切りがない表記は、機械的に分解できません。BNBUSDT を base と quote に割るには、扱う quote 資産の一覧を知っている必要があります。文字列の長さや位置で切る実装は、新しい銘柄が増えた時点で壊れます。

したがって、社内の正規表記を 1 つ決めたうえで、取引所ごとの変換表を明示的に持ちます。一覧は取引所の銘柄情報エンドポイントから取得して定期的に更新し、変換できない銘柄は握りつぶさずに例外として扱います。知らない銘柄を黙って無視する実装は、取扱いが増えたときに一部の銘柄だけ処理されない状態を作ります。

板は「版」を持つものと持たないものがある

板の差分更新を扱う場合、版の管理方法が問題になります。Binance の板には lastUpdateId という更新 ID があり、差分がどこから続くのかを判定できます。bitFlyer の板には同種の ID がなく、判断の材料はスナップショットと更新の到着順になります。

正規化レイヤでは、この差を隠さずに持たせます。版を持つ取引所では ID の連続性で欠損を検知し、持たない取引所では定期的にスナップショットを取り直す。共通の型に押し込めて「どちらも同じ」と扱うと、欠損を検知できない取引所で板が静かにずれます。同じインタフェースで扱いたいのは業務ロジックから見た形であって、整合性の担保方法まで同じにする必要はありません。

版がある場合でも、スナップショットと差分をつなぐ順序には決まりがあります。差分の購読を先に始めてから、スナップショットを取得し、スナップショットの版より古い差分を捨てて、そこから積み上げる。逆にスナップショットを先に取ると、取得している間に届いた差分が抜けます。この手順は取引所のドキュメントに書かれていることが多いので、実装前に確認します。

板の状態を持つ側にも注意が要ります。価格を浮動小数点の連想配列の鍵にすると、同じ値段が別の鍵として登録されることがあります。鍵は文字列か十進の値で持ち、数量がゼロになった段階で削除します。ゼロのまま残す実装は、板の深さを数えるときに誤差を生みます。

まとめ

取引所 API のデータは、銘柄・時刻・数量・売買方向のいずれも表現が揃っていません。文字列で返る数値は文字列のまま十進で受け、float を経由させない。売買方向は「向き」と「メイカーかどうか」が混ざるため、テイカー側の向きに寄せた型を 1 つ決める。時刻は正規化レイヤを出る時点で UTC のタイムゾーン付きに揃える。銘柄は社内の正規表記と変換表を 1 か所に置き、変換できないものは例外にする。板の版管理は取引所ごとに方法が違うため、共通化するのは業務ロジックから見た形だけにとどめる。この 5 点を最初に決めておけば、取引所を増やしたときに壊れるのは変換表だけで済みます。

金融テクノロジー総合研究所では、複数取引所のデータを扱う基盤の設計・実装を受託しています。既存システムのデータ不整合の切り分けや、正規化レイヤの再設計からもご相談いただけます。暗号資産取引所 API 開発の詳細もあわせてご覧ください。ご相談はお問い合わせよりご連絡ください。

参考資料

  • bitFlyer Lightning API ドキュメント(product_code の表記、板の bids / asks と mid_price、ticker の timestamp と ltp、約定履歴の side と exec_date、注文の child_order_acceptance_id)
  • Market data endpoints|Binance Spot API Docs(depth の lastUpdateId と bids / asks、trades の time・price・qty・isBuyerMaker、ticker/24hr の openTime / closeTime と volume / quoteVolume、値が文字列で返ること)
記事一覧に戻る