資金調達率アービトラージbotをBybitで実装する
現物×無期限のデルタニュートラル

「現物ロング+無期限ショート」で価格変動を打ち消し、資金調達だけを受け取るのがキャリー戦略です。原理は単純ですが、4本の脚に往復コストがかかるため、年率20%のキャリーでも回収に1週間以上かかります。この記事では損益分岐の計算から、レッグリスクを抑えた執行、デルタ乖離の監視、撤退条件までをサンプルコード8本で実装します。

確認日
2026-07-30
API バージョン
Bybit V5
Python
3.10 以上
前提
統合口座(UTA)

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

目次

  1. 原理|なぜ価格変動を無視できるのか
  2. この戦略の本当のリスク
  3. 損益分岐の計算|最初にやること
  4. 候補のスクリーニング
  5. 必要証拠金と資金効率
  6. 両脚の執行|レッグリスクを抑える
  7. デルタ乖離の監視とリバランス
  8. 撤退条件
  9. 完成したbot
  10. よくある疑問Q&A
この記事の位置づけ

本記事は実装方法の解説であり、収益を約束するものではありません。「デルタニュートラルだからノーリスク」という説明を見かけますが、それは誤りです。後述するリスクを理解した上で、必ずテストネット・デモで検証してから進めてください。

原理|なぜ価格変動を無視できるのか

ポジションの構成概念
【建てるポジション】
  現物   : 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サンプル1
"""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サンプル2
"""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サンプル3
"""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サンプル4
"""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サンプル5
"""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サンプル6
"""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()}")

完成したbot

carry_bot.pyサンプル7
"""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サンプル8
"""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)。")
参考リンク・関連文献

よくある疑問Q&A

本当に「ノーリスク」で年率10%以上取れるのですか?
ノーリスクではありません。価格方向のリスクは消えますが、清算・レッグ・デルタ乖離・レート反転・流動性の5つのリスクが残ります(本文の表)。特に清算リスクは見落とされがちで、レバレッジを上げると現実的な脅威になります。
また年率の数字はコスト前であることが多い点に注意してください。サンプル1で計算したとおり、メイカー執行でも往復24bps程度かかり、短期保有では回収できません。「年率50%の銘柄があった」としても、正味では大きく下がります。
なぜ現物ではなく先物ロング+先物ショートではないのですか?
同じ銘柄の無期限先物でロングとショートを同時に持っても、資金調達を払う側と受け取る側が打ち消し合って収益がゼロになります(手数料だけ損)。
キャリーが成立するのは「資金調達が発生しない脚」と「発生する脚」を組み合わせるからです。現物には資金調達がないため、perpショート側の受取りだけが残ります。異なる限月の先物同士(カレンダースプレッド)でも似た構成は作れますが、Bybitでは現物×無期限が最も素直です。
統合口座(UTA)でないと駄目ですか?
統合口座を強く推奨します。理由は現物とperpで証拠金を共有できるためで、(1) 資金効率が上がる、(2) 現物の含み益がperpショートの含み損を相殺するため清算されにくい——という2つの利点があります。
証拠金が分離されていると、価格急騰時に現物側に含み益があるのにperpだけ清算されるという最悪の事態が起こりえます。なお現物が担保として認められるかは get_collateral_info() で確認できます(銘柄によって扱いが違います)。
複数銘柄で同時にやったほうがよいですか?
分散の効果はありますが、資金と管理コストが分散する点に注意してください。キャリーは1組あたりの収益が小さいため、資金を分けると各組が最小注文数量を満たせなくなることがあります。
また銘柄が増えるほどデルタ監視とリバランスの手間が増え、リバランスコストが積み上がります。まず1銘柄(流動性の高いBTCまたはETH)で回して実績を測り、コスト前提が正しいことを確認してから増やすのが順序です。
資金調達率が高い銘柄を狙えばよいのでは?
年率100%を超える銘柄は珍しくありませんが、それは異常な過熱を示すシグナルです。そういう銘柄では (1) 価格が急変してデルタ維持が困難、(2) 流動性が薄く解消できない、(3) レートが突然反転する、というリスクが高まります。
サンプル2のスキャナがスプレッドと出来高でフィルタしているのはこのためです。「年率が高い」より「正味年率がプラスで、確実に解消できる」ほうを優先してください。
現物を売るタイミングで課税されませんか?
日本の税務では、暗号資産の売却・交換は原則として課税イベントになります。キャリーの解消時に現物を売れば、その時点の損益が実現します。
本記事は税務の助言はできませんが、実装上の対応としてデルタのリバランスは現物ではなくperp側で行う(サンプル5)ようにしてあります。現物の売買回数を減らせば、記録と計算が単純になります。年間の損益集計とデータ作成は記事40で扱いますが、申告内容は必ず税理士と国税庁の情報で確認してください。
この戦略はいつまで機能しますか?
資金調達率がプラスに偏る構造が続く限り機能しますが、保証はありません。市場が成熟して裁定が効くようになれば、レートは損益分岐付近に収束します。
実際、主要銘柄のレートは以前より低下傾向にあります。サンプル1の損益分岐計算を定期的に回して「まだ成立するか」を確認してください。成立しなくなったら撤退するのが正しい判断です。「昔は儲かった」で続けるのが最も危険です。

注意事項・免責事項

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

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

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

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

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

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

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