※ Bybit V5は機能追加のペースが速いAPIです。パラメータの最終的な正解は必ず公式ドキュメントで確認してください。本記事のコードはすべてテストネットで動作確認しています。
清算は「証拠金残高が維持証拠金を下回ったとき」に起きます。この2つが何で決まるかを押さえれば、清算価格は計算できます。
| 用語 | 意味 | APIフィールド |
|---|---|---|
| IM(当初証拠金) | ポジションを持つのに必要な証拠金。= 建玉額 / レバレッジ | positionIM / totalInitialMargin |
| MM(維持証拠金) | ポジションを維持するのに必要な最低額。これを割ると清算 | positionMM / totalMaintenanceMargin |
| MMR(維持証拠金率) | MM / 建玉額。リスクリミット階層ごとに上がる | 直接は返らない。get_risk_limit から |
| 証拠金残高 | 口座資産 + 未実現損益。清算判定の分子 | totalMarginBalance |
| 口座維持率 | MM / 証拠金残高。100%で清算 | accountMMRate |
| 口座当初証拠金率 | IM / 証拠金残高。100%で新規建て不可 | accountIMRate |
Bybitの accountMMRate は「維持証拠金 ÷ 証拠金残高」で、大きいほど危険です(1.0 = 100% で清算)。
国内取引所でよく使われる「証拠金維持率」は逆数(証拠金÷必要証拠金)で、小さいほど危険という定義が多く、向きが逆です。この取り違えでアラートの不等号を逆に書く事故が頻発します。本記事では一貫してBybitの定義(大きいほど危険)を使います。
| 証拠金モード | 証拠金の共有 | 清算の単位 | 注意点 |
|---|---|---|---|
| クロスマージン(UTA既定) | 口座全体で共有 | 口座単位 | 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(" アラートの不等号を書き間違えないこと。")
APIの liqPrice は便利ですが、「レバレッジを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 — 口座維持率を段階的に評価してアラートを出す
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
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 — 価格ショック時の口座維持率をシミュレートする
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
やること:
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)
"""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 — 維持率に応じてポジションを段階的に縮小する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
設計方針:
- 一度に全決済しない。目標維持率まで「必要な分だけ」減らす
- 縮小対象は『維持証拠金の削減効率が高い順』
= 建玉額が大きく、含み損の大きいポジションから
- 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(" そうなる前に発動させる閾値設計が本質。")
"""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 — 清算・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年。それ以前は取得できない。")
AlertThresholds はBybitの定義に統一してあります。liqPrice を信頼してください。分離マージンなら理論式とほぼ一致します。get_wallet_balance や get_positions にはMMR(率)は直接返りません。positionMM(金額)は返るので、建玉額で割れば実効MMRが求まります。get_risk_limit です(サンプル2の fetch_risk_tiers)。建玉額が大きいほどMMRが上がるため、ポジションを増やすと既存部分の清算価格も不利側に動く点に注意してください。show_risk_events() でADLの発生を確認できます。ADLは自分の証拠金が十分でも発生し、防ぐ手段はありません。correlation_report() で「下位20%の日の相関」を確認してください。平常時より明確に高いなら、分散効果は最も必要なときに消えるため、ストレスVaR(相関1)のほうが実態に近い評価になります。投資は自己判断・自己責任でお願いいたします。本ブログの情報・ツール・ソフトウェアを参考・使用して、取引をされて何らかの損失を被られても、当サイトでは責任は一切負いません(間接的・付随的な障害・損失も含め一切の責任を負いません)。自己責任でご利用ください。
当サイトからダウンロードしたプログラム・EAは本人利用に限り完全無料でご利用いただけます。プログラムの全てまたは一部の複製・コピー・リバースエンジニアリング・改変・2次配布・商用利用・転売・販売等は一切禁止いたします。違反を発見した場合は法的処置を行います。
当サイト上に存在するソフトウェア・ツール・文章・画像・動画等の著作物を無断転載することを禁止します。引用の範囲を超えるものについては、法的処置を行います。
当サイトで配布するツール・ソフトウェアは無料提供ですのでサポートなどは行っておりません(逆に丁寧にサポートしているサイトはほとんど違法サイトですのでご注意ください。投資助言・代理業登録なしでサポート・助言することは法律で禁止されています)。万が一、ツールやソフトウェアに不具合や問題があっても当サイトでは修正・対応などができない場合があります。ご利用の環境でトラブルが発生しても当サイトでは助言することはできませんので自己責任でご利用ください。検索するなどして自分で解決してください。
当サイトでは取引所・証券会社へのリンクを貼っていますが、これは情報として提供するものであり、勧誘行為などは一切行っておりません。そのため、取引所・証券会社の登録方法・使い方・オススメ金融機関・オススメ金融商品などを説明した記事はサイト内にありませんので、ご自身で調べた上で判断ください(本当はサイト内で丁寧に説明したいのですが、仲介・取次ぎにあたる可能性があり第一種金融商品取引業登録を行っていない場合、違法となる可能性があるため出来ません。ご容赦ください)。
当サイトからリンクやバナーなどによって他のサイトに移動された場合、移動先サイトで提供される情報、サービス等について一切の責任を負いません。当サイト掲載情報やリンク先の掲載情報の合法性、正確性、道徳性、最新性、適切性、著作権の許諾や有無など、その内容については一切の保証を致しかねます。
当サイトは、ご訪問いただいた時間、訪問者のアクセス元のホスト名、IPアドレス、OSとブラウザのバージョン、リンク元のURL、モニターの情報等のパソコン環境情報を収集し、アクセス解析をしております。この目的は訪問者が検索サイトからご訪問いただいた場合、どのような検索で当サイトに来訪いただいたかの解析にのみ使用しており、第3者に公開はいたしません。また、個人を特定するものではありません。