Bybit V5 APIのcategory設計を理解する
linear / inverse / spot / option と
統合口座(UTA)のPython実装

V5は現物・デリバティブ・オプションを1つの仕様に統合した代わりに、category を間違えると全リクエストが通らない設計になっています。しかも BTCUSDT は linear と spot の両方に存在する別商品で、instruments-info のフィールド名すらカテゴリで違います。この記事ではカテゴリ差を吸収する銘柄仕様レイヤを作り、以降の全記事で使い回せる基盤にします。サンプルコード8本。

確認日
2026-07-30
API バージョン
Bybit V5
Python
3.10 以上
pybit
5.17.0

※ Bybit V5は機能追加のペースが速いAPIです。パラメータの最終的な正解は必ず公式ドキュメントで確認してください。本記事のコードはすべてテストネットで動作確認しています。

目次

  1. 4つのcategoryの違い
  2. 同じBTCUSDTが2つ存在する
  3. 統合口座(UTA)とaccountTypeの使い分け
  4. instruments-infoはカテゴリでフィールドが違う
  5. カテゴリ差を吸収する銘柄仕様レイヤ
  6. Decimalによる丸めの正しいやり方
  7. 発注前バリデータ
  8. inverseの数量換算|コイン建ての罠
  9. カテゴリを引数で受け取る共通ラッパ
  10. categoryに起因する典型的な事故
  11. よくある疑問Q&A

この記事は記事01で簡易版として紹介した symbol_rules.py の完全版を作ります。以降の記事はすべてこのレイヤを前提にします。

4つのcategoryの違い

category商品代表シンボルqtyの単位決済通貨
linear USDT/USDC無期限・USDT先物 BTCUSDT
BTCPERP
基軸通貨
(BTCの枚数)
USDT / USDC
inverse コイン建て無期限・先物 BTCUSD
BTCUSDH25
USD建ての契約数
(BTCの枚数ではない)
BTC等の基軸通貨
spot 現物 BTCUSDT 基軸通貨
または決済通貨の金額
—(現物交換)
option オプション BTC-25DEC26-
120000-C
コンタクト数 USDC
inverseのqtyは「BTCの枚数」ではない

ここが最も誤解されるポイントです。linear の BTCUSDT で qty="0.001" は0.001 BTCですが、inverse の BTCUSD で qty="100" は100 USD相当の契約です。BTCの枚数は 100 / 価格 になります。

linearのコードをinverseに流用すると、数量が数万倍ずれます。カテゴリを跨ぐコードを書くなら、後述の notional_to_qty() のような換算層を必ず挟んでください。

同じBTCUSDTが2つ存在する

BTCUSDT は linear(無期限先物)と spot(現物)の両方に存在し、別の市場・別の板・別の価格です。

same_symbol.pyサンプル1
# 本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
from pybit.unified_trading import HTTP

http = HTTP(testnet=False)

for category in ("spot", "linear"):
    t = http.get_tickers(category=category, symbol="BTCUSDT")["result"]["list"][0]
    print(f"{category:<8} last={t['lastPrice']:>12} "
          f"bid={t['bid1Price']:>12} ask={t['ask1Price']:>12} "
          f"24h額={float(t['turnover24h']) / 1e9:>6.2f}B")

# 同じシンボル名でも価格が一致しない。この差がベーシス(記事11・記事35)
spot = float(http.get_tickers(category="spot",
                              symbol="BTCUSDT")["result"]["list"][0]["lastPrice"])
perp = float(http.get_tickers(category="linear",
                              symbol="BTCUSDT")["result"]["list"][0]["lastPrice"])
print(f"\nベーシス: {(perp - spot) / spot * 10_000:+.2f} bps "
      f"(perp {perp} vs spot {spot})")
「シンボルが存在しない」エラーの正体

category="spot" で BTCUSD(コイン建て)を照会すると、当然存在しないので空リストか10001が返ります。エラーメッセージからは「categoryが違う」ことが読み取れません。「シンボル名は合っているのに通らない」ときは、まずcategoryを疑ってください。

統合口座(UTA)とaccountTypeの使い分け

統合取引口座(Unified Trading Account)では、現物・無期限・オプションで証拠金が共有されます。これは資金効率の面で大きな利点ですが、1つの銘柄の損失が他の取引の証拠金を削ることも意味します。

