Bybit APIで需給データを取得する
OI・ロングショート比率・
マーク/インデックス価格とベーシス

価格だけを見ていると「誰がどちらに賭けているか」が分かりません。BybitはOI(未決済建玉)・ロングショート比率・3種類の価格を提供しており、これらを組み合わせると需給の偏りが読めます。この記事では取得の実装から、価格×OIの4象限分類、ベーシスの算出、プレミアム指数による資金調達率の先読みまでをサンプルコード8本でまとめます。

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

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

目次

  1. 3種類の価格を区別する
  2. ベーシスの算出と意味
  3. OI(未決済建玉)の取得
  4. 価格×OIの4象限分類
  5. ロングショート比率
  6. プレミアム指数で資金調達率を先読みする
  7. 需給スナップショットの統合
  8. 需給データを使う際の注意
  9. よくある疑問Q&A

3種類の価格を区別する

価格何を表すか使う場面
最終価格
(Last Price)
その取引所で実際に約定した最新価格 利確のトリガー、板の中心
マーク価格
(Mark Price)
インデックス価格に基づく公正価格 清算判定・含み損益の評価(記事22)
インデックス価格
(Index Price)
複数の現物取引所の加重平均 ベーシスの基準、マーク価格の元
清算はマーク価格で判定される

最終価格が瞬間的に急落しても、マーク価格が動いていなければ清算されません。逆もあります。「板が薄い時間帯にヒゲで刈られた」ように見える場面でも、実際にはマーク価格基準で判定されています。

この違いを理解していないと、損切りのトリガー選択(記事14の triggerBy)やバックテストの清算再現(記事31)が誤ります。損切りは MarkPrice、利確は LastPrice が基本です。

price_kinds.pyサンプル1
"""price_kinds.py — 3種類の価格を同時に取得し、乖離を測る
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations

from dataclasses import dataclass
from decimal import Decimal


@dataclass(frozen=True)
class PriceSnapshot:
    symbol: str
    last: Decimal
    mark: Decimal
    index: Decimal
    bid: Decimal
    ask: Decimal
    ts_ms: int

    @property
    def mid(self) -> Decimal:
        return (self.bid + self.ask) / 2 if self.bid and self.ask else self.last

    @property
    def basis_bps(self) -> Decimal:
        """最終価格 − インデックス価格。無期限のプレミアム。"""
        if self.index <= 0:
            return Decimal(0)
        return (self.last - self.index) / self.index * 10_000

    @property
    def mark_gap_bps(self) -> Decimal:
        """最終価格 − マーク価格。清算判定とのズレの目安。"""
        if self.mark <= 0:
            return Decimal(0)
        return (self.last - self.mark) / self.mark * 10_000

    @property
    def mark_index_bps(self) -> Decimal:
        """マーク価格 − インデックス価格。"""
        if self.index <= 0:
            return Decimal(0)
        return (self.mark - self.index) / self.index * 10_000

    def line(self) -> str:
        return (f"{self.symbol:<12}{self.last:>13}{self.mark:>13}"
                f"{self.index:>13}{self.basis_bps:>+10.2f}b"
                f"{self.mark_gap_bps:>+10.2f}b")


def _d(v, default: str = "0") -> Decimal:
    if v in (None, "", "null"):
        return Decimal(default)
    return Decimal(str(v))


def fetch_prices(http, symbol: str,
                 category: str = "linear") -> PriceSnapshot:
    res = http.get_tickers(category=category, symbol=symbol)
    if res["retCode"] != 0:
        raise RuntimeError(f"get_tickers失敗: {res['retMsg']}")
    t = res["result"]["list"][0]

    return PriceSnapshot(
        symbol=t["symbol"],
        last=_d(t.get("lastPrice")),
        mark=_d(t.get("markPrice") or t.get("lastPrice")),
        index=_d(t.get("indexPrice") or t.get("lastPrice")),
        bid=_d(t.get("bid1Price")),
        ask=_d(t.get("ask1Price")),
        ts_ms=int(res.get("time") or 0),
    )


def scan_basis(http, category: str = "linear",
               min_turnover: float = 20_000_000,
               top: int = 15) -> list[PriceSnapshot]:
    """全銘柄のベーシスをスキャンする(1リクエスト)。"""
    res = http.get_tickers(category=category)
    if res["retCode"] != 0:
        raise RuntimeError(f"get_tickers失敗: {res['retMsg']}")

    out = []
    for t in res["result"]["list"]:
        if float(t.get("turnover24h") or 0) < min_turnover:
            continue
        index = _d(t.get("indexPrice"))
        if index <= 0:
            continue
        out.append(PriceSnapshot(
            symbol=t["symbol"], last=_d(t.get("lastPrice")),
            mark=_d(t.get("markPrice")), index=index,
            bid=_d(t.get("bid1Price")), ask=_d(t.get("ask1Price")),
            ts_ms=int(res.get("time") or 0),
        ))
    return sorted(out, key=lambda p: -abs(p.basis_bps))[:top]


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

    c = make_client()

    print(f"{'symbol':<12}{'最終':>13}{'マーク':>13}{'インデックス':>13}"
          f"{'ベーシス':>11}{'マーク乖離':>11}")
    print("-" * 74)
    for sym in ("BTCUSDT", "ETHUSDT", "SOLUSDT"):
        print(fetch_prices(c.http, sym).line())

    print("\n=== ベーシスが大きい銘柄(過熱の指標) ===")
    for p in scan_basis(c.http):
        print(p.line())

    print("\n※ ベーシスが大きくプラス = 先物が現物より高い = 買いが過熱。")
    print("  この状態は資金調達率がプラスになる要因(記事10)。")
    print("  マーク乖離が大きい局面では、最終価格ベースのストップ判定と")
    print("  実際の清算判定がズレる(記事22)。")

ベーシスの算出と意味

ベーシス=無期限価格 − 現物指数です。この乖離が資金調達率を通じて是正される、というのが無期限先物の設計思想です。

basis_history.pyサンプル2
"""basis_history.py — ベーシスの時系列を作り、統計を取る
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

