Bybitデータでバックテスト基盤を自作するPython実装
手数料・スリッページ・資金調達率・清算を織り込む

「バックテストでは勝つのに本番で負ける」の原因は、戦略ではなくコストモデルの欠落にあることが大半です。Maker/Takerの手数料、スリッページ、資金調達率、清算、そしてルックアヘッドバイアス。この記事ではこれら全部を織り込んだイベントドリブンのバックテスト基盤を、本番botとStrategyコードを共有できる形で自作します。サンプルコード8本。

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

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

目次

  1. なぜバックテストは嘘をつくのか
  2. 実際のコストを測る|get_fee_ratesと資金調達率
  3. 中核となる型定義(サンプル2)
  4. 約定モデル|指値の楽観的約定を避ける
  5. バックテストエンジンの本体
  6. 本番と共有できるStrategyインターフェース
  7. 資金調達率を損益に反映する
  8. 清算シミュレーション
  9. 成績レポート|Sharpe・Sortino・Calmar
  10. ルックアヘッドバイアスを自動検出する
  11. 既存ライブラリ(vectorbt / backtesting.py)との比較
  12. 結果を信じる前のチェックリスト
  13. よくある疑問Q&A

この記事は記事07(OHLCV取得)の KlineStore でローカルに保存したデータを使います。

なぜバックテストは嘘をつくのか

原因は戦略の中身ではなく、ほぼ以下の6つに集約されます。

原因何が起きるか影響の大きさ
ルックアヘッド
(未来の情報を使う)
その足の終値を知った状態で、その足で取引している致命的
(成績が数倍に)
楽観的な指値約定価格が触れただけで約定したと仮定。実際は行列に並べず未約定致命的
手数料の未計上高頻度戦略では手数料だけで負ける大
資金調達率の未計上無期限のポジション保持コストが丸ごと抜ける中〜大
(保有時間次第)
スリッページの未計上薄い銘柄・急変時のコストが消える中
生存者バイアス上場廃止した銘柄が最初から除外されている中
最も多い間違いは「同じ足で判断して同じ足で約定させる」

「終値がSMAを上抜けたら買う」というロジックを、その足の終値で約定させると、終値を知った上で終値で取引していることになります。現実には終値が確定した瞬間には次の足が始まっています。

正しくは「足Nの確定情報で判断し、足N+1の始値で約定させる」です。この1点を直すだけで成績が半分以下になる戦略は珍しくありません。逆に言えば、直していない戦略の成績はすべて無意味です。

実際のコストを測る|get_fee_ratesと資金調達率

手数料は口座のVIPランクで変わるため、ハードコードせずAPIから取得します。

costs.pyサンプル1
"""costs.py — 自分の口座の実際の手数料率と、銘柄の資金調達率を取得する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations

from dataclasses import dataclass
from decimal import Decimal

import pandas as pd
from pybit.unified_trading import HTTP


@dataclass(frozen=True)
class FeeModel:
    maker: Decimal      # 例: Decimal("0.0002") = 0.02%
    taker: Decimal      # 例: Decimal("0.00055") = 0.055%

    def cost(self, notional: Decimal, is_maker: bool) -> Decimal:
        """約定金額に対する手数料。マイナス(リベート)もありうる。"""
        rate = self.maker if is_maker else self.taker
        return notional * rate

    def round_trip_bps(self, maker_ratio: float = 0.0) -> float:
        """往復コストをbpsで返す。maker_ratio=1.0なら全部メイカー。"""
        rate = float(self.maker) * maker_ratio + float(self.taker) * (1 - maker_ratio)
        return rate * 2 * 10_000


def fetch_fee_model(http: HTTP, symbol: str = "BTCUSDT",
                    category: str = "linear") -> FeeModel:
    """自分の口座に適用される手数料率を取得する(要APIキー)。"""
    res = http.get_fee_rates(category=category, symbol=symbol)
    if res["retCode"] != 0:
        raise RuntimeError(f"get_fee_rates失敗: {res['retMsg']}")
    row = res["result"]["list"][0]
    return FeeModel(maker=Decimal(row["makerFeeRate"]),
                    taker=Decimal(row["takerFeeRate"]))


def fetch_funding_history(http: HTTP, symbol: str, start_ms: int,
                          category: str = "linear") -> pd.DataFrame:
    """資金調達率の履歴を全件取得する(1回200件・新しい順)。"""
    rows: list[dict] = []
    end = None
    for _ in range(200):                       # 安全上限
        kw = dict(category=category, symbol=symbol, limit=200)
        if end:
            kw["endTime"] = end
        res = http.get_funding_rate_history(**kw)
        if res["retCode"] != 0:
            raise RuntimeError(f"get_funding_rate_history失敗: {res['retMsg']}")
        page = res["result"]["list"]
        if not page:
            break
        rows.extend(page)
        oldest = int(page[-1]["fundingRateTimestamp"])
        if oldest <= start_ms:
            break
        end = oldest - 1

    if not rows:
        return pd.DataFrame(columns=["ts", "funding_rate"]).set_index("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)
    return (df[["ts", "funding_rate"]]
            .drop_duplicates(subset="ts")
            .set_index("ts").sort_index())


def funding_interval_hours(http: HTTP, symbol: str,
                           category: str = "linear") -> float:
    """収受間隔(時間)。銘柄によって8h/4h/1hなど異なるので必ず取得する。"""
    res = http.get_instruments_info(category=category, symbol=symbol)
    minutes = int(res["result"]["list"][0]["fundingInterval"])
    return minutes / 60


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

    c = make_client()
    fees = fetch_fee_model(c.http, "BTCUSDT")
    print(f"maker={float(fees.maker):.5%}  taker={float(fees.taker):.5%}")
    print(f"往復コスト(全テイカー): {fees.round_trip_bps(0.0):.2f} bps")
    print(f"往復コスト(全メイカー): {fees.round_trip_bps(1.0):.2f} bps")

    hours = funding_interval_hours(c.http, "BTCUSDT")
    fund = fetch_funding_history(c.http, "BTCUSDT",
                                 start_ms=int(pd.Timestamp("2025-01-01").timestamp() * 1000))
    ann = fund["funding_rate"].mean() * (24 / hours) * 365
    print(f"\n資金調達: 収受間隔{hours:.0f}h  平均{fund['funding_rate'].mean():+.6%}  "
          f"年率換算 {ann:+.2%}  ({len(fund)}件)")
往復コストを最初に暗算する習慣

全テイカーで往復するなら、手数料だけで10bps強(0.1%強)が消えます。1回の平均利幅が20bpsの戦略なら、半分が手数料です。さらにスリッページと資金調達が乗ります。戦略を書く前に「1トレードあたり何bps取れる想定か」と「往復コストは何bpsか」を比べるだけで、成立しない戦略の大半は着手前に排除できます。

中核となる型定義

bt/types.pyサンプル2
"""bt/types.py — バックテストと本番で共有する型
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations

from dataclasses import dataclass, field
from enum import Enum

import pandas as pd


class Side(str, Enum):
    BUY = "Buy"
    SELL = "Sell"

    @property
    def sign(self) -> int:
        return 1 if self is Side.BUY else -1

    @property
    def opposite(self) -> "Side":
        return Side.SELL if self is Side.BUY else Side.BUY


class OrderType(str, Enum):
    MARKET = "Market"
    LIMIT = "Limit"
    POST_ONLY = "PostOnly"       # 指値かつメイカー確定(約定しないことがある)


@dataclass
class Bar:
    ts: pd.Timestamp
    open: float
    high: float
    low: float
    close: float
    volume: float
    turnover: float = 0.0
    mark_close: float | None = None      # 清算判定用(記事07で取得)


@dataclass
class Order:
    side: Side
    qty: float
    order_type: OrderType = OrderType.MARKET
    price: float | None = None
    reduce_only: bool = False
    tag: str = ""
    ttl_bars: int | None = None          # 何本で失効させるか(Noneで無期限)
    # --- エンジンが埋める ---
    placed_at: pd.Timestamp | None = None
    bars_alive: int = 0


@dataclass
class Fill:
    ts: pd.Timestamp
    side: Side
    qty: float
    price: float
    fee: float
    is_maker: bool
    tag: str = ""
    reason: str = "signal"               # signal / liquidation / ttl など


@dataclass
class Position:
    """一方向のみ保持するシンプルなポジション(ワンウェイ想定)。"""

    qty: float = 0.0                     # 正=ロング、負=ショート
    entry_price: float = 0.0

    @property
    def is_flat(self) -> bool:
        return abs(self.qty) < 1e-12

    @property
    def side(self) -> Side | None:
        if self.is_flat:
            return None
        return Side.BUY if self.qty > 0 else Side.SELL

    def notional(self, price: float) -> float:
        return abs(self.qty) * price

    def unrealised(self, price: float) -> float:
        return (price - self.entry_price) * self.qty

    def apply(self, side: Side, qty: float, price: float) -> float:
        """約定を適用し、実現損益を返す。"""
        signed = qty * side.sign
        realised = 0.0

        if self.is_flat or (self.qty > 0) == (signed > 0):
            # 新規 or 増し玉 -> 加重平均で建値を更新
            total = self.qty + signed
            if abs(total) > 1e-12:
                self.entry_price = (
                    (self.entry_price * self.qty + price * signed) / total
                )
            self.qty = total
        else:
            # 反対売買 -> 決済分の損益を確定
            closing = min(abs(signed), abs(self.qty))
            realised = (price - self.entry_price) * closing * (1 if self.qty > 0 else -1)
            self.qty += signed
            if self.is_flat:
                self.qty, self.entry_price = 0.0, 0.0
            elif (self.qty > 0) != (signed < 0):
                # ドテン(反対側に転換)した場合は建値を約定価格に
                self.entry_price = price
        return realised


@dataclass
class Snapshot:
    ts: pd.Timestamp
    equity: float
    cash: float
    position_qty: float
    price: float
    unrealised: float = 0.0
    funding_paid: float = 0.0
    fee_paid: float = 0.0
    events: list[str] = field(default_factory=list)

約定モデル|指値の楽観的約定を避ける

ここがバックテストの信頼性を左右する最重要部分です。よくある実装はこうです。

よくある楽観的な実装(真似しないこと)悪い例
# 買い指値: その足の安値が指値以下なら約定した、とみなす
if bar.low <= limit_price:
    fill(limit_price)          # <- 楽観的すぎる

これは「価格が自分の指値に触れた瞬間、行列の先頭で約定できた」と仮定しています。現実には、その価格には既に大量の注文が並んでおり、価格がタッチして跳ね返った場合、自分の注文は約定しません。

より保守的なモデルは次の2段構えです。