accountType意味使う場面
UNIFIED統合取引口座。現物・デリバティブ・オプションの証拠金を共有通常はこれ
CONTRACT旧来の通常口座(未アップグレードのinverse用)口座をアップグレードしていない場合
FUND資金調達アカウント(入出金・振替の中継)資金移動(記事25)
account_probe.pyサンプル2
"""account_probe.py — 自分の口座種別と、どのaccountTypeが使えるかを判定する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations

from dataclasses import dataclass


@dataclass
class AccountProfile:
    unified_margin_status: str      # 統合口座の状態コード
    margin_mode: str                # REGULAR_MARGIN / PORTFOLIO_MARGIN 等
    is_master: bool
    usable_account_types: list[str]
    dcp_status: str = ""

    @property
    def is_unified(self) -> bool:
        return "UNIFIED" in self.usable_account_types

    def describe(self) -> str:
        return (
            f"統合口座        : {'はい' if self.is_unified else 'いいえ'}\n"
            f"  unifiedMarginStatus : {self.unified_margin_status}\n"
            f"  marginMode          : {self.margin_mode}\n"
            f"  マスターUID         : {'はい' if self.is_master else 'いいえ(サブ)'}\n"
            f"  使えるaccountType   : {', '.join(self.usable_account_types)}"
        )


def probe_account(http) -> AccountProfile:
    """get_account_info と残高照会の成否から、口座構成を判定する。"""
    info = http.get_account_info()
    if info["retCode"] != 0:
        raise RuntimeError(f"get_account_info失敗: {info['retMsg']}")
    r = info["result"]

    usable: list[str] = []
    for acct in ("UNIFIED", "CONTRACT", "FUND"):
        res = http.get_wallet_balance(accountType=acct)
        # 使えない種別は 10001 などで弾かれる
        if res.get("retCode") == 0:
            usable.append(acct)

    return AccountProfile(
        unified_margin_status=str(r.get("unifiedMarginStatus", "")),
        margin_mode=str(r.get("marginMode", "")),
        is_master=bool(r.get("isMasterTrader", False)) or True,
        usable_account_types=usable,
        dcp_status=str(r.get("dcpStatus", "")),
    )


def resolve_account_type(http) -> str:
    """残高照会に使うべきaccountTypeを自動判定する。

    ハードコードせずこれを通せば、通常口座でも統合口座でも動く。
    """
    for acct in ("UNIFIED", "CONTRACT"):
        if http.get_wallet_balance(accountType=acct).get("retCode") == 0:
            return acct
    raise RuntimeError("UNIFIED / CONTRACT のいずれでも残高を取得できません")


if __name__ == "__main__":
    from client import make_client

    c = make_client()
    print(probe_account(c.http).describe())
    print(f"\n自動判定されたaccountType: {resolve_account_type(c.http)}")

instruments-infoはカテゴリでフィールドが違う

ここが実装で最も効く知識です。同じ「最小注文数量」を表すフィールド名が、カテゴリによって違います。

意味linear / inversespotoption
価格の刻み幅 priceFilter.tickSize priceFilter.tickSize priceFilter.tickSize
数量の刻み幅 lotSizeFilter
.qtyStep
lotSizeFilter
.basePrecision
lotSizeFilter
.qtyStep
最小注文数量 .minOrderQty .minOrderQty .minOrderQty
最小注文金額 .minNotionalValue .minOrderAmt —
最大レバレッジ leverageFilter
.maxLeverage
存在しない 存在しない
成行の最大数量 .maxMktOrderQty — —
spotに leverageFilter は無い

現物に info["leverageFilter"]["maxLeverage"] でアクセスすると KeyError になります。「linearで書いたコードをspotに流用したら落ちた」の典型がこれです。フィールドの存在を前提にせず、必ず .get() とフォールバックで受けてください。

カテゴリ差を吸収する銘柄仕様レイヤ

差分を1箇所に閉じ込めます。以降のコードはこの SymbolRules だけを見れば、カテゴリを意識せずに済みます。

symbol_rules.py(完全版)サンプル3
"""symbol_rules.py — カテゴリ差を吸収した銘柄仕様レイヤ(完全版)
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

