Bybitの証拠金維持率・清算価格をPythonで
自前計算してリスク監視する

APIが返す liqPrice を眺めているだけでは、「あと何%動いたら死ぬか」を戦略側のロジックに組み込めません。さらにUTAでは証拠金が複数ポジション・複数通貨で共有されるため、単一ポジションの清算価格を見ても口座全体のリスクは分かりません。この記事では維持証拠金率から清算価格を自前計算し、口座全体のストレステストと段階的デレバレッジまでをサンプルコード8本で実装します。

確認日
2026-07-31
API バージョン
Bybit V5
Python
3.10 以上
依存
pybit / rich

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

目次

  1. 証拠金の全体像とUTAの特殊性
  2. get_wallet_balanceの各フィールド
  3. 清算価格をMMRから自前計算する
  4. 口座維持率の監視と警告レベル
  5. ストレステストとシナリオ分析
  6. 相関シナリオでのVaR・期待ショートフォール
  7. 段階的オートデレバレッジ
  8. CLIダッシュボードと清算履歴
  9. よくある疑問Q&A

証拠金の全体像とUTAの特殊性

清算は「証拠金残高が維持証拠金を下回ったとき」に起きます。この2つが何で決まるかを押さえれば、清算価格は計算できます。

用語意味APIフィールド
IM(当初証拠金) ポジションを持つのに必要な証拠金。= 建玉額 / レバレッジ positionIM / totalInitialMargin
MM(維持証拠金) ポジションを維持するのに必要な最低額。これを割ると清算 positionMM / totalMaintenanceMargin
MMR(維持証拠金率) MM / 建玉額。リスクリミット階層ごとに上がる 直接は返らない。get_risk_limit から
証拠金残高 口座資産 + 未実現損益。清算判定の分子 totalMarginBalance
口座維持率 MM / 証拠金残高。100%で清算 accountMMRate
口座当初証拠金率 IM / 証拠金残高。100%で新規建て不可 accountIMRate
accountMMRateの向きに注意

Bybitの accountMMRate は「維持証拠金 ÷ 証拠金残高」で、大きいほど危険です(1.0 = 100% で清算)。
国内取引所でよく使われる「証拠金維持率」は逆数(証拠金÷必要証拠金)で、小さいほど危険という定義が多く、向きが逆です。この取り違えでアラートの不等号を逆に書く事故が頻発します。本記事では一貫してBybitの定義(大きいほど危険)を使います。

証拠金モード証拠金の共有清算の単位注意点
クロスマージン(UTA既定) 口座全体で共有 口座単位 1つの銘柄の含み損が全ポジションを巻き込む
分離マージン ポジションごとに独立 ポジション単位 損失は割り当て証拠金まで。ただし資本効率は落ちる
ポートフォリオマージン 口座全体(リスク相殺あり) 口座単位 計算がさらに複雑。本記事の単純式は適用外

get_wallet_balanceの各フィールド

margin_snapshot.pyサンプル1
"""margin_snapshot.py — 口座の証拠金状態を型付きで取得する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

  get_wallet_balance(accountType="UNIFIED") の主要フィールド:

    totalEquity            資産評価額(USD換算)
    totalWalletBalance     ウォレット残高(未実現損益を含まない)
    totalMarginBalance     証拠金残高 = ウォレット残高 + 未実現損益
    totalAvailableBalance  発注に使える残高
    totalPerpUPL           無期限の未実現損益
    totalInitialMargin     当初証拠金の合計
    totalMaintenanceMargin 維持証拠金の合計
    accountIMRate          IM / 証拠金残高(1.0 で新規建て不可)
    accountMMRate          MM / 証拠金残高(1.0 で清算)  ← 最重要
    accountLTV             借入比率(借入をしていなければ空文字)

  注意: accountMMRate は「大きいほど危険」。
        国内取引所の『証拠金維持率』とは向きが逆。
"""
from __future__ import annotations

from dataclasses import dataclass, field
from decimal import Decimal, InvalidOperation

from client import make_client


def d(value, default: str = "0") -> Decimal:
    """空文字・None・不正値を安全にDecimalへ。

    Bybitは値が無い場合に空文字を返すことがある。
    Decimal("") は InvalidOperation を投げるため、必ず経由させる。
    """
    try:
        s = str(value).strip()
        return Decimal(s) if s not in ("", "None") else Decimal(default)
    except (InvalidOperation, ValueError, TypeError):
        return Decimal(default)


@dataclass(frozen=True)
class CoinBalance:
    coin: str
    equity: Decimal
    wallet_balance: Decimal
    usd_value: Decimal
    unrealised_pnl: Decimal
    available_to_withdraw: Decimal
    borrow_amount: Decimal
    collateral_switch: bool

    def line(self) -> str:
        flag = "" if self.collateral_switch else "  [担保無効]"
        return (f"  {self.coin:<8}"
                f"評価 {self.equity:>16.8f}"
                f"  USD {self.usd_value:>12.2f}"
                f"  未実現 {self.unrealised_pnl:>+12.4f}{flag}")


@dataclass(frozen=True)
class AccountMargin:
    total_equity: Decimal
    total_wallet_balance: Decimal
    total_margin_balance: Decimal
    total_available_balance: Decimal
    total_initial_margin: Decimal
    total_maintenance_margin: Decimal
    total_perp_upl: Decimal
    account_im_rate: Decimal
    account_mm_rate: Decimal
    coins: list[CoinBalance] = field(default_factory=list)

    @property
    def mm_rate_pct(self) -> Decimal:
        """維持率をパーセント表示(100%で清算)。"""
        return self.account_mm_rate * 100

    @property
    def buffer_ratio(self) -> Decimal:
        """清算までの余裕。1.0 = ちょうど清算水準。

        証拠金残高が維持証拠金の何倍あるか。
        3.0なら「証拠金が1/3になるまで耐えられる」。
        """
        if self.total_maintenance_margin <= 0:
            return Decimal("999")
        return self.total_margin_balance / self.total_maintenance_margin

    @property
    def loss_tolerance(self) -> Decimal:
        """あと何USD失うと清算されるか。"""
        return self.total_margin_balance - self.total_maintenance_margin

    def report(self) -> str:
        lines = [
            "=== 口座証拠金 ===",
            f"  資産評価額      : {self.total_equity:>14.2f} USD",
            f"  ウォレット残高  : {self.total_wallet_balance:>14.2f} USD",
            f"  証拠金残高      : {self.total_margin_balance:>14.2f} USD",
            f"  発注可能残高    : {self.total_available_balance:>14.2f} USD",
            f"  未実現損益      : {self.total_perp_upl:>+14.2f} USD",
            "",
            f"  当初証拠金 (IM) : {self.total_initial_margin:>14.2f} USD",
            f"  維持証拠金 (MM) : {self.total_maintenance_margin:>14.2f} USD",
            "",
            f"  accountIMRate   : {self.account_im_rate * 100:>13.2f}% "
            "(100%で新規建て不可)",
            f"  accountMMRate   : {self.mm_rate_pct:>13.2f}% "
            "(100%で清算)",
            f"  清算までの余裕  : {self.buffer_ratio:>14.2f}倍",
            f"  許容損失額      : {self.loss_tolerance:>14.2f} USD",
        ]
        if self.coins:
            lines.append("\n  --- 通貨別 ---")
            lines.extend(c.line() for c in self.coins
                         if c.equity != 0 or c.usd_value != 0)
        return "\n".join(lines)