3種類のklineを組み合わせる(記事07)。
mark / index / premium は5フィールド(出来高なし)である点に注意。
"""
from __future__ import annotations

import pandas as pd

FULL_COLS = ["ts", "open", "high", "low", "close", "volume", "turnover"]
PRICE_COLS = ["ts", "open", "high", "low", "close"]


def _to_df(rows: list[list[str]]) -> pd.DataFrame:
    """フィールド数から自動判別してDataFrame化する(記事07と同じ)。"""
    if not rows:
        return pd.DataFrame()
    cols = FULL_COLS if len(rows[0]) >= 7 else PRICE_COLS
    df = pd.DataFrame([r[: len(cols)] for r in rows], columns=cols).astype(float)
    df["ts"] = pd.to_datetime(df["ts"].astype("int64"), unit="ms", utc=True)
    return df.set_index("ts").sort_index()


def fetch_basis_series(http, symbol: str = "BTCUSDT",
                       interval: str = "60", limit: int = 1000,
                       category: str = "linear") -> pd.DataFrame:
    """最終・マーク・インデックス・プレミアム指数を結合する。"""
    kw = dict(category=category, symbol=symbol, interval=interval,
              limit=limit)

    last = _to_df(http.get_kline(**kw)["result"]["list"])
    mark = _to_df(http.get_mark_price_kline(**kw)["result"]["list"])
    index = _to_df(http.get_index_price_kline(**kw)["result"]["list"])
    prem = _to_df(http.get_premium_index_price_kline(**kw)["result"]["list"])

    df = pd.DataFrame({
        "last": last["close"],
        "mark": mark["close"],
        "index": index["close"],
        "premium_index": prem["close"],
        "volume": last.get("volume"),
    }).dropna(subset=["last", "index"])

    df["basis_bps"] = (df["last"] - df["index"]) / df["index"] * 10_000
    df["mark_gap_bps"] = (df["last"] - df["mark"]) / df["mark"] * 10_000
    df["mark_index_bps"] = (df["mark"] - df["index"]) / df["index"] * 10_000
    return df


def basis_stats(df: pd.DataFrame) -> dict:
    b = df["basis_bps"].dropna()
    if b.empty:
        return {"error": "データなし"}
    return {
        "件数": len(b),
        "平均(bps)": round(float(b.mean()), 2),
        "中央値(bps)": round(float(b.median()), 2),
        "標準偏差(bps)": round(float(b.std()), 2),
        "プラスの割合": f"{(b > 0).mean():.1%}",
        "5%点": round(float(b.quantile(0.05)), 2),
        "95%点": round(float(b.quantile(0.95)), 2),
        "最大乖離": round(float(b.abs().max()), 2),
        "自己相関(1)": round(float(b.autocorr(1)), 3),
    }


def basis_percentile(df: pd.DataFrame, window: int = 720) -> pd.Series:
    """ベーシスの相対水準(0〜1)。極端な局面の検出に使う。"""
    return df["basis_bps"].rolling(window, min_periods=window // 4).rank(pct=True)


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

    c = make_client()

    for symbol in ("BTCUSDT", "ETHUSDT"):
        df = fetch_basis_series(c.http, symbol)
        if df.empty:
            continue
        print(f"=== {symbol} のベーシス({len(df)}本の1時間足) ===")
        for k, v in basis_stats(df).items():
            print(f"  {k:<14}: {v}")

        pct = basis_percentile(df)
        latest = df.iloc[-1]
        print(f"\n  直近: ベーシス {latest['basis_bps']:+.2f} bps "
              f"(過去30日の {pct.iloc[-1]:.0%} 分位)")
        print()

    print("※ ベーシスが95%分位を超える局面は『買いが過熱』の状態。")
    print("  資金調達率も高くなりやすく、キャリー戦略の好機だが")
    print("  同時に急変のリスクも高い(記事35)。")

OI(未決済建玉)の取得

パラメータ必須内容
category必須linear / inverse(現物にOIはない)
symbol必須全銘柄一括は不可。1銘柄ずつ
intervalTime必須5min / 15min / 30min / 1h / 4h / 1d
limit任意1〜200(既定50)
cursor任意ページネーション
open_interest.pyサンプル3
"""open_interest.py — OI(未決済建玉)の取得と分析
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

