Bybit APIでポジションを取得・管理するPython実装
positionIdxとヘッジモード完全対応

「ローカルが持っているポジション情報は、必ず取引所とズレる」——これを前提に設計できるかが、botの安定性を決めます。イベント欠損、再起動、手動介入、清算、部分約定。この記事では get_positions の全フィールドの意味から、positionIdx の取り違えによる事故、WebSocketとRESTのハイブリッド同期、そして想定と実際の乖離を検出する仕組みまでをサンプルコード8本で実装します。

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

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

目次

  1. get_positionsの呼び方|symbolかsettleCoinが必須
  2. レスポンス全フィールドの意味
  3. positionIdxとポジションモード
  4. Positionモデルとdataclass化
  5. モードの切り替えと制約
  6. reduceOnlyによる安全な決済
  7. WebSocketとRESTのハイブリッド同期
  8. ドリフト検出|想定と実際の乖離
  9. positionStatusとADLの監視
  10. ポジション管理の典型的な事故
  11. よくある疑問Q&A

get_positionsの呼び方|symbolかsettleCoinが必須

パラメータ必須内容
category必須linear / inverse / option(spotは非対応=現物にポジションの概念がない)
symbol条件付き指定するとその銘柄のみ。settleCoinより優先される
settleCoin条件付きUSDT / USDC / BTC など。linearではsymbolかsettleCoinのどちらかが必須
baseCoinoption用オプションではこちらを使う
limit任意1〜200(既定20)
cursor任意ページネーション
USDTとUSDCは別々に照会する必要がある

settleCoin="USDT" だけで照会すると、USDC建て(BTCPERPなど)のポジションは返ってきません。「全ポジションを取得したつもりが一部だけだった」という状態で全決済処理を書くと、決済し残しが発生します。

本当に「全部」を取りたいなら、linearをUSDTとUSDCの両方+inverseをBTCやETHごとに照会する必要があります。サンプル1でこれを網羅します。

fetch_positions.pyサンプル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}")

レスポンス全フィールドの意味

フィールド意味実装上の使い道
positionIdx0=ワンウェイ / 1=ヘッジBuy / 2=ヘッジSell発注・決済時に引き継ぐ必須項目
sideBuy(ロング)/ Sell(ショート)決済は逆方向。sizeは常に正の値
size保有数量(常に正)符号はsideで判断する
avgPrice平均建値TP/SLの基準(記事14)
positionValueポジション価値リスク量の把握
leverage適用レバレッジ設定と一致しているか確認(記事21)
markPriceマーク価格清算判定と損益評価の基準
liqPrice清算価格空文字の場合がある(後述)
positionIM初期証拠金必要証拠金の把握
positionMM維持証拠金清算距離の自前計算(記事22)
unrealisedPnl含み損益マーク価格ベース
curRealisedPnl現在の保有分の実現損益部分決済した分
cumRealisedPnl累積実現損益通算成績
positionStatusNormal / Liq / Adl異常検知に使う(後述)
adlRankIndicator自動デレバレッジの順位高いほどADL対象になりやすい
isReduceOnly縮小のみ許可の状態かリスクリミット調整時に立つ
autoAddMargin自動証拠金追加 0/1分離マージンでのみ有効
riskId
riskLimitValue
リスクリミット階層最大レバレッジに影響(記事21)
takeProfit
stopLoss
trailingStop
TP/SL/トレールの現在値設定の照合(記事14)
seqクロスシーケンスWebSocketイベントの順序判定
createdTime
updatedTime
作成・更新時刻(ms)保有期間の計測
liqPriceは空文字になることがある

クロスマージンで他のポジションと証拠金を共有している場合、単一ポジションの清算価格が定義できないため liqPrice が空文字になります。float(p["liqPrice"]) と書くと ValueError です。

統合口座のリスク管理は、個別の清算価格ではなく口座全体の維持率(accountMMRate)で見るのが正解です(記事22)。liqPrice は分離マージンの参考値として扱ってください。

positionIdxとポジションモード

モードpositionIdx挙動用途
ワンウェイ
(One-Way)
0 1銘柄につき1方向のみ。反対売買は決済になる 通常はこれ。ロジックが単純
ヘッジ
(Both-Way)
1(Buy側)
2(Sell側)
ロングとショートを同時保有できる 両建て戦略・複数戦略の並走
positionIdxの取り違えは即エラー、または意図しない側の決済

ワンウェイモードで positionIdx=1 を送ると拒否され、ヘッジモードで positionIdx を省略するとどちらのポジションを対象にするか決まらないため失敗します。さらに悪いのは、ヘッジモードで誤った側を指定した場合——意図していない側のポジションが決済されます。

対策は単純です。発注・決済時のpositionIdxは、必ず get_positions() のレスポンスから引き継ぐ。ハードコードしないでください。

position_idx.pyサンプル2
"""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は保有側のまま。ここが要点。")

Positionモデルとdataclass化

生の辞書を持ち回すと、フィールド名のtypoや文字列と数値の取り違えでバグります。受け取った直後に1箇所でdataclassへ変換してしまうのが確実です。

position.pyサンプル3
"""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は entryPrice

同じ「平均建値」を表すフィールドが、RESTでは avgPrice、WebSocketの position トピックでは entryPrice という名前になっています。RESTで書いたパーサをWebSocketに流用すると、建値がゼロになって損益計算が壊れます。

