Backtest-live parity: почему ваш бот торгует не так, как бэктест
Вы прогнали стратегию через бэктест. Sharpe 2.1, MaxDD -8%, PnL +67%. Запустили бота. Через месяц сравниваете: те же сигналы, тот же период — но live PnL на 40% ниже. Просадка в полтора раза глубже. Два трейда из десяти вообще не были исполнены.
Это не баг. Это backtest-live divergence — систематическое расхождение между результатами бэктеста и реальной торговли. Оно есть у всех. Вопрос только в том, знаете ли вы о нём и умеете ли его контролировать.
В этой статье — полная таксономия расхождений, архитектурные паттерны для их минимизации и практический чек-лист для мониторинга parity в продакшене.
Синдром «it worked in backtest»

Каждый алготрейдер проходит через этот цикл:
- Написал стратегию в Jupyter notebook
- Прогнал бэктест на историческом CSV — результаты отличные
- Переписал логику на бота (часто на другом языке или фреймворке)
- Запустил — результаты не совпадают
- Начал искать баг, не нашёл — «рынок изменился»
Проблема не в рынке. Проблема в том, что бэктест и бот — это два разных программных продукта, которые по-разному моделируют одну и ту же реальность. Расхождения неизбежны, но их можно систематизировать и минимизировать.
Таксономия расхождений

Все источники divergence делятся на четыре категории. Для каждой — оценка severity (от 1 до 5) и типичный вклад в расхождение PnL.
1. Data divergences (severity: 3/5)
Данные, которые видит бэктест, и данные, которые видит бот в реальном времени — это не одно и то же.
Timestamps. Биржи отдают свечи с разными правилами привязки к временной метке. Одна биржа помечает свечу началом периода, другая — концом. REST API может вернуть свечу с задержкой 1–3 секунды после фактического закрытия. Бэктест работает с «идеальными» timestamps из исторического файла.
OHLCV-агрегация. Исторические данные часто агрегируются провайдером иначе, чем это делает биржа в реальном времени. Разница в последнем знаке — но при пороговых сигналах (пересечение MA, пробитие уровня) это решает, войдёт стратегия в позицию или нет.
Gap и пропуски. Исторические данные обычно чистые — пропущенные свечи заполнены интерполяцией. В реальном времени WebSocket может отвалиться, и бот пропустит 30 секунд данных.
Типичный вклад в PnL divergence: 2–5% от итогового PnL за год.
2. Execution divergences (severity: 5/5)

