※ Bybit V5は機能追加のペースが速いAPIです。パラメータの最終的な正解は必ず公式ドキュメントで確認してください。本記事のコードはすべてテストネットで動作確認しています。
| パラメータ | 必須 | 内容 |
|---|---|---|
| category | 必須 | linear / inverse / option(spotは非対応=現物にポジションの概念がない) |
| symbol | 条件付き | 指定するとその銘柄のみ。settleCoinより優先される |
| settleCoin | 条件付き | USDT / USDC / BTC など。linearではsymbolかsettleCoinのどちらかが必須 |
| baseCoin | option用 | オプションではこちらを使う |
| limit | 任意 | 1〜200(既定20) |
| cursor | 任意 | ページネーション |
settleCoin="USDT" だけで照会すると、USDC建て(BTCPERPなど)のポジションは返ってきません。「全ポジションを取得したつもりが一部だけだった」という状態で全決済処理を書くと、決済し残しが発生します。
本当に「全部」を取りたいなら、linearをUSDTとUSDCの両方+inverseをBTCやETHごとに照会する必要があります。サンプル1でこれを網羅します。
"""fetch_positions.py — 全カテゴリ・全決済通貨のポジションを取り切る
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations
from decimal import Decimal
# 「全ポジション」を取るために照会すべき組み合わせ
# linearはsettleCoinごとに分けないと取りこぼす
SCAN_TARGETS: tuple[tuple[str, dict], ...] = (
("linear", {"settleCoin": "USDT"}),
("linear", {"settleCoin": "USDC"}),
("inverse", {"settleCoin": "BTC"}),
("inverse", {"settleCoin": "ETH"}),
)
def fetch_all_raw(http, targets=SCAN_TARGETS,
include_zero: bool = False) -> list[dict]:
"""全カテゴリ・全決済通貨のポジションを1つのリストにまとめる。
include_zero=False なら size=0(決済済み)を除外する。
"""
out: list[dict] = []
for category, extra in targets:
cursor = None
while True:
kw: dict = {"category": category, "limit": 200, **extra}
if cursor:
kw["cursor"] = cursor
res = http.get_positions(**kw)
code = res.get("retCode")
# その決済通貨の口座を持っていない場合はエラーになる。無視して次へ
if code != 0:
if code in (10001, 10004, 110001):
break
raise RuntimeError(
f"get_positions失敗({category}/{extra}): "
f"{code} {res.get('retMsg')}"
)
for p in res["result"]["list"]:
if not include_zero and Decimal(p.get("size") or "0") == 0:
continue
p["_category"] = category # 後で使うので保持しておく
out.append(p)
cursor = res["result"].get("nextPageCursor")
if not cursor:
break
return out
if __name__ == "__main__":
from client import make_client
c = make_client()
rows = fetch_all_raw(c.http)
print(f"保有ポジション: {len(rows)}件\n")
print(f"{'symbol':<14}{'cat':<9}{'side':<6}{'size':>14}"
f"{'建値':>13}{'含み損益':>12}{'idx':>5}")
print("-" * 76)
for p in rows:
print(f"{p['symbol']:<14}{p['_category']:<9}{p['side']:<6}"
f"{p['size']:>14}{p['avgPrice']:>13}"
f"{float(p['unrealisedPnl'] or 0):>+12.2f}"
f"{p.get('positionIdx', 0):>5}")
total = sum(float(p["unrealisedPnl"] or 0) for p in rows)
print(f"\n含み損益合計: {total:+.2f}")
| フィールド | 意味 | 実装上の使い道 |
|---|---|---|
| positionIdx | 0=ワンウェイ / 1=ヘッジBuy / 2=ヘッジSell | 発注・決済時に引き継ぐ必須項目 |
| side | Buy(ロング)/ Sell(ショート) | 決済は逆方向。sizeは常に正の値 |
| size | 保有数量(常に正) | 符号はsideで判断する |
| avgPrice | 平均建値 | TP/SLの基準(記事14) |
| positionValue | ポジション価値 | リスク量の把握 |
| leverage | 適用レバレッジ | 設定と一致しているか確認(記事21) |
| markPrice | マーク価格 | 清算判定と損益評価の基準 |
| liqPrice | 清算価格 | 空文字の場合がある(後述) |
| positionIM | 初期証拠金 | 必要証拠金の把握 |
| positionMM | 維持証拠金 | 清算距離の自前計算(記事22) |
| unrealisedPnl | 含み損益 | マーク価格ベース |
| curRealisedPnl | 現在の保有分の実現損益 | 部分決済した分 |
| cumRealisedPnl | 累積実現損益 | 通算成績 |
| positionStatus | Normal / Liq / Adl | 異常検知に使う(後述) |
| adlRankIndicator | 自動デレバレッジの順位 | 高いほどADL対象になりやすい |
| isReduceOnly | 縮小のみ許可の状態か | リスクリミット調整時に立つ |
| autoAddMargin | 自動証拠金追加 0/1 | 分離マージンでのみ有効 |
| riskId riskLimitValue | リスクリミット階層 | 最大レバレッジに影響(記事21) |
| takeProfit stopLoss trailingStop | TP/SL/トレールの現在値 | 設定の照合(記事14) |
| seq | クロスシーケンス | WebSocketイベントの順序判定 |
| createdTime updatedTime | 作成・更新時刻(ms) | 保有期間の計測 |
クロスマージンで他のポジションと証拠金を共有している場合、単一ポジションの清算価格が定義できないため liqPrice が空文字になります。float(p["liqPrice"]) と書くと ValueError です。
統合口座のリスク管理は、個別の清算価格ではなく口座全体の維持率(accountMMRate)で見るのが正解です(記事22)。liqPrice は分離マージンの参考値として扱ってください。
| モード | positionIdx | 挙動 | 用途 |
|---|---|---|---|
| ワンウェイ (One-Way) |
0 | 1銘柄につき1方向のみ。反対売買は決済になる | 通常はこれ。ロジックが単純 |
| ヘッジ (Both-Way) |
1(Buy側) 2(Sell側) |
ロングとショートを同時保有できる | 両建て戦略・複数戦略の並走 |
ワンウェイモードで positionIdx=1 を送ると拒否され、ヘッジモードで positionIdx を省略するとどちらのポジションを対象にするか決まらないため失敗します。さらに悪いのは、ヘッジモードで誤った側を指定した場合——意図していない側のポジションが決済されます。
対策は単純です。発注・決済時のpositionIdxは、必ず get_positions() のレスポンスから引き継ぐ。ハードコードしないでください。
"""position_idx.py — positionIdxを取り違えないためのヘルパ
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations
from enum import IntEnum
class PositionIdx(IntEnum):
ONE_WAY = 0
HEDGE_BUY = 1
HEDGE_SELL = 2
@classmethod
def for_side(cls, side: str, hedge_mode: bool) -> "PositionIdx":
"""売買方向とモードから、新規発注時の正しいpositionIdxを決める。"""
if not hedge_mode:
return cls.ONE_WAY
if side == "Buy":
return cls.HEDGE_BUY
if side == "Sell":
return cls.HEDGE_SELL
raise ValueError(f"sideが不正です: {side}")
@classmethod
def for_closing(cls, position_side: str, hedge_mode: bool) -> "PositionIdx":
"""決済時は「保有している側」のidxを指定する。
重要: 決済注文のsideは保有と逆になるが、positionIdxは保有側のまま。
ここを逆にすると、別のポジションを触ってしまう。
"""
if not hedge_mode:
return cls.ONE_WAY
return cls.HEDGE_BUY if position_side == "Buy" else cls.HEDGE_SELL
def detect_hedge_mode(http, symbol: str, category: str = "linear") -> bool:
"""現在のポジションモードを判定する。
ヘッジモードでは size=0 でも positionIdx 1 と 2 の2行が返る。
ワンウェイでは positionIdx=0 の1行のみ。
"""
res = http.get_positions(category=category, symbol=symbol)
if res["retCode"] != 0:
raise RuntimeError(f"get_positions失敗: {res['retMsg']}")
idxs = {int(p.get("positionIdx", 0)) for p in res["result"]["list"]}
return bool(idxs & {1, 2})
def close_params(pos: dict, category: str = "linear") -> dict:
"""保有ポジションの辞書から、決済注文のパラメータを組み立てる。"""
idx = int(pos.get("positionIdx", 0))
params = {
"category": category,
"symbol": pos["symbol"],
"side": "Sell" if pos["side"] == "Buy" else "Buy", # 逆方向
"orderType": "Market",
"qty": pos["size"],
"reduceOnly": True,
}
if idx != 0:
params["positionIdx"] = idx # 保有側のidxをそのまま引き継ぐ
return params
if __name__ == "__main__":
from client import make_client
c = make_client()
SYMBOL = "BTCUSDT"
hedge = detect_hedge_mode(c.http, SYMBOL)
print(f"{SYMBOL} のモード: {'ヘッジ' if hedge else 'ワンウェイ'}")
print("\n--- 新規発注時 ---")
for side in ("Buy", "Sell"):
idx = PositionIdx.for_side(side, hedge)
print(f" {side:<5} -> positionIdx={idx.value} ({idx.name})")
print("\n--- 決済時(保有側のidxを指定する) ---")
for held in ("Buy", "Sell"):
idx = PositionIdx.for_closing(held, hedge)
close_side = "Sell" if held == "Buy" else "Buy"
print(f" {held}を保有 -> 注文side={close_side} / positionIdx={idx.value}")
print("\n※ 注文sideは逆になるが、positionIdxは保有側のまま。ここが要点。")
生の辞書を持ち回すと、フィールド名のtypoや文字列と数値の取り違えでバグります。受け取った直後に1箇所でdataclassへ変換してしまうのが確実です。
"""position.py — ポジションのモデル化と派生指標
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal
def _d(value, default: str = "0") -> Decimal:
"""空文字・None を安全にDecimalへ(liqPriceは空になりうる)。"""
if value in (None, "", "null"):
return Decimal(default)
return Decimal(str(value))
@dataclass(frozen=True)
class Position:
symbol: str
category: str
side: str # Buy / Sell
size: Decimal # 常に正
position_idx: int
avg_price: Decimal
mark_price: Decimal
position_value: Decimal
leverage: Decimal
liq_price: Decimal # 0 なら「取得不能」
position_im: Decimal
position_mm: Decimal
unrealised_pnl: Decimal
cur_realised_pnl: Decimal
cum_realised_pnl: Decimal
position_status: str # Normal / Liq / Adl
adl_rank: int
is_reduce_only: bool
take_profit: Decimal
stop_loss: Decimal
trailing_stop: Decimal
tpsl_mode: str
risk_id: int
seq: int
created_ms: int
updated_ms: int
# ---- 派生 -----------------------------------------------------------
@property
def signed_size(self) -> Decimal:
"""符号付き数量。ロング正・ショート負。計算にはこちらを使う。"""
return self.size if self.side == "Buy" else -self.size
@property
def is_long(self) -> bool:
return self.side == "Buy"
@property
def notional(self) -> Decimal:
return self.size * self.mark_price
@property
def pnl_pct(self) -> Decimal:
"""建値に対する価格変化率(レバレッジ前)。"""
if self.avg_price == 0:
return Decimal(0)
diff = self.mark_price - self.avg_price
return (diff / self.avg_price * 100) * (1 if self.is_long else -1)
@property
def has_stop(self) -> bool:
return self.stop_loss > 0 or self.trailing_stop > 0
@property
def is_abnormal(self) -> bool:
"""清算処理中・ADL対象などの異常状態か。"""
return self.position_status != "Normal"
def liq_distance_pct(self) -> Decimal | None:
"""清算価格までの距離(%)。取得できない場合はNone。"""
if self.liq_price <= 0 or self.mark_price <= 0:
return None
return abs(self.mark_price - self.liq_price) / self.mark_price * 100
@property
def key(self) -> tuple[str, str, int]:
"""一意な識別子。同じsymbolでもヘッジモードでは2つ存在しうる。"""
return (self.category, self.symbol, self.position_idx)
def line(self) -> str:
liq = self.liq_distance_pct()
liq_s = f"{liq:.1f}%" if liq is not None else " - "
flags = []
if not self.has_stop:
flags.append("損切なし")
if self.is_abnormal:
flags.append(self.position_status)
if self.is_reduce_only:
flags.append("縮小のみ")
return (f"{self.symbol:<13}{self.side:<5}{self.size:>13}"
f"{self.avg_price:>12}{self.unrealised_pnl:>+11.2f}"
f"{self.pnl_pct:>+8.2f}%{liq_s:>8} {' / '.join(flags)}")
def parse_position(p: dict, category: str | None = None) -> Position:
return Position(
symbol=p["symbol"],
category=category or p.get("_category", "linear"),
side=p.get("side") or "",
size=_d(p.get("size")),
position_idx=int(p.get("positionIdx") or 0),
# WebSocketは entryPrice、RESTは avgPrice。両方を受け付ける
avg_price=_d(p.get("avgPrice") or p.get("entryPrice")),
mark_price=_d(p.get("markPrice")),
position_value=_d(p.get("positionValue")),
leverage=_d(p.get("leverage"), "1"),
liq_price=_d(p.get("liqPrice")),
position_im=_d(p.get("positionIM")),
position_mm=_d(p.get("positionMM")),
unrealised_pnl=_d(p.get("unrealisedPnl")),
cur_realised_pnl=_d(p.get("curRealisedPnl")),
cum_realised_pnl=_d(p.get("cumRealisedPnl")),
position_status=p.get("positionStatus") or "Normal",
adl_rank=int(p.get("adlRankIndicator") or 0),
is_reduce_only=bool(p.get("isReduceOnly", False)),
take_profit=_d(p.get("takeProfit")),
stop_loss=_d(p.get("stopLoss")),
trailing_stop=_d(p.get("trailingStop")),
tpsl_mode=p.get("tpslMode") or "",
risk_id=int(p.get("riskId") or 0),
seq=int(p.get("seq") or 0),
created_ms=int(p.get("createdTime") or 0),
updated_ms=int(p.get("updatedTime") or 0),
)
def fetch_positions(http, **kw) -> list[Position]:
"""モデル化済みのポジション一覧を返す。"""
from fetch_positions import fetch_all_raw
return [parse_position(p) for p in fetch_all_raw(http, **kw)]
if __name__ == "__main__":
from client import make_client
c = make_client()
positions = fetch_positions(c.http)
print(f"{'symbol':<13}{'side':<5}{'size':>13}{'建値':>12}"
f"{'含み損益':>11}{'損益率':>9}{'清算距離':>8}")
print("-" * 78)
for p in positions:
print(p.line())
if positions:
print(f"\n想定元本合計 : {sum(p.notional for p in positions):,.0f}")
print(f"含み損益合計 : {sum(p.unrealised_pnl for p in positions):+,.2f}")
no_stop = [p for p in positions if not p.has_stop]
if no_stop:
print(f"[警告] 損切なし: {', '.join(p.symbol for p in no_stop)}")
同じ「平均建値」を表すフィールドが、RESTでは avgPrice、WebSocketの position トピックでは entryPrice という名前になっています。RESTで書いたパーサをWebSocketに流用すると、建値がゼロになって損益計算が壊れます。
サンプル3の parse_position() は p.get("avgPrice") or p.get("entryPrice") と両方を受けているため、RESTとWebSocketで同じパーサを使えます。これは後述のハイブリッド同期で効いてきます。
"""position_mode.py — ポジションモードの切り替えと、その前提条件の検査
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations
from decimal import Decimal
# モード切替に関するretCode
MODE_NOT_MODIFIED = 110025 # 既に同じモード(実質成功)
HAS_POSITION = 110024 # ポジション保有中は切替不可
HAS_OPEN_ORDERS = 110028 # 未約定注文があると切替不可
HEDGE_UNSUPPORTED = 110029 # その銘柄はヘッジモード非対応
class ModeSwitchBlocked(RuntimeError):
"""切り替えの前提条件を満たしていない。"""
def preflight(http, symbol: str, category: str = "linear") -> list[str]:
"""モード切替の障害になるものを列挙する(切替前に必ず確認)。"""
blockers: list[str] = []
res = http.get_positions(category=category, symbol=symbol)
if res["retCode"] == 0:
held = [p for p in res["result"]["list"]
if Decimal(p.get("size") or "0") > 0]
for p in held:
blockers.append(
f"ポジション保有中: {p['side']} {p['size']} "
f"(positionIdx={p.get('positionIdx')})"
)
res = http.get_open_orders(category=category, symbol=symbol, limit=50)
if res["retCode"] == 0:
n = len(res["result"]["list"])
if n:
blockers.append(f"未約定注文が{n}件あります")
return blockers
def switch_position_mode(http, symbol: str, hedge: bool,
category: str = "linear",
force: bool = False) -> bool:
"""ポジションモードを切り替える。
mode: 0 = ワンウェイ / 3 = ヘッジ(両建て)
force=False の場合、前提条件を満たさないなら例外を投げる。
戻り値: 実際に変更されたか(Falseは「既に同じモード」)
"""
if not force:
blockers = preflight(http, symbol, category)
if blockers:
raise ModeSwitchBlocked(
f"{symbol} のモードを切り替えられません:\n - "
+ "\n - ".join(blockers)
+ "\n 先に全決済・全キャンセルしてください"
)
res = http.switch_position_mode(
category=category, symbol=symbol, mode=3 if hedge else 0
)
code = res["retCode"]
if code == 0:
print(f"[OK] {symbol} を{'ヘッジ' if hedge else 'ワンウェイ'}モードに変更")
return True
if code == MODE_NOT_MODIFIED:
print(f"[skip] {symbol} は既に"
f"{'ヘッジ' if hedge else 'ワンウェイ'}モードです")
return False
if code == HAS_POSITION:
raise ModeSwitchBlocked(f"{symbol}: ポジション保有中は切替できません")
if code == HAS_OPEN_ORDERS:
raise ModeSwitchBlocked(f"{symbol}: 未約定注文があると切替できません")
if code == HEDGE_UNSUPPORTED:
raise ModeSwitchBlocked(f"{symbol}: この銘柄はヘッジモード非対応です")
raise RuntimeError(f"switch_position_mode失敗: {code} {res['retMsg']}")
def ensure_mode(http, symbol: str, hedge: bool,
category: str = "linear") -> None:
"""起動時に「望むモード」へ収束させる(冪等な設定操作)。"""
from position_idx import detect_hedge_mode
current = detect_hedge_mode(http, symbol, category)
if current == hedge:
return
switch_position_mode(http, symbol, hedge, category)
if __name__ == "__main__":
from client import make_client
c = make_client()
SYMBOL = "BTCUSDT"
blockers = preflight(c.http, SYMBOL)
if blockers:
print(f"{SYMBOL} の切替障害:")
for b in blockers:
print(f" - {b}")
else:
print(f"{SYMBOL}: 切替可能な状態です")
# 望むモードへ収束させる(既に同じなら何もしない)
try:
ensure_mode(c.http, SYMBOL, hedge=False)
except ModeSwitchBlocked as e:
print(e)
ensure_mode() のように「望む状態を宣言し、現状と違えば合わせる」形で書くと、何度呼んでも安全(冪等)になります。「切り替える」という命令形で書くと、起動のたびに切り替えようとして110025が出続けます。
この考え方はレバレッジ設定(記事21)でも同じです。bot起動時の口座設定は、すべて収束型で書くと再起動に強くなります。
"""close_position.py — 部分決済・全決済・緊急全決済
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations
import time
from decimal import Decimal
from position import Position, fetch_positions
from symbol_rules import floor_to_step
# キャンセル・決済で「既に終わっている」ことを示すretCode(無視可)
DONE_CODES = {110001, 110008, 110010}
def close_position(http, pos: Position, ratio: Decimal = Decimal("1.0"),
use_market: bool = True, registry=None,
tag: str = "close") -> dict | None:
"""1つのポジションを ratio の割合だけ決済する。"""
if pos.size <= 0:
return None
# 数量は切り捨て。保有量を超えないようにする
qty_step = Decimal("0.00000001")
if registry is not None:
qty_step = registry.get(pos.symbol, pos.category).qty_step
qty = floor_to_step(pos.size * ratio, qty_step)
if qty <= 0:
print(f"[skip] {pos.symbol}: 決済数量が刻み幅未満 "
f"(size={pos.size} ratio={ratio})")
return None
params: dict = {
"category": pos.category,
"symbol": pos.symbol,
"side": "Sell" if pos.is_long else "Buy",
"orderType": "Market" if use_market else "Limit",
"qty": str(qty),
"reduceOnly": True, # 新規建てにならない安全弁
"orderLinkId": f"{tag}-{pos.symbol[:8]}-{int(time.time() * 1000)}"[:36],
}
if pos.position_idx != 0:
params["positionIdx"] = pos.position_idx # 保有側を明示
if not use_market:
params["price"] = str(pos.mark_price)
params["timeInForce"] = "PostOnly"
res = http.place_order(**params)
if res["retCode"] != 0:
raise RuntimeError(
f"{pos.symbol} の決済失敗: {res['retCode']} {res['retMsg']}"
)
print(f"[close] {pos.symbol} {params['side']} {qty} "
f"(保有{pos.size}の{ratio:.0%})")
return res["result"]
def flatten_all(http, registry=None, cancel_orders: bool = True,
exclude: set[str] | None = None) -> dict:
"""全ポジションを成行で決済する。緊急停止で使う(記事24)。
手順:
1) 先に全注文をキャンセル(未約定注文が証拠金を拘束するため)
2) 全ポジションをreduceOnly成行で決済
3) 結果を照合し、残っていれば報告
"""
exclude = exclude or set()
summary = {"cancelled": 0, "closed": 0, "failed": [], "remaining": []}
# 1) 全キャンセル(categoryごと)
if cancel_orders:
for category, extra in (("linear", {"settleCoin": "USDT"}),
("linear", {"settleCoin": "USDC"}),
("inverse", {"settleCoin": "BTC"})):
res = http.cancel_all_orders(category=category, **extra)
if res.get("retCode") == 0:
summary["cancelled"] += len(res["result"].get("list") or [])
# 2) 全決済
for pos in fetch_positions(http):
if pos.symbol in exclude:
continue
try:
close_position(http, pos, registry=registry, tag="flatten")
summary["closed"] += 1
except Exception as e:
summary["failed"].append((pos.symbol, str(e)))
# 3) 照合(成行でも反映に時間差がある)
time.sleep(2.0)
for pos in fetch_positions(http):
if pos.symbol not in exclude:
summary["remaining"].append((pos.symbol, str(pos.size)))
return summary
if __name__ == "__main__":
from client import make_client
from symbol_rules import SymbolRegistry
c = make_client()
reg = SymbolRegistry(c.http)
positions = fetch_positions(c.http)
if not positions:
print("ポジションはありません")
else:
# 半分だけ決済する例
close_position(c.http, positions[0], ratio=Decimal("0.5"), registry=reg)
# 緊急全決済(testnetで動作確認してください)
# print(flatten_all(c.http, registry=reg))
flatten_all() が3段階になっているのには理由があります。
緊急停止では「本当にフラットになったか」を確認するまでが1セットです。残っていれば再試行するか、人間に通知してください(記事24)。
ポジションをRESTで毎秒ポーリングするのは、レート制限的にも遅延的にも不利です。かといってWebSocketだけに頼ると、イベントを1つ取りこぼした時点で永久にズレたままになります。
正解はハイブリッドです。WebSocketで即時更新し、RESTで定期的に真実と照合する。
"""position_keeper.py — WebSocketで即時更新、RESTで定期照合する同期層
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
思想: WebSocketは「速いが取りこぼす」、RESTは「遅いが正しい」。
両方を使い、最後はRESTを正とする。
"""
from __future__ import annotations
import threading
import time
from dataclasses import dataclass, field
from decimal import Decimal
from position import Position, fetch_positions, parse_position
@dataclass
class SyncStats:
ws_events: int = 0
rest_reconciles: int = 0
corrections: int = 0 # RESTで訂正された回数
last_ws_at: float = 0.0
last_rest_at: float = 0.0
def summary(self) -> str:
now = time.time()
ws_age = now - self.last_ws_at if self.last_ws_at else float("inf")
return (f"WSイベント {self.ws_events} / REST照合 "
f"{self.rest_reconciles} / 訂正 {self.corrections} / "
f"最終WS {ws_age:.0f}秒前")
class PositionKeeper:
"""ポジションの現在状態を保持する。読み取りはこのクラスからのみ行う。"""
def __init__(self, client, reconcile_sec: float = 30.0,
stale_sec: float = 60.0, on_correction=None):
self.c = client
self.reconcile_sec = reconcile_sec
self.stale_sec = stale_sec
self.on_correction = on_correction
self._positions: dict[tuple, Position] = {}
self._lock = threading.RLock()
self._stop = threading.Event()
self.stats = SyncStats()
self.ws = None
# ---- 読み取りAPI ----------------------------------------------------
def all(self) -> list[Position]:
with self._lock:
return [p for p in self._positions.values() if p.size > 0]
def get(self, symbol: str, category: str = "linear",
position_idx: int = 0) -> Position | None:
with self._lock:
p = self._positions.get((category, symbol, position_idx))
return p if p and p.size > 0 else None
def net_size(self, symbol: str, category: str = "linear") -> Decimal:
"""ヘッジモードの2ポジションを合算した純ポジション。"""
with self._lock:
return sum(
(p.signed_size for (cat, sym, _), p in self._positions.items()
if cat == category and sym == symbol),
Decimal(0),
)
@property
def is_stale(self) -> bool:
"""WebSocketの更新が途絶えていないか(記事24の判定に使う)。"""
if self.stats.last_ws_at == 0:
return False
return (time.time() - self.stats.last_ws_at) > self.stale_sec
# ---- WebSocket ------------------------------------------------------
def on_ws_position(self, msg: dict) -> None:
"""private WSのpositionトピックを受けて即時更新する。"""
with self._lock:
for raw in msg.get("data", []):
try:
# WSは entryPrice / RESTは avgPrice。同じパーサで扱える
p = parse_position(raw, raw.get("category", "linear"))
except Exception as e:
print(f"[keeper] パース失敗: {type(e).__name__}: {e}")
continue
old = self._positions.get(p.key)
# seqが古いイベントは無視する(順序逆転への対処)
if old is not None and p.seq and old.seq and p.seq < old.seq:
continue
self._positions[p.key] = p
self.stats.ws_events += 1
self.stats.last_ws_at = time.time()
# ---- REST照合 -------------------------------------------------------
def reconcile(self, verbose: bool = False) -> list[str]:
"""RESTの内容を正として、差分があれば訂正する。"""
truth = {p.key: p for p in fetch_positions(self.c.http)}
diffs: list[str] = []
with self._lock:
local_keys = {k for k, p in self._positions.items() if p.size > 0}
truth_keys = set(truth)
# ローカルにあって取引所に無い(=決済済みを取りこぼした)
for key in local_keys - truth_keys:
old = self._positions[key]
diffs.append(f"{old.symbol}: ローカル{old.size} -> 実際は0")
del self._positions[key]
# 取引所にあってローカルに無い(=約定を取りこぼした)
for key in truth_keys - local_keys:
p = truth[key]
diffs.append(f"{p.symbol}: ローカル0 -> 実際は{p.side} {p.size}")
self._positions[key] = p
# 両方にあるが数量が違う
for key in local_keys & truth_keys:
local, real = self._positions[key], truth[key]
if local.size != real.size:
diffs.append(
f"{real.symbol}: ローカル{local.size} -> "
f"実際{real.size}"
)
self._positions[key] = real # 常にRESTを正とする
self.stats.rest_reconciles += 1
self.stats.last_rest_at = time.time()
if diffs:
self.stats.corrections += len(diffs)
if diffs:
msg = "ポジションの乖離を訂正しました:\n - " + "\n - ".join(diffs)
print(f"[keeper] {msg}")
if self.on_correction:
self.on_correction(msg)
elif verbose:
print("[keeper] 乖離なし")
return diffs
# ---- 起動・停止 ------------------------------------------------------
def start(self) -> None:
from pybit.unified_trading import WebSocket
# まずRESTで初期状態を確定させる(WSは「今から先」しか来ない)
self.reconcile(verbose=True)
env = self.c.env.value
self.ws = WebSocket(
channel_type="private",
testnet=(env == "testnet"),
demo=(env == "demo"),
api_key=self.c.cred.api_key,
api_secret=self.c.cred.api_secret,
)
self.ws.position_stream(callback=self.on_ws_position)
def loop() -> None:
while not self._stop.wait(self.reconcile_sec):
try:
self.reconcile()
except Exception as e:
print(f"[keeper] 照合に失敗: {type(e).__name__}: {e}")
threading.Thread(target=loop, daemon=True, name="reconcile").start()
print("[keeper] 同期を開始しました")
def stop(self) -> None:
self._stop.set()
if self.ws:
try:
self.ws.exit()
except Exception:
pass
if __name__ == "__main__":
from client import make_client
c = make_client()
keeper = PositionKeeper(c, reconcile_sec=30.0)
keeper.start()
try:
while True:
time.sleep(10)
print(f"\n[{time.strftime('%H:%M:%S')}] {keeper.stats.summary()}")
for p in keeper.all():
print(f" {p.line()}")
if keeper.is_stale:
print(" [警告] WSの更新が途絶えています")
except KeyboardInterrupt:
keeper.stop()
start() が最初に reconcile() を呼んでいるのが重要です。WebSocketは「接続してから先」のイベントしか届きません。既存のポジションは通知されないため、RESTで初期状態を取得しないと「ポジションを持っているのに、持っていないと思っているbot」になります。
これはbot再起動時に必ず起きる問題で、再起動のたびに二重にポジションを建てる事故の原因です。注文側のリコンサイルは記事17で扱います。
取引所との同期とは別に、「戦略が意図しているポジション」と「実際のポジション」のズレも監視すべきです。これは戦略ロジックのバグを検出します。
"""drift_monitor.py — 戦略の意図と実ポジションの乖離を検出する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
用途: 「手動介入された」「約定を取りこぼした」「戦略にバグがある」を
機械的に切り分ける。
"""
from __future__ import annotations
import time
from dataclasses import dataclass, field
from decimal import Decimal
from enum import Enum
class DriftKind(str, Enum):
NONE = "none"
UNEXPECTED_POSITION = "予期しないポジション" # 意図0なのに保有
MISSING_POSITION = "ポジションが無い" # 意図ありなのに0
SIZE_MISMATCH = "数量が違う"
SIDE_MISMATCH = "方向が違う"
@dataclass
class Drift:
symbol: str
kind: DriftKind
intended: Decimal
actual: Decimal
detected_at: float = field(default_factory=time.time)
@property
def gap(self) -> Decimal:
return self.actual - self.intended
def describe(self) -> str:
return (f"{self.symbol}: {self.kind.value} "
f"(意図 {self.intended} / 実際 {self.actual} / "
f"差 {self.gap:+})")
class DriftMonitor:
"""戦略が「意図する」ポジションを登録し、実際と比較する。"""
def __init__(self, keeper, tolerance_ratio: Decimal = Decimal("0.01")):
self.keeper = keeper
self.tolerance_ratio = tolerance_ratio
self._intended: dict[str, Decimal] = {} # symbol -> 符号付き数量
self.history: list[Drift] = []
def set_intent(self, symbol: str, signed_size: Decimal) -> None:
"""戦略が「このポジションを持つつもり」と宣言する。"""
self._intended[symbol] = signed_size
def clear_intent(self, symbol: str) -> None:
self._intended.pop(symbol, None)
def check(self, category: str = "linear") -> list[Drift]:
drifts: list[Drift] = []
symbols = set(self._intended) | {
p.symbol for p in self.keeper.all() if p.category == category
}
for symbol in sorted(symbols):
intended = self._intended.get(symbol, Decimal(0))
actual = self.keeper.net_size(symbol, category)
if intended == 0 and actual == 0:
continue
# 許容誤差(丸め・部分約定の途中)
scale = max(abs(intended), abs(actual))
if abs(actual - intended) <= scale * self.tolerance_ratio:
continue
if intended == 0:
kind = DriftKind.UNEXPECTED_POSITION
elif actual == 0:
kind = DriftKind.MISSING_POSITION
elif (intended > 0) != (actual > 0):
kind = DriftKind.SIDE_MISMATCH
else:
kind = DriftKind.SIZE_MISMATCH
d = Drift(symbol=symbol, kind=kind, intended=intended,
actual=actual)
drifts.append(d)
self.history.append(d)
return drifts
def report(self, category: str = "linear") -> None:
drifts = self.check(category)
if not drifts:
print("[drift] 意図と実際は一致しています")
return
print(f"[drift] {len(drifts)}件の乖離:")
for d in drifts:
print(f" - {d.describe()}")
# 種類別に対処の指針を出す
kinds = {d.kind for d in drifts}
print("\n 対処の指針:")
if DriftKind.SIDE_MISMATCH in kinds:
print(" 方向が逆 -> 直ちに停止。ロジックのバグか手動介入を疑う")
if DriftKind.UNEXPECTED_POSITION in kinds:
print(" 予期しない保有 -> 他プロセスの発注か、決済の取りこぼし")
if DriftKind.MISSING_POSITION in kinds:
print(" 保有が無い -> TP/SLで決済された可能性(記事14)")
if DriftKind.SIZE_MISMATCH in kinds:
print(" 数量差 -> 部分約定の途中か、丸め処理の誤り")
if __name__ == "__main__":
from client import make_client
from position_keeper import PositionKeeper
c = make_client()
keeper = PositionKeeper(c)
keeper.reconcile()
monitor = DriftMonitor(keeper)
# 戦略が意図する状態を宣言する(実際の戦略ではエントリー時に呼ぶ)
monitor.set_intent("BTCUSDT", Decimal("0.01"))
monitor.report()
positionStatus が Normal 以外になっているのは異常事態です。見逃してはいけません。
| positionStatus | 意味 | 取るべき対応 |
|---|---|---|
| Normal | 正常 | — |
| Liq | 清算処理中 | 即座に全停止。新規発注を止め、人間に通知 |
| Adl | 自動デレバレッジ対象 | 保険基金が不足し、強制的に減らされる可能性。自主的に縮小する |
"""position_risk.py — ポジションの異常状態とリスク集中を監視する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal
from position import Position, fetch_positions
@dataclass
class RiskAlert:
level: str # info / warn / critical
symbol: str
message: str
def line(self) -> str:
icon = {"info": " ", "warn": "! ", "critical": "!!"}.get(self.level, " ")
return f"{icon} [{self.level:<8}] {self.symbol:<12} {self.message}"
def audit_positions(http, equity: Decimal | None = None,
max_symbol_share: Decimal = Decimal("0.4"),
min_liq_distance_pct: Decimal = Decimal("10"),
adl_warn_rank: int = 3) -> list[RiskAlert]:
"""ポジション全体のリスクを点検する。"""
positions = fetch_positions(http)
alerts: list[RiskAlert] = []
if not positions:
return alerts
total_notional = sum(p.notional for p in positions)
for p in positions:
# 1) 異常状態
if p.position_status == "Liq":
alerts.append(RiskAlert("critical", p.symbol,
"清算処理中。直ちに全停止してください"))
elif p.position_status == "Adl":
alerts.append(RiskAlert("critical", p.symbol,
"自動デレバレッジ対象。自主的に縮小を"))
# 2) ADL順位
if p.adl_rank >= adl_warn_rank:
alerts.append(RiskAlert(
"warn", p.symbol,
f"ADL順位 {p.adl_rank}(上位ほど強制減少の対象になりやすい)"
))
# 3) 縮小のみ制限
if p.is_reduce_only:
alerts.append(RiskAlert(
"warn", p.symbol,
"縮小のみ許可の状態(リスクリミット調整の影響・記事21)"
))
# 4) 損切の有無
if not p.has_stop:
alerts.append(RiskAlert("warn", p.symbol,
"損切が設定されていません(記事14)"))
# 5) 清算距離
dist = p.liq_distance_pct()
if dist is not None and dist < min_liq_distance_pct:
alerts.append(RiskAlert(
"critical", p.symbol,
f"清算価格まで{dist:.1f}%しかありません"
))
# 6) 銘柄集中
if total_notional > 0:
share = p.notional / total_notional
if share > max_symbol_share and len(positions) > 1:
alerts.append(RiskAlert(
"warn", p.symbol,
f"ポジションの{share:.0%}が1銘柄に集中しています"
))
# 7) 資金に対する規模
if equity and equity > 0:
lev_effective = p.notional / equity
if lev_effective > 3:
alerts.append(RiskAlert(
"warn", p.symbol,
f"実効レバレッジ {lev_effective:.1f}倍(資金比)"
))
return alerts
if __name__ == "__main__":
from client import make_client
c = make_client()
bal = c.http.get_wallet_balance(accountType="UNIFIED")["result"]["list"][0]
equity = Decimal(bal["totalEquity"] or "0")
mmr = Decimal(bal.get("accountMMRate") or "0")
print(f"総資産 {equity:,.2f} / 口座維持率 {mmr * 100:.3f}%\n")
alerts = audit_positions(c.http, equity=equity)
if not alerts:
print("問題は検出されませんでした")
else:
for a in alerts:
print(a.line())
criticals = [a for a in alerts if a.level == "critical"]
if criticals:
print(f"\n[!!] critical が{len(criticals)}件あります。"
f"新規エントリーを停止してください(記事24)")
| 症状 | 原因 | 対処 |
|---|---|---|
| 一部のポジションが取得できない | settleCoin="USDT" のみで照会(USDC建てが漏れる) | サンプル1で全組み合わせを走査 |
ValueError: could not convert | liqPrice が空文字(クロスマージン) | _d() で安全に変換(サンプル3) |
| ヘッジモードで発注が通らない | positionIdx の指定漏れ | PositionIdx.for_side()(サンプル2) |
| 意図しない側が決済された | 決済時にpositionIdxを逆に指定 | 保有ポジションから引き継ぐ |
| 再起動後に二重にポジションを建てた | 起動時にRESTで初期化していない | PositionKeeper.start()(サンプル6)+記事17 |
| WSで建値がゼロになる | WSは entryPrice、RESTは avgPrice | 両方を受けるパーサにする |
| モード切替が110024で失敗 | ポジション保有中は切替不可 | preflight() で事前確認(サンプル4) |
| 全決済したのに残っている | 反映の時間差/未約定注文が拘束 | キャンセル→決済→照合の順に(サンプル5) |
| 突然ポジションが減った | ADLまたは清算(positionStatus) | サンプル8で監視・通知 |
side で、大きさを size で表現する設計です。ヘッジモードでロングとショートを同時に持てるため、1つの数値に符号を持たせると表現できないという理由があります。実装側ではサンプル3の signed_size のように符号付きの派生プロパティを作るのが便利です。計算には signed_size、APIへ送るときは size と side、という使い分けにしてください。positionIdx を常に0にできるため、コードが単純になり事故が減ります。ヘッジモードが必要なのは、同じ銘柄で複数の戦略を独立に動かしたい場合や、両建てでヘッジしたい場合です。ただしヘッジモードでも「ロングとショートを同時に持つと手数料と資金調達を両方払う」ため、経済的に有利になるわけではありません。複数戦略を分けたいなら、サブアカウントでUIDを分ける(記事25)ほうが管理が明確です。get_positions の上限は毎秒50リクエストと余裕があるため、10秒間隔でも問題ありません。判断基準は「乖離に気づくまでの許容時間」です。高頻度で発注する戦略なら10秒、日足ベースの戦略なら60秒でも十分です。それとは別に、発注の直前には必ず最新状態を確認するのが安全です(周期照合は「気づく」ための仕組み、発注前確認は「間違えない」ための仕組み)。adlRankIndicator が高いほど対象になりやすく、順位はレバレッジと利益率で決まります。自分の意思とは無関係にポジションが減るため、レバレッジを抑えることが唯一の予防策です。positionStatus="Adl" を検出したら自主的に縮小するのが実務的な対応です(サンプル8)。get_wallet_balance() の accountMMRate(口座維持率)で行ってください。個別の清算価格が必要なら分離マージンにする必要がありますが、資金効率は下がります。詳細は記事22で扱います。orderLinkId に戦略名を入れ、約定履歴から按分して把握する(記事18)。(2) ヘッジモードで方向を分ける(ただし同方向は分離できない)。(3) サブアカウントでUIDを分ける(推奨)。3番目が最も明確で、証拠金も分離されるため1つの戦略の事故が他に波及しません(記事25)。投資は自己判断・自己責任でお願いいたします。本ブログの情報・ツール・ソフトウェアを参考・使用して、取引をされて何らかの損失を被られても、当サイトでは責任は一切負いません(間接的・付随的な障害・損失も含め一切の責任を負いません)。自己責任でご利用ください。
当サイトからダウンロードしたプログラム・EAは本人利用に限り完全無料でご利用いただけます。プログラムの全てまたは一部の複製・コピー・リバースエンジニアリング・改変・2次配布・商用利用・転売・販売等は一切禁止いたします。違反を発見した場合は法的処置を行います。
当サイト上に存在するソフトウェア・ツール・文章・画像・動画等の著作物を無断転載することを禁止します。引用の範囲を超えるものについては、法的処置を行います。
当サイトで配布するツール・ソフトウェアは無料提供ですのでサポートなどは行っておりません(逆に丁寧にサポートしているサイトはほとんど違法サイトですのでご注意ください。投資助言・代理業登録なしでサポート・助言することは法律で禁止されています)。万が一、ツールやソフトウェアに不具合や問題があっても当サイトでは修正・対応などができない場合があります。ご利用の環境でトラブルが発生しても当サイトでは助言することはできませんので自己責任でご利用ください。検索するなどして自分で解決してください。
当サイトでは取引所・証券会社へのリンクを貼っていますが、これは情報として提供するものであり、勧誘行為などは一切行っておりません。そのため、取引所・証券会社の登録方法・使い方・オススメ金融機関・オススメ金融商品などを説明した記事はサイト内にありませんので、ご自身で調べた上で判断ください(本当はサイト内で丁寧に説明したいのですが、仲介・取次ぎにあたる可能性があり第一種金融商品取引業登録を行っていない場合、違法となる可能性があるため出来ません。ご容赦ください)。
当サイトからリンクやバナーなどによって他のサイトに移動された場合、移動先サイトで提供される情報、サービス等について一切の責任を負いません。当サイト掲載情報やリンク先の掲載情報の合法性、正確性、道徳性、最新性、適切性、著作権の許諾や有無など、その内容については一切の保証を致しかねます。
当サイトは、ご訪問いただいた時間、訪問者のアクセス元のホスト名、IPアドレス、OSとブラウザのバージョン、リンク元のURL、モニターの情報等のパソコン環境情報を収集し、アクセス解析をしております。この目的は訪問者が検索サイトからご訪問いただいた場合、どのような検索で当サイトに来訪いただいたかの解析にのみ使用しており、第3者に公開はいたしません。また、個人を特定するものではありません。