def fetch_account_margin(http, account_type: str = "UNIFIED",
                         include_coins: bool = True) -> AccountMargin:
    """口座の証拠金状態を取得する。"""
    r = http.get_wallet_balance(accountType=account_type)
    if r["retCode"] != 0:
        raise RuntimeError(
            f"get_wallet_balance失敗: {r['retCode']} {r['retMsg']}")
    if not r["result"]["list"]:
        raise RuntimeError("口座情報が空です。accountTypeを確認してください")

    a = r["result"]["list"][0]

    coins = []
    if include_coins:
        for c in a.get("coin", []):
            coins.append(CoinBalance(
                coin=c["coin"],
                equity=d(c.get("equity")),
                wallet_balance=d(c.get("walletBalance")),
                usd_value=d(c.get("usdValue")),
                unrealised_pnl=d(c.get("unrealisedPnl")),
                available_to_withdraw=d(c.get("availableToWithdraw")),
                borrow_amount=d(c.get("borrowAmount")),
                collateral_switch=bool(c.get("collateralSwitch", True)),
            ))

    return AccountMargin(
        total_equity=d(a.get("totalEquity")),
        total_wallet_balance=d(a.get("totalWalletBalance")),
        total_margin_balance=d(a.get("totalMarginBalance")),
        total_available_balance=d(a.get("totalAvailableBalance")),
        total_initial_margin=d(a.get("totalInitialMargin")),
        total_maintenance_margin=d(a.get("totalMaintenanceMargin")),
        total_perp_upl=d(a.get("totalPerpUPL")),
        account_im_rate=d(a.get("accountIMRate")),
        account_mm_rate=d(a.get("accountMMRate")),
        coins=coins,
    )


if __name__ == "__main__":
    c = make_client()
    margin = fetch_account_margin(c.http)
    print(margin.report())

    print("\n【読み方】")
    if margin.total_maintenance_margin <= 0:
        print("  ポジションが無いため維持証拠金はゼロ。清算リスクなし。")
    else:
        print(f"  証拠金残高が {margin.loss_tolerance:.2f} USD 減ると清算されます。")
        print(f"  現在の余裕は維持証拠金の {margin.buffer_ratio:.2f}倍です。")

    print("\n※ accountMMRate は大きいほど危険(1.0=100%で清算)。")
    print("  国内取引所の『証拠金維持率』とは向きが逆なので、")
    print("  アラートの不等号を書き間違えないこと。")

清算価格をMMRから自前計算する

APIの liqPrice は便利ですが、「レバレッジを2倍にしたら清算価格はどこに行くか」といった仮想的な問いに答えられません。自前で計算できれば、発注前に清算距離を評価できます。

liq_price.pyサンプル2
"""liq_price.py — 清算価格を維持証拠金率から自前計算し、APIと照合する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

  分離マージン(Isolated)の清算価格:

    ロング : P_liq = P_entry × (1 − 1/L + MMR)
    ショート: P_liq = P_entry × (1 + 1/L − MMR)

      L   = レバレッジ
      MMR = 維持証拠金率(リスクリミット階層で決まる)

  クロスマージンでは口座全体の余剰証拠金が効くため:

    ロング : P_liq = P_entry − (証拠金残高 − MM_他) / 数量
    ショート: P_liq = P_entry + (証拠金残高 − MM_他) / 数量

  実際にはUSDT建て/USDC建て/インバースで式が微妙に異なり、
  他ポジションの損益も影響する。
  そのため「自前計算 vs API の liqPrice」の一致検証が重要になる。
"""
from __future__ import annotations

from dataclasses import dataclass
from decimal import Decimal

from client import make_client
from margin_snapshot import d, fetch_account_margin


@dataclass(frozen=True)
class RiskTier:
    """リスクリミット階層。建玉額が大きいほどMMRが上がる。"""
    tier_id: int
    max_notional: Decimal
    maintenance_margin_rate: Decimal
    initial_margin_rate: Decimal
    max_leverage: Decimal


def fetch_risk_tiers(http, symbol: str,
                     category: str = "linear") -> list[RiskTier]:
    """銘柄のリスクリミット階層を取得する。"""
    r = http.get_risk_limit(category=category, symbol=symbol)
    if r["retCode"] != 0:
        raise RuntimeError(f"get_risk_limit失敗: {r['retCode']} {r['retMsg']}")

    tiers = []
    for t in r["result"]["list"]:
        tiers.append(RiskTier(
            tier_id=int(t.get("id", 0)),
            max_notional=d(t.get("riskLimitValue")),
            maintenance_margin_rate=d(t.get("maintenanceMargin")),
            initial_margin_rate=d(t.get("initialMargin")),
            max_leverage=d(t.get("maxLeverage"), "1"),
        ))
    return sorted(tiers, key=lambda x: x.max_notional)


def tier_for_notional(tiers: list[RiskTier], notional: Decimal) -> RiskTier:
    """建玉額に対応する階層を選ぶ。"""
    for t in tiers:
        if notional <= t.max_notional:
            return t
    return tiers[-1]


@dataclass(frozen=True)
class PositionRisk:
    symbol: str
    side: str            # "Buy" / "Sell"
    size: Decimal
    entry_price: Decimal
    mark_price: Decimal
    leverage: Decimal
    position_im: Decimal
    position_mm: Decimal
    api_liq_price: Decimal
    mmr: Decimal
    tier_id: int

    @property
    def notional(self) -> Decimal:
        return self.size * self.mark_price

    @property
    def is_long(self) -> bool:
        return self.side == "Buy"

    @property
    def unrealised_pnl(self) -> Decimal:
        diff = self.mark_price - self.entry_price
        return diff * self.size * (Decimal(1) if self.is_long else Decimal(-1))

    def liq_price_isolated(self) -> Decimal:
        """分離マージンの清算価格(理論式)。"""
        if self.leverage <= 0:
            return Decimal(0)
        factor = Decimal(1) / self.leverage - self.mmr
        if self.is_long:
            return self.entry_price * (Decimal(1) - factor)
        return self.entry_price * (Decimal(1) + factor)

    def liq_price_cross(self, available_margin: Decimal) -> Decimal:
        """クロスマージンの清算価格(近似)。

        available_margin: このポジションが使える証拠金
          = 証拠金残高 − 他ポジションの維持証拠金
        """
        if self.size <= 0:
            return Decimal(0)
        # 価格がどれだけ動くと (証拠金 − MM) がゼロになるか
        room = available_margin - self.position_mm
        move = room / self.size
        if self.is_long:
            return max(Decimal(0), self.mark_price - move)
        return self.mark_price + move

    def distance_pct(self, liq_price: Decimal) -> Decimal:
        """現在価格から清算価格までの距離(%)。"""
        if self.mark_price <= 0 or liq_price <= 0:
            return Decimal(0)
        return abs(self.mark_price - liq_price) / self.mark_price * 100


def fetch_position_risks(http, *, category: str = "linear",
                         settle_coin: str = "USDT") -> list[PositionRisk]:
    """全ポジションのリスク情報を、リスク階層と紐付けて取得する。"""
    r = http.get_positions(category=category, settleCoin=settle_coin)
    if r["retCode"] != 0:
        raise RuntimeError(f"get_positions失敗: {r['retCode']} {r['retMsg']}")

    out = []
    tier_cache: dict[str, list[RiskTier]] = {}

    for p in r["result"]["list"]:
        size = d(p.get("size"))
        if size <= 0:
            continue

        symbol = p["symbol"]
        if symbol not in tier_cache:
            tier_cache[symbol] = fetch_risk_tiers(http, symbol, category)

        mark = d(p.get("markPrice"))
        notional = size * mark
        tier = tier_for_notional(tier_cache[symbol], notional)

        out.append(PositionRisk(
            symbol=symbol,
            side=p["side"],
            size=size,
            entry_price=d(p.get("avgPrice")),
            mark_price=mark,
            leverage=d(p.get("leverage"), "1"),
            position_im=d(p.get("positionIM")),
            position_mm=d(p.get("positionMM")),
            api_liq_price=d(p.get("liqPrice")),
            mmr=tier.maintenance_margin_rate,
            tier_id=tier.tier_id,
        ))
    return out