OIは「まだ決済されていない契約の総数」。
増加 = 新規参入、減少 = 決済。価格と組み合わせて需給を読む。
"""
from __future__ import annotations

import time
from datetime import datetime, timezone

import pandas as pd

VALID_INTERVALS = ("5min", "15min", "30min", "1h", "4h", "1d")
MAX_LIMIT = 200


def fetch_open_interest(http, symbol: str, interval: str = "1h",
                        start: str | None = None,
                        category: str = "linear",
                        max_pages: int = 200,
                        sleep: float = 0.1,
                        verbose: bool = False) -> pd.DataFrame:
    """OIの履歴を取得する(cursorページネーション)。"""
    if interval not in VALID_INTERVALS:
        raise ValueError(f"intervalTimeは {VALID_INTERVALS} のいずれか")

    start_ms = None
    if start:
        start_ms = int(pd.Timestamp(start, tz="UTC").timestamp() * 1000)

    rows: list[dict] = []
    cursor = None

    for page in range(max_pages):
        kw: dict = {"category": category, "symbol": symbol,
                    "intervalTime": interval, "limit": MAX_LIMIT}
        if cursor:
            kw["cursor"] = cursor

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

        page_rows = res["result"]["list"]
        if not page_rows:
            break
        rows.extend(page_rows)

        oldest = min(int(r["timestamp"]) for r in page_rows)
        if start_ms and oldest <= start_ms:
            break

        cursor = res["result"].get("nextPageCursor")
        if not cursor:
            break
        if verbose and page % 10 == 0:
            print(f"  {page + 1}ページ / {len(rows)}件")
        time.sleep(sleep)

    if not rows:
        return pd.DataFrame()

    df = pd.DataFrame(rows)
    df["ts"] = pd.to_datetime(df["timestamp"].astype("int64"), unit="ms",
                              utc=True)
    df["open_interest"] = pd.to_numeric(df["openInterest"], errors="coerce")
    df = (df[["ts", "open_interest"]]
          .drop_duplicates(subset="ts")
          .set_index("ts").sort_index())

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


def attach_price(oi: pd.DataFrame, bars: pd.DataFrame) -> pd.DataFrame:
    """OIに価格を結合する(4象限分類の準備)。"""
    df = oi.join(bars[["close"]], how="inner")
    df["oi_change"] = df["open_interest"].diff()
    df["oi_change_pct"] = df["open_interest"].pct_change()
    df["price_change"] = df["close"].diff()
    df["price_change_pct"] = df["close"].pct_change()
    df["oi_value"] = df["open_interest"] * df["close"]
    return df.dropna()


def oi_stats(df: pd.DataFrame) -> dict:
    oi = df["open_interest"]
    return {
        "件数": len(oi),
        "最新OI": f"{oi.iloc[-1]:,.0f}",
        "最新OI額(USDT)": f"{df['oi_value'].iloc[-1]:,.0f}"
        if "oi_value" in df else "-",
        "期間平均": f"{oi.mean():,.0f}",
        "最大": f"{oi.max():,.0f}",
        "最小": f"{oi.min():,.0f}",
        "直近の変化率": f"{oi.pct_change().iloc[-1]:+.2%}",
        "価格との相関": (round(float(df["oi_change_pct"]
                                    .corr(df["price_change_pct"])), 3)
                        if "price_change_pct" in df else "-"),
    }


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

    c = make_client()
    SYMBOL = "BTCUSDT"

    oi = fetch_open_interest(c.http, SYMBOL, "1h", start="2026-06-01",
                             verbose=True)
    print(f"\nOI: {len(oi)}件  {oi.index[0]} 〜 {oi.index[-1]}")

    bars = fetch_klines(c.http, SYMBOL, interval="60", start="2026-06-01",
                        verbose=False)
    merged = attach_price(oi, bars)

    print(f"\n=== {SYMBOL} のOI統計 ===")
    for k, v in oi_stats(merged).items():
        print(f"  {k:<16}: {v}")

    print("\n※ OI変化と価格変化の相関だけでは方向は読めない。")
    print("  4象限分類(サンプル4)で意味を持たせる。")

価格×OIの4象限分類

OI単体では意味が薄いですが、価格変化と組み合わせると「誰が動いたか」の解釈ができます。これはテクニカル分析で古くから使われる枠組みです。

価格OI解釈含意
上昇増加新規の買いトレンドに資金が入っている
上昇減少売りの決済(踏み上げ)ショートカバー主導。持続性は低め
下落増加新規の売り下方向に資金が入っている
下落減少買いの決済(投げ)ロング解消主導。売り枯れの兆候
oi_quadrant.pyサンプル4
"""oi_quadrant.py — 価格×OIの4象限分類
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

注意: この分類は「解釈の枠組み」であり、予測力を保証するものではない。
      シグナルとして使うなら記事32の検証を必ず通すこと。