bt/fill.pyサンプル3
"""bt/fill.py — 約定モデル。楽観的な約定を構造的に避ける
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations

import random
from dataclasses import dataclass

from .types import Bar, Order, OrderType, Side


@dataclass
class FillModel:
    """約定判定と約定価格の決定。

    slippage_bps      : 成行の不利側スリッページ(bps)。実測値を入れる
    limit_needs_cross : True なら指値は「突き抜け」が必要(タッチでは約定しない)
    maker_fill_prob   : 突き抜けた場合の約定確率。記事13の実測値を入れる
    seed              : 再現性のための乱数シード
    """

    slippage_bps: float = 2.0
    limit_needs_cross: bool = True
    maker_fill_prob: float = 0.7
    seed: int = 42

    def __post_init__(self) -> None:
        self._rng = random.Random(self.seed)

    # ---- 成行 ----------------------------------------------------------
    def market_price(self, bar: Bar, side: Side) -> float:
        """成行は「次の足の始値 + 不利側スリッページ」で約定させる。

        ルックアヘッドを避けるため、判断した足の終値では約定させない。
        """
        slip = bar.open * self.slippage_bps / 10_000
        return bar.open + slip * side.sign      # 買いは高く、売りは安く

    # ---- 指値 ----------------------------------------------------------
    def limit_filled(self, bar: Bar, order: Order) -> tuple[bool, float]:
        assert order.price is not None
        px = order.price

        if order.side is Side.BUY:
            touched = bar.low <= px
            crossed = bar.low < px
        else:
            touched = bar.high >= px
            crossed = bar.high > px

        if self.limit_needs_cross:
            if not crossed:
                return False, 0.0
        elif not touched:
            return False, 0.0

        # 突き抜けても行列の後ろなら約定しない。確率でモデル化する
        if order.order_type is OrderType.POST_ONLY:
            if self._rng.random() > self.maker_fill_prob:
                return False, 0.0

        # ギャップで飛んだ場合は、より不利な始値で約定する
        if order.side is Side.BUY and bar.open < px:
            return True, bar.open
        if order.side is Side.SELL and bar.open > px:
            return True, bar.open
        return True, px

    def is_maker(self, order: Order) -> bool:
        return order.order_type in (OrderType.LIMIT, OrderType.POST_ONLY)


@dataclass
class PessimisticFillModel(FillModel):
    """感度分析用の悲観シナリオ。これでも生き残る戦略だけを採用する。"""

    slippage_bps: float = 8.0
    limit_needs_cross: bool = True
    maker_fill_prob: float = 0.4
悲観モデルでも生き残るか試す

PessimisticFillModel を用意しているのは、コスト前提を厳しくしたときの頑健性を見るためです。スリッページを4倍、約定確率を半分にしても収益が残るなら、その戦略はコスト仮定に対して頑健です。逆に、この程度の変更で成績が消える戦略は実運用に耐えません。パラメータ最適化より先にこのテストをやってください。

バックテストエンジンの本体

足Nの確定情報で判断し、足N+1で約定させるという順序をエンジン側で強制します。Strategy側でどう書いても未来を見られない構造にするのが要点です。

bt/engine.pyサンプル4
"""bt/engine.py — イベントドリブンのバックテストエンジン
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

設計の要点:
  - Strategyには「確定した足まで」しか渡さない(ルックアヘッドの構造的防止)
  - 発注は次の足で処理される(同じ足での判断と約定を禁止)