Самый опасный класс расхождений. Бэктест симулирует исполнение идеально — реальность далека от идеала.
Slippage. Бэктест заполняет ордер по цене close (или по цене сигнала). В реальности market-ордер исполняется по best bid/ask плюс проскальзывание, зависящее от объёма и ликвидности. Для позиции в $10K на среднеликвидном альткоине slippage может составлять 0.05–0.3%.
Формула кумулятивного slippage за сделок:
где — slippage -й сделки, зависящий от orderbook depth:
Latency. От момента генерации сигнала до исполнения ордера проходит время: вычисление сигнала (1–50 мс), отправка запроса (10–200 мс), матчинг на бирже (1–10 мс). В бэктесте latency = 0. В live — цена может уйти.
Partial fills. Бэктест предполагает, что 100% ордера исполняется мгновенно. В реальности limit-ордер может быть исполнен частично — или не исполнен вовсе, если цена развернулась. Для market-ордера на неликвидном рынке ордер «проскальзывает» по нескольким уровням стакана.
Queue priority. Limit-ордер, поставленный по цене best bid, не исполнится сразу — он встаёт в очередь за всеми ранее размещёнными ордерами на этом уровне. Бэктест, который считает «цена коснулась — ордер исполнен», систематически завышает fill rate.
Типичный вклад в PnL divergence: 10–30% от итогового PnL за год.
3. Logic divergences (severity: 4/5)
Это расхождения в самом коде стратегии между бэктестом и ботом.
Раздельные кодовые базы. Классический антипаттерн: backtests/strategy_a.py и bot/strategy_a.py — два отдельных файла, которые «делают одно и то же». После трёх месяцев правок они неизбежно расходятся. Кто-то добавил фильтр в бэктест и забыл продублировать в боте. Или наоборот — в боте поправили баг, который остался в бэктесте.
Разные фреймворки. Бэктест на pandas с vectorized операциями, бот на asyncio с event-driven логикой. Даже при идентичной стратегии edge cases обрабатываются по-разному: округление, порядок проверки условий, обработка NaN.
State management. Бэктест обычно stateless — проходит по массиву данных. Бот stateful — хранит позиции, балансы, историю ордеров. Рестарт бота, потеря state, десинхронизация с биржей — всё это источники расхождений.
Типичный вклад в PnL divergence: 5–20% от итогового PnL за год.
4. Cost divergences (severity: 3/5)
Расхождения в моделировании торговых издержек.
Funding rates. Большинство бэктестов perpetual futures не учитывают funding rates вообще. При leverage 10× и средней ставке 0.01% за 8 часов это в год — больше, чем PnL большинства стратегий. Подробный разбор — в статье Funding rates убивают ваш leverage.
Комиссии. Maker/taker комиссии обычно моделируются, но часто с неправильной ставкой. VIP-уровни, BNB-скидки, rebates — всё это влияет на итоговый результат.
Spread. Бэктест по свечам не видит bid-ask spread. На минутной свече close = 3000, но в реальности bid = 2999.5 и ask = 3000.5. Каждая сделка «стоит» полспреда.
Типичный вклад в PnL divergence: 5–15% от итогового PnL за год.
Суммарный эффект
Все четыре категории действуют одновременно и, как правило, в одну сторону — против трейдера:
Суммарная divergence в 20–50% от бэктест-PnL — норма для непродуманной системы. При leverage эффект мультиплицируется.
Архитектурные паттерны для parity
Паттерн 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:
"""
Ядро стратегии. Одинаковый код для бэктеста и live.
Зависит только от данных, не от инфраструктуры.
"""
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: Event-driven unification (подход NautilusTrader)

NautilusTrader реализует parity через единое ядро NautilusKernel — Rust-native engine с детерминированным event-driven ядром и наносекундной резолюцией. Одна и та же реализация стратегии работает и в бэктесте, и в live-торговле.
Архитектура строится на паттерне ports and adapters (hexagonal architecture):
┌──────────────────────────────────┐
│ NautilusKernel │
│ ┌───────────┐ ┌─────────────┐ │
│ │ Strategy │ │ RiskEngine │ │
│ │ (Python) │ │ (Rust) │ │
│ └─────┬─────┘ └──────┬──────┘ │
│ │ │ │
│ ┌─────┴───────────────┴──────┐ │
│ │ Message Bus (Rust) │ │
│ └─────┬───────────────┬──────┘ │
└────────┼───────────────┼─────────┘
│ │
┌─────┴─────┐ ┌─────┴──────┐
│ Backtest │ │ Live │
│ Adapter │ │ Adapter │
│ FillModel │ │ Exchange │
│ (L2 book) │ │ Gateway │
└────────────┘ └────────────┘
Преимущества:
- Deterministic replay. События обрабатываются в строго определённом порядке — результат бэктеста воспроизводим побитово.
- Custom FillModel. L2-моделирование ордербука для каждого исполнения — slippage симулируется на основе реальной глубины стакана.
- Производительность. До 5 миллионов строк/сек, обработка данных, не помещающихся в RAM.
- Redis + PostgreSQL. Cache и message bus через Redis, persistence через PostgreSQL — одинаковая инфраструктура для бэктеста и live.
Паттерн 3: Strategy Interface (подход Freqtrade)
Freqtrade использует единый интерфейс IStrategy: один и тот же класс стратегии работает в бэктесте и в live. Разница только в persistence layer.
class IStrategy:
"""Единый интерфейс — реализация не знает, бэктест это или live."""
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 дополнительно предоставляет:
- Hyperopt через Optuna — оптимизация параметров стратегии
--timeframe-detail— drill-down в более мелкий таймфрейм для уточнения fill (аналогично адаптивному drill-down)
Сравнение паттернов
| Shared Core | Event-driven (NautilusTrader) | Strategy Interface (Freqtrade) | |
|---|---|---|---|
| Сложность внедрения | Низкая | Высокая | Средняя |
| Уровень parity | Средний | Максимальный | Высокий |
| Fill simulation | Отдельный FillModel | L2 orderbook | --timeframe-detail |
| Язык ядра | Python | Rust + Python | Python |
| Подходит для | Собственных движков | Институциональной торговли | Быстрого старта |
Точность fill simulation