"""
from __future__ import annotations

from enum import Enum

import numpy as np
import pandas as pd


class Quadrant(str, Enum):
    NEW_LONG = "新規買い"        # 価格↑ OI↑
    SHORT_COVER = "売り決済"     # 価格↑ OI↓
    NEW_SHORT = "新規売り"       # 価格↓ OI↑
    LONG_LIQUIDATION = "買い決済"  # 価格↓ OI↓
    NEUTRAL = "中立"


def classify(price_change: float, oi_change: float,
             price_threshold: float = 0.0,
             oi_threshold: float = 0.0) -> Quadrant:
    """1本分の4象限分類。閾値で微小な変化を中立にできる。"""
    if abs(price_change) <= price_threshold or abs(oi_change) <= oi_threshold:
        return Quadrant.NEUTRAL
    if price_change > 0:
        return Quadrant.NEW_LONG if oi_change > 0 else Quadrant.SHORT_COVER
    return Quadrant.NEW_SHORT if oi_change > 0 else Quadrant.LONG_LIQUIDATION


def classify_series(df: pd.DataFrame,
                    price_pct_threshold: float = 0.001,
                    oi_pct_threshold: float = 0.001) -> pd.DataFrame:
    """時系列全体を分類する。閾値は相対変化率で指定する。"""
    out = df.copy()
    out["quadrant"] = [
        classify(p, o, price_pct_threshold, oi_pct_threshold).value
        for p, o in zip(out["price_change_pct"], out["oi_change_pct"])
    ]
    return out


def quadrant_summary(df: pd.DataFrame) -> pd.DataFrame:
    """象限ごとの出現率と、その後のリターンを集計する。

    「新規買いの後は上がりやすいか」を実データで確認できる。
    """
    out = df.copy()
    # 次の足のリターン(未来を見ないよう shift(-1) で明示的に扱う)
    out["fwd_return"] = out["close"].pct_change().shift(-1)

    agg = out.groupby("quadrant").agg(
        件数=("quadrant", "count"),
        出現率=("quadrant", lambda s: len(s) / len(out)),
        次足リターン平均=("fwd_return", "mean"),
        次足リターン中央値=("fwd_return", "median"),
        勝率=("fwd_return", lambda s: (s > 0).mean()),
    )
    return agg.sort_values("件数", ascending=False)


def recent_regime(df: pd.DataFrame, window: int = 24) -> str:
    """直近N本の象限分布から、現在の需給状態を要約する。"""
    recent = df["quadrant"].tail(window)
    counts = recent.value_counts(normalize=True)
    dominant = counts.index[0]
    share = counts.iloc[0]

    interp = {
        Quadrant.NEW_LONG.value:
            "新規の買いが優勢。上昇に資金が入っている",
        Quadrant.SHORT_COVER.value:
            "ショートカバー主導。買い戻しが一巡すると勢いが落ちやすい",
        Quadrant.NEW_SHORT.value:
            "新規の売りが優勢。下落に資金が入っている",
        Quadrant.LONG_LIQUIDATION.value:
            "ロング解消主導。投げが出ている",
        Quadrant.NEUTRAL.value: "方向感が乏しい",
    }
    return (f"直近{window}本: {dominant}が{share:.0%}\n"
            f"  -> {interp.get(dominant, '')}")


if __name__ == "__main__":
    from client import make_client
    from kline_fetch import fetch_klines
    from open_interest import attach_price, fetch_open_interest

    c = make_client()
    SYMBOL = "BTCUSDT"

    oi = fetch_open_interest(c.http, SYMBOL, "1h", start="2026-05-01")
    bars = fetch_klines(c.http, SYMBOL, interval="60", start="2026-05-01",
                        verbose=False)
    merged = classify_series(attach_price(oi, bars))

    print(f"=== {SYMBOL} の4象限分布({len(merged)}本) ===")
    print(quadrant_summary(merged).to_string(
        float_format=lambda v: f"{v:+.4%}" if abs(v) < 1 else f"{v:,.0f}"))

    print(f"\n{recent_regime(merged)}")

    print("\n--- 注意 ---")
    print("  次足リターンの差が小さければ、この分類に予測力はない。")
    print("  『解釈として納得できる』ことと『使える』ことは別。")
    print("  シグナルにするなら記事32のWFA/DSRで検証すること。")

ロングショート比率

long_short_ratio.pyサンプル5
"""long_short_ratio.py — ロング/ショート比率の取得と極値検出
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

period: 5min / 15min / 30min / 1h / 4h / 1d
limit : 1〜500(既定50)

注意: buyRatio + sellRatio = 1 になる「アカウント数の比率」。
      建玉量の比率ではない点に注意。
