バックテストとライブの一致性:なぜあなたのボットはバックテストと異なる取引をするのか
あなたは戦略をバックテストに通した。Sharpe 2.1、MaxDD -8%、PnL +67%。ボットを起動した。1ヶ月後に比較した:同じシグナル、同じ期間——しかしライブのPnLは40%低い。ドローダウンは1.5倍深い。10回の取引のうち2回はまったく実行されなかった。
これはバグではない。これはバックテスト-ライブの乖離——バックテスト結果と実際の取引の間の体系的な不一致である。誰にでもある。問題は、あなたがそれを知っているかどうか、そしてコントロールできるかどうかだけだ。
この記事では、乖離の完全な分類体系、それを最小化するためのアーキテクチャパターン、そして本番環境で一致性をモニタリングするための実践的なチェックリストを提供する。
「バックテストでは動いた」症候群

すべてのアルゴトレーダーがこのサイクルを経験する:
- Jupyter notebookで戦略を書いた
- 過去のCSVでバックテストを実行した——結果は素晴らしい
- ロジックをボットとして書き直した(多くの場合、異なる言語やフレームワークで)
- 起動した——結果が合わない
- バグを探したが見つからなかった——「市場が変わった」
問題は市場ではない。問題は、バックテストとボットは同じ現実を異なる方法でモデリングする2つの異なるソフトウェア製品であるということだ。乖離は避けられないが、体系化して最小化することはできる。
乖離の分類体系

すべての乖離の原因は4つのカテゴリに分類される。各カテゴリについて——深刻度評価(1〜5)とPnL乖離への典型的な寄与度。
1. データの乖離(深刻度:3/5)
バックテストが見るデータとボットがリアルタイムで見るデータは同じものではない。
タイムスタンプ。 取引所はタイムスタンプの割り当てに異なるルールでキャンドルを配信する。ある取引所は期間の開始時刻でキャンドルをマークし、別の取引所は終了時刻でマークする。REST APIは実際のクローズ後1〜3秒の遅延でキャンドルを返す場合がある。バックテストはヒストリカルファイルの「理想的な」タイムスタンプで動作する。
OHLCVの集約。 ヒストリカルデータは多くの場合、取引所がリアルタイムで行うのとは異なる方法でプロバイダーによって集約される。差異は最後の桁にあるが、閾値シグナル(MAクロスオーバー、レベルブレイクアウト)ではこれが戦略がポジションに入るかどうかを決定する。
ギャップと欠損データ。 ヒストリカルデータは通常クリーンで、欠損キャンドルは補間で埋められる。リアルタイムではWebSocketが切断され、ボットが30秒間のデータを見逃す可能性がある。
PnL乖離への典型的な寄与度:年間PnLの2〜5%。
2. 約定の乖離(深刻度:5/5)