def verify_against_api(http, category: str = "linear") -> None:
    """自前計算とAPIのliqPriceを照合する。"""
    positions = fetch_position_risks(http, category=category)
    if not positions:
        print("  ポジションがありません。")
        return

    margin = fetch_account_margin(http)
    total_mm = margin.total_maintenance_margin

    print(f"{'symbol':<12}{'side':<6}{'現在価格':>12}"
          f"{'API liq':>12}{'自前(分離)':>13}{'自前(クロス)':>14}{'距離':>8}")
    print("-" * 78)

    for p in positions:
        others_mm = total_mm - p.position_mm
        available = margin.total_margin_balance - others_mm

        iso = p.liq_price_isolated()
        cross = p.liq_price_cross(available)
        dist = p.distance_pct(p.api_liq_price if p.api_liq_price > 0 else cross)

        print(f"{p.symbol:<12}{p.side:<6}{p.mark_price:>12.2f}"
              f"{p.api_liq_price:>12.2f}{iso:>13.2f}{cross:>14.2f}"
              f"{dist:>7.2f}%")

    print("\n【解釈】")
    print("  クロスマージンでは API の liqPrice が正しい。")
    print("  自前計算は『レバレッジを変えたらどうなるか』の")
    print("  シミュレーションに使う。")
    print("  分離マージンなら理論式とAPIがほぼ一致するはず。")


if __name__ == "__main__":
    c = make_client()

    print("=== リスクリミット階層(BTCUSDT) ===")
    tiers = fetch_risk_tiers(c.http, "BTCUSDT")
    print(f"{'tier':>5}{'建玉上限(USDT)':>18}{'MMR':>9}{'IMR':>9}{'最大L':>8}")
    print("-" * 50)
    for t in tiers[:6]:
        print(f"{t.tier_id:>5}{t.max_notional:>18,.0f}"
              f"{t.maintenance_margin_rate * 100:>8.2f}%"
              f"{t.initial_margin_rate * 100:>8.2f}%"
              f"{t.max_leverage:>7.0f}x")
    print("  ...")
    print(f"\n  階層が上がるほどMMRが上がる = 大口ほど清算されやすい。")
    print("  ポジションを増やすと、既存部分の清算価格も動く点に注意。")

    print("\n=== 清算価格の照合 ===")
    verify_against_api(c.http)

    print("\n=== レバレッジ別の清算距離(仮想計算) ===")
    entry = Decimal("120000")
    mmr = tiers[0].maintenance_margin_rate
    print(f"  想定: BTCUSDT ロング @{entry} / MMR={mmr * 100:.2f}%\n")
    print(f"{'レバレッジ':>10}{'清算価格':>14}{'距離':>10}")
    print("-" * 36)
    for lev in (1, 2, 3, 5, 10, 20, 50, 100):
        L = Decimal(lev)
        liq = entry * (Decimal(1) - (Decimal(1) / L - mmr))
        dist = (entry - liq) / entry * 100
        print(f"{lev:>9}x{liq:>14.2f}{dist:>9.2f}%")

    print("\n  ※ 10倍でも約9.5%の逆行で清算される。")
    print("    暗号資産では1日でこの程度動くことは珍しくない。")

口座維持率の監視と警告レベル

margin_alert.pyサンプル3
"""margin_alert.py — 口座維持率を段階的に評価してアラートを出す
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

  accountMMRate は「MM ÷ 証拠金残高」で、1.0(100%)で清算。
  ただし 100% になってから気づいても手遅れ。
  段階的な閾値を設けて、余裕のあるうちに対処する。

  閾値の目安(保守的):
     〜30%  : 正常
    30〜50% : 注意(新規建てを抑制)
    50〜70% : 警告(ポジション縮小を検討)
    70〜85% : 危険(自動デレバレッジ発動)
    85%〜   : 緊急(全決済)

  「何%が適切か」は戦略のボラティリティ次第。
  1日で20%動く銘柄なら、50%でも既に手遅れになりうる。
"""
from __future__ import annotations

from dataclasses import dataclass
from decimal import Decimal
from enum import IntEnum

from client import make_client
from margin_snapshot import AccountMargin, fetch_account_margin


class RiskLevel(IntEnum):
    NORMAL = 0
    CAUTION = 1
    WARNING = 2
    DANGER = 3
    CRITICAL = 4

    @property
    def label(self) -> str:
        return {
            RiskLevel.NORMAL: "正常",
            RiskLevel.CAUTION: "注意",
            RiskLevel.WARNING: "警告",
            RiskLevel.DANGER: "危険",
            RiskLevel.CRITICAL: "緊急",
        }[self]

    @property
    def action(self) -> str:
        return {
            RiskLevel.NORMAL: "対応不要",
            RiskLevel.CAUTION: "新規建てを停止",
            RiskLevel.WARNING: "ポジションを縮小",
            RiskLevel.DANGER: "自動デレバレッジ発動",
            RiskLevel.CRITICAL: "全ポジション決済",
        }[self]


@dataclass(frozen=True)
class AlertThresholds:
    caution: Decimal = Decimal("0.30")
    warning: Decimal = Decimal("0.50")
    danger: Decimal = Decimal("0.70")
    critical: Decimal = Decimal("0.85")

    def level_for(self, mm_rate: Decimal) -> RiskLevel:
        if mm_rate >= self.critical:
            return RiskLevel.CRITICAL
        if mm_rate >= self.danger:
            return RiskLevel.DANGER
        if mm_rate >= self.warning:
            return RiskLevel.WARNING
        if mm_rate >= self.caution:
            return RiskLevel.CAUTION
        return RiskLevel.NORMAL


@dataclass
class MarginAssessment:
    margin: AccountMargin
    level: RiskLevel
    thresholds: AlertThresholds

    @property
    def mm_rate(self) -> Decimal:
        return self.margin.account_mm_rate

    def headroom_to_next_level(self) -> Decimal:
        """次の警告段階まで、証拠金残高が何USD減れる余裕があるか。"""
        next_threshold = {
            RiskLevel.NORMAL: self.thresholds.caution,
            RiskLevel.CAUTION: self.thresholds.warning,
            RiskLevel.WARNING: self.thresholds.danger,
            RiskLevel.DANGER: self.thresholds.critical,
            RiskLevel.CRITICAL: Decimal(1),
        }[self.level]

        mm = self.margin.total_maintenance_margin
        if mm <= 0 or next_threshold <= 0:
            return Decimal(0)
        # MM / balance = threshold となる balance
        target_balance = mm / next_threshold
        return self.margin.total_margin_balance - target_balance

    def price_move_to_liquidation(self, total_notional: Decimal) -> Decimal:
        """全ポジションが不利方向に何%動くと清算されるかの概算。

        近似: 建玉額 × x% の損失で証拠金残高が MM を下回る。
        """
        if total_notional <= 0:
            return Decimal("999")
        return self.margin.loss_tolerance / total_notional * 100

    def report(self) -> str:
        marks = {
            RiskLevel.NORMAL: "  ",
            RiskLevel.CAUTION: "! ",
            RiskLevel.WARNING: "!!",
            RiskLevel.DANGER: "!!!",
            RiskLevel.CRITICAL: "!!!!",
        }
        return "\n".join([
            f"{marks[self.level]} [{self.level.label}] "
            f"accountMMRate = {self.mm_rate * 100:.2f}%",
            f"     推奨アクション: {self.level.action}",
            f"     次の段階まで: {self.headroom_to_next_level():+.2f} USD",
            f"     許容損失額  : {self.margin.loss_tolerance:.2f} USD",
        ])


def assess(http, thresholds: AlertThresholds = AlertThresholds()
           ) -> MarginAssessment:
    margin = fetch_account_margin(http)
    return MarginAssessment(margin, thresholds.level_for(margin.account_mm_rate),
                            thresholds)


def notify_if_needed(assessment: MarginAssessment,
                     min_level: RiskLevel = RiskLevel.WARNING) -> bool:
    """閾値以上なら通知する。記事39のアラート基盤に接続する。"""
    if assessment.level < min_level:
        return False

    message = (f"[Bybit] 証拠金 {assessment.level.label}\n"
               f"accountMMRate: {assessment.mm_rate * 100:.2f}%\n"
               f"証拠金残高: {assessment.margin.total_margin_balance:.2f} USD\n"
               f"維持証拠金: {assessment.margin.total_maintenance_margin:.2f} USD\n"
               f"推奨: {assessment.level.action}")
    print("--- 通知内容 ---")
    print(message)
    print("----------------")
    # 記事39の send_discord(message) などに接続する
    return True


