Bybitのファンディングレート(資金調達率)を
APIで取得・分析するPython実践

資金調達率は無期限先物のポジションを持つ間ずっと発生するコスト(または収入)ですが、バックテストでも実運用でも無視されがちです。年率換算すると数十%に達することもあり、ロング偏重の戦略を体系的に過大評価する原因になります。この記事では全期間の履歴取得、銘柄ごとに異なる収受間隔を踏まえた正しい年率換算、持続性の検定、キャリー収益の試算までをサンプルコード8本で実装します。

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

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

目次

  1. 資金調達率の仕組みと符号の意味
  2. get_funding_rate_historyの仕様と罠
  3. 全期間の履歴を取得する
  4. 収受間隔は銘柄ごとに違う|正しい年率換算
  5. 全銘柄スキャンとランキング
  6. 持続性を検定する|次回は予測できるか
  7. キャリー収益の試算
  8. OHLCVに結合して損益に反映する
  9. 次回ファンディングの監視
  10. 実際に受け取った金額を集計する
  11. よくある疑問Q&A

資金調達率の仕組みと符号の意味

無期限先物は満期がないため、価格を現物に近づける仕組みが必要です。それが資金調達(Funding)で、一定時間ごとにロングとショートの間で資金をやり取りします。

資金調達率市場の状態ロングショート
プラス(+) 先物が現物より高い=買いが過熱 支払い 受取り
マイナス(−) 先物が現物より安い=売りが過熱 受取り 支払い

金額はポジション価値 × 資金調達率です。レバレッジには依存せず、建てている想定元本に対してかかります。

仮想通貨の資金調達率はプラスに偏る

歴史的に、仮想通貨の無期限先物の資金調達率はプラスに偏る傾向があります(レバレッジをかけて買いたい需要が構造的に強いため)。つまりロングを持ち続ける戦略は、常に少しずつコストを払っています。

これを計上しないバックテストはロング戦略を体系的に過大評価し、ショート戦略を過小評価します。年率で数%〜十数%の差が生まれるため、無期限先物で数時間以上ポジションを持つ戦略では必ず織り込んでください(記事31)。

逆にこの偏りは収益源にもなり、「現物ロング+perpショート」で資金調達を受け取り続けるキャリー戦略が成立します(記事35)。

get_funding_rate_historyの仕様と罠

パラメータ必須内容
category必須linear / inverse(現物・オプションには存在しない)
symbol必須全銘柄一括取得はできない。1銘柄ずつ呼ぶ
startTime任意単独指定はエラーになる
endTime任意この時刻までの直近200件が返る
limit任意1〜200(既定200)
startTimeだけを渡すとエラーになる

公式ドキュメントに「startTime のみを渡すとエラーが返る」と明記されています。「2024年以降を取りたい」と考えて startTime を指定すると失敗します。

正しい取得方法はendTime を使って新しい方から遡ることです(記事07のklineと同じパターン)。startTime は「ここまで遡ったら終了」というループの終了条件として使い、リクエストには渡しません。

レスポンスは symbol / fundingRate / fundingRateTimestamp の3フィールドのみで、新しい順(降順)に返ります。

全期間の履歴を取得する

funding_fetch.pyサンプル1
"""funding_fetch.py — 資金調達率の履歴を全期間取得する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

重要: startTime単独指定はエラーになるため、endTimeで遡る方式を使う。
"""
from __future__ import annotations

import time
from datetime import datetime, timezone
from pathlib import Path

import pandas as pd
from pybit.unified_trading import HTTP

MAX_LIMIT = 200
CACHE_DIR = Path("data/funding")


def to_ms(dt: str | datetime | None) -> int | None:
    if dt is None:
        return None
    if isinstance(dt, str):
        dt = datetime.fromisoformat(dt)
    if dt.tzinfo is None:
        dt = dt.replace(tzinfo=timezone.utc)
    return int(dt.timestamp() * 1000)


def fetch_funding_history(http: HTTP, symbol: str,
                          start: str | datetime | None = None,
                          end: str | datetime | None = None,
                          category: str = "linear",
                          sleep: float = 0.1,
                          verbose: bool = True) -> pd.DataFrame:
    """全期間の資金調達率を取得する。

    startを省略すると取引所が持つ最古まで遡る。
    """
    start_ms = to_ms(start) or 0
    cursor_end = to_ms(end) or int(time.time() * 1000)

    rows: list[dict] = []
    pages = 0
    prev_oldest: int | None = None

    while True:
        # startTimeは渡さない(単独指定はエラー、併用しても遡れない)
        res = http.get_funding_rate_history(
            category=category, symbol=symbol,
            endTime=cursor_end, limit=MAX_LIMIT,
        )
        if res["retCode"] != 0:
            raise RuntimeError(
                f"get_funding_rate_history失敗({symbol}): "
                f"{res['retCode']} {res['retMsg']}"
            )

        page = res["result"]["list"]
        if not page:
            break

        # 降順で返るので最後の要素が最古
        oldest = int(page[-1]["fundingRateTimestamp"])

        # カーソルが進まなくなったら打ち切る(無限ループ防止)
        if prev_oldest is not None and oldest >= prev_oldest:
            break
        prev_oldest = oldest

        rows.extend(page)
        pages += 1
        if verbose and pages % 20 == 0:
            print(f"  {pages}ページ / {len(rows)}件  最古 "
                  f"{datetime.fromtimestamp(oldest / 1000, timezone.utc):%Y-%m-%d}")

        if oldest <= start_ms or len(page) < MAX_LIMIT:
            break

        cursor_end = oldest - 1
        time.sleep(sleep)

    if not rows:
        return pd.DataFrame(columns=["funding_rate"],
                            index=pd.DatetimeIndex([], tz="UTC", name="ts"))

    df = pd.DataFrame(rows)
    df["ts"] = pd.to_datetime(df["fundingRateTimestamp"].astype("int64"),
                              unit="ms", utc=True)
    df["funding_rate"] = df["fundingRate"].astype(float)
    df = (df[["ts", "funding_rate"]]
          .drop_duplicates(subset="ts")
          .set_index("ts")
          .sort_index())

    if start_ms:
        df = df[df.index >= pd.Timestamp(start_ms, unit="ms", tz="UTC")]

    if verbose:
        print(f"[完了] {symbol}: {len(df)}件  "
              f"{df.index[0]:%Y-%m-%d} 〜 {df.index[-1]:%Y-%m-%d}")
    return df


