Разработка системы бэктестинга арбитражных стратегий
Арбитражные стратегии эксплуатируют ценовые расхождения между биржами, инструментами или связанными активами. Их бэктестинг значительно сложнее направленных стратегий: нужно моделировать синхронные данные с нескольких источников, latency между биржами и реальную доступность ликвидности.
Типы арбитражных стратегий
Cross-Exchange Arbitrage — BTC стоит $43,200 на Binance и $43,250 на Kraken. Купить там, продать здесь. Проблема: пока исполняется вторая нога, цена может измениться.
Statistical Arbitrage (Pairs Trading) — BTC и ETH исторически движутся вместе. При расширении спреда — покупаем отставший, продаём обогнавший. Ставим на возврат к средней.
Triangular Arbitrage — внутри одной биржи: BTC/USDT → ETH/BTC → ETH/USDT → USDT. Если произведение курсов не равно 1 — есть прибыль.
Funding Rate Arbitrage — если funding rate на Binance Futures > 0, открываем spot long + futures short. Получаем funding, не имея рыночного риска.
Модель данных для cross-exchange арбитража
import pandas as pd
import numpy as np
from dataclasses import dataclass
@dataclass
class ArbitrageOpportunity:
timestamp: int
symbol: str
buy_exchange: str
sell_exchange: str
buy_price: float # best ask на buy exchange
sell_price: float # best bid на sell exchange
gross_spread: float # sell_price - buy_price
spread_pct: float # gross_spread / buy_price
buy_commission: float
sell_commission: float
net_spread_pct: float # spread_pct - buy_commission - sell_commission
max_size_usd: float # ограничен доступной ликвидностью
class CrossExchangeArbitrageBacktester:
def __init__(
self,
commission_per_exchange: float = 0.001,
slippage_per_exchange: float = 0.0005,
min_profit_pct: float = 0.002, # минимальная прибыль для входа
transfer_fee_usd: float = 2.0, # стоимость перевода между биржами
):
self.commission = commission_per_exchange
self.slippage = slippage_per_exchange
self.min_profit = min_profit_pct
self.transfer_fee = transfer_fee_usd
def find_opportunities(
self,
exchange_data: dict[str, pd.DataFrame], # exchange → OHLCV
symbol: str,
) -> pd.DataFrame:
"""Находим арбитражные возможности в исторических данных"""
opportunities = []
# Синхронизируем данные по timestamp
merged = self._merge_exchange_data(exchange_data)
for timestamp, row in merged.iterrows():
exchanges = list(exchange_data.keys())
for i, buy_ex in enumerate(exchanges):
for sell_ex in exchanges:
if buy_ex == sell_ex:
continue
buy_price = row[f'{buy_ex}_ask'] * (1 + self.slippage)
sell_price = row[f'{sell_ex}_bid'] * (1 - self.slippage)
gross_spread = sell_price - buy_price
spread_pct = gross_spread / buy_price
total_commission = self.commission * 2
net_spread = spread_pct - total_commission
if net_spread > self.min_profit:
max_size = min(
row[f'{buy_ex}_ask_size'] * buy_price,
row[f'{sell_ex}_bid_size'] * sell_price,
10_000, # наш лимит на сделку
)
opportunities.append({
'timestamp': timestamp,
'buy_exchange': buy_ex,
'sell_exchange': sell_ex,
'buy_price': buy_price,
'sell_price': sell_price,
'net_spread_pct': net_spread,
'max_size_usd': max_size,
'estimated_profit': max_size * net_spread,
})
return pd.DataFrame(opportunities)
Статистический арбитраж (Pairs Trading)
from scipy import stats
class PairsTradingBacktester:
def __init__(self, window: int = 60, entry_z: float = 2.0, exit_z: float = 0.5):
self.window = window
self.entry_z = entry_z
self.exit_z = exit_z
def compute_spread(
self,
price_a: pd.Series,
price_b: pd.Series,
) -> tuple[pd.Series, float]:
"""Вычисляем коинтегрированный спред"""
# OLS: price_a = beta * price_b + alpha
slope, intercept, r_value, _, _ = stats.linregress(price_b, price_a)
# Проверка коинтеграции (ADF тест)
from statsmodels.tsa.stattools import coint
_, p_value, _ = coint(price_a, price_b)
if p_value > 0.05:
raise ValueError(f"Pairs not cointegrated (p-value={p_value:.3f})")
spread = price_a - slope * price_b - intercept
return spread, slope
def run(
self,
prices_a: pd.Series,
prices_b: pd.Series,
symbol_a: str,
symbol_b: str,
) -> BacktestResult:
portfolio = Portfolio(initial_cash=100_000)
trades = []
for i in range(self.window, len(prices_a)):
# Rolling window для расчёта статистики
window_a = prices_a.iloc[i - self.window:i]
window_b = prices_b.iloc[i - self.window:i]
spread, beta = self.compute_spread(window_a, window_b)
current_spread = prices_a.iloc[i] - beta * prices_b.iloc[i]
# Z-score спреда
spread_mean = spread.mean()
spread_std = spread.std()
if spread_std == 0:
continue
z_score = (current_spread - spread_mean) / spread_std
# Торговые сигналы
position = portfolio.get_position_net(symbol_a)
if position == 0:
if z_score > self.entry_z:
# Спред высокий: продаём A, покупаем B
portfolio.sell(symbol_a, prices_a.iloc[i])
portfolio.buy(symbol_b, prices_b.iloc[i], quantity_usd=50_000)
elif z_score < -self.entry_z:
# Спред низкий: покупаем A, продаём B
portfolio.buy(symbol_a, prices_a.iloc[i], quantity_usd=50_000)
portfolio.sell(symbol_b, prices_b.iloc[i])
elif abs(z_score) < self.exit_z:
# Закрываем позицию
portfolio.close_all()
return BacktestResult(portfolio, trades)
Реалистичные допущения арбитражного бэктеста
Latency — между получением цены и исполнением ордера проходит время. На cross-exchange арбитраже это 10–100ms. За это время цена может уйти. Моделируем добавлением slippage к каждой ноге.
Partial fills — книга заявок показывает объём, но большой ордер "ест" несколько уровней. Моделируем volume-weighted average price (VWAP) исполнения.
Execution correlation — обе ноги арбитража должны исполниться почти одновременно. На практике одна нога может быть исполнена, другая — нет (leg risk). Моделируем через вероятность неисполнения второй ноги.
Funding constraints — для cross-exchange арбитража нужны средства на обеих биржах одновременно. Переводы занимают часы (BTC) до суток (fiat). Это ограничивает масштаб.
Арбитражный бэктест без учёта этих факторов даёт нереалистично оптимистичные результаты. Каждый из них может превратить прибыльную стратегию в убыточную.