記事01の簡易版を置き換えるもの。以降の全記事はこれを前提にする。
"""
from __future__ import annotations

import json
import time
from dataclasses import asdict, dataclass
from decimal import ROUND_CEILING, ROUND_DOWN, ROUND_HALF_UP, Decimal
from pathlib import Path

CACHE_PATH = Path("data/symbol_rules.json")
CACHE_TTL_SEC = 86_400.0          # 銘柄仕様は日次で十分


def _dec(value, default: str = "0") -> Decimal:
    """空文字・None・欠損を安全にDecimalへ。"""
    if value in (None, "", "null"):
        return Decimal(default)
    return Decimal(str(value))


@dataclass(frozen=True)
class SymbolRules:
    symbol: str
    category: str
    status: str                  # Trading / PreLaunch / Delivering / Closed
    base_coin: str
    quote_coin: str
    settle_coin: str
    tick_size: Decimal
    qty_step: Decimal
    min_qty: Decimal
    max_qty: Decimal
    max_mkt_qty: Decimal
    min_notional: Decimal
    max_leverage: Decimal
    contract_type: str = ""
    funding_interval_min: int = 0
    is_inverse: bool = False

    # ---- 派生プロパティ -------------------------------------------------
    @property
    def tradable(self) -> bool:
        return self.status == "Trading"

    @property
    def price_decimals(self) -> int:
        return max(0, -self.tick_size.as_tuple().exponent)

    @property
    def qty_decimals(self) -> int:
        return max(0, -self.qty_step.as_tuple().exponent)

    @property
    def funding_interval_hours(self) -> float:
        return self.funding_interval_min / 60 if self.funding_interval_min else 0.0

    def fmt_price(self, price: Decimal) -> str:
        return f"{round_to_step(price, self.tick_size):.{self.price_decimals}f}"

    def fmt_qty(self, qty: Decimal) -> str:
        return f"{floor_to_step(qty, self.qty_step):.{self.qty_decimals}f}"


# ---- 丸めユーティリティ -------------------------------------------------
def floor_to_step(value: Decimal, step: Decimal) -> Decimal:
    """切り捨て。数量は必ずこちら(過大なポジションを防ぐ)。"""
    if step <= 0:
        return value
    return (value / step).to_integral_value(rounding=ROUND_DOWN) * step


def round_to_step(value: Decimal, step: Decimal) -> Decimal:
    """四捨五入。価格はこちら。"""
    if step <= 0:
        return value
    return (value / step).to_integral_value(rounding=ROUND_HALF_UP) * step


def ceil_to_step(value: Decimal, step: Decimal) -> Decimal:
    """切り上げ。最小数量を満たすまで増やしたい場合に使う。"""
    if step <= 0:
        return value
    return (value / step).to_integral_value(rounding=ROUND_CEILING) * step


# ---- 生レスポンス -> SymbolRules ----------------------------------------
def parse_instrument(info: dict, category: str) -> SymbolRules:
    """カテゴリごとのフィールド名の差をここで吸収する。"""
    pf = info.get("priceFilter") or {}
    lf = info.get("lotSizeFilter") or {}
    lev = info.get("leverageFilter") or {}

    # 数量の刻み幅: linear/inverse/option は qtyStep、spot は basePrecision
    qty_step = _dec(lf.get("qtyStep") or lf.get("basePrecision"), "0")

    # 最小注文金額: linear/inverse は minNotionalValue、spot は minOrderAmt
    min_notional = _dec(lf.get("minNotionalValue") or lf.get("minOrderAmt"), "0")

    # 決済通貨: spotには settleCoin が無いので quoteCoin で代替する
    settle = info.get("settleCoin") or info.get("quoteCoin") or ""

    return SymbolRules(
        symbol=info["symbol"],
        category=category,
        status=info.get("status", ""),
        base_coin=info.get("baseCoin", ""),
        quote_coin=info.get("quoteCoin", ""),
        settle_coin=settle,
        tick_size=_dec(pf.get("tickSize"), "0"),
        qty_step=qty_step,
        min_qty=_dec(lf.get("minOrderQty"), "0"),
        max_qty=_dec(lf.get("maxOrderQty"), "0"),
        max_mkt_qty=_dec(lf.get("maxMktOrderQty") or lf.get("maxOrderQty"), "0"),
        min_notional=min_notional,
        max_leverage=_dec(lev.get("maxLeverage"), "1"),
        contract_type=info.get("contractType", ""),
        funding_interval_min=int(info.get("fundingInterval") or 0),
        is_inverse=(category == "inverse"),
    )


# ---- レジストリ(取得・キャッシュ) ---------------------------------------
class SymbolRegistry:
    """全カテゴリの銘柄仕様を保持し、ディスクにキャッシュする。"""

    def __init__(self, http, cache_path: Path = CACHE_PATH,
                 ttl: float = CACHE_TTL_SEC):
        self.http = http
        self.cache_path = cache_path
        self.ttl = ttl
        self._rules: dict[tuple[str, str], SymbolRules] = {}
        self._loaded_at: float = 0.0
        self._load_cache()

    # ---- キャッシュ入出力 ----------------------------------------------
    def _load_cache(self) -> None:
        if not self.cache_path.exists():
            return
        try:
            raw = json.loads(self.cache_path.read_text(encoding="utf-8"))
        except Exception:
            return
        if time.time() - raw.get("_ts", 0) > self.ttl:
            return          # 期限切れなら使わない
        for key, d in raw.get("rules", {}).items():
            category, symbol = key.split("|", 1)
            self._rules[(category, symbol)] = SymbolRules(
                **{k: (Decimal(v) if k in _DECIMAL_FIELDS else v)
                   for k, v in d.items()}
            )
        self._loaded_at = raw.get("_ts", 0)

    def _save_cache(self) -> None:
        self.cache_path.parent.mkdir(parents=True, exist_ok=True)
        out = {
            "_ts": time.time(),
            "rules": {
                f"{cat}|{sym}": {k: (str(v) if isinstance(v, Decimal) else v)
                                 for k, v in asdict(r).items()}
                for (cat, sym), r in self._rules.items()
            },
        }
        tmp = self.cache_path.with_suffix(".tmp")
        tmp.write_text(json.dumps(out, ensure_ascii=False), encoding="utf-8")
        tmp.replace(self.cache_path)

    # ---- 取得 -----------------------------------------------------------
    def refresh(self, categories: tuple[str, ...] = ("linear", "inverse", "spot")
                ) -> int:
        """指定カテゴリの全銘柄を取得する(1カテゴリ1〜数リクエスト)。"""
        added = 0
        for category in categories:
            cursor = None
            while True:
                kw: dict = {"category": category, "limit": 1000}
                if cursor:
                    kw["cursor"] = cursor
                res = self.http.get_instruments_info(**kw)
                if res["retCode"] != 0:
                    raise RuntimeError(
                        f"instruments-info失敗({category}): {res['retMsg']}"
                    )
                for info in res["result"]["list"]:
                    r = parse_instrument(info, category)
                    self._rules[(category, r.symbol)] = r
                    added += 1
                cursor = res["result"].get("nextPageCursor")
                if not cursor:
                    break
        self._loaded_at = time.time()
        self._save_cache()
        return added

    def get(self, symbol: str, category: str = "linear") -> SymbolRules:
        key = (category, symbol.upper())
        if key not in self._rules:
            if time.time() - self._loaded_at > self.ttl or not self._rules:
                self.refresh((category,))
            if key not in self._rules:
                raise LookupError(
                    f"{symbol} は category={category} に存在しません"
                    f"(categoryの指定を確認してください)"
                )
        return self._rules[key]

    def find(self, symbol: str) -> list[SymbolRules]:
        """シンボル名から、存在する全カテゴリの仕様を返す。"""
        if not self._rules:
            self.refresh()
        return [r for (_, sym), r in self._rules.items() if sym == symbol.upper()]

    def tradable_symbols(self, category: str = "linear",
                         quote: str | None = "USDT") -> list[str]:
        if not self._rules:
            self.refresh((category,))
        return sorted(
            r.symbol for (cat, _), r in self._rules.items()
            if cat == category and r.tradable
            and (quote is None or r.quote_coin == quote)
        )


_DECIMAL_FIELDS = {"tick_size", "qty_step", "min_qty", "max_qty",
                   "max_mkt_qty", "min_notional", "max_leverage"}


# ---- 記事01との後方互換 --------------------------------------------------
def fetch_rules(http, symbol: str, category: str = "linear") -> SymbolRules:
    """単発取得(記事01のインターフェースを維持)。"""
    res = http.get_instruments_info(category=category, symbol=symbol)
    if res["retCode"] != 0:
        raise RuntimeError(f"instruments-info失敗: {res['retMsg']}")
    items = res["result"]["list"]
    if not items:
        raise LookupError(f"{symbol} は category={category} に存在しません")
    return parse_instrument(items[0], category)


if __name__ == "__main__":
    from client import make_client

    c = make_client()
    reg = SymbolRegistry(c.http)
    n = reg.refresh()
    print(f"{n}銘柄の仕様を取得しました\n")

    # 同じBTCUSDTがカテゴリで別物であることを確認
    for r in reg.find("BTCUSDT"):
        print(f"{r.category:<8} tick={r.tick_size} step={r.qty_step} "
              f"minQty={r.min_qty} minNotional={r.min_notional} "
              f"maxLev={r.max_leverage} 資金調達={r.funding_interval_hours:.0f}h")

    print(f"\nUSDT無期限の取引可能銘柄: "
          f"{len(reg.tradable_symbols('linear', 'USDT'))}件")
    print(f"現物(USDT)の取引可能銘柄  : "
          f"{len(reg.tradable_symbols('spot', 'USDT'))}件")
キャッシュがレート制限対策になる

instruments-info の上限は毎秒10リクエストと低めです。発注のたびに呼ぶ実装だと、それだけで制限に当たります。SymbolRegistry は全銘柄をまとめて取得してディスクにキャッシュするので、プロセス再起動を含めて1日1回の取得で済みます(記事05)。

Decimalによる丸めの正しいやり方

対象丸め方向理由
価格ROUND_HALF_UP
(四捨五入)
最も近い有効価格に寄せる。どちらに寄っても損益への影響は小さい
数量(新規)ROUND_DOWN
(切り捨て)
意図より大きいポジションを持たないため。リスク量の誤りを防ぐ
数量(決済)ROUND_DOWN保有量を超えないため。reduceOnly と併用
最小数量への調整ROUND_CEILING
(切り上げ)
「最小数量を満たすまで増やす」意図が明確な場合のみ
rounding_demo.pyサンプル4
"""rounding_demo.py — floatで丸めるとなぜ失敗するのかを実演する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations

from decimal import Decimal

from symbol_rules import ceil_to_step, floor_to_step, round_to_step

print("=== floatの誤差 ===")
print(f"0.1 + 0.2           = {0.1 + 0.2!r}")
print(f"1.1 / 0.1           = {1.1 / 0.1!r}   ← 11 にならない")
print(f"round(2.675, 2)     = {round(2.675, 2)!r}   ← 2.68 にならない")

print("\n=== floatで数量を丸めた場合(危険) ===")
qty_float = 0.1 + 0.2
step_float = 0.001
bad = round(qty_float / step_float) * step_float
print(f"qty={qty_float!r} step={step_float}")
print(f"  結果 = {bad!r}")
print(f"  文字列化 -> '{bad}'  ← qtyStepの倍数から外れて拒否される可能性")

print("\n=== Decimalなら正確 ===")
qty = Decimal("0.1") + Decimal("0.2")
step = Decimal("0.001")
print(f"qty={qty} step={step}")
print(f"  floor  = {floor_to_step(qty, step)}")
print(f"  round  = {round_to_step(qty, step)}")
print(f"  ceil   = {ceil_to_step(qty, step)}")

print("\n=== 丸め方向がリスク量を変える ===")
target_notional = Decimal("1000")     # 1000 USDT分を建てたい
price = Decimal("118342.5")
step = Decimal("0.001")
raw = target_notional / price
print(f"必要数量(生) = {raw:.8f}")
for name, fn in (("floor(正しい)", floor_to_step),
                 ("round", round_to_step),
                 ("ceil(危険)", ceil_to_step)):
    q = fn(raw, step)
    print(f"  {name:<16} qty={q} -> 想定元本 {q * price:>9.2f} USDT "
          f"({(q * price / target_notional - 1) * 100:+.2f}%)")
print("\n※ ceilを使うと意図より大きいポジションになる。"
      "レバレッジをかけているとリスク量の誤りに直結する。")

print("\n=== tickSizeが大きい銘柄では価格の丸めも効く ===")
for tick in ("0.01", "0.1", "0.5", "1", "10"):
    t = Decimal(tick)
    p = Decimal("118342.47")
    print(f"  tickSize={tick:<6} -> {round_to_step(p, t)}")

発注前バリデータ

丸めと制約チェックをまとめ、APIに送る前に自分で弾く関数にします。エラーコードでデバッグするより、手元で理由を明示するほうが速いです。