最も危険な乖離のクラス。バックテストは約定を完璧にシミュレーションするが、現実は理想からほど遠い。
スリッページ。 バックテストはクローズ価格(またはシグナル価格)で注文を約定させる。現実では、成行注文はベストbid/ask価格にスリッページを加えた価格で実行され、スリッページは出来高と流動性に依存する。中程度の流動性のアルトコインで$10Kのポジションの場合、スリッページは0.05〜0.3%になり得る。
回の取引にわたる累積スリッページの公式:
ここでは番目の取引のスリッページで、オーダーブックの深さに依存する:
レイテンシー。 シグナルが生成されてから注文が約定するまでの時間が経過する:シグナル計算(1〜50 ms)、リクエスト送信(10〜200 ms)、取引所でのマッチング(1〜10 ms)。バックテストではレイテンシー = 0。ライブでは価格が動く可能性がある。
部分約定。 バックテストは注文の100%が即座に約定すると仮定する。現実では、指値注文は部分的にしか約定しないか、価格が反転した場合はまったく約定しない可能性がある。流動性の低い市場での成行注文では、注文はオーダーブックの複数のレベルを「滑り落ちる」。
キューの優先順位。 ベストbid価格に置かれた指値注文はすぐに約定しない——そのレベルに以前に置かれたすべての注文の後ろにキューに並ぶ。「価格がタッチした=注文が約定した」と見なすバックテストは体系的に約定率を過大評価する。
PnL乖離への典型的な寄与度:年間PnLの10〜30%。
3. ロジックの乖離(深刻度:4/5)
これはバックテストとボットの間の戦略コード自体の乖離である。
分離されたコードベース。 典型的なアンチパターン:backtests/strategy_a.pyとbot/strategy_a.py——「同じことをする」2つの別々のファイル。3ヶ月の編集の後、それらは必然的に乖離する。誰かがバックテストにフィルターを追加し、ボットに複製するのを忘れた。あるいはその逆——ボットでバグが修正されたがバックテストでは残った。
異なるフレームワーク。 pandasのベクトル化操作によるバックテスト、asyncioのイベント駆動ロジックによるボット。同一の戦略でもエッジケースは異なる方法で処理される:丸め、条件チェックの順序、NaN処理。
ステート管理。 バックテストは通常ステートレスで、データ配列を反復する。ボットはステートフルで、ポジション、残高、注文履歴を保存する。ボットの再起動、ステートの喪失、取引所との非同期——これらすべてが乖離の原因である。
PnL乖離への典型的な寄与度:年間PnLの5〜20%。
4. コストの乖離(深刻度:3/5)
取引コストモデリングの乖離。
ファンディングレート。 ほとんどの無期限先物のバックテストはファンディングレートをまったく考慮しない。10倍レバレッジで平均レート0.01%/8時間の場合、これは/年——ほとんどの戦略のPnLを超える。詳細な分析は記事ファンディングレートがレバレッジを殺すにある。
手数料。 Maker/taker手数料は通常モデル化されるが、しばしば間違ったレートで。VIPティア、BNBディスカウント、リベート——これらすべてが最終結果に影響する。
スプレッド。 キャンドルベースのバックテストはbid-askスプレッドを見ない。1分足キャンドルでclose = 3000だが、実際にはbid = 2999.5、ask = 3000.5。各取引はスプレッドの半分を「コスト」として支払う。
PnL乖離への典型的な寄与度:年間PnLの5〜15%。
累積効果
4つのカテゴリすべてが同時に、そして原則として一方向——トレーダーに不利な方向に作用する:
バックテストPnLからの乖離が20〜50%であることは、未最適化のシステムでは正常である。レバレッジを使用すると効果は倍増する。
一致性のためのアーキテクチャパターン
パターン1:Shared Core(共通コアの抽出)
アイデア:戦略のコア——シグナル生成と約定ロジック——をバックテストとボットの両方で使用される別のモジュールに抽出する。異なるのは周辺のインフラストラクチャのみ:データソースと注文送信メカニズム。
┌─────────────────────────────────────┐
│ strategy_core.py │
│ ┌─────────────┐ ┌───────────────┐ │
│ │ SignalEngine │ │ OrderManager │ │
│ └──────┬──────┘ └──────┬────────┘ │
│ │ │ │
│ generate_signal() create_order()│
└─────────┬───────────────┬───────────┘
│ │
┌─────┴─────┐ ┌─────┴──────┐
│ Backtest │ │ Live │
│ DataFeed │ │ DataFeed │
│ FillModel │ │ Exchange │
└────────────┘ └────────────┘
from dataclasses import dataclass
from typing import Optional
import numpy as np
@dataclass
class Signal:
side: str # 'long' | 'short'
entry_price: float
sl_price: float
tp_price: float
size: float
timestamp: int
@dataclass
class OrderRequest:
side: str
order_type: str # 'market' | 'limit'
price: float
size: float
class StrategyCore:
"""
戦略のコア。バックテストとライブで同一のコード。
インフラストラクチャではなくデータにのみ依存する。
"""
def __init__(self, params: dict):
self.fast_period = params.get('fast_ma', 20)
self.slow_period = params.get('slow_ma', 50)
self.sl_pct = params.get('sl_pct', 0.02)
self.tp_pct = params.get('tp_pct', 0.04)
self.position: Optional[Signal] = None
self._closes: list[float] = []
def on_candle(self, timestamp: int, o: float, h: float,
l: float, c: float, v: float) -> Optional[OrderRequest]:
"""
新しいキャンドルを処理する。OrderRequestまたはNoneを返す。
このメソッドはバックテストとボットから同一に呼び出される。
"""
self._closes.append(c)
if len(self._closes) < self.slow_period:
return None
fast_ma = np.mean(self._closes[-self.fast_period:])
slow_ma = np.mean(self._closes[-self.slow_period:])
if self.position is not None:
exit_order = self._check_exit(h, l, c)
if exit_order:
self.position = None
return exit_order
if self.position is None:
if fast_ma > slow_ma and self._prev_fast_ma <= self._prev_slow_ma:
self.position = Signal(
side='long', entry_price=c,
sl_price=c * (1 - self.sl_pct),
tp_price=c * (1 + self.tp_pct),
size=1.0, timestamp=timestamp,
)
return OrderRequest('buy', 'market', c, 1.0)
self._prev_fast_ma = fast_ma
self._prev_slow_ma = slow_ma
return None
def _check_exit(self, high: float, low: float,
close: float) -> Optional[OrderRequest]:
pos = self.position
if pos.side == 'long':
if low <= pos.sl_price:
return OrderRequest('sell', 'market', pos.sl_price, pos.size)
if high >= pos.tp_price:
return OrderRequest('sell', 'market', pos.tp_price, pos.size)
return None
バックテストとボットは同じStrategyCoreを使用する:
from strategy_core import StrategyCore
def run_backtest(candles, params, fill_model):
core = StrategyCore(params)
trades = []
for candle in candles:
order = core.on_candle(
candle['timestamp'], candle['open'], candle['high'],
candle['low'], candle['close'], candle['volume'],
)
if order:
fill_price = fill_model.simulate_fill(order, candle)
trades.append({'price': fill_price, 'side': order.side})
return trades
from strategy_core import StrategyCore
async def run_live(exchange, symbol, params):
core = StrategyCore(params)
async for candle in exchange.stream_candles(symbol, '1m'):
order = core.on_candle(
candle['timestamp'], candle['open'], candle['high'],
candle['low'], candle['close'], candle['volume'],
)
if order:
await exchange.place_order(symbol, order.side,
order.order_type, order.size)
キールール:StrategyCoreはデータがどこから来るか、注文がどこに送られるかを知らない。OHLCVを受け取りOrderRequestを返す。それ以外はすべてインフラストラクチャレイヤーの責任である。
パターン2:イベント駆動型統一(NautilusTraderアプローチ)
NautilusTraderは統一されたNautilusKernel——決定論的イベント駆動コアとナノ秒分解能を持つRustネイティブエンジン——を通じて一致性を実現する。同一の戦略実装がバックテストとライブトレーディングの両方で動作する。
アーキテクチャはポートとアダプターパターン(ヘキサゴナルアーキテクチャ)に基づいている:
┌──────────────────────────────────┐
│ NautilusKernel │
│ ┌───────────┐ ┌─────────────┐ │
│ │ Strategy │ │ RiskEngine │ │
│ │ (Python) │ │ (Rust) │ │
│ └─────┬─────┘ └──────┬──────┘ │
│ │ │ │
│ ┌─────┴───────────────┴──────┐ │
│ │ Message Bus (Rust) │ │
│ └─────┬───────────────┬──────┘ │
└────────┼───────────────┼─────────┘
│ │
┌─────┴─────┐ ┌─────┴──────┐
│ Backtest │ │ Live │
│ Adapter │ │ Adapter │
│ FillModel │ │ Exchange │
│ (L2 book) │ │ Gateway │
└────────────┘ └────────────┘
利点:
- 決定論的リプレイ。 イベントは厳密に定義された順序で処理される——バックテスト結果はビット再現可能。
- カスタムFillModel。 すべての約定に対するL2オーダーブックシミュレーション——スリッページは実際のオーダーブックの深さに基づいてシミュレーションされる。
- パフォーマンス。 最大500万行/秒、RAMに収まらないデータの処理。
- Redis + PostgreSQL。 Redis経由のキャッシュとメッセージバス、PostgreSQL経由の永続化——バックテストとライブで同一のインフラストラクチャ。
パターン3:Strategy Interface(Freqtradeアプローチ)
Freqtradeは統一されたIStrategyインターフェースを使用する:同一の戦略クラスがバックテストとライブの両方で動作する。唯一の違いは永続化レイヤーである。
class IStrategy:
"""統一インターフェース——実装がバックテストかライブかを知らない。"""
def populate_indicators(self, dataframe, metadata):
"""インジケーターを計算する。"""
dataframe['fast_ma'] = dataframe['close'].rolling(20).mean()
dataframe['slow_ma'] = dataframe['close'].rolling(50).mean()
return dataframe
def populate_entry_trend(self, dataframe, metadata):
"""エントリーシグナルを決定する。"""
dataframe.loc[
(dataframe['fast_ma'] > dataframe['slow_ma']) &
(dataframe['fast_ma'].shift(1) <= dataframe['slow_ma'].shift(1)),
'enter_long'
] = 1
return dataframe
def populate_exit_trend(self, dataframe, metadata):
"""エグジットシグナルを決定する。"""
dataframe.loc[
(dataframe['fast_ma'] < dataframe['slow_ma']),
'exit_long'
] = 1
return dataframe
Freqtradeは追加で提供する:
- Optuna経由のHyperopt——戦略パラメータの最適化
--timeframe-detail——約定精度向上のためのより細かい時間足へのドリルダウン(適応型ドリルダウンと同様)
パターン比較
| Shared Core | イベント駆動型(NautilusTrader) | Strategy Interface(Freqtrade) | |
|---|---|---|---|
| 実装の複雑さ | 低 | 高 | 中 |
| 一致性レベル | 中 | 最大 | 高 |
| 約定シミュレーション | 別のFillModel | L2オーダーブック | --timeframe-detail |
| コア言語 | Python | Rust + Python | Python |
| 適している対象 | カスタムエンジン | 機関投資家トレーディング | クイックスタート |
約定シミュレーションの精度