if __name__ == "__main__":
    from liq_price import fetch_position_risks

    c = make_client()
    a = assess(c.http)
    print(a.report())

    positions = fetch_position_risks(c.http)
    total_notional = sum((p.notional for p in positions), Decimal(0))
    if total_notional > 0:
        move = a.price_move_to_liquidation(total_notional)
        print(f"\n  建玉額合計: {total_notional:.2f} USD")
        print(f"  全ポジションが {move:.2f}% 逆行すると清算水準に到達")

    notify_if_needed(a)

    print("\n=== 閾値テーブル ===")
    th = AlertThresholds()
    for level, rate in ((RiskLevel.NORMAL, Decimal("0.10")),
                        (RiskLevel.CAUTION, Decimal("0.35")),
                        (RiskLevel.WARNING, Decimal("0.55")),
                        (RiskLevel.DANGER, Decimal("0.75")),
                        (RiskLevel.CRITICAL, Decimal("0.90"))):
        got = th.level_for(rate)
        print(f"  MMRate {rate * 100:>5.0f}% → {got.label:<4} "
              f"({got.action})")

    print("\n※ この閾値は保守的な例。")
    print("  ボラの高い銘柄を扱うなら、より低い水準で発動させること。")
    print("  記事12のボラ推定から『1日で何%動くか』を測り、")
    print("  その2倍の余裕を持たせるのが一つの目安。")

ストレステストとシナリオ分析

現在の維持率が安全でも、価格が10%動いたらどうなるかが分からなければリスク管理とは言えません。シナリオ分析で「まだ起きていない事象」を評価します。

stress_test.pyサンプル4
"""stress_test.py — 価格ショック時の口座維持率をシミュレートする
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

  やること:
    1. 現在の全ポジションを取得
    2. 「全銘柄が同時に ±X% 動いた」と仮定して未実現損益を再計算
    3. 証拠金残高と維持証拠金を再計算し、accountMMRate を求める
    4. どの水準で清算に達するかを二分探索で求める

  重要な仮定:
    - 建玉額が変わるとMMRの階層も変わるが、ここでは簡略化して固定
    - 実際の清算は逐次的に起きる(1つ清算されると余裕が戻る)
    - したがってこの試算は「保守的な近似」であり、悲観寄りに出る
"""
from __future__ import annotations

from dataclasses import dataclass
from decimal import Decimal

from client import make_client
from liq_price import PositionRisk, fetch_position_risks
from margin_alert import AlertThresholds, RiskLevel
from margin_snapshot import AccountMargin, fetch_account_margin


@dataclass(frozen=True)
class ScenarioResult:
    shock_pct: Decimal
    pnl_change: Decimal
    margin_balance: Decimal
    maintenance_margin: Decimal
    mm_rate: Decimal
    level: RiskLevel

    @property
    def liquidated(self) -> bool:
        return self.mm_rate >= Decimal(1)

    def line(self) -> str:
        flag = "  ← 清算" if self.liquidated else ""
        return (f"  {self.shock_pct:>+7.1f}%"
                f"  損益 {self.pnl_change:>+12.2f}"
                f"  証拠金 {self.margin_balance:>12.2f}"
                f"  MMRate {self.mm_rate * 100:>7.2f}%"
                f"  [{self.level.label}]{flag}")


def simulate_shock(margin: AccountMargin, positions: list[PositionRisk],
                   shock_pct: Decimal,
                   thresholds: AlertThresholds = AlertThresholds()
                   ) -> ScenarioResult:
    """全銘柄が同時に shock_pct だけ動いたときの口座状態。

    shock_pct が正なら価格上昇。ロングには有利、ショートには不利。
    """
    factor = Decimal(1) + shock_pct / 100
    pnl_change = Decimal(0)
    new_mm = Decimal(0)

    for p in positions:
        new_price = p.mark_price * factor
        direction = Decimal(1) if p.is_long else Decimal(-1)
        # 現在価格からの変化分だけを損益に加える
        pnl_change += (new_price - p.mark_price) * p.size * direction
        # 維持証拠金は建玉額に比例するので、価格変化で増減する
        new_mm += new_price * p.size * p.mmr

    new_balance = margin.total_margin_balance + pnl_change
    mm_rate = (new_mm / new_balance if new_balance > 0
               else Decimal("99"))

    return ScenarioResult(
        shock_pct=shock_pct, pnl_change=pnl_change,
        margin_balance=new_balance, maintenance_margin=new_mm,
        mm_rate=mm_rate, level=thresholds.level_for(mm_rate))


def find_liquidation_shock(margin: AccountMargin,
                           positions: list[PositionRisk],
                           direction: int = -1,
                           max_pct: Decimal = Decimal("100"),
                           tolerance: Decimal = Decimal("0.01")) -> Decimal:
    """清算に達する価格変動幅を二分探索で求める。

    direction: -1 なら下落方向、+1 なら上昇方向。
    """
    lo, hi = Decimal(0), max_pct

    if not simulate_shock(margin, positions,
                          Decimal(direction) * hi).liquidated:
        return Decimal("999")   # この方向では清算されない

    while hi - lo > tolerance:
        mid = (lo + hi) / 2
        if simulate_shock(margin, positions,
                          Decimal(direction) * mid).liquidated:
            hi = mid
        else:
            lo = mid
    return hi


def run_stress_test(http, shocks: tuple[Decimal, ...] = ()) -> None:
    margin = fetch_account_margin(http)
    positions = fetch_position_risks(http)

    if not positions:
        print("  ポジションがありません。ストレステストは不要です。")
        return

    if not shocks:
        shocks = tuple(Decimal(str(x)) for x in
                       (-30, -20, -15, -10, -5, -2, 0, 2, 5, 10, 15, 20, 30))

    print("=== 現在の状態 ===")
    print(f"  証拠金残高  : {margin.total_margin_balance:>12.2f} USD")
    print(f"  維持証拠金  : {margin.total_maintenance_margin:>12.2f} USD")
    print(f"  accountMMRate: {margin.account_mm_rate * 100:>11.2f}%")

    total_long = sum((p.notional for p in positions if p.is_long), Decimal(0))
    total_short = sum((p.notional for p in positions if not p.is_long),
                      Decimal(0))
    print(f"\n  ロング建玉  : {total_long:>12.2f} USD")
    print(f"  ショート建玉: {total_short:>12.2f} USD")
    print(f"  ネット      : {total_long - total_short:>+12.2f} USD "
          f"({'ロング' if total_long > total_short else 'ショート'}偏り)")

    print("\n=== 価格ショックシナリオ(全銘柄が同時に変動) ===")
    for shock in shocks:
        print(simulate_shock(margin, positions, shock).line())

    down = find_liquidation_shock(margin, positions, direction=-1)
    up = find_liquidation_shock(margin, positions, direction=+1)

    print("\n=== 清算までの距離 ===")
    print(f"  下落方向: {down:.2f}%" if down < 999 else
          "  下落方向: 清算されない(ネットショート)")
    print(f"  上昇方向: {up:.2f}%" if up < 999 else
          "  上昇方向: 清算されない(ネットロング)")

    print("\n【この試算の限界】")
    print("  - 全銘柄が同率で動く前提(実際は相関1未満)")
    print("  - リスクリミット階層の変化を無視している")
    print("  - 逐次清算による余裕の回復を考慮していない")
    print("  → 悲観寄りの近似。安全側に外れるので実用上は問題ない。")


if __name__ == "__main__":
    c = make_client()
    run_stress_test(c.http)

相関シナリオでのVaR・期待ショートフォール

portfolio_var.pyサンプル5
"""portfolio_var.py — ポートフォリオVaRと期待ショートフォールの概算
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

  前節のストレステストは「全銘柄が同率で動く」という極端な仮定だった。
  実際には相関が1未満なので、分散効果でリスクは小さくなる。
  ただし暗号資産は下落局面で相関が1に近づく(分散効果が消える)。

  ここでは:
    1. 過去リターンから共分散行列を推定
    2. ポートフォリオの分散を計算 → パラメトリックVaR
    3. 過去シナリオをそのまま適用 → ヒストリカルVaR
    4. VaRを超えた場合の平均損失 → 期待ショートフォール(ES/CVaR)
    5. 相関を1に固定した場合(ストレス相関)と比較

  参考: Jorion, P. (2006) "Value at Risk", 3rd ed.
        Artzner et al. (1999) "Coherent Measures of Risk"
"""
from __future__ import annotations