"""
from __future__ import annotations

from dataclasses import dataclass, field

import pandas as pd

from costs import FeeModel
from .fill import FillModel
from .types import Bar, Fill, Order, OrderType, Position, Side, Snapshot


@dataclass
class BacktestConfig:
    initial_cash: float = 10_000.0
    leverage_cap: float = 5.0
    maintenance_margin_rate: float = 0.005   # 0.5%(銘柄・リスクリミットで変わる)
    funding_interval_hours: float = 8.0
    allow_liquidation: bool = True


@dataclass
class BacktestResult:
    equity: pd.Series
    fills: list[Fill]
    snapshots: list[Snapshot]
    liquidated: bool = False
    stats: dict = field(default_factory=dict)


class Backtester:
    def __init__(self, bars: pd.DataFrame, fees: FeeModel,
                 fill_model: FillModel | None = None,
                 funding: pd.Series | None = None,
                 config: BacktestConfig | None = None):
        self._validate(bars)
        self.bars = bars
        self.fees = fees
        self.fill = fill_model or FillModel()
        self.funding = funding            # index=UTC時刻, value=資金調達率
        self.cfg = config or BacktestConfig()

    # ---- 入力の健全性チェック(ここで落とすのが最も安い) ---------------
    @staticmethod
    def _validate(bars: pd.DataFrame) -> None:
        if not isinstance(bars.index, pd.DatetimeIndex):
            raise TypeError("indexがDatetimeIndexではありません")
        if bars.index.tz is None:
            raise ValueError("indexがtz-naiveです。UTCを明示してください")
        if not bars.index.is_monotonic_increasing:
            raise ValueError("indexが昇順ではありません(未来参照の危険)")
        if bars.index.duplicated().any():
            raise ValueError("重複した足があります")
        if not (bars["high"] >= bars["low"]).all():
            raise ValueError("high < low の足があります(データ破損)")
        for col in ("open", "high", "low", "close", "volume"):
            if col not in bars.columns:
                raise ValueError(f"列 {col} がありません")

    # ---- 本体 ----------------------------------------------------------
    def run(self, strategy) -> BacktestResult:
        cash = self.cfg.initial_cash
        pos = Position()
        pending: list[Order] = []
        fills: list[Fill] = []
        snapshots: list[Snapshot] = []
        equity_curve: list[tuple[pd.Timestamp, float]] = []

        total_fee = 0.0
        total_funding = 0.0
        liquidated = False

        rows = self.bars.itertuples()
        strategy.on_start(self.bars.iloc[:0])

        for i, row in enumerate(rows):
            bar = Bar(ts=row.Index, open=row.open, high=row.high, low=row.low,
                      close=row.close, volume=row.volume,
                      turnover=getattr(row, "turnover", 0.0),
                      mark_close=getattr(row, "mark_close", None))
            events: list[str] = []

            # ---- (1) 前の足で出された注文を、この足で処理する ----------
            still_pending: list[Order] = []
            for order in pending:
                if order.order_type is OrderType.MARKET:
                    price = self.fill.market_price(bar, order.side)
                    ok = True
                else:
                    ok, price = self.fill.limit_filled(bar, order)

                if not ok:
                    order.bars_alive += 1
                    if order.ttl_bars is not None and order.bars_alive >= order.ttl_bars:
                        events.append(f"expire:{order.tag}")
                    else:
                        still_pending.append(order)
                    continue

                qty = order.qty
                if order.reduce_only:
                    qty = min(qty, abs(pos.qty))
                    if qty <= 0:
                        continue

                is_maker = self.fill.is_maker(order)
                notional = qty * price
                fee = float(self.fees.cost(
                    __import__("decimal").Decimal(str(notional)), is_maker))

                realised = pos.apply(order.side, qty, price)
                cash += realised - fee
                total_fee += fee

                f = Fill(ts=bar.ts, side=order.side, qty=qty, price=price,
                         fee=fee, is_maker=is_maker, tag=order.tag)
                fills.append(f)
                strategy.on_fill(f)
                events.append(f"fill:{order.side.value}:{qty:.6f}@{price:.2f}")

            pending = still_pending

            # ---- (2) 資金調達の課金(該当時刻を跨いだら) --------------
            if self.funding is not None and not pos.is_flat:
                paid = self._apply_funding(bar, pos, i)
                if paid:
                    cash -= paid
                    total_funding += paid
                    events.append(f"funding:{-paid:+.4f}")

            # ---- (3) 清算判定(マーク価格ベース) ----------------------
            equity = cash + pos.unrealised(bar.close)
            if self.cfg.allow_liquidation and not pos.is_flat:
                if self._is_liquidated(bar, pos, cash):
                    price = bar.mark_close or bar.close
                    realised = pos.apply(pos.side.opposite, abs(pos.qty), price)
                    cash += realised
                    fills.append(Fill(ts=bar.ts, side=pos.side or Side.SELL,
                                      qty=abs(pos.qty), price=price, fee=0.0,
                                      is_maker=False, reason="liquidation"))
                    pos = Position()
                    liquidated = True
                    events.append("LIQUIDATED")
                    equity = cash
                    equity_curve.append((bar.ts, equity))
                    break                       # 清算で終了

            equity_curve.append((bar.ts, equity))
            snapshots.append(Snapshot(
                ts=bar.ts, equity=equity, cash=cash, position_qty=pos.qty,
                price=bar.close, unrealised=pos.unrealised(bar.close),
                funding_paid=total_funding, fee_paid=total_fee, events=events,
            ))

            # ---- (4) この足が確定したので、Strategyに判断させる --------
            #      ここで渡すのは「i番目まで」=未来を含まない
            history = self.bars.iloc[: i + 1]
            new_orders = strategy.on_bar(history, pos, equity) or []
            for o in new_orders:
                o.placed_at = bar.ts
                pending.append(o)               # 次の足で処理される

        eq = pd.Series(dict(equity_curve), name="equity")
        eq.index = pd.DatetimeIndex(eq.index, tz="UTC")

        return BacktestResult(
            equity=eq, fills=fills, snapshots=snapshots, liquidated=liquidated,
            stats={"total_fee": total_fee, "total_funding": total_funding},
        )

    # ---- 補助 ----------------------------------------------------------
    def _apply_funding(self, bar: Bar, pos: Position, i: int) -> float:
        """この足の区間に資金調達の収受時刻が含まれていれば課金する。

        ロングは資金調達率が正のとき支払い、負のとき受取り。
        """
        if i == 0:
            return 0.0
        prev_ts = self.bars.index[i - 1]
        window = self.funding.loc[
            (self.funding.index > prev_ts) & (self.funding.index <= bar.ts)
        ]
        if window.empty:
            return 0.0
        rate = float(window.sum())
        notional = pos.notional(bar.close)
        # ロング(qty>0)は正のレートで支払い。ショートは受取り。
        return notional * rate * (1 if pos.qty > 0 else -1)

    def _is_liquidated(self, bar: Bar, pos: Position, cash: float) -> bool:
        """維持証拠金を割ったかを、その足の最悪値で判定する。"""
        worst = bar.low if pos.qty > 0 else bar.high
        if bar.mark_close is not None:
            # マーク価格が取れる場合は乖離を考慮して保守的に寄せる
            worst = min(worst, bar.mark_close) if pos.qty > 0 else max(worst, bar.mark_close)
        equity_worst = cash + (worst - pos.entry_price) * pos.qty
        maint = pos.notional(worst) * self.cfg.maintenance_margin_rate
        return equity_worst <= maint

本番と共有できるStrategyインターフェース

バックテスト用と本番用で戦略コードを二重に書くと、必ず食い違います。同じStrategyクラスを両方で使えるようにインターフェースを揃えます。

bt/strategy.pyサンプル5
"""bt/strategy.py — バックテストと本番で共有するStrategy基底クラス
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations

from abc import ABC, abstractmethod

import pandas as pd

from .types import Fill, Order, OrderType, Position, Side


class Strategy(ABC):
    """on_barは「確定した足の履歴」だけを受け取る。

    バックテストではエンジンが履歴をスライスして渡し、
    本番ではWebSocketの確定足(記事26)で同じ形の履歴を渡す。
    このためStrategyの実装は環境を意識しなくてよい。
    """

    def on_start(self, history: pd.DataFrame) -> None:
        pass

    @abstractmethod
    def on_bar(self, history: pd.DataFrame, position: Position,
               equity: float) -> list[Order] | None:
        """確定足ごとに呼ばれる。返したOrderは「次の足」で処理される。"""

    def on_fill(self, fill: Fill) -> None:
        pass


class SmaCrossStrategy(Strategy):
    """SMAクロスの最小例。動作確認用であり、収益性を主張するものではない。"""

    def __init__(self, fast: int = 20, slow: int = 60, risk_pct: float = 0.5,
                 use_post_only: bool = False):
        if fast >= slow:
            raise ValueError("fast < slow である必要があります")
        self.fast, self.slow = fast, slow
        self.risk_pct = risk_pct
        self.use_post_only = use_post_only

    def on_bar(self, history: pd.DataFrame, position: Position,
               equity: float) -> list[Order] | None:
        if len(history) < self.slow + 1:
            return None                     # ウォームアップ中

        close = history["close"]
        fast_now = close.iloc[-self.fast:].mean()
        slow_now = close.iloc[-self.slow:].mean()
        fast_prev = close.iloc[-self.fast - 1:-1].mean()
        slow_prev = close.iloc[-self.slow - 1:-1].mean()

        crossed_up = fast_prev <= slow_prev and fast_now > slow_now
        crossed_down = fast_prev >= slow_prev and fast_now < slow_now

        price = close.iloc[-1]
        target_notional = equity * self.risk_pct
        qty = round(target_notional / price, 6)
        if qty <= 0:
            return None

        otype = OrderType.POST_ONLY if self.use_post_only else OrderType.MARKET
        limit_px = price if self.use_post_only else None
        orders: list[Order] = []

        if crossed_up and position.qty <= 0:
            if position.qty < 0:
                orders.append(Order(side=Side.BUY, qty=abs(position.qty),
                                    order_type=OrderType.MARKET,
                                    reduce_only=True, tag="exit-short"))
            orders.append(Order(side=Side.BUY, qty=qty, order_type=otype,
                                price=limit_px, ttl_bars=3, tag="entry-long"))
        elif crossed_down and position.qty >= 0:
            if position.qty > 0:
                orders.append(Order(side=Side.SELL, qty=position.qty,
                                    order_type=OrderType.MARKET,
                                    reduce_only=True, tag="exit-long"))
            orders.append(Order(side=Side.SELL, qty=qty, order_type=otype,
                                price=limit_px, ttl_bars=3, tag="entry-short"))
        return orders or None

資金調達率を損益に反映する

無期限先物を数時間以上保有する戦略では、資金調達が損益の大きな部分を占めます。収受間隔は銘柄ごとに違う(8時間・4時間・1時間など)ため、必ず fundingInterval を取得してください。

資金調達率ロングショート意味
プラス支払い受取り先物が現物より高い=買いが過熱
マイナス受取り支払い先物が現物より安い=売りが過熱
「ロング偏重の戦略」は資金調達で削られる

仮想通貨の資金調達率はプラスに偏る傾向があります(強気相場では特に)。つまりロングを持ち続ける戦略は、常に少しずつコストを払っています。年率で数%〜十数%になることもあり、これを計上しないバックテストはロング戦略を体系的に過大評価します。逆にショート側は受取りになるため、資金調達を無視するとショート戦略を過小評価します。片方に偏った戦略では必ず織り込んでください(記事10・記事35)。

清算シミュレーション

レバレッジを掛けた戦略では、「途中で清算されて終わり」という結末を再現しないと意味がありません。エンジンの _is_liquidated() は次の考え方で判定しています。

維持証拠金率は銘柄とポジション規模で変わる

maintenance_margin_rate を0.5%固定にしていますが、実際はBybitのリスクリミット階層によりポジションが大きくなるほど上がります。厳密にやるなら get_risk_limit() で階層表を取得して適用します。正確な清算価格の計算とストレステストは記事22で扱います。バックテストの段階では実際より厳しめの値を入れておくのが安全側です。

成績レポート|Sharpe・Sortino・Calmar