def load_or_fetch(http: HTTP, symbol: str, start: str = "2023-01-01",
                  category: str = "linear",
                  verbose: bool = False) -> pd.DataFrame:
    """Parquetキャッシュ付きの取得(差分更新)。"""
    CACHE_DIR.mkdir(parents=True, exist_ok=True)
    path = CACHE_DIR / f"{category}_{symbol}.parquet"

    existing = pd.read_parquet(path) if path.exists() else pd.DataFrame()

    fetch_from = (existing.index[-1].isoformat()
                  if not existing.empty else start)
    fresh = fetch_funding_history(http, symbol, start=fetch_from,
                                  category=category, verbose=verbose)

    merged = pd.concat([existing, fresh]) if not existing.empty else fresh
    merged = merged[~merged.index.duplicated(keep="last")].sort_index()

    tmp = path.with_suffix(".tmp")
    merged.to_parquet(tmp, compression="zstd")
    tmp.replace(path)
    return merged


if __name__ == "__main__":
    http = HTTP(testnet=False)

    df = fetch_funding_history(http, "BTCUSDT", start="2024-01-01")
    print(df.head())
    print(f"\n平均 {df['funding_rate'].mean():+.6%}  "
          f"標準偏差 {df['funding_rate'].std():.6%}")
    print(f"プラスの割合 {(df['funding_rate'] > 0).mean():.1%}")

    # startTime単独指定がエラーになることの確認
    print("\n--- startTime単独指定の挙動 ---")
    res = http.get_funding_rate_history(
        category="linear", symbol="BTCUSDT",
        startTime=int(pd.Timestamp("2025-01-01").timestamp() * 1000),
    )
    print(f"  retCode={res['retCode']} retMsg={res['retMsg']}")
    print("  -> endTimeで遡る方式を使う必要がある")

収受間隔は銘柄ごとに違う|正しい年率換算

「資金調達は8時間ごと」は思い込みです。Bybitでは銘柄によって8時間・4時間・1時間などが混在します。instruments-info の fundingInterval(分単位)から取得してください。

annualize.pyサンプル2
"""annualize.py — 収受間隔を踏まえた正しい年率換算
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

よくある誤り: すべての銘柄を「8時間ごと(1日3回)」と決め打つこと。
             実際は4時間・1時間の銘柄もあり、その場合2〜8倍ずれる。
"""
from __future__ import annotations

from dataclasses import dataclass
from decimal import Decimal

import pandas as pd
from pybit.unified_trading import HTTP

HOURS_PER_YEAR = Decimal("8760")     # 24 * 365


@dataclass(frozen=True)
class FundingSpec:
    symbol: str
    interval_minutes: int

    @property
    def interval_hours(self) -> Decimal:
        return Decimal(self.interval_minutes) / 60

    @property
    def times_per_day(self) -> Decimal:
        return Decimal(24) / self.interval_hours

    @property
    def times_per_year(self) -> Decimal:
        return HOURS_PER_YEAR / self.interval_hours

    def annualize(self, rate: float | Decimal) -> Decimal:
        """1回分のレートを年率に換算する(単利)。"""
        return Decimal(str(rate)) * self.times_per_year

    def annualize_compound(self, rate: float | Decimal) -> Decimal:
        """複利での年率換算。レートが大きい場合は差が出る。"""
        r = Decimal(str(rate))
        n = int(self.times_per_year)
        return (Decimal(1) + r) ** n - Decimal(1)


def fetch_funding_specs(http: HTTP, category: str = "linear"
                        ) -> dict[str, FundingSpec]:
    """全銘柄の収受間隔を取得する(1〜2リクエストで済む)。"""
    specs: dict[str, FundingSpec] = {}
    cursor = None
    while True:
        kw: dict = {"category": category, "limit": 1000}
        if cursor:
            kw["cursor"] = cursor
        res = http.get_instruments_info(**kw)
        if res["retCode"] != 0:
            raise RuntimeError(f"instruments-info失敗: {res['retMsg']}")
        for info in res["result"]["list"]:
            minutes = int(info.get("fundingInterval") or 0)
            if minutes > 0:
                specs[info["symbol"]] = FundingSpec(info["symbol"], minutes)
        cursor = res["result"].get("nextPageCursor")
        if not cursor:
            break
    return specs


def interval_distribution(specs: dict[str, FundingSpec]) -> pd.Series:
    """収受間隔の分布。8時間以外がどれだけあるかを確認する。"""
    s = pd.Series([sp.interval_hours for sp in specs.values()],
                  dtype="float64")
    return s.value_counts().sort_index()