from dataclasses import dataclass
from decimal import Decimal

import numpy as np
import pandas as pd
from scipy import stats

from client import make_client
from kline_fetch import fetch_klines
from liq_price import PositionRisk, fetch_position_risks
from margin_snapshot import fetch_account_margin


@dataclass
class VarResult:
    horizon_days: float
    confidence: float
    parametric_var: float
    historical_var: float
    expected_shortfall: float
    stressed_var: float        # 相関=1 を仮定した場合
    portfolio_vol_daily: float
    diversification_benefit: float

    def report(self, margin_balance: float) -> str:
        def pct(v: float) -> str:
            return f"{v / margin_balance * 100:>6.2f}%" if margin_balance else "  -  "

        return "\n".join([
            f"=== VaR / ES ({self.horizon_days:.0f}日 / "
            f"信頼水準 {self.confidence:.0%}) ===",
            f"  パラメトリックVaR : {self.parametric_var:>12.2f} USD "
            f"({pct(self.parametric_var)})",
            f"  ヒストリカルVaR   : {self.historical_var:>12.2f} USD "
            f"({pct(self.historical_var)})",
            f"  期待ショートフォール: {self.expected_shortfall:>10.2f} USD "
            f"({pct(self.expected_shortfall)})",
            f"  ストレスVaR(相関1): {self.stressed_var:>12.2f} USD "
            f"({pct(self.stressed_var)})",
            "",
            f"  日次ボラ(portfolio): {self.portfolio_vol_daily:>11.2%}",
            f"  分散効果          : {self.diversification_benefit:>12.1%} "
            "(相関1に対する削減率)",
        ])


def fetch_return_matrix(http, symbols: list[str], days: int = 90,
                        category: str = "linear") -> pd.DataFrame:
    """複数銘柄の日次対数リターン行列を作る。"""
    from datetime import datetime, timedelta, timezone

    start = (datetime.now(timezone.utc) - timedelta(days=days + 5)) \
        .strftime("%Y-%m-%d")
    series = {}

    for sym in symbols:
        try:
            bars = fetch_klines(http, sym, interval="D", start=start,
                                category=category, verbose=False)
            close = bars["close"]
            series[sym] = np.log(close / close.shift(1))
        except Exception as e:
            print(f"  [warn] {sym} 取得失敗: {e}")

    df = pd.DataFrame(series).dropna()
    return df


def compute_var(positions: list[PositionRisk], returns: pd.DataFrame,
                confidence: float = 0.99,
                horizon_days: float = 1.0) -> VarResult:
    """ポジションの建玉額をウェイトとしてVaRを計算する。"""
    symbols = [p.symbol for p in positions if p.symbol in returns.columns]
    if not symbols:
        raise ValueError("リターンデータのある銘柄がありません")

    # 符号付きエクスポージャー(ショートはマイナス)
    exposure = np.array([
        float(p.notional) * (1 if p.is_long else -1)
        for p in positions if p.symbol in returns.columns
    ])

    r = returns[symbols]
    cov = r.cov().to_numpy()
    vol = r.std().to_numpy()

    # 1) パラメトリック(正規分布仮定)
    port_var_daily = float(exposure @ cov @ exposure)
    port_sd = np.sqrt(max(port_var_daily, 0.0)) * np.sqrt(horizon_days)
    z = stats.norm.ppf(confidence)
    parametric = float(z * port_sd)

    # 2) ヒストリカル(過去シナリオをそのまま適用)
    pnl_history = (r.to_numpy() @ exposure) * np.sqrt(horizon_days)
    historical = float(-np.percentile(pnl_history, (1 - confidence) * 100))

    # 3) 期待ショートフォール(VaRを超えた損失の平均)
    tail = pnl_history[pnl_history <= -historical]
    es = float(-tail.mean()) if len(tail) else historical

    # 4) ストレス相関(相関=1 → 個別リスクの単純和)
    stressed_sd = float(np.abs(exposure) @ vol) * np.sqrt(horizon_days)
    stressed = float(z * stressed_sd)

    benefit = 1 - parametric / stressed if stressed > 0 else 0.0
    notional_total = float(np.abs(exposure).sum())

    return VarResult(
        horizon_days=horizon_days, confidence=confidence,
        parametric_var=parametric, historical_var=historical,
        expected_shortfall=es, stressed_var=stressed,
        portfolio_vol_daily=(port_sd / notional_total
                             if notional_total else 0.0),
        diversification_benefit=benefit)


def correlation_report(returns: pd.DataFrame) -> None:
    """相関行列と、下落局面での相関を比較する。"""
    corr_all = returns.corr()
    # 市場全体が下落した日だけを抜き出す
    market = returns.mean(axis=1)
    down_days = returns[market < market.quantile(0.20)]

    print("\n=== 相関の比較 ===")
    print(f"  全期間の平均相関      : "
          f"{corr_all.to_numpy()[np.triu_indices_from(corr_all, 1)].mean():.3f}")

    if len(down_days) > 5:
        corr_down = down_days.corr()
        avg_down = corr_down.to_numpy()[
            np.triu_indices_from(corr_down, 1)].mean()
        print(f"  下位20%の日の平均相関 : {avg_down:.3f}")
        print("\n  ※ 下落局面で相関が上がるなら、分散効果は")
        print("    『最も必要なときに消える』。")
        print("    ストレスVaR(相関1)のほうが実態に近い。")


if __name__ == "__main__":
    c = make_client()

    positions = fetch_position_risks(c.http)
    if not positions:
        print("ポジションがありません。仮想ポートフォリオで計算します。\n")
        from decimal import Decimal as D
        positions = [
            PositionRisk("BTCUSDT", "Buy", D("0.5"), D("118000"),
                         D("120000"), D("5"), D("12000"), D("600"),
                         D("0"), D("0.005"), 1),
            PositionRisk("ETHUSDT", "Buy", D("10"), D("3800"),
                         D("3900"), D("5"), D("7800"), D("390"),
                         D("0"), D("0.005"), 1),
            PositionRisk("SOLUSDT", "Sell", D("100"), D("200"),
                         D("195"), D("5"), D("3900"), D("195"),
                         D("0"), D("0.005"), 1),
        ]

    symbols = [p.symbol for p in positions]
    print(f"対象銘柄: {', '.join(symbols)}")

    returns = fetch_return_matrix(c.http, symbols, days=90)
    print(f"リターンデータ: {len(returns)}日分\n")

    try:
        margin = fetch_account_margin(c.http)
        balance = float(margin.total_margin_balance)
    except Exception:
        balance = float(sum(p.notional for p in positions)) / 5

    for horizon in (1.0, 5.0):
        result = compute_var(positions, returns,
                             confidence=0.99, horizon_days=horizon)
        print(result.report(balance))
        print()

    correlation_report(returns)

    print("\n※ VaRは『99%の日は損失がこれ以下』という指標であり、")
    print("  『最大損失』ではない。残り1%の日には")
    print("  期待ショートフォールの水準まで損失が出る。")
    print("  清算リスクの評価には ES を使うほうが妥当。")

段階的オートデレバレッジ

維持率が閾値を超えたときに全決済してしまうと、一時的な逆行で戦略が消えます。段階的に縮小し、余裕が戻れば止める設計にします。

auto_deleverage.pyサンプル6
"""auto_deleverage.py — 維持率に応じてポジションを段階的に縮小する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

  設計方針:
    - 一度に全決済しない。目標維持率まで「必要な分だけ」減らす
    - 縮小対象は『維持証拠金の削減効率が高い順』
      = 建玉額が大きく、含み損の大きいポジションから
    - reduceOnly で必ず減らす方向にする(誤って増やさない)
    - 縮小後に再計測し、目標に達したら止める
    - 縮小の記録を残す(後から「なぜ減ったか」を追える)

  注意: これは清算を避けるための最終手段であり、
        発動した時点で戦略の想定が破綻している。
        発動頻度が高いなら、記事23のサイジングを見直すこと。
"""
from __future__ import annotations