order_validate.pyサンプル5
"""order_validate.py — 発注前の検証(カテゴリ差を意識せずに使える)
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations

from dataclasses import dataclass
from decimal import Decimal

from symbol_rules import SymbolRules, floor_to_step, round_to_step


class OrderRejected(ValueError):
    """発注前検証で弾いた(APIには送っていない)。"""


@dataclass(frozen=True)
class ValidatedOrder:
    symbol: str
    category: str
    side: str
    qty: Decimal
    price: Decimal | None
    qty_str: str
    price_str: str | None
    notional: Decimal

    def to_params(self, order_type: str = "Limit", **extra) -> dict:
        """place_orderに渡すdictを組み立てる。"""
        params = {
            "category": self.category,
            "symbol": self.symbol,
            "side": self.side,
            "orderType": order_type,
            "qty": self.qty_str,
        }
        if order_type == "Limit":
            if self.price_str is None:
                raise OrderRejected("Limit注文にpriceがありません")
            params["price"] = self.price_str
        params.update(extra)
        return params


def validate_order(rules: SymbolRules, side: str, qty: Decimal,
                   price: Decimal | None = None,
                   order_type: str = "Limit",
                   ref_price: Decimal | None = None) -> ValidatedOrder:
    """数量・価格を丸め、全制約を検証する。

    ref_price は成行の想定元本計算に使う参考価格。
    """
    if not rules.tradable:
        raise OrderRejected(f"{rules.symbol}: 取引可能状態ではありません "
                            f"(status={rules.status})")
    if side not in ("Buy", "Sell"):
        raise OrderRejected(f"sideが不正です: {side}")

    # ---- 数量 ----------------------------------------------------------
    q = floor_to_step(qty, rules.qty_step)
    if q <= 0:
        raise OrderRejected(
            f"{rules.symbol}: 数量{qty}を刻み幅{rules.qty_step}で丸めるとゼロ"
            f"(retCode 110017 の原因)"
        )
    if rules.min_qty > 0 and q < rules.min_qty:
        raise OrderRejected(
            f"{rules.symbol}: 数量{q} < 最小{rules.min_qty}"
        )

    limit_max = rules.max_mkt_qty if order_type == "Market" else rules.max_qty
    if limit_max > 0 and q > limit_max:
        raise OrderRejected(
            f"{rules.symbol}: 数量{q} > 最大{limit_max}"
            f"({order_type}注文の上限)"
        )

    # ---- 価格 ----------------------------------------------------------
    p: Decimal | None = None
    if order_type == "Limit":
        if price is None:
            raise OrderRejected("Limit注文にpriceが必要です")
        p = round_to_step(price, rules.tick_size)
        if p <= 0:
            raise OrderRejected(f"価格が不正です: {p}")

    # ---- 想定元本 -------------------------------------------------------
    basis = p or ref_price
    if basis is None:
        raise OrderRejected("成行の検証には ref_price(現在価格)が必要です")

    # inverseはqtyがUSD建てなので、想定元本はqtyそのもの
    notional = q if rules.is_inverse else q * basis

    if rules.min_notional > 0 and notional < rules.min_notional:
        raise OrderRejected(
            f"{rules.symbol}: 想定元本{notional:.4f} < 最小{rules.min_notional}"
            f"(retCode 110094 / 170140 の原因)"
        )

    return ValidatedOrder(
        symbol=rules.symbol, category=rules.category, side=side,
        qty=q, price=p,
        qty_str=rules.fmt_qty(q),
        price_str=rules.fmt_price(p) if p is not None else None,
        notional=notional,
    )


def size_from_notional(rules: SymbolRules, notional: Decimal,
                       price: Decimal) -> Decimal:
    """想定元本(USD/USDT)から数量を求める。カテゴリ差を吸収する。"""
    if rules.is_inverse:
        # inverseはqty自体がUSD建ての契約数
        return floor_to_step(notional, rules.qty_step)
    return floor_to_step(notional / price, rules.qty_step)


if __name__ == "__main__":
    from client import make_client
    from symbol_rules import SymbolRegistry

    c = make_client()
    reg = SymbolRegistry(c.http)

    for category, symbol in (("linear", "BTCUSDT"), ("spot", "BTCUSDT"),
                             ("inverse", "BTCUSD")):
        try:
            rules = reg.get(symbol, category)
        except LookupError as e:
            print(f"{category:<8} {symbol}: {e}")
            continue

        px = Decimal(
            c.http.get_tickers(category=category,
                               symbol=symbol)["result"]["list"][0]["lastPrice"]
        )
        qty = size_from_notional(rules, Decimal("500"), px)

        try:
            v = validate_order(rules, "Buy", qty, price=px * Decimal("0.99"))
            print(f"{category:<8} qty={v.qty_str:<12} price={v.price_str:<12} "
                  f"想定元本={v.notional:.2f}")
        except OrderRejected as e:
            print(f"{category:<8} 弾かれました: {e}")

    # 意図的に小さすぎる数量で弾かれることを確認
    print("\n--- 検証が効いているか確認 ---")
    rules = reg.get("BTCUSDT", "linear")
    for bad_qty in ("0.0000001", "0"):
        try:
            validate_order(rules, "Buy", Decimal(bad_qty),
                           price=Decimal("100000"))
            print(f"  qty={bad_qty}: [NG] 通ってしまいました")
        except OrderRejected as e:
            print(f"  qty={bad_qty}: [OK] {e}")

inverseの数量換算|コイン建ての罠

コイン建て(inverse)は、同じリスク量を取るための数量計算が逆になります。

inverse_convert.pyサンプル6
"""inverse_convert.py — linearとinverseで同じリスク量を取る数量計算
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations

from decimal import Decimal

from symbol_rules import SymbolRules, floor_to_step


def qty_for_usd_exposure(rules: SymbolRules, usd: Decimal,
                         price: Decimal) -> Decimal:
    """USD換算で同額のエクスポージャーを取る数量を返す。

    linear  : qty はBTCの枚数     -> usd / price
    inverse : qty はUSD建て契約数 -> usd そのまま
    spot    : linearと同じ
    """
    if rules.is_inverse:
        return floor_to_step(usd, rules.qty_step)
    return floor_to_step(usd / price, rules.qty_step)


def pnl_per_price_move(rules: SymbolRules, qty: Decimal, entry: Decimal,
                       exit_: Decimal) -> Decimal:
    """価格変化に対する損益。inverseは非線形になる点が重要。

    linear  : (exit - entry) * qty          … 線形
    inverse : qty * (1/entry - 1/exit)      … 非線形(基軸通貨建て)
    """
    if rules.is_inverse:
        # 基軸通貨(BTC)建ての損益
        return qty * (Decimal(1) / entry - Decimal(1) / exit_)
    return (exit_ - entry) * qty