サンプル3の parse_position() は p.get("avgPrice") or p.get("entryPrice") と両方を受けているため、RESTとWebSocketで同じパーサを使えます。これは後述のハイブリッド同期で効いてきます。

モードの切り替えと制約

position_mode.pyサンプル4
"""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起動時の口座設定は、すべて収束型で書くと再起動に強くなります。

reduceOnlyによる安全な決済

close_position.pyサンプル5
"""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)。

WebSocketとRESTのハイブリッド同期

ポジションをRESTで毎秒ポーリングするのは、レート制限的にも遅延的にも不利です。かといってWebSocketだけに頼ると、イベントを1つ取りこぼした時点で永久にズレたままになります。

正解はハイブリッドです。WebSocketで即時更新し、RESTで定期的に真実と照合する。

position_keeper.pyサンプル6
"""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()
起動時に必ずRESTで初期化する

start() が最初に reconcile() を呼んでいるのが重要です。WebSocketは「接続してから先」のイベントしか届きません。既存のポジションは通知されないため、RESTで初期状態を取得しないと「ポジションを持っているのに、持っていないと思っているbot」になります。

これはbot再起動時に必ず起きる問題で、再起動のたびに二重にポジションを建てる事故の原因です。注文側のリコンサイルは記事17で扱います。

ドリフト検出|想定と実際の乖離

取引所との同期とは別に、「戦略が意図しているポジション」と「実際のポジション」のズレも監視すべきです。これは戦略ロジックのバグを検出します。

drift_monitor.pyサンプル7
"""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とADLの監視

positionStatus が Normal 以外になっているのは異常事態です。見逃してはいけません。

positionStatus意味取るべき対応
Normal正常—
Liq清算処理中即座に全停止。新規発注を止め、人間に通知
Adl自動デレバレッジ対象保険基金が不足し、強制的に減らされる可能性。自主的に縮小する
position_risk.pyサンプル8
"""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 convertliqPrice が空文字(クロスマージン)_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で監視・通知
参考リンク(一次情報)

よくある疑問Q&A

sizeが常に正なのはなぜですか?符号付きのほうが扱いやすいのですが
Bybitは方向を side で、大きさを size で表現する設計です。ヘッジモードでロングとショートを同時に持てるため、1つの数値に符号を持たせると表現できないという理由があります。実装側ではサンプル3の signed_size のように符号付きの派生プロパティを作るのが便利です。計算には signed_size、APIへ送るときは size と side、という使い分けにしてください。
ワンウェイとヘッジ、どちらを使うべきですか?
単一戦略ならワンウェイです。positionIdx を常に0にできるため、コードが単純になり事故が減ります。ヘッジモードが必要なのは、同じ銘柄で複数の戦略を独立に動かしたい場合や、両建てでヘッジしたい場合です。ただしヘッジモードでも「ロングとショートを同時に持つと手数料と資金調達を両方払う」ため、経済的に有利になるわけではありません。複数戦略を分けたいなら、サブアカウントでUIDを分ける(記事25)ほうが管理が明確です。
RESTでの照合は何秒間隔が適切ですか?
サンプル6は30秒を既定にしています。get_positions の上限は毎秒50リクエストと余裕があるため、10秒間隔でも問題ありません。判断基準は「乖離に気づくまでの許容時間」です。高頻度で発注する戦略なら10秒、日足ベースの戦略なら60秒でも十分です。それとは別に、発注の直前には必ず最新状態を確認するのが安全です(周期照合は「気づく」ための仕組み、発注前確認は「間違えない」ための仕組み)。
ADL(自動デレバレッジ)とは何ですか?
清算されたポジションを保険基金で処理しきれない場合、利益が出ていて高レバレッジのポジションを取引所が強制的に決済する仕組みです。adlRankIndicator が高いほど対象になりやすく、順位はレバレッジと利益率で決まります。自分の意思とは無関係にポジションが減るため、レバレッジを抑えることが唯一の予防策です。positionStatus="Adl" を検出したら自主的に縮小するのが実務的な対応です(サンプル8)。
liqPriceが空文字になるのはバグですか?
仕様です。クロスマージンでは口座全体の証拠金を共有するため、単一ポジションの清算価格が一意に定まりません(他のポジションの損益で変動するため)。統合口座のリスク管理は get_wallet_balance() の accountMMRate(口座維持率)で行ってください。個別の清算価格が必要なら分離マージンにする必要がありますが、資金効率は下がります。詳細は記事22で扱います。
positionStatusがLiqになったら何をすべきですか?
清算処理が進行中なので、botの新規発注を直ちに止め、人間に通知してください。この段階でbotが追加のポジションを取ろうとすると事態が悪化します。またこの状態に至った原因(レバレッジ過大・損切なし・想定外の相場)を必ず事後分析してください。清算が発生したという事実はリスク設計が破綻していることのシグナルであり、パラメータの微調整では解決しません(記事22・記事23・記事24)。
複数のbotが同じ銘柄を触る場合、どう管理しますか?
ワンウェイモードではポジションが1つに合算されるため、どちらのbotの分か区別できません。方法は3つです。(1) orderLinkId に戦略名を入れ、約定履歴から按分して把握する(記事18)。(2) ヘッジモードで方向を分ける(ただし同方向は分離できない)。(3) サブアカウントでUIDを分ける(推奨)。3番目が最も明確で、証拠金も分離されるため1つの戦略の事故が他に波及しません(記事25)。

注意事項・免責事項

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

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

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

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

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

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

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