import json
import time
from dataclasses import dataclass, field, asdict
from decimal import Decimal
from pathlib import Path

from client import make_client
from liq_price import PositionRisk, fetch_position_risks
from margin_alert import AlertThresholds, RiskLevel
from margin_snapshot import fetch_account_margin
from symbol_rules import fetch_rules

LOG_PATH = Path("./deleverage_log.jsonl")


@dataclass(frozen=True)
class DeleverageConfig:
    trigger_mm_rate: Decimal = Decimal("0.70")   # これを超えたら発動
    target_mm_rate: Decimal = Decimal("0.40")    # ここまで下げる
    max_reduce_ratio: Decimal = Decimal("0.50")  # 1回で減らす上限(全体比)
    min_step_ratio: Decimal = Decimal("0.10")    # 1ポジションの最小縮小率
    max_iterations: int = 5
    settle_pause_sec: float = 2.0
    category: str = "linear"
    dry_run: bool = True                          # 既定は試算のみ


@dataclass
class ReduceAction:
    symbol: str
    side: str
    current_size: Decimal
    reduce_size: Decimal
    close_side: str
    reason: str
    executed: bool = False
    order_id: str = ""
    error: str = ""

    def line(self) -> str:
        mark = "実行" if self.executed else ("試算" if not self.error else "失敗")
        tail = f"  ({self.error})" if self.error else ""
        return (f"  [{mark}] {self.symbol} {self.side} "
                f"{self.current_size} → {self.current_size - self.reduce_size} "
                f"({self.reduce_size} を {self.close_side}){tail}")


def rank_positions(positions: list[PositionRisk]) -> list[PositionRisk]:
    """縮小の優先順位を決める。

    優先度が高い = 減らしたときに維持証拠金が大きく減る
                   かつ 含み損が大きい(切っても惜しくない)

    スコア = 維持証拠金 + 含み損の絶対値(損失のみ加点)
    """
    def score(p: PositionRisk) -> Decimal:
        loss = -p.unrealised_pnl if p.unrealised_pnl < 0 else Decimal(0)
        return p.position_mm + loss * Decimal("0.5")

    return sorted(positions, key=score, reverse=True)


def required_mm_reduction(margin_balance: Decimal, current_mm: Decimal,
                          target_rate: Decimal) -> Decimal:
    """目標維持率に到達するために減らすべき維持証拠金の額。

    ただし決済すると含み損が実現し、証拠金残高も減る点に注意。
    ここでは保守的に「証拠金残高は変わらない」と仮定する
    (実際は含み損の実現でさらに悪化するので、多めに減らす)。
    """
    target_mm = margin_balance * target_rate
    return max(Decimal(0), current_mm - target_mm)


def plan_deleverage(http, cfg: DeleverageConfig
                    ) -> tuple[list[ReduceAction], dict]:
    """縮小計画を立てる(発注はしない)。"""
    margin = fetch_account_margin(http)
    positions = fetch_position_risks(http, category=cfg.category)

    context = {
        "mm_rate": float(margin.account_mm_rate),
        "margin_balance": float(margin.total_margin_balance),
        "maintenance_margin": float(margin.total_maintenance_margin),
        "positions": len(positions),
    }

    if margin.account_mm_rate < cfg.trigger_mm_rate:
        return [], context
    if not positions:
        return [], context

    need = required_mm_reduction(
        margin.total_margin_balance, margin.total_maintenance_margin,
        cfg.target_mm_rate)
    context["required_mm_reduction"] = float(need)

    total_mm = margin.total_maintenance_margin
    max_reduce_mm = total_mm * cfg.max_reduce_ratio
    need = min(need, max_reduce_mm)

    actions: list[ReduceAction] = []
    remaining = need

    for p in rank_positions(positions):
        if remaining <= 0:
            break
        if p.position_mm <= 0:
            continue

        # このポジションを何割減らせば remaining を賄えるか
        ratio = min(Decimal(1), remaining / p.position_mm)
        ratio = max(ratio, cfg.min_step_ratio)

        rules = fetch_rules(http, p.symbol, cfg.category)
        reduce_size = rules.round_qty(p.size * ratio)
        if reduce_size < rules.min_qty:
            continue
        reduce_size = min(reduce_size, p.size)

        actions.append(ReduceAction(
            symbol=p.symbol, side=p.side,
            current_size=p.size, reduce_size=reduce_size,
            close_side="Sell" if p.is_long else "Buy",
            reason=(f"MM={p.position_mm:.2f} "
                    f"含み損益={p.unrealised_pnl:+.2f}")))

        remaining -= p.position_mm * (reduce_size / p.size)

    return actions, context


def execute_actions(http, actions: list[ReduceAction],
                    cfg: DeleverageConfig) -> None:
    """縮小を実行する。reduceOnly で必ず減らす方向にする。"""
    for a in actions:
        if cfg.dry_run:
            continue
        rules = fetch_rules(http, a.symbol, cfg.category)
        r = http.place_order(
            category=cfg.category, symbol=a.symbol,
            side=a.close_side, orderType="Market",
            qty=rules.format_qty(a.reduce_size),
            reduceOnly=True,
            orderLinkId=f"dlv-{int(time.time() * 1000) % 1_000_000}")
        if r["retCode"] == 0:
            a.executed = True
            a.order_id = r["result"]["orderId"]
        else:
            a.error = f"{r['retCode']} {r['retMsg']}"


def log_deleverage(actions: list[ReduceAction], context: dict) -> None:
    """発動記録を残す。後から『なぜポジションが減ったか』を追える。"""
    record = {
        "ts": time.time(),
        "context": context,
        "actions": [asdict(a) for a in actions],
    }
    with LOG_PATH.open("a", encoding="utf-8") as f:
        f.write(json.dumps(record, ensure_ascii=False, default=str) + "\n")


def run_deleverage(http, cfg: DeleverageConfig = DeleverageConfig()) -> None:
    """発動判定 → 計画 → 実行 → 再計測 のループ。"""
    for i in range(1, cfg.max_iterations + 1):
        actions, context = plan_deleverage(http, cfg)

        print(f"\n--- 反復 {i}/{cfg.max_iterations} ---")
        print(f"  accountMMRate: {context['mm_rate'] * 100:.2f}% "
              f"(発動閾値 {cfg.trigger_mm_rate * 100:.0f}% / "
              f"目標 {cfg.target_mm_rate * 100:.0f}%)")

        if not actions:
            print("  縮小不要です。")
            return

        print(f"  必要なMM削減: "
              f"{context.get('required_mm_reduction', 0):.2f} USD")
        for a in actions:
            print(a.line())

        execute_actions(http, actions, cfg)
        log_deleverage(actions, context)

        if cfg.dry_run:
            print("\n  [dry_run] 実際の発注は行いませんでした。")
            print("  cfg.dry_run=False で実行されます。")
            return

        time.sleep(cfg.settle_pause_sec)

    print("\n  最大反復回数に達しました。手動での確認が必要です。")


if __name__ == "__main__":
    c = make_client()

    print("=== オートデレバレッジ(試算モード) ===")
    run_deleverage(c.http, DeleverageConfig(
        trigger_mm_rate=Decimal("0.30"),   # 試算用に低めの閾値
        target_mm_rate=Decimal("0.15"),
        dry_run=True))

    print("\n【運用上の注意】")
    print("  - 発動は『戦略の想定が破綻した』サイン。")
    print("    頻発するなら記事23のサイジングを見直すこと。")
    print("  - 決済すると含み損が実現し、証拠金残高も減る。")
    print("    そのため1回で目標に届かないことがある(だから反復する)。")
    print("  - 急落中は成行のスリッページが拡大する。")
    print("    記事19の分割執行は使えない(時間が無い)ので、")
    print("    そうなる前に発動させる閾値設計が本質。")