if __name__ == "__main__":
    from client import make_client
    from symbol_rules import SymbolRegistry

    c = make_client()
    reg = SymbolRegistry(c.http)

    USD = Decimal("10000")
    price = Decimal(
        c.http.get_tickers(category="linear",
                           symbol="BTCUSDT")["result"]["list"][0]["lastPrice"]
    )

    print(f"BTC価格 {price} で、{USD} USD相当を建てる場合\n")
    for category, symbol in (("linear", "BTCUSDT"), ("inverse", "BTCUSD")):
        try:
            rules = reg.get(symbol, category)
        except LookupError:
            continue
        qty = qty_for_usd_exposure(rules, USD, price)
        unit = "USD契約" if rules.is_inverse else "BTC"
        print(f"  {category:<8} qty = {qty} {unit}")

    print("\n=== 価格が10%下落したときの損益 ===")
    down = price * Decimal("0.9")
    for category, symbol in (("linear", "BTCUSDT"), ("inverse", "BTCUSD")):
        try:
            rules = reg.get(symbol, category)
        except LookupError:
            continue
        qty = qty_for_usd_exposure(rules, USD, price)
        pnl = pnl_per_price_move(rules, qty, price, down)
        if rules.is_inverse:
            print(f"  {category:<8} {pnl:+.6f} BTC "
                  f"(≒ {pnl * down:+.2f} USD相当)")
        else:
            print(f"  {category:<8} {pnl:+.2f} USDT")

    print("\n※ inverseは損益が基軸通貨建てで、価格の逆数に比例する(非線形)。")
    print("  ロングでは価格下落時に『BTC建ての損失』と『BTC自体の下落』が")
    print("  同時に効くため、linearより実質的な損失が大きくなる。")
inverseの損益は非線形

コイン建て契約の損益は qty × (1/entry − 1/exit) という形になり、価格に対して線形ではありません。ロングの場合、価格が下がると「BTC建ての損失が出る」だけでなく「その損失BTCの価値も下がる」という二重の効果が働きます。

linear用に作ったリスク計算やサイジング(記事23)をそのままinverseに適用すると、想定より大きなリスクを取ります。初学のうちはlinear(USDT建て)に絞ることを勧めます。

カテゴリを引数で受け取る共通ラッパ

market.pyサンプル7
"""market.py — カテゴリを保持して、以降の呼び出しから省略できるようにする
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations

from dataclasses import dataclass
from decimal import Decimal

from symbol_rules import SymbolRegistry, SymbolRules


@dataclass
class Market:
    """1つの (category, symbol) に紐づく操作をまとめたファサード。

    categoryの指定漏れ・取り違えを型レベルで防ぐのが目的。
    """

    http: object
    registry: SymbolRegistry
    symbol: str
    category: str = "linear"

    @property
    def rules(self) -> SymbolRules:
        return self.registry.get(self.symbol, self.category)

    # ---- 相場 -----------------------------------------------------------
    def ticker(self) -> dict:
        res = self.http.get_tickers(category=self.category, symbol=self.symbol)
        return res["result"]["list"][0]

    def last(self) -> Decimal:
        return Decimal(self.ticker()["lastPrice"])

    def mark(self) -> Decimal:
        t = self.ticker()
        # 現物にはmarkPriceが無いのでlastPriceで代替する
        return Decimal(t.get("markPrice") or t["lastPrice"])

    def best_bid_ask(self) -> tuple[Decimal, Decimal]:
        ob = self.http.get_orderbook(category=self.category,
                                     symbol=self.symbol, limit=1)["result"]
        return Decimal(ob["b"][0][0]), Decimal(ob["a"][0][0])

    def mid(self) -> Decimal:
        bid, ask = self.best_bid_ask()
        return (bid + ask) / 2

    def spread_bps(self) -> Decimal:
        bid, ask = self.best_bid_ask()
        return (ask - bid) / ((ask + bid) / 2) * 10_000

    # ---- 発注(検証つき) ------------------------------------------------
    def limit(self, side: str, notional_usd: Decimal, price: Decimal,
              **extra) -> dict:
        from order_validate import size_from_notional, validate_order

        rules = self.rules
        qty = size_from_notional(rules, notional_usd, price)
        v = validate_order(rules, side, qty, price=price, order_type="Limit")
        return self.http.place_order(**v.to_params("Limit", **extra))

    def market(self, side: str, notional_usd: Decimal, **extra) -> dict:
        from order_validate import size_from_notional, validate_order

        rules = self.rules
        px = self.last()
        qty = size_from_notional(rules, notional_usd, px)
        v = validate_order(rules, side, qty, order_type="Market", ref_price=px)
        return self.http.place_order(**v.to_params("Market", **extra))

    # ---- 情報表示 -------------------------------------------------------
    def describe(self) -> str:
        r = self.rules
        bid, ask = self.best_bid_ask()
        return (
            f"{r.symbol} ({r.category})  status={r.status}\n"
            f"  価格      : bid {bid} / ask {ask}  スプレッド {self.spread_bps():.2f} bps\n"
            f"  刻み幅     : tick {r.tick_size} / qty {r.qty_step}\n"
            f"  最小       : qty {r.min_qty} / 金額 {r.min_notional}\n"
            f"  最大レバ   : {r.max_leverage}x"
            + (f"  資金調達 {r.funding_interval_hours:.0f}h"
               if r.funding_interval_min else "")
        )


if __name__ == "__main__":
    from client import make_client

    c = make_client()
    reg = SymbolRegistry(c.http)

    for category, symbol in (("linear", "BTCUSDT"), ("spot", "BTCUSDT"),
                             ("linear", "ETHUSDT")):
        m = Market(http=c.http, registry=reg, symbol=symbol, category=category)
        print(m.describe())
        print()

    # 以降 category を書かなくてよい
    m = Market(http=c.http, registry=reg, symbol="BTCUSDT", category="linear")
    print(f"BTCUSDT(linear) の中値: {m.mid()}")

categoryに起因する典型的な事故

症状原因対処
「シンボルが存在しない」categoryの取り違え(BTCUSDをspotで照会など)registry.find(symbol) でどのカテゴリに存在するか確認
KeyError: 'leverageFilter'spotにleverageFilterは無いparse_instrument() で吸収(サンプル3)
KeyError: 'qtyStep'spotは basePrecision同上
数量が数万倍ずれるinverseのqtyをBTC枚数だと思っているqty_for_usd_exposure()(サンプル6)
retCode 10001accountType の誤り(通常口座でUNIFIED等)resolve_account_type()(サンプル2)
retCode 110094最小注文金額のフィールド名がカテゴリで違うため検証漏れvalidate_order()(サンプル5)
現物で markPrice が無い現物にマーク価格の概念がないMarket.mark() でフォールバック(サンプル7)
上場前の銘柄で発注が通らないstatus が PreLaunchrules.tradable を確認
test_symbol_rules.pyサンプル8
"""test_symbol_rules.py — カテゴリ差の吸収が壊れていないか検証する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

  pytest test_symbol_rules.py -v