"""
from __future__ import annotations

import time

import numpy as np
import pandas as pd

VALID_PERIODS = ("5min", "15min", "30min", "1h", "4h", "1d")
MAX_LIMIT = 500


def fetch_long_short_ratio(http, symbol: str, period: str = "1h",
                           category: str = "linear",
                           max_pages: int = 50,
                           sleep: float = 0.1) -> pd.DataFrame:
    if period not in VALID_PERIODS:
        raise ValueError(f"periodは {VALID_PERIODS} のいずれか")

    rows: list[dict] = []
    cursor = None

    for _ in range(max_pages):
        kw: dict = {"category": category, "symbol": symbol,
                    "period": period, "limit": MAX_LIMIT}
        if cursor:
            kw["cursor"] = cursor

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

        page = res["result"]["list"]
        if not page:
            break
        rows.extend(page)

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

    if not rows:
        return pd.DataFrame()

    df = pd.DataFrame(rows)
    df["ts"] = pd.to_datetime(df["timestamp"].astype("int64"), unit="ms",
                              utc=True)
    df["buy_ratio"] = pd.to_numeric(df["buyRatio"], errors="coerce")
    df["sell_ratio"] = pd.to_numeric(df["sellRatio"], errors="coerce")
    df = (df[["ts", "buy_ratio", "sell_ratio"]]
          .drop_duplicates(subset="ts")
          .set_index("ts").sort_index())

    # ロング/ショートの倍率(1.0で均衡)
    df["ls_ratio"] = df["buy_ratio"] / df["sell_ratio"].replace(0, np.nan)
    df["skew"] = df["buy_ratio"] - df["sell_ratio"]      # -1 〜 +1
    return df


def detect_extremes(df: pd.DataFrame, window: int = 720,
                    high_pct: float = 0.95,
                    low_pct: float = 0.05) -> pd.DataFrame:
    """比率の相対的な極値を検出する。

    絶対値で「ロング70%は多い」と判断するのは危険。
    銘柄ごとに平常時の水準が違うため、相対分位で見る。
    """
    out = df.copy()
    out["pct_rank"] = out["skew"].rolling(
        window, min_periods=window // 4).rank(pct=True)
    out["extreme_long"] = out["pct_rank"] >= high_pct
    out["extreme_short"] = out["pct_rank"] <= low_pct
    return out


def contrarian_check(df: pd.DataFrame, bars: pd.DataFrame,
                     forward: int = 24) -> dict:
    """「偏りが極端なとき逆張りが効くか」を実データで確認する。

    ポジションが片方に偏りすぎると反転しやすい、という仮説の検証。
    """
    merged = detect_extremes(df).join(bars[["close"]], how="inner").dropna()
    if merged.empty:
        return {"error": "結合できるデータがありません"}

    # forward本先までのリターン(未来参照は集計のみに使う)
    merged["fwd"] = merged["close"].shift(-forward) / merged["close"] - 1

    long_ext = merged.loc[merged["extreme_long"], "fwd"].dropna()
    short_ext = merged.loc[merged["extreme_short"], "fwd"].dropna()
    base = merged["fwd"].dropna()

    def summarise(s: pd.Series, label: str) -> dict:
        if len(s) < 10:
            return {label: f"サンプル不足({len(s)})"}
        return {
            f"{label}_件数": len(s),
            f"{label}_平均": f"{s.mean():+.3%}",
            f"{label}_勝率": f"{(s > 0).mean():.1%}",
        }

    out = {"全体_平均": f"{base.mean():+.3%}",
           "全体_勝率": f"{(base > 0).mean():.1%}"}
    out.update(summarise(long_ext, "ロング偏り極大後"))
    out.update(summarise(short_ext, "ショート偏り極大後"))
    return out


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

    c = make_client()
    SYMBOL = "BTCUSDT"

    lsr = fetch_long_short_ratio(c.http, SYMBOL, "1h")
    if lsr.empty:
        raise SystemExit("データを取得できませんでした")

    print(f"=== {SYMBOL} のロングショート比率({len(lsr)}件) ===")
    print(f"  期間     : {lsr.index[0]} 〜 {lsr.index[-1]}")
    print(f"  直近     : ロング {lsr['buy_ratio'].iloc[-1]:.1%} / "
          f"ショート {lsr['sell_ratio'].iloc[-1]:.1%} "
          f"(倍率 {lsr['ls_ratio'].iloc[-1]:.2f})")
    print(f"  期間平均 : ロング {lsr['buy_ratio'].mean():.1%}")
    print(f"  偏りの幅 : {lsr['skew'].min():+.3f} 〜 {lsr['skew'].max():+.3f}")

    ext = detect_extremes(lsr)
    print(f"\n  極端なロング偏り : {ext['extreme_long'].sum()}回")
    print(f"  極端なショート偏り: {ext['extreme_short'].sum()}回")

    bars = fetch_klines(c.http, SYMBOL, interval="60",
                        start=str(lsr.index[0].date()), verbose=False)
    print(f"\n=== 逆張りの有効性(24本先のリターン) ===")
    for k, v in contrarian_check(lsr, bars).items():
        print(f"  {k:<24}: {v}")

    print("\n※ 極値後のリターンが全体平均と大差なければ、")
    print("  この指標に逆張りの予測力はない。")
    print("  『多くの人が信じている指標』と『機能する指標』は別。")
比率は「アカウント数」であり「建玉量」ではない

buyRatio / sellRatio はポジションを持っているアカウント数の比率です。「ロング70%」は人数が70%という意味で、建玉の金額が70%とは限りません。

小口の個人が多数ロング、大口が少数でショート、という状況では比率が偏っていても金額では均衡していることがあります。金額ベースの偏りを見たいならOI(サンプル3)と組み合わせてください。

プレミアム指数で資金調達率を先読みする

資金調達率はプレミアム指数(先物と現物指数の乖離)を平均化して算出されます。収受時刻までの推移を見れば、次回のレートの方向が読めます。

premium_forecast.pyサンプル6
"""premium_forecast.py — プレミアム指数から次回資金調達率の傾向を読む
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

注意: 正確な算出式(クランプ・平均化の期間・金利成分)は
      取引所の仕様に依存する。ここでは「方向と大きさの目安」を
      得ることを目的とし、完全な予測は行わない。