bt/report.pyサンプル6
"""bt/report.py — 成績指標の算出とレポート出力
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations

import numpy as np
import pandas as pd

from .engine import BacktestResult

# 年間の足数。1時間足なら 24*365
BARS_PER_YEAR = {"1min": 525_600, "5min": 105_120, "15min": 35_040,
                 "1h": 8_760, "4h": 2_190, "1D": 365}


def max_drawdown(equity: pd.Series) -> tuple[float, pd.Timestamp | None, int]:
    """最大ドローダウン率、発生時刻、最長水没期間(本数)を返す。"""
    peak = equity.cummax()
    dd = equity / peak - 1.0
    trough = dd.idxmin() if len(dd) else None

    under = (equity < peak).astype(int)
    longest, run = 0, 0
    for v in under:
        run = run + 1 if v else 0
        longest = max(longest, run)
    return float(dd.min()) if len(dd) else 0.0, trough, longest


def metrics(result: BacktestResult, bars_per_year: int = 8_760) -> dict:
    eq = result.equity.dropna()
    if len(eq) < 3:
        return {"error": "データ不足"}

    ret = eq.pct_change().dropna()
    years = len(eq) / bars_per_year
    total_return = eq.iloc[-1] / eq.iloc[0] - 1.0
    cagr = (eq.iloc[-1] / eq.iloc[0]) ** (1 / years) - 1 if years > 0 else float("nan")

    std = ret.std(ddof=1)
    sharpe = (ret.mean() / std * np.sqrt(bars_per_year)) if std > 0 else float("nan")

    downside = ret[ret < 0]
    dstd = downside.std(ddof=1) if len(downside) > 1 else np.nan
    sortino = (ret.mean() / dstd * np.sqrt(bars_per_year)) if dstd and dstd > 0 else float("nan")

    mdd, trough, longest = max_drawdown(eq)
    calmar = cagr / abs(mdd) if mdd < 0 else float("nan")

    # トレード単位の統計
    fills = [f for f in result.fills if f.reason == "signal"]
    fee_total = result.stats.get("total_fee", 0.0)
    funding_total = result.stats.get("total_funding", 0.0)
    gross = eq.iloc[-1] - eq.iloc[0] + fee_total + funding_total

    return {
        "期間(年)": round(years, 2),
        "総リターン": f"{total_return:+.2%}",
        "CAGR": f"{cagr:+.2%}",
        "Sharpe": round(float(sharpe), 2),
        "Sortino": round(float(sortino), 2),
        "最大DD": f"{mdd:.2%}",
        "最大DD時刻": str(trough),
        "最長水没(本)": longest,
        "Calmar": round(float(calmar), 2),
        "約定回数": len(fills),
        "手数料合計": round(fee_total, 2),
        "資金調達合計": round(funding_total, 2),
        "コスト前損益": round(gross, 2),
        "コスト比率": (f"{(fee_total + funding_total) / abs(gross):.1%}"
                     if gross else "n/a"),
        "清算": "あり" if result.liquidated else "なし",
    }


def monthly_table(result: BacktestResult) -> pd.DataFrame:
    eq = result.equity.dropna()
    m = eq.resample("ME").last().pct_change().dropna()
    df = m.to_frame("return")
    df["year"] = df.index.year
    df["month"] = df.index.month
    return df.pivot_table(index="year", columns="month", values="return")


def print_report(result: BacktestResult, bars_per_year: int = 8_760) -> None:
    print("=" * 62)
    for k, v in metrics(result, bars_per_year).items():
        print(f"  {k:<14}: {v}")
    print("=" * 62)
    print("\n月次リターン")
    with pd.option_context("display.float_format", lambda v: f"{v:+.1%}"):
        print(monthly_table(result).to_string())
    print("\n※ 過去の結果は将来の成績を保証しません。"
          "この数値は手数料・スリッページ・資金調達を含む条件での試算です。")
run_backtest.pyサンプル7
"""run_backtest.py — 一連の流れをつなぐ実行スクリプト
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations

import pandas as pd

from bt.engine import BacktestConfig, Backtester
from bt.fill import FillModel, PessimisticFillModel
from bt.report import print_report
from bt.strategy import SmaCrossStrategy
from client import make_client
from costs import FeeModel, fetch_fee_model, fetch_funding_history, funding_interval_hours
from kline_store import KlineStore


def main() -> None:
    c = make_client()
    store = KlineStore()

    SYMBOL, INTERVAL = "BTCUSDT", "60"
    bars = store.load(SYMBOL, INTERVAL)
    if bars.empty:
        raise SystemExit("先に記事07のKlineStore.update()でデータを取得してください")
    bars = bars.loc["2024-01-01":]

    # 実際の手数料と資金調達率を使う
    try:
        fees = fetch_fee_model(c.http, SYMBOL)
    except Exception as e:
        print(f"手数料取得に失敗({e})。既定値を使います")
        from decimal import Decimal
        fees = FeeModel(maker=Decimal("0.0002"), taker=Decimal("0.00055"))

    start_ms = int(bars.index[0].timestamp() * 1000)
    funding = fetch_funding_history(c.http, SYMBOL, start_ms)["funding_rate"]
    hours = funding_interval_hours(c.http, SYMBOL)

    cfg = BacktestConfig(initial_cash=10_000, funding_interval_hours=hours)

    for label, model in (("標準モデル", FillModel(slippage_bps=2.0, maker_fill_prob=0.7)),
                         ("悲観モデル", PessimisticFillModel())):
        print(f"\n########## {label} ##########")
        bt = Backtester(bars, fees, fill_model=model, funding=funding, config=cfg)
        result = bt.run(SmaCrossStrategy(fast=20, slow=60, use_post_only=True))
        print_report(result, bars_per_year=8_760)


if __name__ == "__main__":
    main()

ルックアヘッドバイアスを自動検出する

「未来を見ていないか」は目視では確認できません。テストで機械的に検出します。原理は単純です。未来のデータを改変しても、過去の判断が変わらなければ健全です。

