※ Bybit V5は機能追加のペースが速いAPIです。パラメータの最終的な正解は必ず公式ドキュメントで確認してください。本記事のコードはすべてテストネットで動作確認しています。
この記事は記事01で簡易版として紹介した symbol_rules.py の完全版を作ります。以降の記事はすべてこのレイヤを前提にします。
| 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 |
ここが最も誤解されるポイントです。linear の BTCUSDT で qty="0.001" は0.001 BTCですが、inverse の BTCUSD で qty="100" は100 USD相当の契約です。BTCの枚数は 100 / 価格 になります。
linearのコードをinverseに流用すると、数量が数万倍ずれます。カテゴリを跨ぐコードを書くなら、後述の notional_to_qty() のような換算層を必ず挟んでください。
BTCUSDT は linear(無期限先物)と spot(現物)の両方に存在し、別の市場・別の板・別の価格です。
# 本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
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を疑ってください。
統合取引口座(Unified Trading Account)では、現物・無期限・オプションで証拠金が共有されます。これは資金効率の面で大きな利点ですが、1つの銘柄の損失が他の取引の証拠金を削ることも意味します。
| accountType | 意味 | 使う場面 |
|---|---|---|
| UNIFIED | 統合取引口座。現物・デリバティブ・オプションの証拠金を共有 | 通常はこれ |
| CONTRACT | 旧来の通常口座(未アップグレードのinverse用) | 口座をアップグレードしていない場合 |
| FUND | 資金調達アカウント(入出金・振替の中継) | 資金移動(記事25) |
"""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)}")
ここが実装で最も効く知識です。同じ「最小注文数量」を表すフィールド名が、カテゴリによって違います。
| 意味 | linear / inverse | spot | option |
|---|---|---|---|
| 価格の刻み幅 | priceFilter.tickSize | priceFilter.tickSize | priceFilter.tickSize |
| 数量の刻み幅 | lotSizeFilter .qtyStep |
lotSizeFilter .basePrecision |
lotSizeFilter .qtyStep |
| 最小注文数量 | .minOrderQty | .minOrderQty | .minOrderQty |
| 最小注文金額 | .minNotionalValue | .minOrderAmt | — |
| 最大レバレッジ | leverageFilter .maxLeverage |
存在しない | 存在しない |
| 成行の最大数量 | .maxMktOrderQty | — | — |
現物に info["leverageFilter"]["maxLeverage"] でアクセスすると KeyError になります。「linearで書いたコードをspotに流用したら落ちた」の典型がこれです。フィールドの存在を前提にせず、必ず .get() とフォールバックで受けてください。
差分を1箇所に閉じ込めます。以降のコードはこの SymbolRules だけを見れば、カテゴリを意識せずに済みます。
"""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)。
| 対象 | 丸め方向 | 理由 |
|---|---|---|
| 価格 | ROUND_HALF_UP (四捨五入) | 最も近い有効価格に寄せる。どちらに寄っても損益への影響は小さい |
| 数量(新規) | ROUND_DOWN (切り捨て) | 意図より大きいポジションを持たないため。リスク量の誤りを防ぐ |
| 数量(決済) | ROUND_DOWN | 保有量を超えないため。reduceOnly と併用 |
| 最小数量への調整 | ROUND_CEILING (切り上げ) | 「最小数量を満たすまで増やす」意図が明確な場合のみ |
"""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 — 発注前の検証(カテゴリ差を意識せずに使える)
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
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_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より実質的な損失が大きくなる。")
コイン建て契約の損益は qty × (1/entry − 1/exit) という形になり、価格に対して線形ではありません。ロングの場合、価格が下がると「BTC建ての損失が出る」だけでなく「その損失BTCの価値も下がる」という二重の効果が働きます。
linear用に作ったリスク計算やサイジング(記事23)をそのままinverseに適用すると、想定より大きなリスクを取ります。初学のうちはlinear(USDT建て)に絞ることを勧めます。
"""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の取り違え(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 10001 | accountType の誤り(通常口座でUNIFIED等) | resolve_account_type()(サンプル2) |
| retCode 110094 | 最小注文金額のフィールド名がカテゴリで違うため検証漏れ | validate_order()(サンプル5) |
現物で markPrice が無い | 現物にマーク価格の概念がない | Market.mark() でフォールバック(サンプル7) |
| 上場前の銘柄で発注が通らない | status が PreLaunch | rules.tradable を確認 |
"""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
get_instruments_info は limit=1000 を指定でき、nextPageCursor でページを繰ります。linearは数百銘柄なので1〜2リクエスト、3カテゴリ合わせても数リクエストで済みます。上限は毎秒10リクエストなので余裕があります。ただし発注のたびに個別に呼ぶのは完全な無駄なので、サンプル3の SymbolRegistry でまとめて取得・キャッシュしてください。linear です。USDT建てもUSDC建ても linear に含まれ、settleCoin と quoteCoin で区別します。get_positions などで settleCoin を指定して照会する際は、USDTとUSDCで別々に呼ぶ必要がある点に注意してください。SymbolRegistry.tradable_symbols(category, quote) で絞り込めるようにしてあります。status の変化(上場・上場廃止・PreLaunch)は日中にも起きるため、発注が想定外に拒否されたときはキャッシュを破棄して再取得する処理を入れてください。BTC-25DEC26-120000-C のような形式(基軸通貨-満期-権利行使価格-Call/Put)で、照会には baseCoin を使うなど他カテゴリと勝手が違います。またグリークス(delta/gamma/vega/theta)の管理が必要で、戦略設計の難易度が大きく上がります。本シリーズは無期限先物と現物を主対象としており、オプションは SymbolRegistry の対応範囲外(既定のカテゴリに含めていません)としています。fetch_rules(http, symbol, category) という同じ関数名・同じ引数を維持しているので、記事01・記事13・記事14のコードはそのまま動きます。追加されたのは SymbolRegistry(一括取得とキャッシュ)と、カテゴリ差の吸収、ceil_to_step などです。以降の記事はこの完全版を前提にします。投資は自己判断・自己責任でお願いいたします。本ブログの情報・ツール・ソフトウェアを参考・使用して、取引をされて何らかの損失を被られても、当サイトでは責任は一切負いません(間接的・付随的な障害・損失も含め一切の責任を負いません)。自己責任でご利用ください。
当サイトからダウンロードしたプログラム・EAは本人利用に限り完全無料でご利用いただけます。プログラムの全てまたは一部の複製・コピー・リバースエンジニアリング・改変・2次配布・商用利用・転売・販売等は一切禁止いたします。違反を発見した場合は法的処置を行います。
当サイト上に存在するソフトウェア・ツール・文章・画像・動画等の著作物を無断転載することを禁止します。引用の範囲を超えるものについては、法的処置を行います。
当サイトで配布するツール・ソフトウェアは無料提供ですのでサポートなどは行っておりません(逆に丁寧にサポートしているサイトはほとんど違法サイトですのでご注意ください。投資助言・代理業登録なしでサポート・助言することは法律で禁止されています)。万が一、ツールやソフトウェアに不具合や問題があっても当サイトでは修正・対応などができない場合があります。ご利用の環境でトラブルが発生しても当サイトでは助言することはできませんので自己責任でご利用ください。検索するなどして自分で解決してください。
当サイトでは取引所・証券会社へのリンクを貼っていますが、これは情報として提供するものであり、勧誘行為などは一切行っておりません。そのため、取引所・証券会社の登録方法・使い方・オススメ金融機関・オススメ金融商品などを説明した記事はサイト内にありませんので、ご自身で調べた上で判断ください(本当はサイト内で丁寧に説明したいのですが、仲介・取次ぎにあたる可能性があり第一種金融商品取引業登録を行っていない場合、違法となる可能性があるため出来ません。ご容赦ください)。
当サイトからリンクやバナーなどによって他のサイトに移動された場合、移動先サイトで提供される情報、サービス等について一切の責任を負いません。当サイト掲載情報やリンク先の掲載情報の合法性、正確性、道徳性、最新性、適切性、著作権の許諾や有無など、その内容については一切の保証を致しかねます。
当サイトは、ご訪問いただいた時間、訪問者のアクセス元のホスト名、IPアドレス、OSとブラウザのバージョン、リンク元のURL、モニターの情報等のパソコン環境情報を収集し、アクセス解析をしております。この目的は訪問者が検索サイトからご訪問いただいた場合、どのような検索で当サイトに来訪いただいたかの解析にのみ使用しており、第3者に公開はいたしません。また、個人を特定するものではありません。