if __name__ == "__main__":
    http = HTTP(testnet=False)
    specs = fetch_funding_specs(http)

    print(f"資金調達がある銘柄: {len(specs)}件\n")
    print("収受間隔の分布:")
    for hours, count in interval_distribution(specs).items():
        print(f"  {hours:>5.1f}時間ごと(1日{24 / hours:>4.0f}回): "
              f"{count:>4}銘柄")

    print("\n--- 同じレート0.01%でも、間隔が違えば年率が変わる ---")
    rate = Decimal("0.0001")     # 0.01%
    print(f"{'間隔':>8}{'年間回数':>10}{'単利年率':>12}{'複利年率':>12}")
    print("-" * 44)
    for minutes in (60, 240, 480):
        sp = FundingSpec("SAMPLE", minutes)
        print(f"{sp.interval_hours:>6.0f}h{sp.times_per_year:>10.0f}"
              f"{sp.annualize(rate) * 100:>11.2f}%"
              f"{sp.annualize_compound(rate) * 100:>11.2f}%")

    print("\n※ 8時間前提で計算すると、1時間間隔の銘柄では年率を")
    print("  8分の1に見誤る。必ずfundingIntervalを取得すること。")

    for sym in ("BTCUSDT", "ETHUSDT"):
        if sym in specs:
            sp = specs[sym]
            print(f"\n{sym}: {sp.interval_hours:.0f}時間ごと "
                  f"(年間{sp.times_per_year:.0f}回)")

全銘柄スキャンとランキング

get_tickers は全銘柄の現在の資金調達率と次回収受時刻を1リクエストで返します。履歴が要らない用途ならこれが最も効率的です。

funding_scan.pyサンプル3
"""funding_scan.py — 全銘柄の資金調達率をスキャンしてランキングする
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

get_tickers(1リクエスト)+ instruments-info(1〜2リクエスト)だけで
全銘柄の年率換算ランキングが作れる。履歴取得は不要。
"""
from __future__ import annotations

from datetime import datetime, timezone

import pandas as pd
from pybit.unified_trading import HTTP

from annualize import fetch_funding_specs


def scan_funding(http: HTTP, category: str = "linear",
                 min_turnover: float = 10_000_000) -> pd.DataFrame:
    """全銘柄の現在の資金調達率を年率換算して返す。"""
    specs = fetch_funding_specs(http, category)

    res = http.get_tickers(category=category)
    if res["retCode"] != 0:
        raise RuntimeError(f"get_tickers失敗: {res['retMsg']}")

    rows = []
    now_ms = int(datetime.now(timezone.utc).timestamp() * 1000)

    for t in res["result"]["list"]:
        sym = t["symbol"]
        spec = specs.get(sym)
        if spec is None:
            continue
        rate = float(t.get("fundingRate") or 0)
        turnover = float(t.get("turnover24h") or 0)
        if turnover < min_turnover:
            continue

        next_ms = int(t.get("nextFundingTime") or 0)
        mins_to_next = (next_ms - now_ms) / 60000 if next_ms else float("nan")

        bid = float(t.get("bid1Price") or 0)
        ask = float(t.get("ask1Price") or 0)
        last = float(t.get("lastPrice") or 0)
        spread_bps = (ask - bid) / last * 10_000 if last and bid and ask else float("nan")

        rows.append({
            "symbol": sym,
            "funding_rate": rate,
            "interval_h": float(spec.interval_hours),
            "apr_pct": float(spec.annualize(rate)) * 100,
            "next_in_min": mins_to_next,
            "turnover24h": turnover,
            "spread_bps": spread_bps,
            "oi_value": float(t.get("openInterestValue") or 0),
        })

    return pd.DataFrame(rows)


def report(df: pd.DataFrame, top: int = 12) -> None:
    cols = ["symbol", "funding_rate", "interval_h", "apr_pct",
            "next_in_min", "spread_bps", "turnover24h"]

    with pd.option_context("display.float_format", lambda v: f"{v:,.4f}"):
        print("=== 資金調達率 年率上位(ショート側が受け取る) ===")
        print(df.nlargest(top, "apr_pct")[cols].to_string(index=False))

        print("\n=== 資金調達率 年率下位(ロング側が受け取る) ===")
        print(df.nsmallest(top, "apr_pct")[cols].to_string(index=False))

    print(f"\n--- 全体の統計({len(df)}銘柄) ---")
    print(f"  年率の中央値   : {df['apr_pct'].median():+.2f}%")
    print(f"  年率の平均     : {df['apr_pct'].mean():+.2f}%")
    print(f"  プラスの割合   : {(df['funding_rate'] > 0).mean():.1%}")
    print(f"  |年率|>20% の数: {(df['apr_pct'].abs() > 20).sum()}銘柄")

    # アービトラージ候補(記事35): 年率が高くスプレッドが狭い銘柄
    cand = df[(df["apr_pct"] > 15) & (df["spread_bps"] < 8)]
    if not cand.empty:
        print(f"\n--- キャリー候補(年率>15% かつ スプレッド<8bps) ---")
        print(cand.nlargest(8, "apr_pct")[
            ["symbol", "apr_pct", "spread_bps", "turnover24h"]
        ].to_string(index=False))
        print("  ※ 高い資金調達率は、それ自体がリスクの高さを示している。")
        print("    実行可否は記事35の損益分岐計算で判断すること。")


if __name__ == "__main__":
    http = HTTP(testnet=False)
    report(scan_funding(http))
「年率が高い」は「安全に儲かる」ではない

年率100%を超える銘柄は珍しくありませんが、それはその銘柄が異常に過熱していることのシグナルです。価格が急変してデルタニュートラルを維持できなくなる、流動性が枯れて解消できない、資金調達率が突然反転する——といったリスクと引き換えです。

