Разработка системы бэктестинга с учётом ликвидности
Моделирование ликвидности — наиболее сложный аспект реалистичного бэктестинга. Стратегия работает на малом капитале, но при масштабировании ликвидность рынка становится ограничивающим фактором. Крупный ордер не может быть исполнен по одной цене — он "ест" стакан, получая средневзвешенную цену (VWAP) хуже рыночной.
Проблема liquidity scaling
Типичная ошибка: бэктест показывает отличные результаты, стратегия масштабируется с $10K до $1M — и перестаёт работать. Причина: на $10K сделка составляет 0.01% дневного объёма, на $1M — 1%, и это уже заметно влияет на цену.
Правило thumb: ордер размером > 0.5–1% от дневного объёма начинает заметно двигать рынок.
Order Book Simulation
Для точного моделирования нужны order book snapshots. Реконструируем, сколько объёма доступно на каждом уровне:
import numpy as np
from dataclasses import dataclass
@dataclass
class OrderBookLevel:
price: float
quantity: float
@dataclass
class SimulatedOrderBook:
symbol: str
timestamp: int
bids: list[OrderBookLevel] # отсортированы по убыванию цены
asks: list[OrderBookLevel] # отсортированы по возрастанию цены
def simulate_market_buy(self, quantity: float) -> tuple[float, float]:
"""Симулируем исполнение market buy order. Возвращает (avg_price, filled_qty)"""
remaining = quantity
total_cost = 0.0
filled = 0.0
for level in self.asks:
if remaining <= 0:
break
fill_qty = min(remaining, level.quantity)
total_cost += fill_qty * level.price
filled += fill_qty
remaining -= fill_qty
if filled == 0:
return 0.0, 0.0
return total_cost / filled, filled
def simulate_market_sell(self, quantity: float) -> tuple[float, float]:
"""Симулируем исполнение market sell order"""
remaining = quantity
total_proceeds = 0.0
filled = 0.0
for level in self.bids:
if remaining <= 0:
break
fill_qty = min(remaining, level.quantity)
total_proceeds += fill_qty * level.price
filled += fill_qty
remaining -= fill_qty
if filled == 0:
return 0.0, 0.0
return total_proceeds / filled, filled
VWAP модель market impact
Если полных order book данных нет, используем аппроксимацию через объём свечи:
class VWAPLiquidityModel:
"""
Модель market impact основанная на Almgren-Chriss framework.
Предполагает линейный market impact пропорциональный participation rate.
"""
def __init__(
self,
participation_rate: float = 0.05, # % от дневного объёма на исполнение
market_impact_coefficient: float = 0.1, # коэффициент impact
):
self.participation_rate = participation_rate
self.impact_coeff = market_impact_coefficient
def estimate_execution_price(
self,
side: str,
order_size_usd: float,
candle_volume_usd: float,
candle_close: float,
daily_volume_usd: float,
) -> dict:
"""
Оцениваем цену исполнения с учётом market impact.
Возвращает словарь с price, partial_fill, execution_time_candles
"""
# Можем исполнить только participation_rate от объёма свечи
max_fillable_usd = candle_volume_usd * self.participation_rate
if order_size_usd > max_fillable_usd:
# Придётся растягивать исполнение на несколько свечей
candles_needed = int(np.ceil(order_size_usd / max_fillable_usd))
effective_order = max_fillable_usd # исполняем частями
partial_fill = True
else:
candles_needed = 1
effective_order = order_size_usd
partial_fill = False
# Market impact (линейная модель)
order_fraction = effective_order / daily_volume_usd
impact_pct = self.impact_coeff * np.sqrt(order_fraction)
if side == 'BUY':
execution_price = candle_close * (1 + impact_pct)
else:
execution_price = candle_close * (1 - impact_pct)
return {
'execution_price': execution_price,
'filled_usd': effective_order,
'partial_fill': partial_fill,
'candles_to_complete': candles_needed,
'slippage_pct': impact_pct * 100,
}
Almgren-Chriss Framework
Более сложная, но и более точная модель для оценки торговых издержек крупных позиций:
class AlmgrenChrissModel:
"""
Almgren-Chriss optimal execution model.
Оптимальное расписание исполнения, минимизирующее
impact cost + timing risk.
"""
def __init__(
self,
daily_volume: float,
price_volatility: float, # дневная волатильность
bid_ask_spread: float,
market_depth: float, # $/% temporary impact
permanent_impact: float, # постоянный impact на цену
):
self.V = daily_volume
self.sigma = price_volatility
self.epsilon = bid_ask_spread / 2
self.eta = market_depth
self.gamma = permanent_impact
def optimal_schedule(
self,
total_size: float,
time_horizon: int, # в торговых периодах
risk_aversion: float = 1e-6,
) -> list[float]:
"""Возвращает оптимальные размеры частичных ордеров"""
T = time_horizon
N = T # периодов = количеству частичных ордеров
kappa_sq = (risk_aversion * self.sigma**2) / (self.eta / self.V)
kappa = np.sqrt(max(kappa_sq, 0))
# Оптимальная траектория
schedule = []
for j in range(N):
t = j / N
# Almgren-Chriss решение
x_j = total_size * np.sinh(kappa * (1 - t)) / np.sinh(kappa)
if j > 0:
trade_j = schedule[-1] - x_j if j > 0 else total_size - x_j
schedule.append(trade_j)
return schedule
Масштабируемость стратегии
Важная аналитика для принятия решений об инвестировании:
def analyze_capacity(
strategy_backtest: BacktestResult,
volume_data: pd.DataFrame,
participation_rate: float = 0.05,
) -> pd.DataFrame:
"""Анализируем как меняются результаты стратегии при разных размерах капитала"""
capital_levels = [10_000, 50_000, 100_000, 500_000, 1_000_000, 5_000_000]
results = []
for capital in capital_levels:
# Масштабируем все ордера пропорционально капиталу
scale_factor = capital / strategy_backtest.initial_capital
adjusted_returns = []
for trade in strategy_backtest.trades:
order_size = trade['size_usd'] * scale_factor
avg_daily_vol = volume_data.loc[trade['date'], 'volume_usd']
# Рассчитываем дополнительный slippage от масштаба
participation = order_size / avg_daily_vol
extra_slippage = 0.1 * np.sqrt(participation) # square-root impact model
adjusted_pnl = trade['pnl'] * scale_factor - order_size * extra_slippage
adjusted_returns.append(adjusted_pnl / capital)
adjusted_sharpe = np.mean(adjusted_returns) / np.std(adjusted_returns) * np.sqrt(252)
results.append({
'capital': capital,
'sharpe': adjusted_sharpe,
'annual_return_pct': np.mean(adjusted_returns) * 252 * 100,
})
return pd.DataFrame(results)
Результат такого анализа — кривая "капитал vs доходность". Стратегия сохраняет эффективность до определённого порога, после которого market impact нивелирует преимущество. Этот порог — максимальная ёмкость стратегии (strategy capacity).