"""
from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime, timezone

import numpy as np
import pandas as pd

from basis_history import _to_df


@dataclass
class FundingOutlook:
    symbol: str
    current_rate: float          # get_tickers が返す次回予定レート
    next_funding_ms: int
    premium_now: float
    premium_mean: float          # 収受期間中の平均プレミアム
    premium_trend: float         # プレミアムの傾き
    minutes_to_next: float

    @property
    def direction(self) -> str:
        if self.premium_trend > 1e-5:
            return "上昇(レートは高くなる方向)"
        if self.premium_trend < -1e-5:
            return "低下(レートは低くなる方向)"
        return "横ばい"

    def report(self) -> str:
        return "\n".join([
            f"{self.symbol}",
            f"  次回予定レート : {self.current_rate * 100:+.5f}%",
            f"  次回まで       : {self.minutes_to_next:.0f}分",
            f"  現在のプレミアム: {self.premium_now * 100:+.5f}%",
            f"  期間平均       : {self.premium_mean * 100:+.5f}%",
            f"  推移           : {self.direction}",
        ])


def fetch_premium_series(http, symbol: str, interval: str = "5",
                         limit: int = 200,
                         category: str = "linear") -> pd.DataFrame:
    """プレミアム指数のkline(5フィールド)を取得する。"""
    res = http.get_premium_index_price_kline(
        category=category, symbol=symbol, interval=interval, limit=limit)
    if res["retCode"] != 0:
        raise RuntimeError(f"premium kline失敗: {res['retMsg']}")
    return _to_df(res["result"]["list"])


def outlook(http, symbol: str, interval_hours: float = 8.0,
            category: str = "linear") -> FundingOutlook:
    ticker = http.get_tickers(category=category,
                              symbol=symbol)["result"]["list"][0]
    rate = float(ticker.get("fundingRate") or 0)
    next_ms = int(ticker.get("nextFundingTime") or 0)
    now_ms = int(datetime.now(timezone.utc).timestamp() * 1000)

    prem = fetch_premium_series(http, symbol, interval="5",
                                category=category)
    if prem.empty:
        raise RuntimeError("プレミアム指数を取得できません")

    # 直近の収受期間ぶんだけを見る
    bars_in_period = int(interval_hours * 60 / 5)
    window = prem["close"].tail(max(bars_in_period, 12))

    # 傾き(線形回帰の係数)
    x = np.arange(len(window), dtype=float)
    trend = float(np.polyfit(x, window.to_numpy(), 1)[0]) if len(window) > 2 else 0.0

    return FundingOutlook(
        symbol=symbol, current_rate=rate, next_funding_ms=next_ms,
        premium_now=float(prem["close"].iloc[-1]),
        premium_mean=float(window.mean()),
        premium_trend=trend,
        minutes_to_next=(next_ms - now_ms) / 60000 if next_ms else float("nan"),
    )


def compare_premium_and_funding(http, symbol: str,
                                category: str = "linear") -> pd.DataFrame:
    """過去のプレミアム平均と、実際に確定した資金調達率を比較する。

    「プレミアムから資金調達率をどれだけ説明できるか」を測る。
    """
    from funding_fetch import fetch_funding_history

    funding = fetch_funding_history(http, symbol, start="2026-06-01",
                                    category=category, verbose=False)
    if funding.empty:
        return pd.DataFrame()

    prem = fetch_premium_series(http, symbol, interval="60", limit=1000)
    if prem.empty:
        return pd.DataFrame()

    rows = []
    for ts, rate in funding["funding_rate"].items():
        # 収受時刻の直前8時間のプレミアム平均
        window = prem["close"].loc[ts - pd.Timedelta(hours=8):ts]
        if window.empty:
            continue
        rows.append({"ts": ts, "funding_rate": float(rate),
                     "premium_mean": float(window.mean()),
                     "premium_last": float(window.iloc[-1])})

    df = pd.DataFrame(rows).set_index("ts")
    if len(df) > 3:
        df.attrs["corr"] = float(df["funding_rate"].corr(df["premium_mean"]))
    return df


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

    c = make_client()

    for symbol in ("BTCUSDT", "ETHUSDT"):
        try:
            print(outlook(c.http, symbol).report())
            print()
        except Exception as e:
            print(f"{symbol}: {e}\n")

    print("=== プレミアム指数と確定レートの関係 ===")
    df = compare_premium_and_funding(c.http, "BTCUSDT")
    if not df.empty:
        corr = df.attrs.get("corr")
        print(f"  サンプル {len(df)}件 / 相関 {corr:.3f}")
        print(df.tail(6).to_string(float_format=lambda v: f"{v:+.6f}"))
        print("\n  相関が高ければ、プレミアムの推移から次回レートの")
        print("  方向を読める(記事35のエントリー判断に使える)。")
    else:
        print("  データを取得できませんでした")

需給スナップショットの統合

market_snapshot.pyサンプル7
"""market_snapshot.py — 需給データを1つのDataFrameに統合する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