スクリーニングの段階でスプレッドと出来高でフィルタする(上のコードで spread_bps < 8 を条件にしている)のが最低限の防御です。実行判断は記事35の損益分岐計算まで進んでから行ってください。

持続性を検定する|次回は予測できるか

「今プラスなら次回もプラスか?」——これが分かれば、キャリー戦略のエントリー判断に使えます。自己相関と符号の的中率で測ります。

funding_persistence.pyサンプル4
"""funding_persistence.py — 資金調達率の持続性を統計的に検定する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

目的: 「今の資金調達率から次回を予測できるか」を数値で確認する。
      予測できるなら、キャリー戦略のエントリー判断に使える。
"""
from __future__ import annotations

from dataclasses import dataclass

import numpy as np
import pandas as pd


@dataclass
class PersistenceStats:
    symbol: str
    n: int
    mean: float
    std: float
    positive_ratio: float
    acf1: float                  # 1期ラグの自己相関
    acf3: float
    sign_hit_rate: float         # 符号が次回も同じである割合
    half_life: float | None      # 平均回帰の半減期(回数)
    max_run_positive: int        # プラスが連続した最長回数

    def line(self) -> str:
        hl = f"{self.half_life:.1f}回" if self.half_life else "  -  "
        return (f"{self.symbol:<12}{self.n:>7}{self.mean * 100:>+9.4f}%"
                f"{self.positive_ratio:>8.1%}{self.acf1:>8.3f}"
                f"{self.sign_hit_rate:>9.1%}{hl:>8}"
                f"{self.max_run_positive:>7}")


def longest_run(series: pd.Series, positive: bool = True) -> int:
    """同じ符号が連続した最長回数。"""
    mask = (series > 0) if positive else (series < 0)
    best = run = 0
    for v in mask:
        run = run + 1 if v else 0
        best = max(best, run)
    return best


def ou_half_life(series: pd.Series) -> float | None:
    """AR(1)係数から平均回帰の半減期を求める。

    x_t = a + b * x_{t-1} + e  のとき、半減期 = -ln(2) / ln(b)
    b が1に近いほど持続的、0に近いほどランダム。
    """
    x = series.dropna()
    if len(x) < 30:
        return None
    lag = x.shift(1).dropna()
    y = x.loc[lag.index]
    b = np.polyfit(lag.values, y.values, 1)[0]
    if not (0 < b < 1):
        return None
    return float(-np.log(2) / np.log(b))


def analyse_persistence(df: pd.DataFrame, symbol: str,
                        col: str = "funding_rate") -> PersistenceStats:
    s = df[col].dropna()
    if len(s) < 10:
        raise ValueError(f"{symbol}: データが不足しています ({len(s)}件)")

    sign = np.sign(s)
    # 符号が次回も同じである割合(0を除く)
    pairs = pd.DataFrame({"now": sign, "next": sign.shift(-1)}).dropna()
    pairs = pairs[(pairs["now"] != 0) & (pairs["next"] != 0)]
    hit = (pairs["now"] == pairs["next"]).mean() if len(pairs) else float("nan")

    return PersistenceStats(
        symbol=symbol, n=len(s),
        mean=float(s.mean()), std=float(s.std()),
        positive_ratio=float((s > 0).mean()),
        acf1=float(s.autocorr(lag=1)),
        acf3=float(s.autocorr(lag=3)),
        sign_hit_rate=float(hit),
        half_life=ou_half_life(s),
        max_run_positive=longest_run(s, positive=True),
    )


def sign_transition_matrix(df: pd.DataFrame,
                           col: str = "funding_rate") -> pd.DataFrame:
    """符号の遷移確率。プラスの後にプラスが来る確率などが分かる。"""
    sign = np.sign(df[col].dropna())
    cur = sign.iloc[:-1].reset_index(drop=True)
    nxt = sign.iloc[1:].reset_index(drop=True)
    tab = pd.crosstab(cur, nxt, normalize="index")
    tab.index = [{-1.0: "現在マイナス", 0.0: "現在ゼロ",
                  1.0: "現在プラス"}.get(i, str(i)) for i in tab.index]
    tab.columns = [{-1.0: "次マイナス", 0.0: "次ゼロ",
                    1.0: "次プラス"}.get(c, str(c)) for c in tab.columns]
    return tab


if __name__ == "__main__":
    from pybit.unified_trading import HTTP

    from funding_fetch import fetch_funding_history

    http = HTTP(testnet=False)
    symbols = ("BTCUSDT", "ETHUSDT", "SOLUSDT", "DOGEUSDT")

    print(f"{'symbol':<12}{'件数':>7}{'平均':>10}{'正の割合':>8}"
          f"{'ACF(1)':>8}{'符号的中':>9}{'半減期':>8}{'最長連続':>7}")
    print("-" * 70)

    results = []
    for sym in symbols:
        try:
            df = fetch_funding_history(http, sym, start="2024-01-01",
                                       verbose=False)
            st = analyse_persistence(df, sym)
            results.append((sym, df, st))
            print(st.line())
        except Exception as e:
            print(f"{sym:<12} 取得失敗: {e}")

    if results:
        sym, df, st = results[0]
        print(f"\n=== {sym} の符号遷移確率 ===")
        print(sign_transition_matrix(df).to_string(
            float_format=lambda v: f"{v:.1%}"))

    print("\n--- 解釈 ---")
    print("  ACF(1)が高く符号的中率が60%を超えるなら、資金調達率は持続的。")
    print("  つまり『今プラスなら次もプラス』が期待でき、")
    print("  キャリー戦略(記事35)のエントリー判断に使える。")
    print("  ただし持続性が高いことは『反転しない』を意味しない。")
    print("  最長連続回数の後には必ず反転が来ている点に注意。")