CLIダッシュボードと清算履歴

risk_dashboard.pyサンプル7
"""risk_dashboard.py — 証拠金・清算価格のリアルタイムCLIダッシュボード
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

  pip install rich

  rich が無い環境向けに、素のprint版へ自動フォールバックする。
  ポーリング間隔は記事05のレート制限を考慮して5秒以上を推奨。
"""
from __future__ import annotations

import time
from decimal import Decimal

from client import make_client
from liq_price import fetch_position_risks
from margin_alert import AlertThresholds, RiskLevel, assess
from margin_snapshot import fetch_account_margin

LEVEL_STYLE = {
    RiskLevel.NORMAL: "green",
    RiskLevel.CAUTION: "yellow",
    RiskLevel.WARNING: "dark_orange",
    RiskLevel.DANGER: "red",
    RiskLevel.CRITICAL: "bold white on red",
}


def build_tables(http):
    """rich用のテーブルを組み立てる。"""
    from rich.table import Table

    margin = fetch_account_margin(http)
    positions = fetch_position_risks(http)
    level = AlertThresholds().level_for(margin.account_mm_rate)

    acc = Table(title="口座証拠金", expand=True)
    acc.add_column("項目"); acc.add_column("値", justify="right")
    acc.add_row("証拠金残高", f"{margin.total_margin_balance:,.2f} USD")
    acc.add_row("当初証拠金 (IM)", f"{margin.total_initial_margin:,.2f} USD")
    acc.add_row("維持証拠金 (MM)", f"{margin.total_maintenance_margin:,.2f} USD")
    acc.add_row("未実現損益",
                f"[{'green' if margin.total_perp_upl >= 0 else 'red'}]"
                f"{margin.total_perp_upl:+,.2f} USD[/]")
    acc.add_row("accountIMRate", f"{margin.account_im_rate * 100:.2f}%")
    acc.add_row("accountMMRate",
                f"[{LEVEL_STYLE[level]}]"
                f"{margin.account_mm_rate * 100:.2f}%[/]")
    acc.add_row("状態", f"[{LEVEL_STYLE[level]}]{level.label}[/]")
    acc.add_row("許容損失額", f"{margin.loss_tolerance:,.2f} USD")

    pos = Table(title=f"ポジション ({len(positions)}件)", expand=True)
    for col, just in (("銘柄", "left"), ("方向", "left"), ("数量", "right"),
                      ("平均建値", "right"), ("マーク", "right"),
                      ("含み損益", "right"), ("清算価格", "right"),
                      ("清算距離", "right"), ("MM", "right")):
        pos.add_column(col, justify=just)

    for p in positions:
        dist = p.distance_pct(p.api_liq_price)
        dist_style = ("red" if dist < 5 else
                      "yellow" if dist < 15 else "green")
        pnl_style = "green" if p.unrealised_pnl >= 0 else "red"
        pos.add_row(
            p.symbol,
            f"[{'green' if p.is_long else 'red'}]{p.side}[/]",
            f"{p.size}", f"{p.entry_price:,.2f}", f"{p.mark_price:,.2f}",
            f"[{pnl_style}]{p.unrealised_pnl:+,.2f}[/]",
            f"{p.api_liq_price:,.2f}" if p.api_liq_price > 0 else "-",
            f"[{dist_style}]{dist:.2f}%[/]" if p.api_liq_price > 0 else "-",
            f"{p.position_mm:,.2f}")

    return acc, pos, level


def run_rich(http, interval: float = 5.0) -> None:
    from rich.console import Console, Group
    from rich.live import Live

    console = Console()
    with Live(console=console, refresh_per_second=2, screen=False) as live:
        while True:
            try:
                acc, pos, _ = build_tables(http)
                live.update(Group(acc, pos))
            except Exception as e:
                console.print(f"[red]エラー: {type(e).__name__}: {e}[/]")
            time.sleep(interval)


def run_plain(http, interval: float = 5.0) -> None:
    """rich が無い場合のフォールバック。"""
    while True:
        try:
            a = assess(http)
            positions = fetch_position_risks(http)

            print("\033[2J\033[H", end="")   # 画面クリア
            print(f"=== Bybit リスク監視 "
                  f"({time.strftime('%Y-%m-%d %H:%M:%S')}) ===\n")
            print(a.report())
            print(f"\n{'銘柄':<12}{'方向':<6}{'数量':>12}"
                  f"{'含み損益':>14}{'清算価格':>13}{'距離':>9}")
            print("-" * 68)
            for p in positions:
                dist = p.distance_pct(p.api_liq_price)
                print(f"{p.symbol:<12}{p.side:<6}{p.size:>12}"
                      f"{p.unrealised_pnl:>+14.2f}"
                      f"{p.api_liq_price:>13.2f}{dist:>8.2f}%")
        except Exception as e:
            print(f"エラー: {type(e).__name__}: {e}")
        time.sleep(interval)


if __name__ == "__main__":
    c = make_client()
    print("Ctrl+C で終了します。\n")
    try:
        try:
            import rich  # noqa: F401
            run_rich(c.http, interval=5.0)
        except ImportError:
            print("(rich が未インストールのため簡易表示を使います)")
            run_plain(c.http, interval=5.0)
    except KeyboardInterrupt:
        print("\n終了しました。")
liquidation_history.pyサンプル8
"""liquidation_history.py — 清算・ADL・ファンディングの発生履歴を洗い出す
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

  get_transaction_log() の type フィールドで種別を判別する:
    TRADE            通常の約定
    SETTLEMENT       ファンディング決済
    LIQUIDATION      清算
    ADL              自動デレバレッジ(相手側の清算に巻き込まれた)
    BONUS / TRANSFER / DELIVERY など

  重要な制約:
    - 1回の照会は7日以内の範囲のみ(startTime/endTime)
    - データ保持期間は2年
    → 長期の集計は7日ずつページングする必要がある(記事18と同じ構造)
"""
from __future__ import annotations

import time
from collections import defaultdict
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from decimal import Decimal

from client import make_client
from margin_snapshot import d

SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000
RISK_TYPES = {"LIQUIDATION", "ADL", "SETTLEMENT"}


