※ Bybit V5は機能追加のペースが速いAPIです。パラメータの最終的な正解は必ず公式ドキュメントで確認してください。本記事のコードはすべてテストネットで動作確認しています。
本記事は実装方法の解説であり、収益を約束するものではありません。「デルタニュートラルだからノーリスク」という説明を見かけますが、それは誤りです。後述するリスクを理解した上で、必ずテストネット・デモで検証してから進めてください。
【建てるポジション】
現物 : BTC を 0.1 枚 買う (spot) +0.1 BTC
無期限 : BTCUSDT を 0.1 枚 売る (linear) -0.1 BTC
────────────────────
合計デルタ 0.0 BTC
【価格が10%上昇した場合】
現物 : +10%の利益
無期限 : -10%の損失
合計 : 0(打ち消し合う)
【資金調達(8時間ごと・レートが +0.01% の場合)】
無期限ショート : 想定元本 × 0.01% を受け取る
現物 : 何も発生しない
合計 : +0.01% ずつ積み上がる
-> 価格がどう動いても、資金調達だけが残る(ように見える)
これがキャリー戦略(cash-and-carry の変形)の原理です。資金調達率がプラスに偏るという仮想通貨市場の構造的な性質(記事10)を収益源にします。
価格方向のリスクは消えますが、別のリスクが残ります。これらを理解せずに始めるのが最も危険です。
| リスク | 内容 | 対策 |
|---|---|---|
| 資金調達の反転 | レートがマイナスに転じると、受け取りから支払いに変わる | 符号反転で撤退(サンプル6) |
| コスト割れ | 往復コストを回収する前に解消すると赤字 | 損益分岐の事前計算(サンプル1) |
| レッグリスク | 片脚だけ約定して、価格変動をむき出しで受ける | 同時執行と即時ヘッジ(サンプル4) |
| デルタ乖離 | 価格変動で両脚の価値がずれ、方向リスクが発生 | 定期リバランス(サンプル5) |
| 清算リスク | 価格急騰でショート側が清算されると、ヘッジが外れて現物だけが残る | レバレッジを抑える・維持率監視(記事22) |
| 流動性リスク | 解消時に板が薄く、想定より不利な価格になる | スプレッド・板厚でフィルタ(記事09) |
| 上場廃止・取引停止 | 片方の市場が停止すると解消できない | 流動性の高い主要銘柄に限定 |
「デルタニュートラルなら清算されない」と考えるのは危険な誤解です。統合口座で証拠金を共有していれば全体の含み損益は打ち消し合いますが、価格が急騰すると無期限ショート側の含み損が急増します。
現物側の含み益がそれを補うため通常は問題になりませんが、現物と無期限で証拠金の扱いが異なる場合や、レバレッジをかけて資金効率を上げている場合には、ショート側だけが清算されることがあります。
清算されるとヘッジが外れて現物のロングだけが残ります。その時点で「デルタニュートラル」ではなくなり、価格変動をむき出しで受けます。だからレバレッジを1〜2倍程度に抑え、口座維持率を常時監視する(記事22)ことが必須です。
実装より先に、この計算をしてください。多くの場合「思ったより儲からない」ことが分かります。
"""breakeven.py — キャリー戦略の損益分岐を計算する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
コストの構造(4本の脚):
建て: 現物買い + perp売り
解消: 現物売り + perp買い
-> 手数料は 4回、スプレッド損失は実質 2往復分
"""
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal
@dataclass(frozen=True)
class CarryCosts:
"""すべて bps(1bps = 0.01%)で指定する。実測値を入れること。"""
spot_maker_bps: Decimal = Decimal("10.0") # 現物メイカー 0.1%
spot_taker_bps: Decimal = Decimal("10.0")
perp_maker_bps: Decimal = Decimal("2.0") # perpメイカー 0.02%
perp_taker_bps: Decimal = Decimal("5.5") # perpテイカー 0.055%
spot_spread_bps: Decimal = Decimal("2.0") # 片道
perp_spread_bps: Decimal = Decimal("1.0")
slippage_bps: Decimal = Decimal("1.0") # 片道・片脚
def round_trip(self, use_maker: bool = True) -> Decimal:
"""建て+解消の総コスト(bps)。
メイカーならスプレッドを払わず、テイカーなら払う。
"""
if use_maker:
fee = (self.spot_maker_bps + self.perp_maker_bps) * 2
spread = Decimal(0)
slip = Decimal(0)
else:
fee = (self.spot_taker_bps + self.perp_taker_bps) * 2
# 建て・解消 × 現物・perp の4回スプレッドを踏む
spread = (self.spot_spread_bps + self.perp_spread_bps) * 2
slip = self.slippage_bps * 4
return fee + spread + slip
def breakdown(self, use_maker: bool = True) -> str:
if use_maker:
return (f" 手数料(4回) : "
f"{(self.spot_maker_bps + self.perp_maker_bps) * 2:>7.2f} bps\n"
f" スプレッド : {Decimal(0):>7.2f} bps(メイカー)\n"
f" 合計 : {self.round_trip(True):>7.2f} bps")
return (f" 手数料(4回) : "
f"{(self.spot_taker_bps + self.perp_taker_bps) * 2:>7.2f} bps\n"
f" スプレッド(4回) : "
f"{(self.spot_spread_bps + self.perp_spread_bps) * 2:>7.2f} bps\n"
f" スリッページ : {self.slippage_bps * 4:>7.2f} bps\n"
f" 合計 : {self.round_trip(False):>7.2f} bps")
@dataclass
class BreakevenResult:
hold_days: Decimal
interval_hours: Decimal
events: Decimal # 保有期間中の収受回数
cost_bps: Decimal
required_rate: Decimal # 1回あたりに必要なレート(比率)
required_apr: Decimal # 年率換算
def line(self) -> str:
return (f"{self.hold_days:>7}日{self.events:>8.0f}回"
f"{self.required_rate * 100:>12.5f}%"
f"{self.required_apr * 100:>11.2f}%")
def required_rate(costs: CarryCosts, hold_days: Decimal,
interval_hours: Decimal = Decimal("8"),
use_maker: bool = True) -> BreakevenResult:
"""指定期間の保有でコストを回収するために必要な1回あたりのレート。"""
cost_bps = costs.round_trip(use_maker)
events = hold_days * 24 / interval_hours
if events <= 0:
raise ValueError("保有期間が短すぎます")
rate = cost_bps / events / Decimal("10000")
apr = rate * (Decimal("8760") / interval_hours)
return BreakevenResult(hold_days=hold_days, interval_hours=interval_hours,
events=events, cost_bps=cost_bps,
required_rate=rate, required_apr=apr)
def breakeven_days(costs: CarryCosts, observed_rate: Decimal,
interval_hours: Decimal = Decimal("8"),
use_maker: bool = True) -> Decimal | None:
"""観測されたレートで、コストを回収できる日数。"""
if observed_rate <= 0:
return None
cost_bps = costs.round_trip(use_maker)
per_event_bps = observed_rate * Decimal("10000")
events_needed = cost_bps / per_event_bps
return events_needed * interval_hours / 24
def net_apr(observed_rate: Decimal, costs: CarryCosts,
hold_days: Decimal, interval_hours: Decimal = Decimal("8"),
use_maker: bool = True) -> Decimal:
"""コスト差引後の年率。これがプラスでなければ実行しない。"""
events = hold_days * 24 / interval_hours
gross_bps = observed_rate * Decimal("10000") * events
net_bps = gross_bps - costs.round_trip(use_maker)
return net_bps / Decimal("10000") / hold_days * Decimal("365")
if __name__ == "__main__":
costs = CarryCosts()
for label, maker in (("メイカー執行", True), ("テイカー執行", False)):
print(f"=== {label} のコスト内訳 ===")
print(costs.breakdown(maker))
print()
print("=== 必要な1回あたりレート(8時間間隔) ===")
print(f"{'保有':>7}{'収受回数':>8}{'必要レート':>12}{'必要年率':>11}")
print("-" * 40)
for maker in (True, False):
print(f"[{'メイカー' if maker else 'テイカー'}]")
for days in ("0.5", "1", "3", "7", "14", "30"):
print(" " + required_rate(costs, Decimal(days),
use_maker=maker).line())
print()
print("=== 観測レート別の損益分岐日数と正味年率(メイカー) ===")
print(f"{'レート':>10}{'年率(粗)':>11}{'分岐日数':>10}"
f"{'7日保有の正味年率':>18}{'30日保有':>11}")
print("-" * 60)
for r in ("0.0001", "0.0003", "0.0005", "0.001", "0.003"):
rate = Decimal(r)
gross_apr = rate * Decimal("1095") # 8h間隔の年率
bd = breakeven_days(costs, rate)
n7 = net_apr(rate, costs, Decimal("7"))
n30 = net_apr(rate, costs, Decimal("30"))
print(f"{rate * 100:>9.4f}%{gross_apr * 100:>10.1f}%"
f"{float(bd):>9.1f}日{n7 * 100:>17.1f}%{n30 * 100:>10.1f}%")
print("\n--- 結論 ---")
print(" メイカー執行でも、往復24bpsのコストがかかる。")
print(" レート0.01%(年率11%)なら回収に8日必要。")
print(" 短期で解消する前提のキャリーは成立しない。")
print(" テイカー執行では回収期間が2倍以上になり、実用性が大きく落ちる。")
この計算が示すのは明確な事実です。テイカー執行では往復コストが50bps前後になり、年率20%のキャリーでも回収に2週間以上かかります。その間に資金調達率が反転すれば赤字です。
したがってこの戦略はPostOnly(メイカー)で両脚を建てられるかが成否を分けます。約定を待つ時間と、待っている間の機会損失・レッグリスクとのトレードオフになります(サンプル4)。
"""carry_scanner.py — キャリー候補のスクリーニング
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
条件:
- 現物と無期限の両方が存在する
- 資金調達率が損益分岐を上回る
- 両市場に十分な流動性がある(記事09)
- 資金調達の符号が安定している(記事10の持続性)
"""
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal
import pandas as pd
from breakeven import CarryCosts, breakeven_days, net_apr
@dataclass
class CarryCandidate:
symbol: str
funding_rate: Decimal
interval_hours: Decimal
gross_apr: Decimal
net_apr_7d: Decimal
net_apr_30d: Decimal
breakeven_days: Decimal | None
perp_spread_bps: Decimal
spot_spread_bps: Decimal
perp_turnover: Decimal
spot_turnover: Decimal
basis_bps: Decimal # perp価格 − 現物価格
positive_ratio: float = 0.0 # 過去のプラス率(持続性)
@property
def viable(self) -> bool:
"""実行可能な候補か。"""
return (self.net_apr_7d > 0
and self.breakeven_days is not None
and self.breakeven_days <= 14)
def line(self) -> str:
bd = (f"{float(self.breakeven_days):.1f}日"
if self.breakeven_days else " - ")
mark = " *" if self.viable else " "
return (f"{mark}{self.symbol:<12}{self.funding_rate * 100:>+9.4f}%"
f"{self.gross_apr * 100:>9.1f}%{self.net_apr_7d * 100:>10.1f}%"
f"{bd:>9}{self.perp_spread_bps:>8.1f}b"
f"{self.spot_spread_bps:>8.1f}b"
f"{self.basis_bps:>+9.1f}b")
def _spread_bps(t: dict) -> Decimal:
bid = Decimal(str(t.get("bid1Price") or 0))
ask = Decimal(str(t.get("ask1Price") or 0))
if bid <= 0 or ask <= 0:
return Decimal("9999")
mid = (bid + ask) / 2
return (ask - bid) / mid * 10_000
def scan_carry_candidates(http, costs: CarryCosts | None = None,
min_perp_turnover: Decimal = Decimal("20000000"),
min_spot_turnover: Decimal = Decimal("2000000"),
max_spread_bps: Decimal = Decimal("10"),
check_persistence: bool = False
) -> list[CarryCandidate]:
"""現物と無期限の両方に上場している銘柄からキャリー候補を探す。"""
costs = costs or CarryCosts()
# 1) 無期限のティッカー(資金調達率を含む)
perp = {t["symbol"]: t for t in
http.get_tickers(category="linear")["result"]["list"]}
# 2) 現物のティッカー
spot = {t["symbol"]: t for t in
http.get_tickers(category="spot")["result"]["list"]}
# 3) 収受間隔(銘柄ごとに違う・記事10)
from annualize import fetch_funding_specs
specs = fetch_funding_specs(http, "linear")
out: list[CarryCandidate] = []
for symbol, pt in perp.items():
st = spot.get(symbol)
if st is None:
continue # 現物が無ければ組めない
spec = specs.get(symbol)
if spec is None:
continue
perp_turnover = Decimal(str(pt.get("turnover24h") or 0))
spot_turnover = Decimal(str(st.get("turnover24h") or 0))
if perp_turnover < min_perp_turnover or spot_turnover < min_spot_turnover:
continue
perp_sp = _spread_bps(pt)
spot_sp = _spread_bps(st)
if perp_sp > max_spread_bps or spot_sp > max_spread_bps:
continue
rate = Decimal(str(pt.get("fundingRate") or 0))
if rate <= 0:
continue # プラスのレートだけを対象(ショートで受け取る)
perp_price = Decimal(str(pt.get("lastPrice") or 0))
spot_price = Decimal(str(st.get("lastPrice") or 0))
basis = ((perp_price - spot_price) / spot_price * 10_000
if spot_price > 0 else Decimal(0))
# 実際のスプレッドを反映したコストで計算する
adjusted = CarryCosts(
spot_maker_bps=costs.spot_maker_bps,
perp_maker_bps=costs.perp_maker_bps,
spot_taker_bps=costs.spot_taker_bps,
perp_taker_bps=costs.perp_taker_bps,
spot_spread_bps=spot_sp, perp_spread_bps=perp_sp,
slippage_bps=costs.slippage_bps,
)
cand = CarryCandidate(
symbol=symbol, funding_rate=rate,
interval_hours=spec.interval_hours,
gross_apr=spec.annualize(rate),
net_apr_7d=net_apr(rate, adjusted, Decimal("7"),
spec.interval_hours),
net_apr_30d=net_apr(rate, adjusted, Decimal("30"),
spec.interval_hours),
breakeven_days=breakeven_days(adjusted, rate,
spec.interval_hours),
perp_spread_bps=perp_sp, spot_spread_bps=spot_sp,
perp_turnover=perp_turnover, spot_turnover=spot_turnover,
basis_bps=basis,
)
# 4) 持続性の確認(オプション・リクエスト数が増える)
if check_persistence:
try:
from funding_fetch import fetch_funding_history
hist = fetch_funding_history(http, symbol, start="2025-01-01",
verbose=False)
if not hist.empty:
cand.positive_ratio = float(
(hist["funding_rate"] > 0).mean())
except Exception:
pass
out.append(cand)
return sorted(out, key=lambda c: -c.net_apr_7d)
def report(candidates: list[CarryCandidate], top: int = 15) -> None:
print(f"{' symbol':<14}{'レート':>9}{'粗年率':>9}{'正味7d':>10}"
f"{'分岐':>9}{'perp幅':>9}{'現物幅':>9}{'ベーシス':>9}")
print("-" * 78)
for c in candidates[:top]:
print(c.line())
viable = [c for c in candidates if c.viable]
print(f"\n候補 {len(candidates)}件 / うち実行可能 {len(viable)}件")
print(" * = 正味年率がプラスかつ損益分岐14日以内")
if viable:
print(f"\n最有力: {viable[0].symbol}")
v = viable[0]
print(f" レート {v.funding_rate * 100:+.4f}% "
f"({v.interval_hours:.0f}時間ごと)")
print(f" 粗年率 {v.gross_apr * 100:.1f}% -> "
f"正味 7日保有 {v.net_apr_7d * 100:.1f}% / "
f"30日保有 {v.net_apr_30d * 100:.1f}%")
print(f" ベーシス {v.basis_bps:+.1f} bps")
else:
print("\n現時点で実行可能な候補はありません。")
print(" 資金調達率が低い局面では、この戦略は成立しません。")
if __name__ == "__main__":
from client import make_client
c = make_client()
report(scan_carry_candidates(c.http))
"""margin_calc.py — キャリーポジションの必要証拠金と清算耐性を試算する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
統合口座(UTA)では現物とperpで証拠金を共有できるため、
別口座で建てるより資金効率が良い。ただし清算リスクは残る。
"""
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal
@dataclass
class MarginPlan:
notional: Decimal # 片脚の想定元本(USDT)
spot_cost: Decimal # 現物購入に必要な資金
perp_margin: Decimal # perpショートの必要証拠金
total_required: Decimal
perp_leverage: Decimal
capital_efficiency: Decimal # 想定元本 / 必要資金
def report(self) -> str:
return "\n".join([
f" 片脚の想定元本 : {self.notional:>12,.2f} USDT",
f" 現物購入資金 : {self.spot_cost:>12,.2f} USDT",
f" perp証拠金 : {self.perp_margin:>12,.2f} USDT"
f" (レバレッジ {self.perp_leverage}x)",
f" 必要資金合計 : {self.total_required:>12,.2f} USDT",
f" 資金効率 : {self.capital_efficiency:>12.2f}倍",
])
def plan_margin(notional: Decimal,
perp_leverage: Decimal = Decimal("2")) -> MarginPlan:
"""キャリー1組に必要な資金を計算する。
現物は全額必要(レバレッジをかけない前提)。
perpショートはレバレッジで証拠金を圧縮できる。
"""
if perp_leverage <= 0:
raise ValueError("perp_leverage は正の値が必要")
spot_cost = notional # 現物は現金決済
perp_margin = notional / perp_leverage
total = spot_cost + perp_margin
return MarginPlan(
notional=notional, spot_cost=spot_cost, perp_margin=perp_margin,
total_required=total, perp_leverage=perp_leverage,
capital_efficiency=(notional / total) if total > 0 else Decimal(0),
)
def liquidation_headroom(perp_leverage: Decimal,
mmr: Decimal = Decimal("0.005")) -> Decimal:
"""perpショートが清算される価格上昇率の目安(%)。
分離マージン前提の近似。クロスマージン・統合口座では
現物側の含み益が補うため実際にはこれより余裕がある。
"""
return (Decimal(1) / perp_leverage - mmr) * 100
def stress_test(notional: Decimal, perp_leverage: Decimal,
price_moves_pct: tuple[Decimal, ...] = (
Decimal("10"), Decimal("25"), Decimal("50"),
Decimal("100"))) -> str:
"""価格が急騰したときの各脚の損益を試算する。"""
lines = [
f"想定元本 {notional:,.0f} USDT / perpレバレッジ {perp_leverage}x",
f"perp単独の清算余裕(近似): 価格 "
f"+{liquidation_headroom(perp_leverage):.1f}% で清算",
"",
f"{'価格変化':>9}{'現物損益':>13}{'perp損益':>13}"
f"{'合計':>11}{'perp証拠金比':>13}",
"-" * 60,
]
perp_margin = notional / perp_leverage
for move in price_moves_pct:
spot_pnl = notional * move / 100
perp_pnl = -notional * move / 100
total = spot_pnl + perp_pnl
# perpの含み損が証拠金に対して何%か
ratio = (-perp_pnl / perp_margin * 100) if perp_margin > 0 else Decimal(0)
flag = " <- 清算域" if ratio >= 100 else ""
lines.append(f"{move:>+8.0f}%{spot_pnl:>+13,.0f}{perp_pnl:>+13,.0f}"
f"{total:>+11,.0f}{ratio:>12.0f}%{flag}")
lines += [
"",
" 合計が常に0 = デルタニュートラルは機能している。",
" しかしperp単独では証拠金を食い尽くす場面がある。",
" 統合口座なら現物の含み益が補うが、",
" 現物とperpが別の証拠金プールなら清算される。",
]
return "\n".join(lines)
if __name__ == "__main__":
print("=== レバレッジ別の資金効率 ===")
for lev in (Decimal("1"), Decimal("2"), Decimal("3"), Decimal("5")):
plan = plan_margin(Decimal("10000"), lev)
print(f"\n[perpレバレッジ {lev}x]")
print(plan.report())
print(f" perp清算余裕(近似): "
f"+{liquidation_headroom(lev):.1f}%")
print("\n\n=== ストレステスト(レバレッジ2x) ===")
print(stress_test(Decimal("10000"), Decimal("2")))
print("\n\n=== ストレステスト(レバレッジ5x・危険) ===")
print(stress_test(Decimal("10000"), Decimal("5")))
print("\n※ 資金効率を上げるほど清算リスクが上がる。")
print(" キャリーの年率は数%〜十数%なので、")
print(" 清算リスクを取って効率を上げる意味は小さい。")
print(" レバレッジ1〜2倍に抑えるのが妥当。")
現物だけ買えてperpが売れていない状態は、ただのロングポジションです。デルタニュートラルではありません。この状態で価格が急落すれば、キャリーの年率数%とは比較にならない損失が出ます。
対策は「片脚が約定したら、もう片脚を必ず成立させる」ことです。メイカーで待つのが理想ですが、一定時間で成立しなければテイカーで確定させるほうが安全です。コストより方向リスクのほうが大きいためです。
"""leg_executor.py — 両脚の同時執行(レッグリスクの最小化)
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
方針:
1) 両脚にPostOnly指値を出す(メイカー手数料を狙う)
2) 一定時間待つ
3) 片脚だけ約定したら、残りをテイカーで即座に成立させる
4) 両脚未約定ならキャンセルして再試行
"""
from __future__ import annotations
import time
from dataclasses import dataclass, field
from decimal import Decimal
from enum import Enum
class LegState(str, Enum):
PENDING = "pending"
FILLED = "filled"
CANCELLED = "cancelled"
FAILED = "failed"
@dataclass
class Leg:
category: str
symbol: str
side: str
qty: Decimal
link_id: str
state: LegState = LegState.PENDING
filled_qty: Decimal = Decimal(0)
avg_price: Decimal = Decimal(0)
is_maker: bool = True
@property
def is_complete(self) -> bool:
return self.state is LegState.FILLED and self.filled_qty >= self.qty
@dataclass
class ExecutionResult:
legs: list[Leg]
elapsed_sec: float
forced_taker: bool = False
aborted: bool = False
notes: list[str] = field(default_factory=list)
@property
def both_filled(self) -> bool:
return all(l.is_complete for l in self.legs)
@property
def delta_imbalance(self) -> Decimal:
"""両脚の約定数量の差。0でなければデルタが偏っている。"""
if len(self.legs) != 2:
return Decimal(0)
return abs(self.legs[0].filled_qty - self.legs[1].filled_qty)
def report(self) -> str:
lines = [f"執行結果({self.elapsed_sec:.1f}秒 / "
f"{'テイカー確定あり' if self.forced_taker else '全メイカー'})"]
for l in self.legs:
lines.append(f" {l.category:<7}{l.symbol:<11}{l.side:<5}"
f"{l.state.value:<11}"
f"{l.filled_qty}/{l.qty} @ {l.avg_price}"
f"{' (Taker)' if not l.is_maker else ''}")
lines.append(f" デルタ偏り: {self.delta_imbalance}")
for n in self.notes:
lines.append(f" ! {n}")
return "\n".join(lines)
class CarryExecutor:
"""現物とperpの両脚を成立させる。"""
def __init__(self, client, registry, maker_wait_sec: float = 30.0,
poll_sec: float = 1.0):
self.c = client
self.reg = registry
self.maker_wait_sec = maker_wait_sec
self.poll_sec = poll_sec
# ---- 注文の状態確認 --------------------------------------------------
def _check(self, leg: Leg) -> None:
res = self.c.http.get_order_history(
category=leg.category, symbol=leg.symbol,
orderLinkId=leg.link_id)
rows = (res.get("result") or {}).get("list") or []
if not rows:
return
o = rows[0]
leg.filled_qty = Decimal(str(o.get("cumExecQty") or 0))
leg.avg_price = Decimal(str(o.get("avgPrice") or 0))
status = o.get("orderStatus", "")
if status == "Filled":
leg.state = LegState.FILLED
elif status in ("Cancelled", "Deactivated", "Rejected"):
leg.state = (LegState.FILLED if leg.filled_qty > 0
else LegState.CANCELLED)
def _place_maker(self, leg: Leg, price: Decimal) -> bool:
rules = self.reg.get(leg.symbol, leg.category)
from symbol_rules import round_to_step
params = {
"category": leg.category, "symbol": leg.symbol,
"side": leg.side, "orderType": "Limit",
"qty": rules.fmt_qty(leg.qty),
"price": rules.fmt_price(round_to_step(price, rules.tick_size)),
"timeInForce": "PostOnly",
"orderLinkId": leg.link_id,
}
res = self.c.http.place_order(**params)
if res["retCode"] == 0:
return True
# 110072 = 重複(既に発注済み)は成功扱い(記事17)
if res["retCode"] in (110072, 170141):
return True
print(f" [NG] {leg.symbol} {leg.side}: "
f"{res['retCode']} {res['retMsg']}")
leg.state = LegState.FAILED
return False
def _force_taker(self, leg: Leg) -> None:
"""残数量をテイカーで成立させる(方向リスクを消すため)。"""
remaining = leg.qty - leg.filled_qty
if remaining <= 0:
return
rules = self.reg.get(leg.symbol, leg.category)
# 未約定の指値をキャンセルしてから成行
self.c.http.cancel_order(category=leg.category, symbol=leg.symbol,
orderLinkId=leg.link_id)
res = self.c.http.place_order(
category=leg.category, symbol=leg.symbol, side=leg.side,
orderType="Market", qty=rules.fmt_qty(remaining),
orderLinkId=f"{leg.link_id}-t"[:36],
)
if res["retCode"] == 0:
leg.is_maker = False
leg.state = LegState.FILLED
leg.filled_qty = leg.qty
else:
leg.state = LegState.FAILED
print(f" [NG] テイカー確定に失敗 {leg.symbol}: "
f"{res['retCode']} {res['retMsg']}")
# ---- 本体 -----------------------------------------------------------
def open_carry(self, symbol: str, qty: Decimal,
force_taker_on_timeout: bool = True) -> ExecutionResult:
"""現物ロング + perpショートを建てる。"""
stamp = int(time.time())
legs = [
Leg("spot", symbol, "Buy", qty, f"carry-s-{stamp}"[:36]),
Leg("linear", symbol, "Sell", qty, f"carry-p-{stamp}"[:36]),
]
notes: list[str] = []
started = time.time()
# 1) 両脚にPostOnly指値を置く(板の内側に置いて約定を待つ)
for leg in legs:
ob = self.c.http.get_orderbook(category=leg.category,
symbol=leg.symbol,
limit=1)["result"]
price = (Decimal(ob["b"][0][0]) if leg.side == "Buy"
else Decimal(ob["a"][0][0]))
if not self._place_maker(leg, price):
# 片方の発注に失敗したら、もう片方も出さずに中止
notes.append(f"{leg.symbol} {leg.side} の発注に失敗。中止します")
for other in legs:
if other.state is LegState.PENDING:
self.c.http.cancel_order(
category=other.category, symbol=other.symbol,
orderLinkId=other.link_id)
return ExecutionResult(legs, time.time() - started,
aborted=True, notes=notes)
# 2) 約定を待つ
deadline = started + self.maker_wait_sec
while time.time() < deadline:
for leg in legs:
if leg.state is LegState.PENDING:
self._check(leg)
if all(l.is_complete for l in legs):
return ExecutionResult(legs, time.time() - started,
notes=notes)
time.sleep(self.poll_sec)
# 3) 片脚だけ約定 = 方向リスクがむき出し。即座に揃える
filled = [l for l in legs if l.filled_qty > 0]
unfilled = [l for l in legs if not l.is_complete]
if filled and unfilled and force_taker_on_timeout:
notes.append("片脚のみ約定。残脚をテイカーで確定します"
"(コストより方向リスクを優先)")
for leg in unfilled:
self._force_taker(leg)
return ExecutionResult(legs, time.time() - started,
forced_taker=True, notes=notes)
# 4) 両脚未約定 -> 全部キャンセルして再試行に回す
if not filled:
notes.append("両脚未約定。キャンセルします")
for leg in legs:
self.c.http.cancel_order(category=leg.category,
symbol=leg.symbol,
orderLinkId=leg.link_id)
leg.state = LegState.CANCELLED
return ExecutionResult(legs, time.time() - started,
aborted=True, notes=notes)
return ExecutionResult(legs, time.time() - started, notes=notes)
def close_carry(self, symbol: str, qty: Decimal) -> ExecutionResult:
"""現物を売り、perpショートを買い戻す(解消)。"""
stamp = int(time.time())
legs = [
Leg("spot", symbol, "Sell", qty, f"unwind-s-{stamp}"[:36]),
Leg("linear", symbol, "Buy", qty, f"unwind-p-{stamp}"[:36]),
]
started = time.time()
notes = ["解消時はメイカーを待たず、確実性を優先します"]
# 解消は方向リスクを残さないことが最優先なので成行で揃える
for leg in legs:
rules = self.reg.get(leg.symbol, leg.category)
params = {
"category": leg.category, "symbol": leg.symbol,
"side": leg.side, "orderType": "Market",
"qty": rules.fmt_qty(leg.qty),
"orderLinkId": leg.link_id,
}
if leg.category == "linear":
params["reduceOnly"] = True
res = self.c.http.place_order(**params)
if res["retCode"] == 0:
leg.state = LegState.FILLED
leg.filled_qty = leg.qty
leg.is_maker = False
else:
leg.state = LegState.FAILED
notes.append(f"{leg.symbol} の解消に失敗: "
f"{res['retCode']} {res['retMsg']}")
return ExecutionResult(legs, time.time() - started,
forced_taker=True, notes=notes)
if __name__ == "__main__":
from client import make_client
from symbol_rules import SymbolRegistry
c = make_client()
reg = SymbolRegistry(c.http)
ex = CarryExecutor(c, reg, maker_wait_sec=30.0)
# testnetで最小ロットを試す
print("キャリーを建てます(testnet推奨)")
result = ex.open_carry("BTCUSDT", Decimal("0.001"))
print(result.report())
if result.both_filled:
print("\n両脚成立。デルタニュートラルが構成されました")
elif result.aborted:
print("\n中止しました(ポジションは残っていません)")
else:
print("\n[警告] 片脚のみ成立。手動で確認してください")
建てた時点では数量が一致していても、価格が動くと両脚の価値がずれます。現物は数量が固定ですが、perpの想定元本は価格に比例して変わるため、放置すると方向リスクが生まれます。
"""delta_monitor.py — デルタ乖離の監視とリバランス
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
乖離が生まれる原因:
- 建てた時点の数量差(丸めや片脚の部分約定)
- 現物の手数料が基軸通貨で引かれる(受け取るBTCが少しだけ減る)
- 資金調達の受払いがUSDT側で発生する
"""
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal
@dataclass
class DeltaState:
symbol: str
spot_qty: Decimal # 現物の保有数量(基軸通貨)
perp_qty: Decimal # perpショートの数量(符号なし)
price: Decimal
spot_notional: Decimal
perp_notional: Decimal
@property
def net_qty(self) -> Decimal:
"""純デルタ(基軸通貨建て)。0が理想。"""
return self.spot_qty - self.perp_qty
@property
def net_notional(self) -> Decimal:
return self.net_qty * self.price
@property
def imbalance_ratio(self) -> Decimal:
"""想定元本に対する乖離率。"""
base = max(self.spot_notional, self.perp_notional)
if base <= 0:
return Decimal(0)
return abs(self.net_notional) / base
@property
def direction(self) -> str:
if self.net_qty > 0:
return "ロング超過(現物が多い)"
if self.net_qty < 0:
return "ショート超過(perpが多い)"
return "均衡"
def report(self) -> str:
return "\n".join([
f"{self.symbol} 価格 {self.price:,.2f}",
f" 現物 : {self.spot_qty} "
f"({self.spot_notional:>12,.2f} USDT)",
f" perp : {self.perp_qty} "
f"({self.perp_notional:>12,.2f} USDT)",
f" 純デルタ: {self.net_qty:+} "
f"({self.net_notional:>+12,.2f} USDT)",
f" 乖離率 : {self.imbalance_ratio:.3%} {self.direction}",
])
def measure_delta(client, symbol: str, base_coin: str | None = None
) -> DeltaState:
"""現物残高とperpポジションから純デルタを測る。"""
base = base_coin or symbol.replace("USDT", "").replace("USDC", "")
# 現物の保有数量はウォレット残高から取る
bal = client.http.get_wallet_balance(accountType="UNIFIED")
spot_qty = Decimal(0)
if bal.get("retCode") == 0:
for coin in bal["result"]["list"][0].get("coin", []):
if coin["coin"] == base:
spot_qty = Decimal(str(coin.get("walletBalance") or 0))
break
# perpショートの数量
perp_qty = Decimal(0)
pos = client.http.get_positions(category="linear", symbol=symbol)
if pos.get("retCode") == 0:
for p in pos["result"]["list"]:
size = Decimal(str(p.get("size") or 0))
if size > 0 and p.get("side") == "Sell":
perp_qty = size
ticker = client.http.get_tickers(category="linear",
symbol=symbol)["result"]["list"][0]
price = Decimal(str(ticker["lastPrice"]))
return DeltaState(symbol=symbol, spot_qty=spot_qty, perp_qty=perp_qty,
price=price, spot_notional=spot_qty * price,
perp_notional=perp_qty * price)
def rebalance(client, registry, state: DeltaState,
threshold: Decimal = Decimal("0.01"),
dry_run: bool = True) -> dict | None:
"""乖離が閾値を超えたらperp側を調整して均衡に戻す。
現物側を動かすと課税イベントになりうるため、
調整はperp側で行うのが実務的。
"""
if state.imbalance_ratio < threshold:
print(f" 乖離 {state.imbalance_ratio:.3%} < 閾値 "
f"{threshold:.1%}。調整不要")
return None
rules = registry.get(state.symbol, "linear")
from symbol_rules import floor_to_step
diff = state.net_qty # 正 = 現物が多い = perpショートを増やす
qty = floor_to_step(abs(diff), rules.qty_step)
if qty < rules.min_qty:
print(f" 調整量 {qty} が最小注文数量 {rules.min_qty} 未満。"
f"見送ります")
return None
# 現物が多い -> perpを追加で売る / perpが多い -> 買い戻す
side = "Sell" if diff > 0 else "Buy"
params = {
"category": "linear", "symbol": state.symbol, "side": side,
"orderType": "Market", "qty": rules.fmt_qty(qty),
"orderLinkId": f"rebal-{state.symbol[:6]}-{int(state.price)}"[:36],
}
if side == "Buy":
params["reduceOnly"] = True
print(f" 調整: perp {side} {qty} "
f"(乖離 {state.imbalance_ratio:.3%} -> 0付近)")
if dry_run:
print(" [dry-run] 実際には発注しません")
return None
return client.http.place_order(**params)
if __name__ == "__main__":
from client import make_client
from symbol_rules import SymbolRegistry
c = make_client()
reg = SymbolRegistry(c.http)
state = measure_delta(c, "BTCUSDT")
print(state.report())
print()
rebalance(c, reg, state, threshold=Decimal("0.01"), dry_run=True)
print("\n※ リバランスにも手数料がかかる。")
print(" 閾値を小さくしすぎると、調整コストで収益が消える。")
print(" 1%程度から始めて、実績を見て調整するのが実務的。")
"""exit_rules.py — キャリーポジションの撤退判定
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
撤退すべき状況:
1) 資金調達率がマイナスに転じた(受け取りから支払いに)
2) 累積受取りがコストを上回った上で、レートが低下した
3) ベーシスが異常に開いた(解消コストが膨らむ)
4) 口座維持率が危険水準(記事22)
5) 流動性が枯れた(解消できなくなる前に逃げる)
"""
from __future__ import annotations
from dataclasses import dataclass, field
from decimal import Decimal
from enum import IntEnum
class ExitAction(IntEnum):
HOLD = 0
STOP_ADDING = 1 # 新規の積み増しを止める
UNWIND_PARTIAL = 2 # 一部を解消
UNWIND_ALL = 3 # 全解消
EMERGENCY = 4 # 即座に成行で全解消
@dataclass
class CarryPosition:
symbol: str
qty: Decimal
opened_at: float
entry_cost_bps: Decimal # 建てたときに払ったコスト
collected_bps: Decimal = Decimal(0) # これまでに受け取った資金調達
funding_events: int = 0
@property
def net_bps(self) -> Decimal:
"""解消コストを考慮しない、現時点の粗利(bps)。"""
return self.collected_bps - self.entry_cost_bps
def net_after_unwind(self, unwind_cost_bps: Decimal) -> Decimal:
"""今解消した場合の正味(bps)。"""
return self.net_bps - unwind_cost_bps
@property
def hold_days(self) -> float:
import time
return (time.time() - self.opened_at) / 86400
@dataclass
class ExitDecision:
action: ExitAction
reasons: list[str] = field(default_factory=list)
def describe(self) -> str:
if self.action is ExitAction.HOLD:
return "継続"
return f"{self.action.name}: " + " / ".join(self.reasons)
@dataclass
class ExitConfig:
min_funding_rate: Decimal = Decimal("0") # これ以下で撤退検討
negative_streak_limit: int = 2 # 連続マイナス回数
max_basis_bps: Decimal = Decimal("50") # ベーシスの上限
max_account_mm_rate: Decimal = Decimal("0.35")
min_perp_turnover: Decimal = Decimal("5000000")
max_hold_days: float = 90.0
def decide_exit(pos: CarryPosition, current_rate: Decimal,
negative_streak: int, basis_bps: Decimal,
account_mm_rate: Decimal, perp_turnover: Decimal,
unwind_cost_bps: Decimal,
cfg: ExitConfig | None = None) -> ExitDecision:
cfg = cfg or ExitConfig()
reasons: list[str] = []
action = ExitAction.HOLD
def escalate(new: ExitAction, why: str) -> None:
nonlocal action
reasons.append(why)
if new > action:
action = new
# 1) 口座維持率(最優先)
if account_mm_rate >= cfg.max_account_mm_rate:
escalate(ExitAction.EMERGENCY,
f"口座維持率 {account_mm_rate:.1%} が危険水準")
# 2) 流動性の枯渇(解消できなくなる前に逃げる)
if perp_turnover < cfg.min_perp_turnover:
escalate(ExitAction.UNWIND_ALL,
f"perpの出来高が {perp_turnover:,.0f} まで低下")
# 3) 資金調達の反転
if current_rate < cfg.min_funding_rate:
if negative_streak >= cfg.negative_streak_limit:
escalate(ExitAction.UNWIND_ALL,
f"資金調達が{negative_streak}回連続でマイナス")
else:
escalate(ExitAction.STOP_ADDING,
f"資金調達がマイナス({current_rate * 100:+.4f}%)")
# 4) ベーシスの拡大(解消コストが膨らむ)
if abs(basis_bps) > cfg.max_basis_bps:
escalate(ExitAction.UNWIND_PARTIAL,
f"ベーシスが {basis_bps:+.1f} bps に拡大")
# 5) 利益確定の判断: コストを回収済みでレートが下がったなら降りる
net_now = pos.net_after_unwind(unwind_cost_bps)
if net_now > 0 and current_rate < cfg.min_funding_rate:
escalate(ExitAction.UNWIND_ALL,
f"コスト回収済み(正味 {net_now:+.1f} bps)でレート低下")
# 6) 保有期間の上限
if pos.hold_days > cfg.max_hold_days:
escalate(ExitAction.UNWIND_ALL,
f"保有 {pos.hold_days:.0f}日が上限を超過")
# 7) まだコストを回収できていない場合は粘る判断もある
if action is ExitAction.HOLD and net_now < 0:
reasons.append(f"未回収(正味 {net_now:+.1f} bps)。継続して回収を待つ")
return ExitDecision(action=action, reasons=reasons)
if __name__ == "__main__":
import time
pos = CarryPosition(
symbol="BTCUSDT", qty=Decimal("0.1"),
opened_at=time.time() - 5 * 86400, # 5日前に建てた
entry_cost_bps=Decimal("12"), # 建てコスト12bps
collected_bps=Decimal("15"), # 15bps受け取った
funding_events=15,
)
unwind_cost = Decimal("12")
print(f"{pos.symbol}: 保有 {pos.hold_days:.1f}日 / "
f"受取 {pos.collected_bps} bps / 建てコスト {pos.entry_cost_bps} bps")
print(f" 今解消した場合の正味: "
f"{pos.net_after_unwind(unwind_cost):+.1f} bps\n")
scenarios = [
("正常", Decimal("0.0001"), 0, Decimal("5"), Decimal("0.05"),
Decimal("50000000")),
("レート低下", Decimal("0.00001"), 0, Decimal("5"), Decimal("0.05"),
Decimal("50000000")),
("レート反転1回", Decimal("-0.0001"), 1, Decimal("5"),
Decimal("0.05"), Decimal("50000000")),
("レート反転2回", Decimal("-0.0002"), 2, Decimal("5"),
Decimal("0.05"), Decimal("50000000")),
("ベーシス拡大", Decimal("0.0001"), 0, Decimal("80"),
Decimal("0.05"), Decimal("50000000")),
("流動性枯渇", Decimal("0.0001"), 0, Decimal("5"), Decimal("0.05"),
Decimal("1000000")),
("維持率危険", Decimal("0.0001"), 0, Decimal("5"), Decimal("0.40"),
Decimal("50000000")),
]
print(f"{'シナリオ':<16}{'判定'}")
print("-" * 70)
for name, rate, streak, basis, mmr, turnover in scenarios:
d = decide_exit(pos, rate, streak, basis, mmr, turnover, unwind_cost)
print(f"{name:<16}{d.describe()}")
"""carry_bot.py — 資金調達率キャリーbotの本体
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
必ずtestnet/デモで十分に検証してから使用してください。
流れ:
1) 候補をスキャン(サンプル2)
2) 損益分岐を確認(サンプル1)
3) 資金と証拠金を計画(サンプル3)
4) 両脚を執行(サンプル4)
5) デルタとレートを監視(サンプル5・6)
6) 撤退条件で解消
"""
from __future__ import annotations
import logging
import time
from dataclasses import dataclass, field
from decimal import Decimal
log = logging.getLogger("carry_bot")
@dataclass
class CarryBotConfig:
max_positions: int = 3
notional_per_position: Decimal = Decimal("1000")
perp_leverage: Decimal = Decimal("2")
min_net_apr: Decimal = Decimal("0.08") # 正味年率8%以上で参入
max_breakeven_days: Decimal = Decimal("10")
delta_threshold: Decimal = Decimal("0.01")
check_interval_sec: float = 300.0
maker_wait_sec: float = 60.0
dry_run: bool = True
class CarryBot:
def __init__(self, client, registry, cfg: CarryBotConfig | None = None):
from breakeven import CarryCosts
from leg_executor import CarryExecutor
self.c = client
self.reg = registry
self.cfg = cfg or CarryBotConfig()
self.costs = CarryCosts()
self.executor = CarryExecutor(client, registry,
maker_wait_sec=self.cfg.maker_wait_sec)
self.positions: dict[str, object] = {}
self.negative_streak: dict[str, int] = {}
self.stats = {"opened": 0, "closed": 0, "rebalanced": 0,
"rejected": 0}
# ---- 参入 -----------------------------------------------------------
def try_open(self) -> None:
from carry_scanner import scan_carry_candidates
from margin_calc import plan_margin
if len(self.positions) >= self.cfg.max_positions:
return
candidates = scan_carry_candidates(self.c.http, self.costs)
for cand in candidates:
if cand.symbol in self.positions:
continue
if not cand.viable:
continue
if cand.net_apr_7d < self.cfg.min_net_apr:
continue
if (cand.breakeven_days is None
or cand.breakeven_days > self.cfg.max_breakeven_days):
continue
# 資金の確認
plan = plan_margin(self.cfg.notional_per_position,
self.cfg.perp_leverage)
bal = self.c.http.get_wallet_balance(
accountType="UNIFIED")["result"]["list"][0]
available = Decimal(str(bal.get("totalAvailableBalance") or 0))
if available < plan.total_required:
log.info("%s: 資金不足(必要 %.0f / 利用可能 %.0f)",
cand.symbol, plan.total_required, available)
continue
price = Decimal(str(
self.c.http.get_tickers(category="linear",
symbol=cand.symbol)
["result"]["list"][0]["lastPrice"]))
rules = self.reg.get(cand.symbol, "linear")
from symbol_rules import floor_to_step
qty = floor_to_step(self.cfg.notional_per_position / price,
rules.qty_step)
if qty < rules.min_qty:
continue
log.info("参入: %s 正味年率 %.1f%% / 分岐 %.1f日 / qty %s",
cand.symbol, cand.net_apr_7d * 100,
float(cand.breakeven_days), qty)
if self.cfg.dry_run:
log.info(" [dry-run] 実際には発注しません")
self.stats["rejected"] += 1
return
result = self.executor.open_carry(cand.symbol, qty)
log.info("%s", result.report())
if result.both_filled:
from exit_rules import CarryPosition
entry_cost = self.costs.round_trip(
use_maker=not result.forced_taker) / 2
self.positions[cand.symbol] = CarryPosition(
symbol=cand.symbol, qty=qty, opened_at=time.time(),
entry_cost_bps=entry_cost,
)
self.stats["opened"] += 1
return # 1周期で1つだけ建てる
# ---- 監視 -----------------------------------------------------------
def monitor(self) -> None:
from delta_monitor import measure_delta, rebalance
from exit_rules import ExitAction, decide_exit
if not self.positions:
return
bal = self.c.http.get_wallet_balance(
accountType="UNIFIED")["result"]["list"][0]
mm_rate = Decimal(str(bal.get("accountMMRate") or 0))
for symbol in list(self.positions):
pos = self.positions[symbol]
ticker = self.c.http.get_tickers(
category="linear", symbol=symbol)["result"]["list"][0]
rate = Decimal(str(ticker.get("fundingRate") or 0))
turnover = Decimal(str(ticker.get("turnover24h") or 0))
spot_t = self.c.http.get_tickers(
category="spot", symbol=symbol)["result"]["list"][0]
perp_p = Decimal(str(ticker["lastPrice"]))
spot_p = Decimal(str(spot_t["lastPrice"]))
basis = ((perp_p - spot_p) / spot_p * 10_000
if spot_p > 0 else Decimal(0))
# マイナスの連続回数を追跡
if rate < 0:
self.negative_streak[symbol] = \
self.negative_streak.get(symbol, 0) + 1
else:
self.negative_streak[symbol] = 0
# 1) デルタのリバランス
state = measure_delta(self.c, symbol)
if state.imbalance_ratio >= self.cfg.delta_threshold:
log.info("%s: デルタ乖離 %.3f%%", symbol,
state.imbalance_ratio * 100)
if not self.cfg.dry_run:
rebalance(self.c, self.reg, state,
self.cfg.delta_threshold, dry_run=False)
self.stats["rebalanced"] += 1
# 2) 撤退判定
unwind_cost = self.costs.round_trip(use_maker=False) / 2
decision = decide_exit(
pos, rate, self.negative_streak.get(symbol, 0),
basis, mm_rate, turnover, unwind_cost)
if decision.action >= ExitAction.UNWIND_ALL:
log.warning("%s: 解消します — %s", symbol,
decision.describe())
if not self.cfg.dry_run:
result = self.executor.close_carry(symbol, pos.qty)
log.info("%s", result.report())
if result.both_filled:
del self.positions[symbol]
self.stats["closed"] += 1
elif decision.action is not ExitAction.HOLD:
log.info("%s: %s", symbol, decision.describe())
# ---- 実行ループ ------------------------------------------------------
def run(self, max_cycles: int | None = None) -> None:
log.info("キャリーbotを起動します(dry_run=%s)", self.cfg.dry_run)
cycle = 0
try:
while max_cycles is None or cycle < max_cycles:
cycle += 1
log.info("--- cycle %d (保有 %d件) ---",
cycle, len(self.positions))
try:
self.monitor()
self.try_open()
except Exception:
log.exception("cycleでエラー")
time.sleep(self.cfg.check_interval_sec)
except KeyboardInterrupt:
log.info("停止要求を受信")
finally:
log.info("統計: %s", self.stats)
if self.positions:
log.warning("ポジションが %d件残っています: %s",
len(self.positions), list(self.positions))
log.warning(" 手動で解消するか、"
"再起動して監視を継続してください")
if __name__ == "__main__":
from client import make_client
from symbol_rules import SymbolRegistry
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-7s %(message)s")
c = make_client()
bot = CarryBot(c, SymbolRegistry(c.http),
CarryBotConfig(dry_run=True, max_positions=2,
notional_per_position=Decimal("500")))
bot.run(max_cycles=3)
"""carry_pnl.py — キャリー戦略の実績を集計する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
試算(サンプル1)と実測を突き合わせ、前提が正しかったかを確認する。
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import pandas as pd
def carry_actual_pnl(client, symbol: str, days: int = 30) -> dict:
"""資金調達の受取りと、両脚の手数料を集計する。"""
from exec_fetch import fetch_executions
from funding_actual import fetch_settlements
start = datetime.now(timezone.utc) - timedelta(days=days)
# 資金調達(記事10のサンプル8)
settlements = fetch_settlements(client.http, days=days)
funding = 0.0
if not settlements.empty and "symbol" in settlements.columns:
sub = settlements[settlements["symbol"] == symbol]
funding = float(sub["funding"].sum()) if not sub.empty else 0.0
# 両脚の手数料(記事18)
fees = {"spot": 0.0, "linear": 0.0}
volumes = {"spot": 0.0, "linear": 0.0}
maker_counts = {"spot": 0, "linear": 0}
total_counts = {"spot": 0, "linear": 0}
for category in ("spot", "linear"):
execs = fetch_executions(client.http, start, category=category,
symbol=symbol, verbose=False)
if execs.empty:
continue
trade = execs[execs["execType"] == "Trade"]
fees[category] = float(trade["execFee"].sum())
volumes[category] = float(trade["execValue"].sum())
maker_counts[category] = int(trade["isMaker"].sum())
total_counts[category] = len(trade)
total_fee = fees["spot"] + fees["linear"]
total_volume = volumes["spot"] + volumes["linear"]
net = funding - total_fee
return {
"symbol": symbol,
"期間(日)": days,
"資金調達 受取": round(funding, 4),
"現物 手数料": round(fees["spot"], 4),
"perp 手数料": round(fees["linear"], 4),
"手数料 合計": round(total_fee, 4),
"正味損益": round(net, 4),
"約定金額 合計": round(total_volume, 2),
"実効コスト(bps)": (round(total_fee / total_volume * 10_000, 2)
if total_volume else 0),
"Maker率(現物)": (f"{maker_counts['spot'] / total_counts['spot']:.0%}"
if total_counts["spot"] else "-"),
"Maker率(perp)": (f"{maker_counts['linear'] / total_counts['linear']:.0%}"
if total_counts["linear"] else "-"),
}
if __name__ == "__main__":
from client import make_client
c = make_client()
for symbol in ("BTCUSDT", "ETHUSDT"):
print(f"=== {symbol} ===")
try:
for k, v in carry_actual_pnl(c, symbol, days=30).items():
print(f" {k:<18}: {v}")
except Exception as e:
print(f" 集計失敗: {e}")
print()
print("※ 正味損益がマイナスなら、コスト前提が甘かったか")
print(" 保有期間が短すぎた(サンプル1の損益分岐を再確認)。")
print(" Maker率が低い場合は執行の改善余地がある(記事13)。")
get_collateral_info() で確認できます(銘柄によって扱いが違います)。投資は自己判断・自己責任でお願いいたします。本ブログの情報・ツール・ソフトウェアを参考・使用して、取引をされて何らかの損失を被られても、当サイトでは責任は一切負いません(間接的・付随的な障害・損失も含め一切の責任を負いません)。自己責任でご利用ください。
当サイトからダウンロードしたプログラム・EAは本人利用に限り完全無料でご利用いただけます。プログラムの全てまたは一部の複製・コピー・リバースエンジニアリング・改変・2次配布・商用利用・転売・販売等は一切禁止いたします。違反を発見した場合は法的処置を行います。
当サイト上に存在するソフトウェア・ツール・文章・画像・動画等の著作物を無断転載することを禁止します。引用の範囲を超えるものについては、法的処置を行います。
当サイトで配布するツール・ソフトウェアは無料提供ですのでサポートなどは行っておりません(逆に丁寧にサポートしているサイトはほとんど違法サイトですのでご注意ください。投資助言・代理業登録なしでサポート・助言することは法律で禁止されています)。万が一、ツールやソフトウェアに不具合や問題があっても当サイトでは修正・対応などができない場合があります。ご利用の環境でトラブルが発生しても当サイトでは助言することはできませんので自己責任でご利用ください。検索するなどして自分で解決してください。
当サイトでは取引所・証券会社へのリンクを貼っていますが、これは情報として提供するものであり、勧誘行為などは一切行っておりません。そのため、取引所・証券会社の登録方法・使い方・オススメ金融機関・オススメ金融商品などを説明した記事はサイト内にありませんので、ご自身で調べた上で判断ください(本当はサイト内で丁寧に説明したいのですが、仲介・取次ぎにあたる可能性があり第一種金融商品取引業登録を行っていない場合、違法となる可能性があるため出来ません。ご容赦ください)。
当サイトからリンクやバナーなどによって他のサイトに移動された場合、移動先サイトで提供される情報、サービス等について一切の責任を負いません。当サイト掲載情報やリンク先の掲載情報の合法性、正確性、道徳性、最新性、適切性、著作権の許諾や有無など、その内容については一切の保証を致しかねます。
当サイトは、ご訪問いただいた時間、訪問者のアクセス元のホスト名、IPアドレス、OSとブラウザのバージョン、リンク元のURL、モニターの情報等のパソコン環境情報を収集し、アクセス解析をしております。この目的は訪問者が検索サイトからご訪問いただいた場合、どのような検索で当サイトに来訪いただいたかの解析にのみ使用しており、第3者に公開はいたしません。また、個人を特定するものではありません。