キャリー収益の試算

「現物ロング+perpショート」で資金調達を受け取り続けた場合の収益を、コストを差し引いた上で試算します。

carry_sim.pyサンプル5
"""carry_sim.py — デルタニュートラル・キャリーの収益試算
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

構成: 現物ロング + 無期限ショート(価格変動の影響を打ち消す)
収益: 資金調達の受取り − 往復手数料 − スプレッド
"""
from __future__ import annotations

from dataclasses import dataclass
from decimal import Decimal

import pandas as pd


@dataclass
class CarryCost:
    """往復のコスト(bps単位)。実測値を入れること。"""

    spot_taker_bps: Decimal = Decimal("10.0")     # 現物テイカー 0.1%
    perp_taker_bps: Decimal = Decimal("5.5")      # perpテイカー 0.055%
    spot_maker_bps: Decimal = Decimal("10.0")
    perp_maker_bps: Decimal = Decimal("2.0")
    spread_bps: Decimal = Decimal("2.0")          # 片道のスプレッド損失

    def entry_exit_bps(self, use_maker: bool = False) -> Decimal:
        """建てて解消するまでの総コスト(bps)。

        現物とperpの両脚で、それぞれ建て・解消の2回ずつ発生する。
        """
        if use_maker:
            legs = self.spot_maker_bps + self.perp_maker_bps
            spread = Decimal(0)          # メイカーならスプレッドは払わない
        else:
            legs = self.spot_taker_bps + self.perp_taker_bps
            spread = self.spread_bps * 2
        return legs * 2 + spread


@dataclass
class CarryResult:
    symbol: str
    days: float
    funding_collected_bps: Decimal
    cost_bps: Decimal
    net_bps: Decimal
    apr_pct: Decimal
    breakeven_days: Decimal | None
    events: int

    def report(self) -> str:
        be = (f"{self.breakeven_days:.1f}日" if self.breakeven_days
              else "回収不能")
        sign = "利益" if self.net_bps > 0 else "損失"
        return (
            f"{self.symbol}  保有 {self.days:.1f}日({self.events}回収受)\n"
            f"  資金調達の受取り : {self.funding_collected_bps:>+9.2f} bps\n"
            f"  往復コスト       : {-self.cost_bps:>+9.2f} bps\n"
            f"  正味             : {self.net_bps:>+9.2f} bps  ({sign})\n"
            f"  年率換算         : {self.apr_pct:>+9.2f} %\n"
            f"  損益分岐         : {be}(この期間より短いと赤字)"
        )


def simulate_carry(funding: pd.DataFrame, symbol: str,
                   cost: CarryCost | None = None,
                   use_maker: bool = False,
                   start: str | None = None,
                   end: str | None = None) -> CarryResult:
    """実際の資金調達履歴から、キャリーの正味収益を計算する。

    ショート側なので、資金調達率がプラスのとき受け取る。
    """
    cost = cost or CarryCost()
    s = funding["funding_rate"]
    if start:
        s = s[s.index >= start]
    if end:
        s = s[s.index <= end]
    if s.empty:
        raise ValueError("対象期間にデータがありません")

    days = (s.index[-1] - s.index[0]).total_seconds() / 86400
    if days <= 0:
        days = 1 / 24

    # perpショートは、レートがプラスのとき受け取る(符号そのまま)
    collected = Decimal(str(s.sum())) * 10_000        # bps
    total_cost = cost.entry_exit_bps(use_maker)
    net = collected - total_cost

    apr = net / Decimal(str(days)) * Decimal("365") / 100

    # 1日あたりの平均受取りで、コストを何日で回収できるか
    per_day = collected / Decimal(str(days)) if days else Decimal(0)
    breakeven = (total_cost / per_day) if per_day > 0 else None

    return CarryResult(symbol=symbol, days=days,
                       funding_collected_bps=collected,
                       cost_bps=total_cost, net_bps=net, apr_pct=apr,
                       breakeven_days=breakeven, events=len(s))


def required_rate_for_breakeven(cost: CarryCost, hold_days: float,
                                interval_hours: float,
                                use_maker: bool = False) -> Decimal:
    """指定期間の保有でコストを回収するために必要な1回あたりのレート。"""
    total_cost_bps = cost.entry_exit_bps(use_maker)
    events = Decimal(str(hold_days)) * 24 / Decimal(str(interval_hours))
    if events <= 0:
        return Decimal(0)
    return total_cost_bps / events / 10_000     # 比率に戻す


if __name__ == "__main__":
    from pybit.unified_trading import HTTP

    from annualize import fetch_funding_specs
    from funding_fetch import fetch_funding_history

    http = HTTP(testnet=False)
    specs = fetch_funding_specs(http)

    for sym in ("BTCUSDT", "ETHUSDT"):
        try:
            df = fetch_funding_history(http, sym, start="2025-01-01",
                                       verbose=False)
        except Exception as e:
            print(f"{sym}: 取得失敗 {e}")
            continue

        print("=" * 58)
        for label, maker in (("テイカー執行", False), ("メイカー執行", True)):
            r = simulate_carry(df, sym, use_maker=maker)
            print(f"[{label}]")
            print(r.report())
            print()

    print("=" * 58)
    print("損益分岐に必要な1回あたりのレート(保有期間別・8時間間隔)")
    cost = CarryCost()
    print(f"{'保有':>8}{'テイカー':>14}{'メイカー':>14}")
    print("-" * 38)
    for d in (0.5, 1, 3, 7, 30):
        t = required_rate_for_breakeven(cost, d, 8.0, False)
        m = required_rate_for_breakeven(cost, d, 8.0, True)
        print(f"{d:>6}日{t * 100:>13.4f}%{m * 100:>13.4f}%")

    print("\n※ 短期保有ではコストの回収が難しい。")
    print("  1日以内で解消する前提のキャリーは、ほぼ成立しない。")