test_lookahead.pyサンプル8
"""test_lookahead.py — 未来参照の自動検出
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

原理: ある時点t以降のデータを壊しても、t以前の判断が1つも変わらなければ、
      その戦略はtの時点で未来を見ていない。
"""
from __future__ import annotations

import numpy as np
import pandas as pd

from bt.engine import Backtester
from bt.strategy import Strategy


def orders_signature(bt: Backtester, strategy: Strategy) -> list[tuple]:
    """発注の履歴を、比較可能なタプル列として取り出す。"""
    result = bt.run(strategy)
    return [(f.ts, f.side.value, round(f.qty, 8), f.tag) for f in result.fills]


def assert_no_lookahead(bars: pd.DataFrame, fees, make_strategy,
                        cut_ratio: float = 0.7, seed: int = 0) -> None:
    """未来を壊した場合と壊さない場合で、cut以前の発注が一致するか検証する。"""
    cut = int(len(bars) * cut_ratio)
    cut_ts = bars.index[cut]

    base = orders_signature(Backtester(bars, fees), make_strategy())

    # cut以降の価格をランダムに壊す(未来だけを別物にする)
    rng = np.random.default_rng(seed)
    tampered = bars.copy()
    idx = tampered.index[cut:]
    noise = rng.uniform(0.5, 1.5, size=len(idx))
    for col in ("open", "high", "low", "close"):
        tampered.loc[idx, col] = tampered.loc[idx, col].to_numpy() * noise
    # high/lowの整合性を保つ
    tampered.loc[idx, "high"] = tampered.loc[idx, ["open", "high", "low", "close"]].max(axis=1)
    tampered.loc[idx, "low"] = tampered.loc[idx, ["open", "high", "low", "close"]].min(axis=1)

    tampered_sig = orders_signature(Backtester(tampered, fees), make_strategy())

    before_base = [o for o in base if o[0] <= cut_ts]
    before_tamp = [o for o in tampered_sig if o[0] <= cut_ts]

    if before_base != before_tamp:
        # 最初に食い違った箇所を報告する
        for i, (a, b) in enumerate(zip(before_base, before_tamp)):
            if a != b:
                raise AssertionError(
                    f"ルックアヘッドを検出: {i}番目の発注が未来の改変で変化しました\n"
                    f"  元      : {a}\n  改変後  : {b}"
                )
        raise AssertionError(
            f"ルックアヘッドを検出: 発注件数が変化しました "
            f"({len(before_base)} -> {len(before_tamp)})"
        )
    print(f"[OK] ルックアヘッドなし(cut={cut_ts}, "
          f"検証対象 {len(before_base)}件の発注)")


if __name__ == "__main__":
    from decimal import Decimal

    from bt.strategy import SmaCrossStrategy
    from costs import FeeModel
    from kline_store import KlineStore

    bars = KlineStore().load("BTCUSDT", "60")
    fees = FeeModel(maker=Decimal("0.0002"), taker=Decimal("0.00055"))

    # 健全な戦略 -> 通る
    assert_no_lookahead(bars, fees, lambda: SmaCrossStrategy(20, 60))

    # わざと未来を見る戦略 -> 検出されるべき
    class Cheater(SmaCrossStrategy):
        def on_bar(self, history, position, equity):
            # historyには未来が入っていないので、本来はこれで漏れない。
            # 実際の事故は「エンジン外で全期間のindicatorを事前計算し、
            # その値を参照する」形で起きる。下はそれを模したもの。
            if getattr(self, "_precomputed", None) is None:
                self._precomputed = bars["close"].shift(-1)   # 未来の終値
            fut = self._precomputed.get(history.index[-1])
            if fut is None or pd.isna(fut):
                return None
            from bt.types import Order, OrderType, Side
            side = Side.BUY if fut > history["close"].iloc[-1] else Side.SELL
            if position.side and position.side is not side:
                return [Order(side=side, qty=abs(position.qty),
                              order_type=OrderType.MARKET, reduce_only=True,
                              tag="cheat-exit")]
            if position.is_flat:
                qty = round(equity * 0.5 / history["close"].iloc[-1], 6)
                return [Order(side=side, qty=qty, tag="cheat")]
            return None

    try:
        assert_no_lookahead(bars, fees, Cheater)
        print("[NG] 検出できませんでした(テストが機能していません)")
    except AssertionError as e:
        print(f"[OK] 期待どおり検出しました:\n{e}")
事故は「事前計算」で起きる

エンジンが履歴をスライスして渡していれば、Strategy内部で未来を見ることはできません。実際の事故は「全期間の指標をpandasで一括計算してから、それをStrategyに参照させる」という書き方で起きます。shift(-1)、rolling(...).mean() の中心化、fillna(method="bfill")、標準化のための全期間平均——これらは全部未来を含みます。上のテストをCIに入れておけば、この種のバグを自動で捕まえられます。

既存ライブラリ(vectorbt / backtesting.py)との比較

観点自作(本記事)backtesting.pyvectorbt
速度遅い(Pythonループ)中非常に速い(ベクトル化)
資金調達率織り込める標準では非対応自分で組み込む
清算の再現可能困難自分で組み込む
PostOnly約定確率モデル化できる不可不可
本番コードの共有同じStrategyを使える不可不可
パラメータ探索自分で書く内蔵非常に強力
学習コスト実装が必要低い高い(独自API)