価格・OI・比率・ベーシス・資金調達率を横断的に並べ、
銘柄選定やレジーム判定の材料にする。
"""
from __future__ import annotations

from dataclasses import dataclass, field

import numpy as np
import pandas as pd


@dataclass
class SymbolSnapshot:
    symbol: str
    last: float
    change_24h: float
    turnover_24h: float
    open_interest: float
    oi_value: float
    funding_rate: float
    funding_apr: float
    basis_bps: float
    spread_bps: float
    long_ratio: float = float("nan")
    oi_change_24h: float = float("nan")

    @property
    def oi_to_turnover(self) -> float:
        """OI ÷ 24h売買代金。高いほど『持ち合い』、低いほど回転が速い。"""
        if self.turnover_24h <= 0:
            return float("nan")
        return self.oi_value / self.turnover_24h

    def line(self) -> str:
        return (f"{self.symbol:<12}{self.change_24h:>+8.2%}"
                f"{self.turnover_24h / 1e9:>9.2f}B"
                f"{self.oi_value / 1e6:>10.0f}M"
                f"{self.funding_apr:>+9.1f}%{self.basis_bps:>+9.1f}b"
                f"{self.spread_bps:>8.2f}b{self.oi_to_turnover:>8.2f}")


def build_snapshot(http, category: str = "linear",
                   min_turnover: float = 20_000_000,
                   with_long_short: bool = False,
                   top: int = 20) -> list[SymbolSnapshot]:
    """全銘柄の需給スナップショットを作る(基本は2リクエスト)。"""
    from annualize import fetch_funding_specs

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

    out: list[SymbolSnapshot] = []
    for t in res["result"]["list"]:
        turnover = float(t.get("turnover24h") or 0)
        if turnover < min_turnover:
            continue

        symbol = t["symbol"]
        spec = specs.get(symbol)
        rate = float(t.get("fundingRate") or 0)
        last = float(t.get("lastPrice") or 0)
        index = float(t.get("indexPrice") or 0)
        bid = float(t.get("bid1Price") or 0)
        ask = float(t.get("ask1Price") or 0)

        basis = ((last - index) / index * 10_000) if index > 0 else float("nan")
        spread = ((ask - bid) / last * 10_000) if last and bid and ask else float("nan")
        apr = float(spec.annualize(rate)) * 100 if spec else float("nan")

        out.append(SymbolSnapshot(
            symbol=symbol, last=last,
            change_24h=float(t.get("price24hPcnt") or 0),
            turnover_24h=turnover,
            open_interest=float(t.get("openInterest") or 0),
            oi_value=float(t.get("openInterestValue") or 0),
            funding_rate=rate, funding_apr=apr,
            basis_bps=basis, spread_bps=spread,
        ))

    out.sort(key=lambda s: -s.turnover_24h)
    out = out[:top]

    # ロングショート比率は1銘柄ずつなのでオプション扱い
    if with_long_short:
        from long_short_ratio import fetch_long_short_ratio

        for s in out:
            try:
                lsr = fetch_long_short_ratio(http, s.symbol, "1h",
                                             category, max_pages=1)
                if not lsr.empty:
                    s.long_ratio = float(lsr["buy_ratio"].iloc[-1])
            except Exception:
                pass

    return out


def regime_summary(snaps: list[SymbolSnapshot]) -> str:
    """市場全体の需給レジームを要約する。"""
    if not snaps:
        return "データがありません"

    aprs = np.array([s.funding_apr for s in snaps
                     if np.isfinite(s.funding_apr)])
    basis = np.array([s.basis_bps for s in snaps
                      if np.isfinite(s.basis_bps)])
    changes = np.array([s.change_24h for s in snaps])

    positive_funding = (aprs > 0).mean() if aprs.size else float("nan")
    median_apr = np.median(aprs) if aprs.size else float("nan")

    if positive_funding > 0.8 and median_apr > 15:
        regime = "強い買い過熱(キャリーの好機だが急変リスクも高い)"
    elif positive_funding > 0.6:
        regime = "やや買い優勢(平常)"
    elif positive_funding < 0.3:
        regime = "売り優勢(ロング側が資金調達を受け取れる)"
    else:
        regime = "中立"

    return "\n".join([
        f"対象 {len(snaps)}銘柄",
        f"  資金調達がプラスの割合 : {positive_funding:.0%}",
        f"  年率の中央値           : {median_apr:+.1f}%",
        f"  ベーシス中央値         : {np.median(basis):+.1f} bps"
        if basis.size else "",
        f"  24h変化率の中央値      : {np.median(changes):+.2%}",
        f"  レジーム判定           : {regime}",
    ])


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

    c = make_client()
    snaps = build_snapshot(c.http, top=20)

    print(f"{'symbol':<12}{'24h変化':>8}{'売買代金':>10}"
          f"{'OI額':>11}{'資金年率':>9}{'ベーシス':>10}"
          f"{'スプレッド':>9}{'OI/代金':>8}")
    print("-" * 78)
    for s in snaps:
        print(s.line())

    print()
    print(regime_summary(snaps))

    print("\n※ OI/売買代金 が高い = 建玉が滞留している(動きが出ると大きい)")
    print("  低い = 回転が速い(デイトレード主体)")

需給データを使う際の注意

注意点内容
この取引所のデータのみBybitのOI・比率はBybitの中だけの数値。市場全体の需給ではない
比率は人数ベースbuyRatio は建玉量ではなくアカウント数の比率
OIの単位linearは基軸通貨、inverseはUSD建て。openInterestValue(金額)で比較する
4象限は解釈であって予測ではない「納得できる」ことと「機能する」ことは別(記事32で検証)
リクエスト数OIも比率も銘柄ごと。全銘柄を取ると数百リクエスト(記事05)
ベーシスと資金調達は連動両方をシグナルに使うと実質的に同じ情報を二重に使うことになる
test_snapshot.pyサンプル8
"""test_snapshot.py — 需給データの計算が正しいことを検証する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

  pytest test_snapshot.py -v