往復コストが4本の脚に発生する

デルタニュートラルなキャリーでは、現物の建て・解消、perpの建て・解消の4回の執行が発生します。全テイカーだと合計30bps前後になり、年率20%のキャリーでも回収に1週間以上かかります。

だからキャリー戦略ではメイカー執行(PostOnly)が事実上必須です。上のコードでテイカーとメイカーを比較すると、損益分岐日数が数倍変わることが確認できます。実装は記事35で扱います。

OHLCVに結合して損益に反映する

funding_merge.pyサンプル6
"""funding_merge.py — 資金調達をOHLCVに結合し、損益への影響を可視化する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

バックテスト(記事31)に資金調達を織り込む前段。
「ロングを持ち続けた場合、どれだけ削られたか」を実データで確認する。
"""
from __future__ import annotations

import pandas as pd


def attach_funding(bars: pd.DataFrame, funding: pd.DataFrame) -> pd.DataFrame:
    """足に、その足の区間で発生した資金調達率を割り当てる。

    足の区間 (prev, current] に含まれる収受を合計する。
    複数回入る場合(1時間間隔の銘柄で4時間足など)にも対応。
    """
    out = bars.copy()
    rates = funding["funding_rate"]

    # 各収受を、それが属する足にマッピングする
    idx = out.index
    assigned = pd.Series(0.0, index=idx, name="funding_rate")
    counts = pd.Series(0, index=idx, name="funding_events")

    # searchsorted で「その収受時刻以上の最初の足」を求める
    positions = idx.searchsorted(rates.index, side="left")
    for pos, rate in zip(positions, rates.values):
        if pos >= len(idx):
            continue
        assigned.iloc[pos] += float(rate)
        counts.iloc[pos] += 1

    out["funding_rate"] = assigned
    out["funding_events"] = counts
    return out


def long_hold_impact(merged: pd.DataFrame) -> pd.DataFrame:
    """ロングを持ち続けた場合の、価格リターンと資金調達コストを分解する。"""
    df = merged.copy()
    df["price_return"] = df["close"].pct_change().fillna(0)
    # ロングは資金調達率がプラスのとき支払う(マイナスのリターン)
    df["funding_cost"] = -df["funding_rate"]
    df["net_return"] = df["price_return"] + df["funding_cost"]

    df["cum_price"] = (1 + df["price_return"]).cumprod() - 1
    df["cum_funding"] = df["funding_cost"].cumsum()
    df["cum_net"] = (1 + df["net_return"]).cumprod() - 1
    return df


def summarise(df: pd.DataFrame, label: str = "") -> str:
    days = (df.index[-1] - df.index[0]).total_seconds() / 86400
    price = df["cum_price"].iloc[-1]
    fund = df["cum_funding"].iloc[-1]
    net = df["cum_net"].iloc[-1]
    events = int(df["funding_events"].sum())

    share = abs(fund) / abs(price) * 100 if price else float("nan")
    return "\n".join([
        f"{label}  {df.index[0]:%Y-%m-%d} 〜 {df.index[-1]:%Y-%m-%d}"
        f"({days:.0f}日 / 収受{events}回)",
        f"  価格リターン       : {price:>+9.2%}",
        f"  資金調達コスト     : {fund:>+9.2%}",
        f"  正味リターン       : {net:>+9.2%}",
        f"  年率の資金調達負担 : {fund / days * 365:>+9.2%}",
        f"  価格リターンに対する資金調達の比率: {share:>6.1f}%",
    ])


if __name__ == "__main__":
    from pybit.unified_trading import HTTP

    from funding_fetch import fetch_funding_history
    from kline_fetch import fetch_klines

    http = HTTP(testnet=False)
    SYMBOL, START = "BTCUSDT", "2025-01-01"

    bars = fetch_klines(http, SYMBOL, interval="60", start=START,
                        verbose=False)
    funding = fetch_funding_history(http, SYMBOL, start=START, verbose=False)

    merged = attach_funding(bars, funding)
    impact = long_hold_impact(merged)

    print(summarise(impact, f"{SYMBOL} ロング保有"))

    print("\n--- 月別の資金調達負担 ---")
    monthly = impact["funding_cost"].resample("ME").sum()
    for ts, v in monthly.items():
        bar = "#" * int(abs(v) * 2000)
        print(f"  {ts:%Y-%m}  {v:>+8.3%}  {bar}")

    print("\n※ この負担を計上しないバックテストは、")
    print("  ロング戦略の成績を年率で数%〜十数%過大評価する。")

次回ファンディングの監視

funding_monitor.pyサンプル7
"""funding_monitor.py — 次回収受の直前に、支払いを避ける/受け取りを狙う
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

用途:
  - 収受直前に不利なポジションを畳む(支払いを回避)
  - 有利な側のポジションを収受直前に建てる
注意: 収受狙いの短期売買は、往復手数料が資金調達を上回ることが多い。
      サンプル5の損益分岐計算で必ず確認すること。
"""
from __future__ import annotations

import time
from dataclasses import dataclass
from datetime import datetime, timezone
from decimal import Decimal