"""
from __future__ import annotations

from decimal import Decimal

import pytest

from symbol_rules import ceil_to_step, floor_to_step, parse_instrument, round_to_step

# 実際のレスポンスを模したフィクスチャ(カテゴリごとのフィールド差を再現)
LINEAR_RAW = {
    "symbol": "BTCUSDT", "status": "Trading", "baseCoin": "BTC",
    "quoteCoin": "USDT", "settleCoin": "USDT", "contractType": "LinearPerpetual",
    "fundingInterval": 480,
    "priceFilter": {"tickSize": "0.1"},
    "lotSizeFilter": {"qtyStep": "0.001", "minOrderQty": "0.001",
                      "maxOrderQty": "100", "maxMktOrderQty": "50",
                      "minNotionalValue": "5"},
    "leverageFilter": {"minLeverage": "1", "maxLeverage": "100"},
}

SPOT_RAW = {
    "symbol": "BTCUSDT", "status": "Trading", "baseCoin": "BTC",
    "quoteCoin": "USDT",
    "priceFilter": {"tickSize": "0.01"},
    # 現物は basePrecision / minOrderAmt で、qtyStep も leverageFilter も無い
    "lotSizeFilter": {"basePrecision": "0.000001", "quotePrecision": "0.00000001",
                      "minOrderQty": "0.000048", "maxOrderQty": "71.73956243",
                      "minOrderAmt": "1", "maxOrderAmt": "2000000"},
}

INVERSE_RAW = {
    "symbol": "BTCUSD", "status": "Trading", "baseCoin": "BTC",
    "quoteCoin": "USD", "settleCoin": "BTC", "contractType": "InversePerpetual",
    "fundingInterval": 480,
    "priceFilter": {"tickSize": "0.1"},
    "lotSizeFilter": {"qtyStep": "1", "minOrderQty": "1",
                      "maxOrderQty": "1000000"},
    "leverageFilter": {"minLeverage": "1", "maxLeverage": "100"},
}


def test_linear_parsed():
    r = parse_instrument(LINEAR_RAW, "linear")
    assert r.qty_step == Decimal("0.001")
    assert r.min_notional == Decimal("5")
    assert r.max_leverage == Decimal("100")
    assert r.is_inverse is False
    assert r.funding_interval_hours == 8.0


def test_spot_uses_base_precision():
    """spotは basePrecision を qty_step として扱えること。"""
    r = parse_instrument(SPOT_RAW, "spot")
    assert r.qty_step == Decimal("0.000001")     # basePrecision
    assert r.min_notional == Decimal("1")        # minOrderAmt
    assert r.max_leverage == Decimal("1")        # leverageFilter無し -> 既定1
    assert r.settle_coin == "USDT"               # settleCoin無し -> quoteCoin


def test_inverse_flag():
    r = parse_instrument(INVERSE_RAW, "inverse")
    assert r.is_inverse is True
    assert r.settle_coin == "BTC"
    assert r.min_notional == Decimal("0")        # 該当フィールド無し


def test_no_keyerror_on_missing_filters():
    """フィールドが欠けていても例外にならないこと。"""
    minimal = {"symbol": "XXXUSDT", "status": "Trading"}
    r = parse_instrument(minimal, "linear")
    assert r.tick_size == Decimal("0")
    assert r.qty_step == Decimal("0")
    assert r.max_leverage == Decimal("1")


def test_decimals_derived():
    r = parse_instrument(LINEAR_RAW, "linear")
    assert r.price_decimals == 1      # tickSize 0.1
    assert r.qty_decimals == 3        # qtyStep 0.001
    assert r.fmt_price(Decimal("118342.47")) == "118342.5"
    assert r.fmt_qty(Decimal("0.0019")) == "0.001"


@pytest.mark.parametrize("value,step,floor_,round_,ceil_", [
    ("0.0019", "0.001", "0.001", "0.002", "0.002"),
    ("0.3000000000000000444", "0.001", "0.300", "0.300", "0.300"),
    ("118342.47", "0.1", "118342.4", "118342.5", "118342.5"),
])
def test_rounding(value, step, floor_, round_, ceil_):
    v, s = Decimal(value), Decimal(step)
    assert floor_to_step(v, s) == Decimal(floor_)
    assert round_to_step(v, s) == Decimal(round_)
    assert ceil_to_step(v, s) == Decimal(ceil_)


def test_zero_step_is_passthrough():
    """刻み幅が0(不明)の場合は値をそのまま返すこと。"""
    v = Decimal("1.23456")
    assert floor_to_step(v, Decimal("0")) == v
    assert round_to_step(v, Decimal("0")) == v
参考リンク(一次情報)

よくある疑問Q&A

linearとinverse、どちらを使うべきですか?
特別な理由がなければlinear(USDT建て)です。損益がUSDT建てで線形になり、計算も検証も単純です。inverseの利点は「BTCを担保にBTCの取引ができる(USDTを持たなくてよい)」ことですが、損益が非線形になり、リスク管理が難しくなります。BTC建てで資産を増やしたいという明確な意図がある場合以外は、linearを選んでください。
統合口座(UTA)にアップグレードすべきですか?
APIの観点では統合口座のほうが扱いやすいです。V5の多くのエンドポイントは統合口座を前提に設計されており(10028「統合口座専用API」というエラーコードがあるほどです)、現物と先物で証拠金を共有できるため資金効率も上がります。ただしアップグレードは口座構成を変える操作なので、ポジションを持っていない状態で行い、事前に公式の説明を確認してください。当サイトからは手順の案内はできません。
全銘柄の仕様を取得するのに何リクエストかかりますか?
get_instruments_info は limit=1000 を指定でき、nextPageCursor でページを繰ります。linearは数百銘柄なので1〜2リクエスト、3カテゴリ合わせても数リクエストで済みます。上限は毎秒10リクエストなので余裕があります。ただし発注のたびに個別に呼ぶのは完全な無駄なので、サンプル3の SymbolRegistry でまとめて取得・キャッシュしてください。
USDC無期限(BTCPERP)はどのcategoryですか?
linear です。USDT建てもUSDC建ても linear に含まれ、settleCoin と quoteCoin で区別します。get_positions などで settleCoin を指定して照会する際は、USDTとUSDCで別々に呼ぶ必要がある点に注意してください。SymbolRegistry.tradable_symbols(category, quote) で絞り込めるようにしてあります。
銘柄仕様は変わりますか?キャッシュのTTLはどれくらいが適切ですか?
tickSizeやqtyStepは変わることがあります(流動性の変化や取引所の仕様変更)。またレバレッジ上限やリスクリミットも変更されます。1日1回の更新(TTL 86400秒)で実務上は十分ですが、status の変化(上場・上場廃止・PreLaunch)は日中にも起きるため、発注が想定外に拒否されたときはキャッシュを破棄して再取得する処理を入れてください。
オプション(option)はどう扱えばいいですか?
シンボル名が BTC-25DEC26-120000-C のような形式(基軸通貨-満期-権利行使価格-Call/Put)で、照会には baseCoin を使うなど他カテゴリと勝手が違います。またグリークス(delta/gamma/vega/theta)の管理が必要で、戦略設計の難易度が大きく上がります。本シリーズは無期限先物と現物を主対象としており、オプションは SymbolRegistry の対応範囲外(既定のカテゴリに含めていません)としています。
記事01のsymbol_rules.pyを置き換えて大丈夫ですか?
はい。サンプル3は fetch_rules(http, symbol, category) という同じ関数名・同じ引数を維持しているので、記事01・記事13・記事14のコードはそのまま動きます。追加されたのは SymbolRegistry(一括取得とキャッシュ)と、カテゴリ差の吸収、ceil_to_step などです。以降の記事はこの完全版を前提にします。

注意事項・免責事項

投資は自己判断・自己責任でお願いいたします。本ブログの情報・ツール・ソフトウェアを参考・使用して、取引をされて何らかの損失を被られても、当サイトでは責任は一切負いません(間接的・付随的な障害・損失も含め一切の責任を負いません)。自己責任でご利用ください。

当サイトからダウンロードしたプログラム・EAは本人利用に限り完全無料でご利用いただけます。プログラムの全てまたは一部の複製・コピー・リバースエンジニアリング・改変・2次配布・商用利用・転売・販売等は一切禁止いたします。違反を発見した場合は法的処置を行います。

当サイト上に存在するソフトウェア・ツール・文章・画像・動画等の著作物を無断転載することを禁止します。引用の範囲を超えるものについては、法的処置を行います。

当サイトで配布するツール・ソフトウェアは無料提供ですのでサポートなどは行っておりません(逆に丁寧にサポートしているサイトはほとんど違法サイトですのでご注意ください。投資助言・代理業登録なしでサポート・助言することは法律で禁止されています)。万が一、ツールやソフトウェアに不具合や問題があっても当サイトでは修正・対応などができない場合があります。ご利用の環境でトラブルが発生しても当サイトでは助言することはできませんので自己責任でご利用ください。検索するなどして自分で解決してください。

当サイトでは取引所・証券会社へのリンクを貼っていますが、これは情報として提供するものであり、勧誘行為などは一切行っておりません。そのため、取引所・証券会社の登録方法・使い方・オススメ金融機関・オススメ金融商品などを説明した記事はサイト内にありませんので、ご自身で調べた上で判断ください(本当はサイト内で丁寧に説明したいのですが、仲介・取次ぎにあたる可能性があり第一種金融商品取引業登録を行っていない場合、違法となる可能性があるため出来ません。ご容赦ください)。

当サイトからリンクやバナーなどによって他のサイトに移動された場合、移動先サイトで提供される情報、サービス等について一切の責任を負いません。当サイト掲載情報やリンク先の掲載情報の合法性、正確性、道徳性、最新性、適切性、著作権の許諾や有無など、その内容については一切の保証を致しかねます。

当サイトは、ご訪問いただいた時間、訪問者のアクセス元のホスト名、IPアドレス、OSとブラウザのバージョン、リンク元のURL、モニターの情報等のパソコン環境情報を収集し、アクセス解析をしております。この目的は訪問者が検索サイトからご訪問いただいた場合、どのような検索で当サイトに来訪いただいたかの解析にのみ使用しており、第3者に公開はいたしません。また、個人を特定するものではありません。