"""
from __future__ import annotations

from decimal import Decimal

import pandas as pd
import pytest

from oi_quadrant import Quadrant, classify
from price_kinds import PriceSnapshot


def make_price(last: str, mark: str, index: str) -> PriceSnapshot:
    return PriceSnapshot(symbol="TESTUSDT", last=Decimal(last),
                         mark=Decimal(mark), index=Decimal(index),
                         bid=Decimal(last), ask=Decimal(last), ts_ms=0)


def test_basis_sign():
    """先物が現物より高ければベーシスはプラス。"""
    p = make_price("101", "100.5", "100")
    assert p.basis_bps == Decimal("100")      # +1% = 100bps

    p = make_price("99", "99.5", "100")
    assert p.basis_bps == Decimal("-100")


def test_zero_index_does_not_crash():
    """インデックス価格が0でもゼロ割りしないこと。"""
    p = make_price("100", "100", "0")
    assert p.basis_bps == Decimal(0)
    assert p.mark_index_bps == Decimal(0)


def test_mark_gap():
    p = make_price("100.5", "100", "100")
    assert p.mark_gap_bps == Decimal("50")    # +0.5% = 50bps


@pytest.mark.parametrize("price,oi,expected", [
    (0.01, 0.01, Quadrant.NEW_LONG),
    (0.01, -0.01, Quadrant.SHORT_COVER),
    (-0.01, 0.01, Quadrant.NEW_SHORT),
    (-0.01, -0.01, Quadrant.LONG_LIQUIDATION),
])
def test_quadrant_classification(price, oi, expected):
    assert classify(price, oi) is expected


def test_quadrant_threshold_makes_neutral():
    """閾値を超えない微小な変化は中立になること。"""
    assert classify(0.0001, 0.01, price_threshold=0.001) is Quadrant.NEUTRAL
    assert classify(0.01, 0.0001, oi_threshold=0.001) is Quadrant.NEUTRAL


def test_long_short_ratio_sums_to_one():
    """buyRatio + sellRatio は1になる(アカウント数の比率)。"""
    df = pd.DataFrame({"buy_ratio": [0.6, 0.55], "sell_ratio": [0.4, 0.45]})
    total = df["buy_ratio"] + df["sell_ratio"]
    assert (total - 1.0).abs().max() < 1e-9


def test_oi_value_scales_with_price():
    """OI額 = OI × 価格 という関係。"""
    oi = 1000.0
    price = 120_000.0
    assert oi * price == 120_000_000.0
参考リンク(一次情報)

よくある疑問Q&A

OIが増えているのに価格が動きません
買いと売りの新規が同量入っている状態です。OIはロングとショートのペアの数なので、両側が同時に増えれば価格は動きません。
この状態は「エネルギーが溜まっている」と解釈されることが多く、どちらかが投げると大きく動く可能性があります。ただしこれは事後の説明であり、方向は予測できません。サンプル4の quadrant_summary() で、実際に次足リターンに差が出るかを確認してください。
ロングショート比率で逆張りすれば勝てますか?
実データで検証してください。サンプル5の contrarian_check() は、まさにその仮説を検証する関数です。極端な偏りの後のリターンが全体平均と大差なければ、この指標に予測力はありません。
「多くの人が信じている指標」が「機能する指標」とは限りません。特に比率が人数ベースである点(大口の動きが反映されにくい)は、この指標の構造的な弱点です。
マーク価格とインデックス価格の差は何ですか?
インデックス価格は複数の現物取引所の加重平均で、マーク価格はそれに一定の調整(移動平均のプレミアムなど)を加えた公正価格です。通常は近い値ですが、急変時に差が出ます。
実装上は清算判定に使われるのはマーク価格という点だけ押さえれば十分です。ベーシスの計算にはインデックス価格を使います(現物との乖離を見たいため)。サンプル1の mark_index_bps で両者の差を監視できます。
全銘柄のOIを取得したいのですが
get_open_interest は銘柄ごとなので、500銘柄なら500リクエストです。記事05の流量ゲートを通してください。
ただし「現在のOI」だけなら get_tickers の openInterest / openInterestValue で1リクエストで全銘柄取れます(サンプル7)。履歴が必要な場合だけ個別に取得する、という使い分けが効率的です。
ベーシスと資金調達率は同じものですか?
別物ですが強く連動します。ベーシス(先物と現物の乖離)が大きいと資金調達率が高くなり、それが乖離を是正する——という関係です。
したがって両方をシグナルに使うと、実質的に同じ情報を二重に使うことになります。記事32の観点では、これは「独立した根拠が2つある」のではなく「1つの根拠を2回数えている」状態です。どちらか一方を使ってください。
他の取引所のOIも合わせて見るべきですか?
市場全体の需給を知りたいなら有効ですが、本シリーズの範囲外です(Bybit APIだけでは取得できません)。集計サービスのAPIを使う方法がありますが、データの定義が取引所ごとに違う(OIの単位、比率の算出方法)ため、単純な合算には注意が必要です。
実務的には、自分が取引する取引所のデータで完結させるほうが検証が単純で、前提のズレも生じにくいです。
intervalTimeを細かくすれば精度が上がりますか?
OIは集計値なので細かくしても情報量はあまり増えません。5min間隔にすると件数が12倍になりリクエストも増えますが、ノイズも増えます。
需給の変化は数時間〜数日単位で見るほうが解釈しやすいため、1h または 4h から始めることを勧めます。高頻度の需給変化を見たいなら、OIではなく記事09のOFIや記事08の出来高デルタのほうが適しています。

注意事項・免責事項

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

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

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

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

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

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

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