Fill simulation — главный источник execution divergence. Три уровня точности:
Уровень 1: Naive (fill по цене close)
fill_price = candle['close']
Ошибка: не учитывает slippage, spread, partial fills. Систематически завышает PnL.
Уровень 2: Slippage model
def simulate_fill(order, candle, slippage_bps=5):
"""Fill с учётом slippage."""
base_price = candle['close']
slip = base_price * slippage_bps / 10000
if order.side == 'buy':
return base_price + slip # Покупаем дороже
else:
return base_price - slip # Продаём дешевле
Ошибка: фиксированный slippage не учитывает ликвидность и размер ордера. Лучше, чем naive, но всё ещё грубая модель.
Уровень 3: Adaptive drill-down с 1s/100ms данными
Лучший вариант: использовать реальные данные мелкой гранулярности для точного определения порядка заполнения SL/TP. Подробно описано в статье Adaptive drill-down: бэктест с переменной гранулярностью.
class RealisticFillModel:
"""
Комбинированная модель fill: slippage + spread + volume impact.
"""
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
Формула market impact (модель Альмгрена-Кристса, упрощённая):
где — волатильность, — коэффициент impact, — объём ордера, — объём рынка за период.
Практический чек-лист parity

Перед запуском бота в live проверьте каждый пункт:
Код:
- Стратегия использует shared core (один модуль для бэктеста и live)
- Нет дублирования логики сигналов в двух местах
- Unit-тесты проверяют идентичность выходов core для одинаковых входов
- Порядок проверки условий идентичен (SL перед TP? TP перед SL?)
Данные:
- Timestamp-формат одинаковый (UTC, один и тот же провайдер)
- OHLCV-агрегация использует одни правила
- Обработка пропущенных свечей идентична
- Нет look-ahead bias — бэктест не заглядывает в будущее
Исполнение:
- Slippage model калиброван по реальным данным
- Partial fills смоделированы (или хотя бы пессимистично оценены)
- Limit-ордера имеют модель queue priority
- Latency учтена (задержка 100–500 мс от сигнала до fill)
Costs:
- Maker/taker комиссии включены с актуальной ставкой
- Funding rates учтены для perpetual futures
- Spread смоделирован (хотя бы средний)
Инфраструктура:
- State persistence: бот восстанавливает позиции после рестарта
- Reconnection logic: WebSocket переподключается без потери данных
- Logging: все ордера и fills логируются для post-mortem анализа
Мониторинг divergence в продакшене
Parity — не разовая проверка, а непрерывный процесс. После запуска бота необходимо отслеживать расхождения в реальном времени.
Shadow mode (paper trading)

Запустите бота параллельно с бэктестом на тех же данных. Бот генерирует сигналы, но не отправляет ордера — только логирует. Одновременно бэктест обрабатывает те же данные. Сравните:
class DivergenceMonitor:
"""
Сравнивает сигналы бэктеста и live-бота в реальном времени.
"""
def __init__(self, tolerance_pct=0.5):
self.tolerance = tolerance_pct / 100
self.divergences = []
def compare_signal(self, backtest_signal, live_signal, timestamp):
"""Сравнить сигнал бэктеста и live."""
if backtest_signal is None and live_signal is None:
return # Оба молчат — 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):
"""Еженедельный отчёт о divergence."""
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,
}
Метрики для дашборда
| Метрика | Формула | Порог тревоги |
|---|---|---|
| Signal match rate | < 95% | |
| Avg slippage | (bps) | > 10 bps |
| Fill rate | < 90% | |
| PnL divergence | > 20% | |
| Latency p99 | 99-й перцентиль signal-to-fill | > 500 мс |
Калибровка slippage model