約定シミュレーションは約定の乖離の主な原因である。精度の3つのレベル:
レベル1:ナイーブ(クローズ価格で約定)
fill_price = candle['close']
エラー: スリッページ、スプレッド、部分約定を考慮しない。PnLを体系的に過大評価する。
レベル2:スリッページモデル
def simulate_fill(order, candle, slippage_bps=5):
"""スリッページ付き約定。"""
base_price = candle['close']
slip = base_price * slippage_bps / 10000
if order.side == 'buy':
return base_price + slip # Buy at a higher price
else:
return base_price - slip # Sell at a lower price
エラー: 固定スリッページは流動性と注文サイズを考慮しない。ナイーブよりは良いが、依然として粗いモデル。
レベル3:1s/100msデータによる適応型ドリルダウン
最良のオプション:SL/TPの約定順序を正確に決定するために実際の細粒度データを使用する。詳細は記事適応型ドリルダウン:可変粒度によるバックテストに記載。
class RealisticFillModel:
"""
複合約定モデル:スリッページ + スプレッド + 出来高インパクト。
"""
def __init__(self, avg_spread_bps=3, impact_coeff=0.1):
self.avg_spread_bps = avg_spread_bps
self.impact_coeff = impact_coeff
def simulate_fill(self, order, candle, order_size_usd):
base_price = candle['close']
spread_cost = base_price * self.avg_spread_bps / 20000
candle_volume_usd = candle['volume'] * candle['close']
participation_rate = order_size_usd / max(candle_volume_usd, 1)
impact = base_price * self.impact_coeff * np.sqrt(participation_rate)
if order.side == 'buy':
return base_price + spread_cost + impact
else:
return base_price - spread_cost - impact
マーケットインパクトの公式(簡略化Almgren-Chrissモデル):
ここではボラティリティ、はインパクト係数、は注文量、は期間の市場出来高。
実践的な一致性チェックリスト
ボットをライブで起動する前に、各項目を確認する:
コード:
- 戦略が共有コアを使用している(バックテストとライブで1つのモジュール)
- シグナルロジックが2箇所で重複していない
- ユニットテストが同一入力に対する同一のコア出力を検証する
- 条件チェックの順序が同一である(SLがTPの前か?TPがSLの前か?)
データ:
- タイムスタンプ形式が同一(UTC、同一プロバイダー)
- OHLCV集約が同一ルールを使用
- 欠損キャンドルの処理が同一
- Look-ahead biasがない——バックテストが未来を覗いていない
約定:
- スリッページモデルが実データでキャリブレーションされている
- 部分約定がモデル化されている(少なくとも悲観的に推定されている)
- 指値注文にキュー優先順位モデルがある
- レイテンシーが考慮されている(シグナルから約定まで100〜500 msの遅延)
コスト:
- Maker/taker手数料が現在のレートで含まれている
- 無期限先物でファンディングレートが考慮されている
- スプレッドがモデル化されている(少なくとも平均値)
インフラストラクチャ:
- ステート永続化:ボットが再起動後にポジションを回復する
- 再接続ロジック:WebSocketがデータ損失なく再接続する
- ロギング:すべての注文と約定が事後分析のためにログに記録される
本番環境での乖離モニタリング
一致性は一度のチェックではなく、継続的なプロセスである。ボットを起動した後、乖離をリアルタイムで追跡する必要がある。
シャドーモード(ペーパートレーディング)
同じデータでバックテストと並行してボットを実行する。ボットはシグナルを生成するが注文は送信しない——ログに記録するだけ。同時に、バックテストが同じデータを処理する。比較する:
class DivergenceMonitor:
"""
バックテストとライブボットのシグナルをリアルタイムで比較する。
"""
def __init__(self, tolerance_pct=0.5):
self.tolerance = tolerance_pct / 100
self.divergences = []
def compare_signal(self, backtest_signal, live_signal, timestamp):
"""バックテストとライブのシグナルを比較する。"""
if backtest_signal is None and live_signal is None:
return # Both silent — OK
if (backtest_signal is None) != (live_signal is None):
self.divergences.append({
'timestamp': timestamp,
'type': 'signal_mismatch',
'backtest': backtest_signal,
'live': live_signal,
'severity': 'HIGH',
})
return
price_diff = abs(
backtest_signal.entry_price - live_signal.entry_price
) / backtest_signal.entry_price
if price_diff > self.tolerance:
self.divergences.append({
'timestamp': timestamp,
'type': 'price_divergence',
'diff_pct': price_diff * 100,
'severity': 'MEDIUM',
})
def compare_fill(self, backtest_fill, live_fill, timestamp):
"""約定を比較する。"""
if backtest_fill and live_fill:
slippage = (live_fill['price'] - backtest_fill['price']
) / backtest_fill['price']
self.divergences.append({
'timestamp': timestamp,
'type': 'fill_divergence',
'slippage_bps': slippage * 10000,
'severity': 'LOW' if abs(slippage) < 0.001 else 'MEDIUM',
})
def report(self):
"""週次乖離レポート。"""
from collections import Counter
severity_counts = Counter(d['severity'] for d in self.divergences)
return {
'total_divergences': len(self.divergences),
'by_severity': dict(severity_counts),
'avg_slippage_bps': np.mean([
d['slippage_bps'] for d in self.divergences
if d['type'] == 'fill_divergence'
]) if any(d['type'] == 'fill_divergence'
for d in self.divergences) else 0,
}
ダッシュボードメトリクス
| メトリクス | 計算式 | アラート閾値 |
|---|---|---|
| シグナル一致率 | < 95% | |
| 平均スリッページ | (bps) | > 10 bps |
| 約定率 | < 90% | |
| PnL乖離 | > 20% | |
| レイテンシー p99 | シグナルから約定の99パーセンタイル | > 500 ms |
スリッページモデルのキャリブレーション
2〜4週間のデータを蓄積した後、実データでバックテストのスリッページモデルをキャリブレーションできる:
def calibrate_slippage(live_fills: list[dict]) -> dict:
"""
実際の約定を使用してスリッページモデルをキャリブレーションする。
live_fills: [{'expected_price': ..., 'actual_price': ..., 'size_usd': ..., 'volume_usd': ...}]
"""
slippages = []
participation_rates = []
for fill in live_fills:
slip = abs(fill['actual_price'] - fill['expected_price']
) / fill['expected_price']
part = fill['size_usd'] / max(fill['volume_usd'], 1)
slippages.append(slip)
participation_rates.append(part)
slippages = np.array(slippages)
participation_rates = np.array(participation_rates)
from scipy.optimize import curve_fit
def model(x, k, base):
return k * np.sqrt(x) + base
popt, _ = curve_fit(model, participation_rates, slippages,
p0=[0.1, 0.0001])
return {
'impact_coeff': popt[0],
'base_slippage': popt[1],
'mean_slippage_bps': np.mean(slippages) * 10000,
'p95_slippage_bps': np.percentile(slippages, 95) * 10000,
}
他のツールとの関連
バックテスト-ライブの一致性は孤立したタスクではない。「幻想のないバックテスト」シリーズの他のツールと交差する:
- 適応型ドリルダウン ——約定シミュレーションの精度を向上させ、約定の一致性の重要な要素。
- ファンディングレート ——バックテストがファンディングをモデル化しない場合、レバレッジ3倍超で一致性は不可能。
- Parquetキャッシュ ——事前計算された時間足とインジケーターにより、バックテストがボットと同じデータを見ることを保証する。RunningCandleBufferエミュレーション = リアルタイム更新。
- Polars vs Pandas ——pandas(バックテスト)からPolars(ライブ)に切り替える際、数値結果が一致することを確認する必要がある。
- ウォークフォワード ——アウトオブサンプルデータでのウォークフォワードは戦略がどのように劣化するかを示す——これはインサンプルのバックテストよりもライブに近い。
推奨事項
-
共有コアは必須。 シグナル生成のための単一コードベースが一致性の最低要件。同一ロジックの2つのファイルは1ヶ月以内に乖離を保証する。
-
約定モデルをキャリブレーションする。 固定5 bpsのスリッページは何もないよりまし。実データでキャリブレーションされたスリッページモデルは大幅に優れている。
-
最初の2〜4週間はシャドーモードを使用する。 シグナル一致率が95%以上に達するまで実際のお金で取引しない。
-
ファンディングレートをモデル化する。 無期限先物では、これはオプションではなく必須。ファンディングはレバレッジ5倍超ですべてのPnLを消費する可能性がある。
-
すべてをログに記録する。 すべてのシグナル、すべての注文、すべての約定——タイムスタンプ付き。ログがなければ事後分析は不可能。
-
比較を自動化する。 週次のDivergenceMonitorレポートが自動的に届くべき。PnLがマイナスになるまで待たない。
-
デフォルトで悲観的なバックテスト。 バックテストで期待値を控えめにしてライブで嬉しい驚きを得る方が、その逆よりも良い。スリッページモデルは保守的であるべき。
結論
バックテスト-ライブの一致性はシステムの属性ではなく、プロセスである。完璧な一致性は存在しない:バックテストは定義上、現実のモデルであり、モデルは常に単純化する。しかし「モデルが5%異なる」と「モデルが50%異なる」の違いはアーキテクチャによって決まる。
成熟度の3つのレベル:
- ベーシック。 共有コア、固定スリッページ、手数料。乖離:10〜20%。
- アドバンスト。 イベント駆動型アーキテクチャ、適応型ドリルダウン、ファンディングモデル、シャドーモード。乖離:5〜10%。
- 機関投資家レベル。 L2オーダーブックシミュレーション、キャリブレーションされたインパクトモデル、リアルタイム乖離モニタリング。乖離:2〜5%。
あなたのタスクは、自分がどのレベルにいるかを判断し、自分のポジションサイズとレバレッジにとってどの程度の乖離が許容可能かを理解することだ。
参考リンク
- NautilusTrader — High-Performance Algorithmic Trading Platform
- Freqtrade — Free, open source crypto trading bot
- Almgren, R., Chriss, N. — Optimal Execution of Portfolio Transactions (2001)
- Lopez de Prado — Advances in Financial Machine Learning, Chapter 12: Backtesting
- Ernest Chan — Quantitative Trading: How to Build Your Own Algorithmic Trading Business
- Hexagonal Architecture (Ports and Adapters) — Alistair Cockburn
- Optuna — Hyperparameter Optimization Framework
Citation
@article{soloviov2026backtestliveparity,
author = {Soloviov, Eugen},
title = {Backtest-live parity: why your bot trades differently from the backtest},
year = {2026},
url = {https://marketmaker.cc/ru/blog/post/backtest-live-parity},
description = {Complete taxonomy of divergences between backtesting and live trading: from slippage and partial fills to codebase desynchronization. Architectural patterns for achieving parity and a production monitoring checklist.}
}
MarketMaker.cc Team
クオンツ・リサーチ&戦略