実務的な使い分けはこうです。広いパラメータ空間の粗探索はvectorbtで高速に回し、有望な候補だけを自作エンジンでコスト込みに精査する。この二段構えが効率的です。ただし最終判断は必ずコストを織り込んだ側で行ってください。粗探索の段階で「手数料なし」の成績を見て有望と判断すると、そもそも成立しない領域を探し続けることになります。

結果を信じる前のチェックリスト

確認基準
ルックアヘッドテストを通したかサンプル8が通ること(必須)
悲観フィルモデルでも残るかスリッページ4倍・約定確率半分でも収益が残る
コスト比率手数料+資金調達がコスト前損益の50%を超えるなら執行の見直しが先
約定回数数十回では統計的に何も言えない。最低100回以上、できれば数百回
清算の有無1回でも清算が出たらレバレッジ設計が破綻している
最長水没期間自分が耐えられる期間か(成績より重要な実務指標)
複数銘柄・複数期間で成立するか1銘柄1期間の好成績は偶然の可能性が高い
パラメータ感度最適値の隣が急に悪化するなら過剰最適化(記事32)
ここまでやっても、まだ足りない

本記事のエンジンは「コストとルックアヘッド」を潰しますが、「多数のパラメータを試して最良を選んだ」ことによるバイアスには対処できません。100通り試して最良のSharpeが1.5だったとして、それは偶然でも達成できる水準です。この問題にはWalk-Forward検証・Purged K-Fold・Deflated Sharpe Ratioという専用の手法が必要で、記事32で扱います。本記事のエンジンで良い結果が出た時点は、まだ何も検証できていない状態です。

参考文献・一次情報
  • Bailey, Borwein, López de Prado & Zhu (2014) Pseudo-Mathematics and Financial Charlatanism: The Effects of Backtest Overfitting on Out-of-Sample Performance(Notices of the AMS)— バックテストの多重検定問題を扱った基本文献
  • López de Prado (2018) Advances in Financial Machine Learning 第13章(Backtesting)
  • Get Fee Rate: bybit-exchange.github.io/docs/v5/account/fee-rate
  • Get Funding Rate History: /market/history-fund-rate
  • Get Risk Limit: /market/risk-limit

よくある疑問Q&A

1分足でバックテストすれば精度が上がりますか?
足を細かくしても、足の中で何が起きたかは分かりません。1分足のOHLCだけでは「高値と安値のどちらが先に付いたか」が不明で、ストップと利確が同じ足に入ると判定できません(この曖昧さは足を細かくしても残ります)。精度を本当に上げたいならティックデータで検証する必要があります(記事08)。実務的には足の粒度を上げるより、コストモデルを厳しくするほうが効果的です。
同じ足で高値と安値の両方に触れた場合、どう処理すべきですか?
曖昧なので不利な側を先に約定させるのが正しい方針です(ストップが先に掛かったとみなす)。有利な側を選ぶと成績が体系的に過大になります。本記事のエンジンは1本の足で1注文ずつ処理する単純な構成ですが、ストップと利確を同時に持つ戦略(記事14)を検証する場合は、この「不利側優先」ルールを明示的に実装してください。
maker_fill_probはどう決めればいいですか?
推測せず実測してください。記事13のTIFベンチマークで、自分が使う銘柄・時間帯・ロットでPostOnlyの約定率を測れます。銘柄の流動性と自分の指値の置き方で大きく変わるため、汎用的な「正解の値」はありません。測れない場合は0.5以下の保守的な値を使い、感度分析(悲観モデル)で影響を確認してください。
Sharpeレシオはいくつあれば良いのですか?
単独の数値では判断できません。何回パラメータを試したかによって、同じSharpeの意味が変わります。100通り試して得たSharpe 2.0は、1通りで得たSharpe 1.0より信頼できないことがあります。この「試行回数で割り引く」考え方がDeflated Sharpe Ratioで、記事32で計算方法を扱います。また、最長水没期間や最大DDのほうが実運用では重要な指標です(心理的に耐えられなければ、途中で止めてしまうため)。
複利計算にすべきか、固定ロットにすべきか
検証の目的で変わります。戦略のエッジを見たいなら固定ロット(リターンが均質になり統計解析しやすい)、実際の資産推移を見たいなら複利です。複利にすると後半の1トレードの影響が過大になり、たまたま後半に大勝ちした戦略が異常に良く見えます。両方回して、固定ロットで安定して勝てているかを先に確認するのが実務的です。サイジングの設計は記事23で扱います。
複数銘柄のポートフォリオはどう検証しますか?
本記事のエンジンは単一銘柄用です。複数銘柄に拡張する場合、証拠金の共有(統合口座では全銘柄で証拠金が共有される)と銘柄間の相関を扱う必要があり、複雑さが一段上がります。まずは単一銘柄で戦略が成立することを確認し、そのうえで銘柄をまたぐ配分をポートフォリオ層として別に設計するのが見通しが良いです(記事25でHRPによる配分を扱います)。
エンジンが遅くて最適化が回りません
Pythonのループなので、数百万本×数百パラメータは現実的ではありません。対策は3つです。(1) 粗探索はvectorbtで行い、候補だけを自作エンジンで精査する。(2) 検証期間を代表的な区間に絞る(ただし期間選択そのものがバイアスになるので注意)。(3) itertuples() をnumpy配列アクセスに置き換える(数倍速くなります)。なお「たくさんのパラメータを試せること」自体が過剰最適化のリスクを高めるので、速度が制約になっているのは必ずしも悪いことではありません。

注意事項・免責事項

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

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

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

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

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

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

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