@dataclass
class FundingEvent:
    symbol: str
    rate: Decimal
    next_time_ms: int
    interval_hours: Decimal

    @property
    def minutes_to_next(self) -> float:
        now_ms = int(time.time() * 1000)
        return (self.next_time_ms - now_ms) / 60000

    @property
    def next_at(self) -> datetime:
        return datetime.fromtimestamp(self.next_time_ms / 1000, timezone.utc)

    @property
    def apr_pct(self) -> Decimal:
        times = Decimal("8760") / self.interval_hours
        return self.rate * times * 100

    def cost_bps_for(self, is_long: bool) -> Decimal:
        """このポジション方向で、次回に支払う(マイナスなら受け取る)bps。"""
        return self.rate * 10_000 * (1 if is_long else -1)


def upcoming_funding(http, symbols: list[str], specs: dict,
                     category: str = "linear") -> list[FundingEvent]:
    res = http.get_tickers(category=category)
    if res["retCode"] != 0:
        raise RuntimeError(f"get_tickers失敗: {res['retMsg']}")

    wanted = set(symbols)
    out: list[FundingEvent] = []
    for t in res["result"]["list"]:
        if t["symbol"] not in wanted:
            continue
        spec = specs.get(t["symbol"])
        if spec is None:
            continue
        out.append(FundingEvent(
            symbol=t["symbol"],
            rate=Decimal(str(t.get("fundingRate") or 0)),
            next_time_ms=int(t.get("nextFundingTime") or 0),
            interval_hours=spec.interval_hours,
        ))
    return sorted(out, key=lambda e: e.next_time_ms)


def check_positions_before_funding(client, specs: dict,
                                   warn_minutes: float = 15.0,
                                   warn_bps: Decimal = Decimal("3.0")
                                   ) -> list[str]:
    """保有ポジションについて、次回収受の支払い額を警告する。"""
    from position import fetch_positions

    positions = fetch_positions(client.http)
    if not positions:
        return []

    events = {e.symbol: e for e in upcoming_funding(
        client.http, [p.symbol for p in positions], specs)}

    warnings: list[str] = []
    for p in positions:
        ev = events.get(p.symbol)
        if ev is None:
            continue
        cost_bps = ev.cost_bps_for(p.is_long)
        cost_usd = p.notional * cost_bps / 10_000

        if ev.minutes_to_next <= warn_minutes and cost_bps >= warn_bps:
            warnings.append(
                f"{p.symbol} {p.side}: {ev.minutes_to_next:.0f}分後に "
                f"{cost_bps:.2f} bps(約 {cost_usd:.2f} USDT)支払い "
                f"[年率 {ev.apr_pct:+.1f}%]"
            )
        elif ev.minutes_to_next <= warn_minutes and cost_bps <= -warn_bps:
            warnings.append(
                f"{p.symbol} {p.side}: {ev.minutes_to_next:.0f}分後に "
                f"{-cost_bps:.2f} bps(約 {-cost_usd:.2f} USDT)受取り"
            )
    return warnings


if __name__ == "__main__":
    from client import make_client

    from annualize import fetch_funding_specs

    c = make_client()
    specs = fetch_funding_specs(c.http)

    watch = ["BTCUSDT", "ETHUSDT", "SOLUSDT"]
    print(f"{'symbol':<11}{'レート':>11}{'年率':>10}{'次回まで':>10}"
          f"{'次回時刻(UTC)':>18}")
    print("-" * 62)
    for ev in upcoming_funding(c.http, watch, specs):
        print(f"{ev.symbol:<11}{ev.rate * 100:>+10.4f}%"
              f"{ev.apr_pct:>+9.1f}%{ev.minutes_to_next:>9.0f}分"
              f"{ev.next_at:%Y-%m-%d %H:%M}")

    print("\n--- 保有ポジションの支払い予定 ---")
    ws = check_positions_before_funding(c, specs, warn_minutes=60,
                                        warn_bps=Decimal("1.0"))
    if ws:
        for w in ws:
            print(f"  {w}")
    else:
        print("  警告対象はありません")

実際に受け取った金額を集計する

試算だけでなく、実際にいくら払った/受け取ったかを確認できます。get_transaction_log の SETTLEMENT 種別が資金調達です。

funding_actual.pyサンプル8
"""funding_actual.py — 実際に発生した資金調達を取引ログから集計する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

注意: get_transaction_log の時間範囲は最大7日。
      長期間を集計するには7日ずつ窓をずらして繰り返す。
"""
from __future__ import annotations

import time
from datetime import datetime, timedelta, timezone

import pandas as pd

WINDOW_DAYS = 7          # APIの上限
MAX_PAGES = 200


def fetch_settlements(http, days: int = 30,
                      account_type: str = "UNIFIED") -> pd.DataFrame:
    """指定日数分の資金調達(SETTLEMENT)を7日窓で取得する。"""
    end = datetime.now(timezone.utc)
    start = end - timedelta(days=days)

    rows: list[dict] = []
    window_start = start

    while window_start < end:
        window_end = min(window_start + timedelta(days=WINDOW_DAYS), end)
        cursor = None

        for _ in range(MAX_PAGES):
            kw: dict = {
                "accountType": account_type,
                "type": "SETTLEMENT",          # 資金調達
                "startTime": int(window_start.timestamp() * 1000),
                "endTime": int(window_end.timestamp() * 1000),
                "limit": 50,
            }
            if cursor:
                kw["cursor"] = cursor

            res = http.get_transaction_log(**kw)
            if res["retCode"] != 0:
                print(f"[warn] {window_start:%Y-%m-%d}: "
                      f"{res['retCode']} {res['retMsg']}")
                break

            page = res["result"]["list"]
            rows.extend(page)
            cursor = res["result"].get("nextPageCursor")
            if not cursor or not page:
                break
            time.sleep(0.05)

        window_start = window_end

    if not rows:
        return pd.DataFrame()

    df = pd.DataFrame(rows)
    df["ts"] = pd.to_datetime(df["transactionTime"].astype("int64"),
                              unit="ms", utc=True)
    for col in ("funding", "fee", "cashFlow", "change", "cashBalance"):
        if col in df.columns:
            df[col] = pd.to_numeric(df[col], errors="coerce").fillna(0.0)
    return df.set_index("ts").sort_index()