@dataclass(frozen=True)
class LogEntry:
    ts: int
    symbol: str
    type: str
    side: str
    qty: Decimal
    price: Decimal
    fee: Decimal
    cash_flow: Decimal
    change: Decimal
    balance: Decimal
    currency: str

    @property
    def when(self) -> str:
        return datetime.fromtimestamp(
            self.ts / 1000, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S")

    def line(self) -> str:
        return (f"  {self.when}  {self.type:<12}{self.symbol:<12}"
                f"{self.side:<5}{self.qty:>12}"
                f"{self.change:>+14.4f} {self.currency}")


def fetch_transaction_log(http, *, account_type: str = "UNIFIED",
                          category: str = "linear",
                          days: int = 30,
                          log_type: str = "") -> list[LogEntry]:
    """取引ログを7日ずつ遡って取得する。

    startTime だけを渡すと7日制約に抵触するため、
    必ず [start, end] の窓を7日以内で刻む。
    """
    now_ms = int(time.time() * 1000)
    oldest_ms = now_ms - days * 24 * 60 * 60 * 1000
    entries: list[LogEntry] = []
    window_end = now_ms

    while window_end > oldest_ms:
        window_start = max(oldest_ms, window_end - SEVEN_DAYS_MS)
        cursor = ""

        while True:
            params = {
                "accountType": account_type,
                "category": category,
                "startTime": window_start,
                "endTime": window_end,
                "limit": 50,
            }
            if log_type:
                params["type"] = log_type
            if cursor:
                params["cursor"] = cursor

            r = http.get_transaction_log(**params)
            if r["retCode"] != 0:
                raise RuntimeError(
                    f"get_transaction_log失敗: {r['retCode']} {r['retMsg']}")

            for e in r["result"]["list"]:
                entries.append(LogEntry(
                    ts=int(e.get("transactionTime", 0)),
                    symbol=e.get("symbol", ""),
                    type=e.get("type", ""),
                    side=e.get("side", ""),
                    qty=d(e.get("qty")),
                    price=d(e.get("tradePrice")),
                    fee=d(e.get("fee")),
                    cash_flow=d(e.get("cashFlow")),
                    change=d(e.get("change")),
                    balance=d(e.get("cashBalance")),
                    currency=e.get("currency", ""),
                ))

            cursor = r["result"].get("nextPageCursor", "")
            if not cursor:
                break
            time.sleep(0.06)

        window_end = window_start
        time.sleep(0.06)

    return sorted(entries, key=lambda x: x.ts, reverse=True)


def summarize(entries: list[LogEntry]) -> None:
    """種別ごとに集計する。"""
    by_type: dict[str, list[LogEntry]] = defaultdict(list)
    for e in entries:
        by_type[e.type].append(e)

    print(f"\n=== 種別別の集計({len(entries)}件) ===")
    print(f"{'種別':<16}{'件数':>7}{'損益合計':>16}{'手数料':>14}")
    print("-" * 55)

    for t, items in sorted(by_type.items(),
                           key=lambda kv: -len(kv[1])):
        total = sum((x.change for x in items), Decimal(0))
        fee = sum((x.fee for x in items), Decimal(0))
        mark = " ←要確認" if t in ("LIQUIDATION", "ADL") else ""
        print(f"{t:<16}{len(items):>7}{total:>+16.4f}{fee:>14.4f}{mark}")


def show_risk_events(entries: list[LogEntry]) -> None:
    """清算・ADLを抜き出して表示する。"""
    events = [e for e in entries if e.type in ("LIQUIDATION", "ADL")]

    print(f"\n=== 清算・ADLイベント ({len(events)}件) ===")
    if not events:
        print("  発生していません。")
        return

    for e in events:
        print(e.line())

    total_loss = sum((e.change for e in events), Decimal(0))
    print(f"\n  合計影響額: {total_loss:+.4f}")
    print("\n  ※ LIQUIDATION は自分のポジションが清算されたもの。")
    print("    ADL は相手方の清算に巻き込まれて強制決済されたもの。")
    print("    ADLは自分の証拠金が十分でも発生しうる(防げない)。")


def funding_summary(entries: list[LogEntry]) -> None:
    """ファンディング決済の収支を銘柄別に集計する。"""
    funding = [e for e in entries if e.type == "SETTLEMENT"]
    if not funding:
        return

    by_symbol: dict[str, Decimal] = defaultdict(Decimal)
    for e in funding:
        by_symbol[e.symbol] += e.change

    print(f"\n=== ファンディング収支 ({len(funding)}件) ===")
    for symbol, total in sorted(by_symbol.items(),
                                key=lambda kv: kv[1]):
        sign = "受取" if total > 0 else "支払"
        print(f"  {symbol:<12}{total:>+14.4f}  ({sign})")

    net = sum(by_symbol.values(), Decimal(0))
    print(f"  {'合計':<12}{net:>+14.4f}")


if __name__ == "__main__":
    c = make_client()

    print("=== 取引ログを取得中(過去30日) ===")
    entries = fetch_transaction_log(c.http, days=30)
    print(f"  {len(entries)}件を取得しました。")

    summarize(entries)
    show_risk_events(entries)
    funding_summary(entries)

    print("\n※ 7日を超える範囲は自動で分割照会している。")
    print("  startTime だけを渡すと直近7日しか返らないので注意(記事18)。")
    print("  データ保持期間は2年。それ以前は取得できない。")
参考リンク・文献
  • Get Wallet Balance(accountMMRate / accountIMRate): bybit-exchange.github.io/docs/v5/account/wallet-balance
  • Get Position Info(liqPrice / positionMM / positionIM): /v5/position
  • Get Risk Limit: /market/risk-limit
  • Get Transaction Log: /account/transaction-log
  • UTA の証拠金計算: /v5/acct-mode
  • Jorion, P. (2006) Value at Risk: The New Benchmark for Managing Financial Risk, 3rd ed., McGraw-Hill
  • Artzner, P., Delbaen, F., Eber, J.-M. & Heath, D. (1999) "Coherent Measures of Risk", Mathematical Finance 9(3)
  • Acerbi, C. & Tasche, D. (2002) "On the Coherence of Expected Shortfall", Journal of Banking & Finance 26(7)

よくある疑問Q&A

accountMMRateは大きいほうが安全ですか
逆です。大きいほど危険で、1.0(100%)で清算されます。Bybitの定義は「維持証拠金 ÷ 証拠金残高」だからです。
国内取引所でよく使われる「証拠金維持率」は逆数(証拠金÷必要証拠金)で小さいほど危険、という定義が多く、向きが逆です。この取り違えでアラートの不等号を逆に書く事故が頻発します。サンプル3の AlertThresholds はBybitの定義に統一してあります。
自前計算した清算価格がAPIのliqPriceと合いません
クロスマージンなら合わなくて当然です。クロスでは口座全体の余剰証拠金が清算価格に影響するため、単一ポジションの理論式では計算できません。他ポジションの含み損益が動けば清算価格も動きます。
自前計算の使い道は「レバレッジを変えたら清算価格はどこに行くか」という仮想シナリオです。現在の清算価格そのものはAPIの liqPrice を信頼してください。分離マージンなら理論式とほぼ一致します。
MMR(維持証拠金率)はどこで取得できますか
get_wallet_balance や get_positions にはMMR(率)は直接返りません。positionMM(金額)は返るので、建玉額で割れば実効MMRが求まります。
階層構造そのものを知りたい場合は get_risk_limit です(サンプル2の fetch_risk_tiers)。建玉額が大きいほどMMRが上がるため、ポジションを増やすと既存部分の清算価格も不利側に動く点に注意してください。
追証(追加証拠金)はありますか
Bybitのデリバティブは清算によって損失が確定する仕組みで、通常は口座残高を超える請求は発生しません(相互保険基金が損失を吸収)。
ただし極端な急変で基金が不足した場合、ADL(自動デレバレッジ)で利益側のポジションが強制決済されることがあります。サンプル8の show_risk_events() でADLの発生を確認できます。ADLは自分の証拠金が十分でも発生し、防ぐ手段はありません。
ストレステストの結果が実際より悲観的に見えます
サンプル4は「全銘柄が同率で同時に動く」(相関1)を仮定しているためです。実際は相関1未満なので分散効果があり、損失はもう少し小さくなります。
ただし暗号資産は下落局面で相関が1に近づくことが知られています。サンプル5の correlation_report() で「下位20%の日の相関」を確認してください。平常時より明確に高いなら、分散効果は最も必要なときに消えるため、ストレスVaR(相関1)のほうが実態に近い評価になります。
VaRを見ていれば清算は避けられますか
避けられません。VaRは「99%の日は損失がこれ以下」という指標であり、最大損失ではありません。残り1%の日には期待ショートフォール(ES)の水準まで損失が出ます。
清算リスクの評価にはESを使うほうが妥当です。さらにESも過去データに基づく推定なので、過去に無い規模の変動には無力です。最終的にはサンプル4のシナリオ分析で「−30%が来たらどうなるか」を直接見てください。
オートデレバレッジは全決済にすべきではないですか
全決済は確実ですが、一時的な逆行で戦略が消えます。サンプル6は目標維持率まで「必要な分だけ」減らし、余裕が戻れば止める設計です。
ただし発動した時点で戦略の想定が破綻しているのも事実です。発動が頻発するなら、記事23のサイジングとレバレッジ設定を見直してください。デレバレッジは対症療法であり、根本対策ではありません。全停止の判断は記事24のサーキットブレーカーに委ねます。
監視はどのくらいの頻度で回すべきですか
RESTポーリングなら5秒間隔が目安です。get_wallet_balance と get_positions の2本を叩くため、1秒間隔だと記事05のUID別レート制限を圧迫します。
より速い検知が必要なら記事27のprivate WSを併用します。ただしwallet トピックは購読時にスナップショットを送らず、未実現損益の変化では発火しません。WSだけで清算リスクを監視することはできないため、RESTポーリングを主系として残してください。

注意事項・免責事項

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

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

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

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

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

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

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