После накопления данных за 2–4 недели можно откалибровать slippage model бэктеста по реальным данным:
def calibrate_slippage(live_fills: list[dict]) -> dict:
"""
Калибровка slippage model по реальным fills.
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,
}
Связь с другими инструментами
Backtest-live parity — не изолированная задача. Она пересекается с другими инструментами из серии «Бэктесты без иллюзий»:
- Adaptive drill-down — повышает точность fill simulation, ключевой компонент execution parity.
- Funding rates — если бэктест не моделирует funding, parity невозможна при leverage > 3×.
- Parquet-кэш — предвычисленные таймфреймы и индикаторы гарантируют, что бэктест видит те же данные, что и бот. Эмуляция RunningCandleBuffer = реалтайм-обновление.
- Polars vs Pandas — при переходе с pandas (бэктест) на Polars (live) нужно убедиться, что числовые результаты совпадают.
- Walk-Forward — walk-forward на out-of-sample данных показывает, как стратегия деградирует — это ближе к live, чем in-sample бэктест.
Рекомендации
-
Shared core — обязательно. Одна кодовая база для генерации сигналов — минимальное требование для parity. Два файла с одинаковой логикой — гарантированная divergence через месяц.
-
Калибруйте fill model. Фиксированный slippage 5 bps — лучше, чем ничего. Slippage model, откалиброванный по реальным данным — значительно лучше.
-
Используйте shadow mode первые 2–4 недели. Не торгуйте реальными деньгами, пока signal match rate не достигнет 95%+.
-
Моделируйте funding rates. Для perpetual futures это не опционально — это обязательно. Funding может съесть весь PnL при leverage > 5×.
-
Логируйте всё. Каждый сигнал, каждый ордер, каждый fill — с timestamps. Без логов post-mortem анализ невозможен.
-
Автоматизируйте сравнение. Еженедельный отчёт DivergenceMonitor должен приходить автоматически. Не ждите, пока PnL уйдёт в минус.
-
Пессимистичный бэктест по умолчанию. Лучше занизить ожидания в бэктесте и приятно удивиться в live, чем наоборот. Slippage model должен быть консервативным.
Заключение

Backtest-live parity — это не свойство системы, а процесс. Идеальной parity не существует: бэктест по определению — модель реальности, а модель всегда упрощает. Но разницу между «модель отличается на 5%» и «модель отличается на 50%» определяет архитектура.
Три уровня зрелости:
- Базовый. Shared core, фиксированный slippage, комиссии. Divergence: 10–20%.
- Продвинутый. Event-driven architecture, adaptive drill-down, funding model, shadow mode. Divergence: 5–10%.
- Институциональный. L2 orderbook simulation, калиброванный impact model, real-time divergence monitoring. Divergence: 2–5%.
Ваша задача — определить, на каком уровне вы находитесь, и понимать, какую divergence вы считаете приемлемой для вашего размера позиции и leverage.
Полезные ссылки
- NautilusTrader — High-Performance Algorithmic Trading Platform
- Freqtrade — Free, open source crypto trading bot
- Almgren, R., Chriss, N. — Optimal Execution of Portfolio Transactions (2001)
- López 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
Цитирование
@article{soloviov2026backtestliveparity,
author = {Soloviov, Eugen},
title = {Backtest-live parity: почему ваш бот торгует не так, как бэктест},
year = {2026},
url = {https://marketmaker.cc/ru/blog/post/backtest-live-parity},
description = {Полная таксономия расхождений между бэктестом и live-торговлей: от slippage и partial fills до рассинхронизации кодовых баз. Архитектурные паттерны для достижения parity и чек-лист мониторинга в продакшене.}
}
MarketMaker.cc Team
Количественные исследования и стратегии