def report(df: pd.DataFrame) -> None:
    if df.empty:
        print("該当期間に資金調達の記録はありません")
        return

    # fundingフィールドがマイナスなら支払い、プラスなら受取り
    total = df["funding"].sum()
    paid = df[df["funding"] < 0]["funding"].sum()
    received = df[df["funding"] > 0]["funding"].sum()

    print(f"期間: {df.index[0]:%Y-%m-%d} 〜 {df.index[-1]:%Y-%m-%d}")
    print(f"  記録件数   : {len(df)}")
    print(f"  受け取り   : {received:>+12,.4f}")
    print(f"  支払い     : {paid:>+12,.4f}")
    print(f"  正味       : {total:>+12,.4f}")

    if "symbol" in df.columns:
        print("\n--- 銘柄別 ---")
        by_symbol = (df.groupby("symbol")["funding"]
                     .agg(["sum", "count"])
                     .sort_values("sum"))
        for sym, r in by_symbol.iterrows():
            print(f"  {sym:<14}{r['sum']:>+12,.4f}  ({int(r['count'])}回)")

    print("\n--- 週別 ---")
    weekly = df["funding"].resample("W").sum()
    for ts, v in weekly.items():
        print(f"  {ts:%Y-%m-%d}週  {v:>+10,.4f}")


if __name__ == "__main__":
    from client import make_client

    c = make_client()
    df = fetch_settlements(c.http, days=30)
    report(df)
    print("\n※ 試算(サンプル5)と実測がずれる場合、")
    print("  ポジション量の変動やデルタの乖離が原因。")
    print("  年間の損益集計は記事40で扱う。")
参考リンク(一次情報)

よくある疑問Q&A

資金調達率はレバレッジによって変わりますか?
変わりません。ポジション価値(想定元本)に対してかかります。レバレッジ10倍で1,000 USDT分建てても、レバレッジ2倍で1,000 USDT分建てても、支払う資金調達は同額です。
ただし証拠金に対する比率は変わります。レバレッジ10倍なら証拠金100 USDTに対して1,000 USDT分の資金調達がかかるため、証拠金比では10倍のコストになります。高レバレッジで長期保有する戦略は、この点で不利です。
収受時刻の直前に建てて直後に決済すれば、資金調達だけ取れますか?
理屈は成立しますが、ほぼ確実に手数料負けします。サンプル5の損益分岐計算で確認できますが、往復のテイカー手数料は約11bps(perpのみの場合)で、1回の資金調達が0.01%=1bpsなら11回分の資金調達に相当します。
加えて収受時刻の直前は多くの参加者が同じことを狙うためスプレッドが開き、スリッページも増えます。短期の収受狙いは成立しないと考えるのが妥当で、キャリー戦略は数日〜数週間の保有を前提にします(記事35)。
次回の資金調達率は事前に分かりますか?
get_tickers の fundingRate は次回適用される予定のレートです(確定値ではなく収受時刻まで変動します)。より精度を上げたいなら get_premium_index_price_kline(プレミアム指数)を使います。プレミアム指数は先物と現物指数の乖離を表し、これが資金調達率の算出根拠になるため、収受までの推移を見れば方向は読めます。
ただし正確な算出式は取引所の仕様(クランプやプレミアムの平均化期間)に依存するため、完全な事前予測はできません。
なぜstartTimeを単独で指定するとエラーになるのですか?
Bybitの仕様です(公式ドキュメントに明記されています)。startTime 単独はエラー、endTime 単独はその時刻までの200件、両方省略で最新200件、という挙動です。
実装上はendTime を使って新しい方から遡るのが唯一の全期間取得方法です(サンプル1)。記事07のklineも同じパターンなので、ページネーションの実装を共通化できます。
現物には資金調達がないのに、なぜ現物と組み合わせるのですか?
現物に資金調達が無いことがまさに利点です。「現物ロング+perpショート」なら、価格変動の損益は打ち消し合い(デルタニュートラル)、perpショート側の資金調達受取りだけが残ります。
これがキャリー戦略の原理です。統合口座(UTA)では現物とperpで証拠金を共有できるため資金効率も上がります(記事03)。実装と、デルタ乖離・レッグリスクの管理は記事35で扱います。
全銘柄の履歴を取ると何リクエストになりますか?
symbol が必須なので銘柄ごとに繰り返す必要があります。500銘柄×1年分(8時間間隔なら約1,100件=6ページ)で約3,000リクエストです。記事05の流量ゲートを通し、Parquetにキャッシュ(サンプル1の load_or_fetch)してください。
なお「現在のレート」だけならスキャン(サンプル3)で1〜3リクエストで済みます。履歴が本当に必要かを先に検討してください。
transaction_logのfundingフィールドの符号はどちら向きですか?
口座視点でプラスが入金(受取り)、マイナスが出金(支払い)です。資金調達率の符号とは別なので混同しないでください。「資金調達率がプラス+ロング保有」なら支払いなので、funding フィールドはマイナスになります。
集計時は実際のフィールドの値をそのまま合計すれば正味の損益になります(サンプル8)。試算値と実測がずれる場合は、ポジション量の変動やデルタの乖離が原因です。

注意事項・免責事項

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

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

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

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

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

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

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