Разработка системы бэктестинга с учётом комиссий и проскальзывания
Бэктест без учёта комиссий и проскальзывания — оптимистичная сказка. Стратегия, показывающая 30% годовых в идеальных условиях, после добавления реальных торговых издержек может оказаться убыточной. Правильная модель издержек критически важна для честной оценки стратегий.
Структура торговых издержек
Комиссия биржи (Exchange Fee):
- Maker fee: 0.01–0.1% (за limit orders, добавляющие ликвидность)
- Taker fee: 0.05–0.5% (за market orders, забирающие ликвидность)
- VIP скидки при больших объёмах
Проскальзывание (Slippage):
- Market impact: крупный ордер двигает цену против вас
- Bid-ask spread: разница между ценой покупки и продажи
- Gap slippage: цена на открытии следующей свечи отличается от close предыдущей
Funding rate (для фьючерсов): платёж каждые 8 часов за удержание позиции.
Latency cost (косвенно): пока ордер дошёл до биржи, цена ушла.
Реализация модели комиссий
from dataclasses import dataclass
from decimal import Decimal
from enum import Enum
class OrderType(Enum):
MARKET = "market"
LIMIT = "limit"
LIMIT_POST_ONLY = "limit_post_only"
@dataclass
class FeeSchedule:
"""Тарифная сетка биржи"""
maker_fee: float
taker_fee: float
# VIP уровни (объём в USD за 30 дней → тарифы)
vip_tiers: list[tuple[float, float, float]] = None # (min_volume, maker, taker)
def get_fee(self, order_type: OrderType, volume_30d: float = 0) -> float:
if self.vip_tiers and volume_30d > 0:
for min_vol, maker, taker in sorted(self.vip_tiers, reverse=True):
if volume_30d >= min_vol:
return maker if order_type != OrderType.MARKET else taker
if order_type == OrderType.MARKET:
return self.taker_fee
elif order_type == OrderType.LIMIT_POST_ONLY:
return self.maker_fee
else:
return self.maker_fee # limit orders обычно maker
# Тарифные сетки популярных бирж
BINANCE_SPOT = FeeSchedule(
maker_fee=0.001, taker_fee=0.001,
vip_tiers=[
(1_000_000, 0.0009, 0.001),
(5_000_000, 0.0008, 0.0009),
(20_000_000, 0.0007, 0.0008),
]
)
BYBIT_PERP = FeeSchedule(maker_fee=-0.0001, taker_fee=0.0006) # Bybit даёт rebate за maker
Модели проскальзывания
class SlippageModel:
"""Базовый класс для моделей проскальзывания"""
def get_fill_price(self, order_price: float, bar, side: str) -> float:
raise NotImplementedError
class FixedSlippage(SlippageModel):
"""Фиксированное проскальзывание в %"""
def __init__(self, slippage_pct: float = 0.0005):
self.slippage_pct = slippage_pct
def get_fill_price(self, order_price: float, bar, side: str) -> float:
if side == 'BUY':
return order_price * (1 + self.slippage_pct)
else:
return order_price * (1 - self.slippage_pct)
class VolumeImpactSlippage(SlippageModel):
"""Проскальзывание, растущее с размером ордера относительно объёма"""
def __init__(self, impact_factor: float = 0.1):
self.impact_factor = impact_factor
def get_fill_price(self, order_price: float, bar, side: str, order_size_usd: float = 0) -> float:
# Линейный market impact: slippage = impact_factor × (order_size / bar_volume)
bar_volume_usd = bar.volume * bar.close
market_impact = self.impact_factor * order_size_usd / bar_volume_usd if bar_volume_usd > 0 else 0
if side == 'BUY':
return order_price * (1 + market_impact)
else:
return order_price * (1 - market_impact)
class BidAskSlippage(SlippageModel):
"""Учитываем bid-ask спред: market orders всегда исполняются через спред"""
def __init__(self, typical_spread_pct: float = 0.0001):
self.half_spread = typical_spread_pct / 2
def get_fill_price(self, order_price: float, bar, side: str) -> float:
# Для market order: покупаем по ask (mid + half_spread), продаём по bid (mid - half_spread)
if side == 'BUY':
return order_price * (1 + self.half_spread)
else:
return order_price * (1 - self.half_spread)
Funding Rate для фьючерсов
class FundingRateModel:
"""Учёт funding rate для вечных фьючерсов"""
def __init__(self, funding_interval_hours: int = 8):
self.interval = funding_interval_hours
self.funding_history: dict[str, pd.Series] = {} # symbol → historical rates
def load_funding_history(self, symbol: str, rates: pd.Series):
self.funding_history[symbol] = rates
def calculate_funding_cost(
self,
symbol: str,
position_value: float,
from_ts: int,
to_ts: int,
position_side: str,
) -> float:
"""Рассчитываем суммарный funding за период"""
if symbol not in self.funding_history:
return 0.0
rates = self.funding_history[symbol]
# Фильтруем funding события в диапазоне
mask = (rates.index >= from_ts) & (rates.index < to_ts)
period_rates = rates[mask]
total_funding = 0.0
for rate in period_rates:
# При position_side == 'LONG': платим если rate > 0, получаем если rate < 0
if position_side == 'LONG':
funding_payment = -position_value * rate
else:
funding_payment = position_value * rate
total_funding += funding_payment
return total_funding
Сравнение с/без издержек
def analyze_cost_impact(
results_ideal: BacktestResult,
results_realistic: BacktestResult,
) -> dict:
"""Сравниваем результаты с и без торговых издержек"""
ideal_return = results_ideal.total_return_pct
real_return = results_realistic.total_return_pct
ideal_sharpe = results_ideal.metrics.sharpe_ratio
real_sharpe = results_realistic.metrics.sharpe_ratio
total_fees = results_realistic.total_commission_paid
fee_drag_pct = total_fees / results_realistic.initial_capital * 100
return {
'ideal_annual_return': ideal_return,
'real_annual_return': real_return,
'return_drag_from_fees': ideal_return - real_return,
'ideal_sharpe': ideal_sharpe,
'real_sharpe': real_sharpe,
'total_fees_paid': total_fees,
'fee_drag_pct': fee_drag_pct,
'fees_as_pct_of_gross_pnl': total_fees / max(results_ideal.gross_pnl, 0.01) * 100,
'breakeven_win_rate_with_fees': results_realistic.breakeven_win_rate,
}
Практические значения для крипто
| Биржа/тип | Maker fee | Taker fee | Типичное проскальзывание |
|---|---|---|---|
| Binance Spot | 0.10% | 0.10% | 0.01–0.05% |
| Binance Futures | 0.02% | 0.05% | 0.01–0.03% |
| Bybit Perp | -0.01% (rebate) | 0.06% | 0.01–0.03% |
| Kraken Spot | 0.16% | 0.26% | 0.02–0.10% |
| DEX (Uniswap) | 0.30% (pool fee) | 0.30% | 0.05–0.50% |
Для стратегий с высокой частотой сделок (десятки в день) комиссии критично важны. Стратегия с 100 сделками в месяц при 0.1% taker fee теряет 10% капитала в год только на комиссиях — это нужно окупать из P&L.







