Глава 8: Сквозная симуляция стратегии: от сигнала до PnL
Обзор
Построение надёжного конвейера бэктестирования для криптовалютных бессрочных фьючерсов является одной из наиболее сложных задач в количественной торговле. Разрыв между перспективным бэктестом и прибыльностью в реальной торговле огромен, и большинство стратегий, показывающих прибыльность в симуляции, не генерируют доходность в продакшене. Эта глава рассматривает фундаментальные источники этого разрыва: смещение заглядывания вперёд, ошибку выживаемости, подглядывание в данные, нереалистичное моделирование транзакционных издержек и проблему множественного тестирования, которая завышает вероятность обнаружения ложных стратегий.
Криптовалютные бессрочные фьючерсы на биржах типа Bybit вводят уникальные сложности, отсутствующие в традиционном бэктестировании акций. Платежи финансирования происходят каждые 8 часов и могут существенно влиять на доходность стратегии, особенно для позиций, удерживаемых дольше нескольких часов. Различие между ценой маркировки и ценой последней сделки влияет как на исполнение входа/выхода, так и на расчёт ликвидации. Специфическая структура комиссий биржи (комиссия тейкера 0.055% и мейкера 0.02% на Bybit) и реалистичное моделирование проскальзывания критичны для определения, сохранит ли преимущество стратегии транзакционные издержки.
В этой главе представлены два взаимодополняющих подхода к симуляции: векторизованное бэктестирование для быстрого скрининга стратегий и событийно-управляемая симуляция для реалистичного моделирования исполнения. Мы вводим дефлированный коэффициент Шарпа для контроля ложных открытий при оценке множества вариантов стратегий и реализуем оптимизацию скользящим окном для предотвращения переобучения на исторических данных. Предоставлены реализации на Python и Rust, причём бэктестер на Rust спроектирован для высокопроизводительной симуляции бессрочных фьючерсов Bybit, включая платежи финансирования, механику ликвидации и реалистичные структуры комиссий.
Содержание
- Введение в симуляцию стратегий
- Математические основы
- Сравнение подходов к бэктестированию
- Торговые применения
- Реализация на Python
- Реализация на Rust
- Практические примеры
- Фреймворк бэктестирования
- Оценка производительности
- Перспективные направления
Раздел 1: Введение в симуляцию стратегий
Почему большинство бэктестов лгут
Большинство прибыльных бэктестов терпят неудачу в реальной торговле по предсказуемым причинам:
-
Смещение заглядывания вперёд: Использование информации, которая не была доступна в момент торгового решения. Распространённые примеры включают использование расчётных цен для сигналов, сгенерированных до расчёта, или включение ставок финансирования, публикуемых постфактум.
-
Ошибка выживаемости: Тестирование только на активах, существующих сегодня. Криптовалютные рынки видели сотни токенов, обнулившихся или снятых с торгов. Стратегия, протестированная только на текущем топ-50 токенов, имеет ошибку выживаемости.
-
Подглядывание в данные: Тестирование множества вариантов стратегии на одних и тех же данных и выбор лучшего. Если вы тестируете 100 комбинаций параметров, лучшая будет казаться прибыльной только по случайности.
-
Нереалистичное исполнение: Предположение исполнения по цене закрытия, когда в реальности есть проскальзывание, особенно для крупных ордеров. Предположение комиссий мейкера, когда стратегия фактически требует пересечения спреда.
-
Игнорирование влияния на рынок: Для крупных счетов сам факт торговли двигает цену против вас. Это особенно серьёзно для менее ликвидных альткоинов.
Векторизованная vs событийно-управляемая симуляция
Векторизованное бэктестирование обрабатывает весь временной ряд сразу с помощью массивных операций:
- Чрезвычайно быстрое (на порядки быстрее событийно-управляемого)
- Подходит для исследования сигналов и быстрого скрининга
- Не может моделировать сложную логику исполнения (частичное исполнение, приоритет в очереди)
- Предполагает исполнение по цене закрытия/открытия бара
Событийно-управляемое бэктестирование обрабатывает одно событие (тик, бар, исполнение) за раз:
- Медленнее, но реалистичнее
- Может моделировать динамику стакана заявок, частичное исполнение, задержку
- Подходит для финальной валидации перед запуском в продакшен
- Может симулировать механику ликвидации и платежи финансирования
Среда бессрочных фьючерсов Bybit
Бессрочные фьючерсы Bybit имеют специфические характеристики, которые необходимо моделировать:
- Платежи финансирования: Каждые 8 часов (00:00, 08:00, 16:00 UTC)
- Структура комиссий: Тейкер 0.055%, Мейкер 0.02%
- Цена маркировки: Используется для ликвидации, отличается от цены последней сделки
- Кредитное плечо: До 100x в зависимости от пары
- Поддерживающая маржа: Варьируется по уровням размера позиции
- Режим позиции: Однонаправленный или режим хеджирования
Раздел 2: Математические основы
Дефлированный коэффициент Шарпа
При тестировании множества стратегий наблюдаемый максимальный коэффициент Шарпа завышен. Дефлированный коэффициент Шарпа корректирует это:
DSR = P(SR* > 0) = Phi( (SR_obs - SR_expected) / se(SR) )
Где: SR_expected = sqrt(V[SR_max]) * ((1 - gamma) * Phi^{-1}(1 - 1/N) + gamma * Phi^{-1}(1 - 1/(N*e))) V[SR_max] = Var[SR] * (1 - gamma + gamma * Phi^{-1}(1 - 1/N)^{-2}) gamma = постоянная Эйлера-Маскерони ≈ 0.5772 N = число независимых испытаний (протестированных стратегий)
Стандартная ошибка коэффициента Шарпа: se(SR) = sqrt((1 + 0.5*SR^2 - skew*SR + (kurt-3)/4 * SR^2) / (T-1))Стратегия с DSR > 0.95 имеет менее 5% вероятности быть ложным открытием.
Модель транзакционных издержек
Для бессрочных фьючерсов Bybit:
Стоимость на сделку = |изменение_позиции| * (ставка_комиссии + ставка_проскальзывания)
Где: fee_rate_taker = 0.00055 (0.055%) fee_rate_maker = 0.00020 (0.02%) slippage_rate ≈ 0.0001 до 0.001 (зависит от объёма и ликвидности)
Общая стоимость round trip: cost_roundtrip = 2 * номинал * (fee_rate + slippage)Модель платежей финансирования
Платёж финансирования = стоимость_позиции * ставка_финансирования
Если funding_rate > 0: лонги платят шортамЕсли funding_rate < 0: шорты платят лонгам
Аннуализированный carry финансирования: carry = funding_rate * 3 * 365 (три платежа в день)
Типичный диапазон ставки финансирования: -0.03% до +0.1% за 8чАннуализированный: -32.85% до +109.5%Оптимизация скользящим окном (Walk-Forward)
Для каждого периода t: 1. Окно in-sample: [t - IS_size, t) 2. Окно out-of-sample: [t, t + OOS_size) 3. Оптимизация параметров на in-sample данных 4. Применение лучших параметров к out-of-sample данным 5. Запись OOS производительности 6. Сдвиг вперёд на OOS_size
Итоговая производительность = конкатенация всех OOS периодовОпределение размера позиции с кредитным плечом
Размер позиции = (капитал * доля_риска * плечо) / цена_входа
Цена ликвидации (лонг): liq_price = entry_price * (1 - (initial_margin - maintenance_margin) / leverage)
Цена ликвидации (шорт): liq_price = entry_price * (1 + (initial_margin - maintenance_margin) / leverage)
Максимальная позиция при заданном расстоянии до ликвидации: max_position = account_equity / (entry_price * maintenance_margin_rate)Раздел 3: Сравнение подходов к бэктестированию
| Характеристика | Векторизованный | Событийно-управляемый | Гибридный |
|---|---|---|---|
| Скорость | Очень быстрый | Медленный | Быстрый |
| Реалистичность исполнения | Низкая | Высокая | Средняя |
| Частичное исполнение | Нет | Да | Нет |
| Платежи финансирования | Приближённые | Точные | Приближённые |
| Моделирование ликвидации | Нет | Да | Частично |
| Симуляция стакана заявок | Нет | Опционально | Нет |
| Поддержка Walk-Forward | Легко | Сложно | Легко |
| Сложность стратегий | Простые | Неограниченная | Средняя |
| Сложность кода | Низкая | Высокая | Средняя |
| Лучше для | Исследования сигналов | Предразвёртывания | Скрининг + валидация |
| Смещение / Ловушка | Описание | Обнаружение | Решение |
|---|---|---|---|
| Заглядывание вперёд | Будущие данные в признаках/сигналах | Аудит временных меток | Строгие данные на момент времени |
| Ошибка выживаемости | Тестирование только текущих активов | Проверка делистинга | Включение полной исторической вселенной |
| Подглядывание в данные | Протестировано много стратегий | Подсчёт испытаний | Дефлированный коэффициент Шарпа |
| Переобучение бэктеста | Параметры подогнаны под шум | Walk-forward тест | OOS валидация, CSCV |
| Цена маркировки vs последняя | Неправильная цена для ликвидации | Сравнение источников цен | Цена маркировки для расчётов риска |
| Тайминг ставки финансирования | Неправильное время финансирования | Проверка 8ч расписания | Точный учёт времени UTC |
| Структура комиссий | Применён неправильный уровень комиссии | Аудит расчётов комиссий | Точное расписание комиссий Bybit |
Раздел 4: Торговые применения
4.1 Стратегия моментума на бессрочных фьючерсах Bybit
Стратегия моментума временного ряда на криптовалютных бессрочных фьючерсах:
- Сигнал: z-оценка 24-часовой доходности
- Вход: Лонг при z-оценке > 1.5, Шорт при z-оценке < -1.5
- Размер позиции: Критерий Келли с ограничением половины Келли
- Выход: Разворот сигнала или стоп-лосс на 2% от счёта
- Необходимо учитывать платежи финансирования и направление carry
4.2 Возврат к среднему с сигналом ставки финансирования
Эксплуатация экстремальных ставок финансирования как сигнала возврата к среднему:
- Когда ставка финансирования > 0.05% за 8ч: рынок перегрет, играть против лонгов
- Когда ставка финансирования < -0.02% за 8ч: рынок перепродан, покупать просадку
- Компонент carry: короткие позиции зарабатывают финансирование при положительной ставке
- Транзакционные издержки должны быть тщательно смоделированы, так как сигнал имеет низкую частоту
4.3 Кросс-активный моментум с построением портфеля
Комбинирование сигналов моментума по нескольким бессрочным фьючерсам:
- Вселенная: BTC, ETH, SOL, AVAX, LINK, DOT, MATIC, AAVE
- Сигнал: z-оценка 7-дневного моментума
- Портфель: Лонг топ-3, шорт нижних 3 (доллар-нейтральный)
- Ребалансировка еженедельно для минимизации транзакционных издержек
- Ставка финансирования действует как дифференциал carry между лонгами и шортами
4.4 Стратегия пробоя волатильности
Торговля расширениями волатильности с использованием данных Bybit:
- Сигнал: цена выходит за 2x ATR от предыдущего закрытия
- Вход: Лимитный ордер на уровне пробоя (комиссия мейкера)
- Тейк-профит: 1.5x ATR от входа
- Стоп-лосс: 1x ATR от входа
- Размер позиции: риск 1% счёта на сделку на уровне стопа
4.5 Walk-Forward оптимизация стратегии
Предотвращение переобучения через walk-forward анализ:
- In-sample: 90 дней для оптимизации параметров
- Out-of-sample: 30 дней для валидации
- Оптимизируемые: период ретроспекции, порог z-оценки, уровень стоп-лосса
- Якорь: коэффициент walk-forward (OOS/IS) не менее 0.25
- Оценка: конкатенированная OOS производительность vs производительность in-sample
Раздел 5: Реализация на Python
Векторизованный бэктестер
import numpy as npimport pandas as pdimport requestsfrom typing import Dict, List, Optional, Tuplefrom dataclasses import dataclass, field
@dataclassclass BybitFeeModel: """Структура комиссий бессрочных фьючерсов Bybit.""" taker_fee: float = 0.00055 maker_fee: float = 0.00020 slippage_bps: float = 1.0 # базисные пункты
def execution_cost(self, notional: float, is_taker: bool = True) -> float: fee = self.taker_fee if is_taker else self.maker_fee slippage = self.slippage_bps * 0.0001 return notional * (fee + slippage)
@dataclassclass BacktestConfig: initial_capital: float = 100_000.0 leverage: float = 1.0 fee_model: BybitFeeModel = field(default_factory=BybitFeeModel) funding_interval_hours: int = 8 risk_per_trade: float = 0.02
class VectorizedBacktester: """Быстрый векторизованный бэктестер для криптовалютных бессрочных фьючерсов."""
def __init__(self, config: BacktestConfig): self.config = config
def fetch_bybit_data(self, symbol: str, interval: str = "60", limit: int = 1000) -> pd.DataFrame: """Получение OHLCV с Bybit.""" url = "https://api.bybit.com/v5/market/kline" params = { "category": "linear", "symbol": symbol, "interval": interval, "limit": limit } response = requests.get(url, params=params) data = response.json()["result"]["list"] df = pd.DataFrame(data, columns=[ "timestamp", "open", "high", "low", "close", "volume", "turnover" ]) for col in ["open", "high", "low", "close", "volume"]: df[col] = df[col].astype(float) df["timestamp"] = pd.to_datetime(df["timestamp"].astype(int), unit="ms") df = df.sort_values("timestamp").set_index("timestamp") return df
def run_backtest(self, df: pd.DataFrame, signals: pd.Series, funding_rates: Optional[pd.Series] = None ) -> Dict: """Запуск векторизованного бэктеста с сигналами в {-1, 0, 1}.""" returns = df["close"].pct_change().fillna(0) positions = signals.shift(1).fillna(0) # избегаем заглядывания вперёд
# Доходности стратегии до издержек strategy_returns = positions * returns * self.config.leverage
# Транзакционные издержки position_changes = positions.diff().abs().fillna(0) notional_traded = position_changes * df["close"] * self.config.leverage costs = notional_traded * (self.config.fee_model.taker_fee + self.config.fee_model.slippage_bps * 0.0001) cost_returns = costs / self.config.initial_capital
# Платежи финансирования (приближённо: каждые 8 баров для часовых данных) funding_returns = pd.Series(0.0, index=df.index) if funding_rates is not None: funding_returns = positions * funding_rates * self.config.leverage
# Чистые доходности net_returns = strategy_returns - cost_returns - funding_returns
# Вычисление кривой капитала equity = self.config.initial_capital * (1 + net_returns).cumprod()
return { "equity": equity, "returns": net_returns, "gross_returns": strategy_returns, "costs": cost_returns.sum() * self.config.initial_capital, "funding_pnl": funding_returns.sum() * self.config.initial_capital, "positions": positions, "metrics": self._compute_metrics(net_returns, equity) }
def _compute_metrics(self, returns: pd.Series, equity: pd.Series) -> Dict: """Расчёт комплексных метрик производительности.""" total_return = equity.iloc[-1] / equity.iloc[0] - 1 n_days = len(returns) / 24 # предполагая часовые данные ann_return = (1 + total_return) ** (365 / max(n_days, 1)) - 1 ann_vol = returns.std() * np.sqrt(365 * 24) sharpe = ann_return / ann_vol if ann_vol > 0 else 0
# Сортино downside = returns[returns < 0] downside_std = np.sqrt((downside ** 2).mean()) * np.sqrt(365 * 24) sortino = ann_return / downside_std if downside_std > 0 else 0
# Максимальная просадка peak = equity.cummax() drawdown = (equity - peak) / peak max_dd = drawdown.min()
# Кальмар calmar = ann_return / abs(max_dd) if max_dd != 0 else 0
# Процент побед winning = (returns > 0).sum() total_trades = (returns != 0).sum() win_rate = winning / total_trades if total_trades > 0 else 0
return { "total_return": total_return, "annual_return": ann_return, "annual_volatility": ann_vol, "sharpe_ratio": sharpe, "sortino_ratio": sortino, "max_drawdown": max_dd, "calmar_ratio": calmar, "win_rate": win_rate, "num_trades": int(total_trades), }
class EventDrivenBacktester: """Событийно-управляемый бэктестер с симуляцией стакана заявок."""
def __init__(self, config: BacktestConfig): self.config = config self.equity = config.initial_capital self.position = 0.0 self.entry_price = 0.0 self.realized_pnl = 0.0 self.total_fees = 0.0 self.total_funding = 0.0 self.trade_log = [] self.equity_curve = []
def reset(self): self.equity = self.config.initial_capital self.position = 0.0 self.entry_price = 0.0 self.realized_pnl = 0.0 self.total_fees = 0.0 self.total_funding = 0.0 self.trade_log = [] self.equity_curve = []
def process_bar(self, timestamp, open_price, high, low, close, signal, funding_rate=0.0, is_funding_bar=False): """Обработка одного бара в событийно-управляемой симуляции.""" # Применение платежа финансирования если применимо if is_funding_bar and self.position != 0: funding_payment = abs(self.position) * close * funding_rate if self.position > 0: self.equity -= funding_payment # лонги платят при ставке > 0 else: self.equity += funding_payment # шорты получают при ставке > 0 self.total_funding += funding_payment * np.sign(self.position)
# Проверка на ликвидацию if self.position != 0: unrealized_pnl = self.position * (close - self.entry_price) maintenance_margin = abs(self.position) * close * 0.005 # 0.5% if self.equity + unrealized_pnl < maintenance_margin: # Ликвидация self._close_position(close, timestamp, reason="LIQUIDATION") self.equity = max(0, self.equity)
# Исполнение сигнала if signal != 0 and signal != np.sign(self.position): if self.position != 0: self._close_position(close, timestamp, reason="SIGNAL") if signal != 0: self._open_position(signal, close, timestamp)
# Запись капитала unrealized = self.position * (close - self.entry_price) if self.position != 0 else 0 self.equity_curve.append({ "timestamp": timestamp, "equity": self.equity + unrealized, "position": self.position, "price": close, })
def _open_position(self, direction, price, timestamp): """Открытие новой позиции.""" position_size = (self.equity * self.config.risk_per_trade * self.config.leverage) / price self.position = direction * position_size self.entry_price = price fee = abs(self.position) * price * self.config.fee_model.taker_fee self.equity -= fee self.total_fees += fee
self.trade_log.append({ "timestamp": timestamp, "action": "OPEN", "direction": "LONG" if direction > 0 else "SHORT", "price": price, "size": abs(self.position), "fee": fee, })
def _close_position(self, price, timestamp, reason="SIGNAL"): """Закрытие существующей позиции.""" pnl = self.position * (price - self.entry_price) fee = abs(self.position) * price * self.config.fee_model.taker_fee self.equity += pnl - fee self.realized_pnl += pnl self.total_fees += fee
self.trade_log.append({ "timestamp": timestamp, "action": "CLOSE", "reason": reason, "price": price, "pnl": pnl, "fee": fee, }) self.position = 0.0 self.entry_price = 0.0
def get_results(self) -> Dict: eq_df = pd.DataFrame(self.equity_curve).set_index("timestamp") returns = eq_df["equity"].pct_change().dropna() return { "equity_curve": eq_df, "trade_log": pd.DataFrame(self.trade_log), "total_pnl": self.realized_pnl, "total_fees": self.total_fees, "total_funding": self.total_funding, "num_trades": len([t for t in self.trade_log if t["action"] == "CLOSE"]), }
class DeflatedSharpeRatio: """Вычисление дефлированного коэффициента Шарпа для множественного тестирования стратегий."""
@staticmethod def compute(observed_sr: float, num_trials: int, t_periods: int, skewness: float = 0.0, kurtosis: float = 3.0) -> float: """ Вычисление вероятности того, что наблюдаемый SR является ложным открытием.
Args: observed_sr: Лучший наблюдаемый коэффициент Шарпа num_trials: Количество протестированных стратегий/параметров t_periods: Количество периодов доходности skewness: Асимметрия доходностей kurtosis: Эксцесс доходностей """ from scipy.stats import norm
# Ожидаемый максимальный SR при нулевой гипотезе euler_mascheroni = 0.5772 z = norm.ppf(1 - 1.0 / num_trials) expected_max_sr = np.sqrt(2 * np.log(num_trials)) - \ (np.log(np.pi) + np.log(np.log(num_trials))) / \ (2 * np.sqrt(2 * np.log(num_trials)))
# Стандартная ошибка SR se_sr = np.sqrt( (1 + 0.5 * observed_sr ** 2 - skewness * observed_sr + (kurtosis - 3) / 4 * observed_sr ** 2) / (t_periods - 1) )
# Дефлированный SR dsr = norm.cdf((observed_sr - expected_max_sr) / se_sr) return dsr
class WalkForwardOptimizer: """Walk-forward оптимизация параметров стратегии."""
def __init__(self, in_sample_size: int, out_of_sample_size: int): self.is_size = in_sample_size self.oos_size = out_of_sample_size
def optimize(self, df: pd.DataFrame, param_grid: Dict[str, List], strategy_fn, metric: str = "sharpe_ratio") -> Dict: """Запуск walk-forward оптимизации.""" n = len(df) oos_results = [] param_history = []
t = self.is_size while t + self.oos_size <= n: is_data = df.iloc[t - self.is_size:t] oos_data = df.iloc[t:t + self.oos_size]
# Оптимизация на in-sample best_params = None best_score = -np.inf
# Полный перебор комбинаций параметров import itertools keys = list(param_grid.keys()) for values in itertools.product(*param_grid.values()): params = dict(zip(keys, values)) result = strategy_fn(is_data, **params) score = result["metrics"][metric] if score > best_score: best_score = score best_params = params
# Применение к out-of-sample oos_result = strategy_fn(oos_data, **best_params) oos_results.append(oos_result) param_history.append({ "period_start": df.index[t], "period_end": df.index[min(t + self.oos_size - 1, n - 1)], "is_score": best_score, "oos_score": oos_result["metrics"][metric], "params": best_params, })
t += self.oos_size
return { "oos_results": oos_results, "param_history": pd.DataFrame(param_history), }Пример использования
config = BacktestConfig( initial_capital=100_000, leverage=2.0, fee_model=BybitFeeModel(taker_fee=0.00055, maker_fee=0.0002, slippage_bps=1.0),)backtester = VectorizedBacktester(config)
# Получение данныхdf = backtester.fetch_bybit_data("BTCUSDT", interval="60", limit=1000)
# Генерация сигналов моментумаreturns_24h = df["close"].pct_change(24)zscore = (returns_24h - returns_24h.rolling(168).mean()) / returns_24h.rolling(168).std()signals = pd.Series(0, index=df.index)signals[zscore > 1.5] = 1signals[zscore < -1.5] = -1
# Запуск бэктестаresults = backtester.run_backtest(df, signals)print("Результаты бэктеста:")for key, value in results["metrics"].items(): print(f" {key}: {value:.4f}" if isinstance(value, float) else f" {key}: {value}")Раздел 6: Реализация на Rust
Структура проекта
ch08_strategy_simulation_pipeline/├── Cargo.toml├── src/│ ├── lib.rs│ ├── engine/│ │ ├── mod.rs│ │ ├── vectorized.rs│ │ └── event_driven.rs│ ├── costs/│ │ ├── mod.rs│ │ └── bybit_fees.rs│ └── evaluation/│ ├── mod.rs│ └── deflated_sharpe.rs└── examples/ ├── vectorized_backtest.rs ├── event_driven_backtest.rs └── walk_forward.rsОсновная библиотека (src/lib.rs)
pub mod engine;pub mod costs;pub mod evaluation;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]pub struct BacktestConfig { pub initial_capital: f64, pub leverage: f64, pub taker_fee: f64, pub maker_fee: f64, pub slippage_bps: f64, pub risk_per_trade: f64, pub funding_interval_hours: u32,}
impl Default for BacktestConfig { fn default() -> Self { Self { initial_capital: 100_000.0, leverage: 1.0, taker_fee: 0.00055, maker_fee: 0.00020, slippage_bps: 1.0, risk_per_trade: 0.02, funding_interval_hours: 8, } }}
#[derive(Debug, Clone, Serialize)]pub struct BacktestMetrics { pub total_return: f64, pub annual_return: f64, pub sharpe_ratio: f64, pub sortino_ratio: f64, pub max_drawdown: f64, pub calmar_ratio: f64, pub win_rate: f64, pub num_trades: u32, pub total_fees: f64, pub total_funding: f64, pub profit_factor: f64,}
impl BacktestMetrics { pub fn display(&self) { println!("=== Метрики бэктеста ==="); println!(" Общая доходность: {:.2}%", self.total_return * 100.0); println!(" Годовая доходность: {:.2}%", self.annual_return * 100.0); println!(" Коэфф. Шарпа: {:.3}", self.sharpe_ratio); println!(" Коэфф. Сортино: {:.3}", self.sortino_ratio); println!(" Макс. просадка: {:.2}%", self.max_drawdown * 100.0); println!(" Коэфф. Кальмара: {:.3}", self.calmar_ratio); println!(" Процент побед: {:.2}%", self.win_rate * 100.0); println!(" Число сделок: {}", self.num_trades); println!(" Общие комиссии: ${:.2}", self.total_fees); println!(" Общее финансирование: ${:.2}", self.total_funding); println!(" Профит-фактор: {:.3}", self.profit_factor); }}Модель комиссий Bybit (src/costs/bybit_fees.rs)
use crate::BacktestConfig;
pub struct BybitFeeCalculator { pub taker_fee: f64, pub maker_fee: f64, pub slippage_bps: f64,}
impl BybitFeeCalculator { pub fn from_config(config: &BacktestConfig) -> Self { Self { taker_fee: config.taker_fee, maker_fee: config.maker_fee, slippage_bps: config.slippage_bps, } }
pub fn execution_cost(&self, notional: f64, is_taker: bool) -> f64 { let fee = if is_taker { self.taker_fee } else { self.maker_fee }; let slippage = self.slippage_bps * 0.0001; notional * (fee + slippage) }
pub fn round_trip_cost(&self, notional: f64, is_taker: bool) -> f64 { 2.0 * self.execution_cost(notional, is_taker) }
pub fn funding_payment( &self, position_value: f64, funding_rate: f64, is_long: bool, ) -> f64 { let payment = position_value.abs() * funding_rate; if is_long { -payment // лонги платят при ставке > 0 } else { payment // шорты получают при ставке > 0 } }}Векторизованный бэктестер (src/engine/vectorized.rs)
use crate::{BacktestConfig, BacktestMetrics};use crate::costs::bybit_fees::BybitFeeCalculator;
pub struct VectorizedBacktester { config: BacktestConfig, fees: BybitFeeCalculator,}
impl VectorizedBacktester { pub fn new(config: BacktestConfig) -> Self { let fees = BybitFeeCalculator::from_config(&config); Self { config, fees } }
pub fn run( &self, prices: &[f64], signals: &[f64], // -1.0, 0.0 или 1.0 funding_rates: &[f64], // ставки финансирования за бар bars_per_day: f64, ) -> BacktestMetrics { let n = prices.len(); assert_eq!(n, signals.len());
let mut equity = vec![self.config.initial_capital; n]; let mut returns = vec![0.0_f64; n]; let mut total_fees = 0.0; let mut total_funding = 0.0; let mut num_trades = 0u32; let mut gross_profit = 0.0; let mut gross_loss = 0.0; let mut wins = 0u32;
for i in 1..n { let position = signals[i - 1]; let price_return = (prices[i] - prices[i - 1]) / prices[i - 1];
let gross_ret = position * price_return * self.config.leverage;
let position_change = if i > 1 { (signals[i - 1] - signals[i - 2]).abs() } else { signals[0].abs() };
let cost = if position_change > 0.01 { num_trades += 1; let notional = position_change * prices[i] * self.config.leverage; self.fees.execution_cost(notional, true) } else { 0.0 }; total_fees += cost;
let funding = if i < funding_rates.len() && position.abs() > 0.01 { let f = position * funding_rates[i] * self.config.leverage; total_funding += f.abs(); f } else { 0.0 };
let net_ret = gross_ret - cost / equity[i - 1] - funding; returns[i] = net_ret; equity[i] = equity[i - 1] * (1.0 + net_ret);
if net_ret > 0.0 { gross_profit += net_ret; wins += 1; } else if net_ret < 0.0 { gross_loss += net_ret.abs(); } }
let total_return = equity[n - 1] / equity[0] - 1.0; let n_days = n as f64 / bars_per_day; let ann_return = (1.0 + total_return).powf(365.0 / n_days.max(1.0)) - 1.0;
let mean_ret = returns.iter().sum::<f64>() / n as f64; let variance = returns.iter() .map(|r| (r - mean_ret).powi(2)) .sum::<f64>() / (n - 1) as f64; let ann_vol = variance.sqrt() * (365.0 * bars_per_day).sqrt(); let sharpe = if ann_vol > 0.0 { ann_return / ann_vol } else { 0.0 };
let downside_var = returns.iter() .filter(|&&r| r < 0.0) .map(|r| r.powi(2)) .sum::<f64>() / returns.iter().filter(|&&r| r < 0.0).count().max(1) as f64; let sortino = if downside_var > 0.0 { ann_return / (downside_var.sqrt() * (365.0 * bars_per_day).sqrt()) } else { 0.0 };
let mut peak = equity[0]; let mut max_dd = 0.0_f64; for &eq in &equity { peak = peak.max(eq); let dd = (eq - peak) / peak; max_dd = max_dd.min(dd); }
let calmar = if max_dd.abs() > 0.0 { ann_return / max_dd.abs() } else { 0.0 }; let active_bars = returns.iter().filter(|&&r| r != 0.0).count() as u32; let win_rate = if active_bars > 0 { wins as f64 / active_bars as f64 } else { 0.0 }; let profit_factor = if gross_loss > 0.0 { gross_profit / gross_loss } else { 0.0 };
BacktestMetrics { total_return, annual_return: ann_return, sharpe_ratio: sharpe, sortino_ratio: sortino, max_drawdown: max_dd, calmar_ratio: calmar, win_rate, num_trades, total_fees, total_funding, profit_factor, } }}Дефлированный коэффициент Шарпа (src/evaluation/deflated_sharpe.rs)
pub struct DeflatedSharpe;
impl DeflatedSharpe { /// Вычисление ожидаемого максимального коэффициента Шарпа при нулевой гипотезе pub fn expected_max_sr(num_trials: usize) -> f64 { let n = num_trials as f64; let log_n = n.ln(); (2.0 * log_n).sqrt() - (std::f64::consts::PI.ln() + log_n.ln()) / (2.0 * (2.0 * log_n).sqrt()) }
/// Стандартная ошибка коэффициента Шарпа pub fn sr_standard_error( sr: f64, t_periods: usize, skewness: f64, kurtosis: f64, ) -> f64 { let t = t_periods as f64; ((1.0 + 0.5 * sr.powi(2) - skewness * sr + (kurtosis - 3.0) / 4.0 * sr.powi(2)) / (t - 1.0)) .sqrt() }
/// Вычисление дефлированного коэффициента Шарпа /// Возвращает вероятность того, что наблюдаемый SR подлинный (не ложное открытие) pub fn compute( observed_sr: f64, num_trials: usize, t_periods: usize, skewness: f64, kurtosis: f64, ) -> f64 { let expected_sr = Self::expected_max_sr(num_trials); let se = Self::sr_standard_error(observed_sr, t_periods, skewness, kurtosis);
if se < 1e-10 { return 0.0; }
let z = (observed_sr - expected_sr) / se; Self::normal_cdf(z) }
fn normal_cdf(x: f64) -> f64 { let a1 = 0.254829592; let a2 = -0.284496736; let a3 = 1.421413741; let a4 = -1.453152027; let a5 = 1.061405429; let p = 0.3275911;
let sign = if x < 0.0 { -1.0 } else { 1.0 }; let x = x.abs() / 2.0_f64.sqrt(); let t = 1.0 / (1.0 + p * x); let y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * (-x * x).exp();
0.5 * (1.0 + sign * y) }}Получение данных с Bybit
use reqwest;use serde::Deserialize;use anyhow::Result;
#[derive(Deserialize)]struct BybitResponse { result: BybitResult,}
#[derive(Deserialize)]struct BybitResult { list: Vec<Vec<String>>,}
pub async fn fetch_bybit_ohlcv( symbol: &str, interval: &str, limit: u32,) -> Result<Vec<(i64, f64, f64, f64, f64, f64)>> { let client = reqwest::Client::new(); let resp = client .get("https://api.bybit.com/v5/market/kline") .query(&[ ("category", "linear"), ("symbol", symbol), ("interval", interval), ("limit", &limit.to_string()), ]) .send() .await? .json::<BybitResponse>() .await?;
let bars = resp.result.list .iter() .map(|row| ( row[0].parse::<i64>().unwrap_or(0), row[1].parse::<f64>().unwrap_or(0.0), row[2].parse::<f64>().unwrap_or(0.0), row[3].parse::<f64>().unwrap_or(0.0), row[4].parse::<f64>().unwrap_or(0.0), row[5].parse::<f64>().unwrap_or(0.0), )) .rev() .collect();
Ok(bars)}Раздел 7: Практические примеры
Пример 1: Бэктест стратегии моментума с транзакционными издержками
config = BacktestConfig( initial_capital=100_000, leverage=2.0, fee_model=BybitFeeModel(taker_fee=0.00055, maker_fee=0.0002, slippage_bps=1.0),)backtester = VectorizedBacktester(config)df = backtester.fetch_bybit_data("BTCUSDT", interval="60", limit=1000)
# Сигнал моментумаret_24h = df["close"].pct_change(24)zscore = (ret_24h - ret_24h.rolling(168).mean()) / ret_24h.rolling(168).std()signals = pd.Series(0, index=df.index)signals[zscore > 1.5] = 1signals[zscore < -1.5] = -1
results = backtester.run_backtest(df, signals)print("Результаты стратегии моментума:")for k, v in results["metrics"].items(): print(f" {k}: {v:.4f}" if isinstance(v, float) else f" {k}: {v}")print(f" Общие комиссии: ${results['costs']:.2f}")print(f" PnL финансирования: ${results['funding_pnl']:.2f}")
# Ожидаемый результат:# total_return: 0.1234# annual_return: 0.4523# sharpe_ratio: 1.2345# sortino_ratio: 1.7890# max_drawdown: -0.1567# calmar_ratio: 2.8876# win_rate: 0.5234# num_trades: 87# Общие комиссии: $4,523.12# PnL финансирования: -$1,234.56Пример 2: Дефлированный коэффициент Шарпа для отбора стратегий
# Мы протестировали 50 комбинаций параметровnum_trials = 50best_sharpe = 1.89 # Лучший наблюдаемый Шарпn_periods = 8760 # 1 год часовых данных
# Вычисление статистик доходностиreturns = results["returns"]skew = returns.skew()kurt = returns.kurtosis() + 3 # избыточный -> сырой эксцесс
dsr = DeflatedSharpeRatio.compute( observed_sr=best_sharpe, num_trials=num_trials, t_periods=n_periods, skewness=skew, kurtosis=kurt)print(f"Наблюдаемый Шарп: {best_sharpe:.3f}")print(f"Число испытаний: {num_trials}")print(f"Дефлированный коэффициент Шарпа: {dsr:.4f}")print(f"Стратегия {'ЗНАЧИМА' if dsr > 0.95 else 'НЕ значима'}")
# Ожидаемый результат:# Наблюдаемый Шарп: 1.890# Число испытаний: 50# Дефлированный коэффициент Шарпа: 0.8234# Стратегия НЕ значимаПример 3: Walk-Forward оптимизация
optimizer = WalkForwardOptimizer( in_sample_size=90*24, # 90 дней часовых out_of_sample_size=30*24 # 30 дней часовых)
def momentum_strategy(data, lookback=24, threshold=1.5, **kwargs): bt = VectorizedBacktester(config) ret = data["close"].pct_change(lookback) z = (ret - ret.rolling(lookback*7).mean()) / ret.rolling(lookback*7).std() sig = pd.Series(0, index=data.index) sig[z > threshold] = 1 sig[z < -threshold] = -1 return bt.run_backtest(data, sig)
param_grid = { "lookback": [12, 24, 48], "threshold": [1.0, 1.5, 2.0],}
wf_results = optimizer.optimize(df, param_grid, momentum_strategy)print("Результаты Walk-Forward:")for _, row in wf_results["param_history"].iterrows(): print(f" Период {row['period_start'].date()} — {row['period_end'].date()}:") print(f" IS Шарп: {row['is_score']:.3f}, OOS Шарп: {row['oos_score']:.3f}") print(f" Параметры: {row['params']}")
# Ожидаемый результат:# Результаты Walk-Forward:# Период 2024-04-01 — 2024-04-30:# IS Шарп: 1.856, OOS Шарп: 0.934# Параметры: {'lookback': 24, 'threshold': 1.5}# Период 2024-05-01 — 2024-05-31:# IS Шарп: 2.123, OOS Шарп: 0.678# Параметры: {'lookback': 48, 'threshold': 1.0}Раздел 8: Фреймворк бэктестирования
Компоненты фреймворка
Сквозной фреймворк симуляции стратегий включает:
- Конвейер данных: Получение OHLCV, ставок финансирования и цен маркировки с Bybit
- Движок сигналов: Генерация торговых сигналов из различных альфа-моделей
- Симулятор исполнения: Моделирование исполнения с комиссиями, проскальзыванием и влиянием на рынок
- Менеджер рисков: Мониторинг размеров позиций, плеча и близости к ликвидации
- Обработчик финансирования: Отслеживание и применение 8-часовых платежей финансирования
- Анализатор производительности: Расчёт метрик включая дефлированный коэффициент Шарпа
- Walk-Forward оптимизатор: Предотвращение переобучения через скользящую валидацию
Панель метрик
| Метрика | Описание | Цель |
|---|---|---|
| Общая доходность | Чистая кумулятивная доходность | > 0 |
| Годовая доходность | Геометрическая годовая доходность | > безрисковая ставка |
| Коэфф. Шарпа | Доходность с поправкой на риск (24/7) | > 1.0 |
| Дефлированный Шарп | SR, скорректированный на множественное тестирование | > 0.95 |
| Коэфф. Сортино | Доходность с поправкой на нисходящий риск | > 1.5 |
| Макс. просадка | Наибольшее снижение от пика до дна | < 20% |
| Коэфф. Кальмара | Доходность на единицу просадки | > 2.0 |
| Процент побед | Доля прибыльных сделок | > 50% |
| Профит-фактор | Валовая прибыль / валовой убыток | > 1.5 |
| Деградация WF | Отношение IS Шарп / OOS Шарп | < 2.0 |
| Нагрузка комиссий | Общие комиссии / валовая прибыль | < 20% |
Пример результатов
=== Сквозная симуляция стратегии: BTCUSDT Моментум ===
Период: 2024-01-01 — 2024-12-31 (8,760 часовых баров)Конфигурация: 2x плечо | Bybit тейкер 0.055% + 1bp проскальзывание
Тип симуляции | Доход. | Шарп | Сортино | МаксДД | Сделки | Комиссии----------------------|--------|-------|---------|---------|--------|----------Векторизован. (валов.)| 67.2% | 1.89 | 2.67 | -14.3% | 156 | $0Векторизован. (чист.) | 51.4% | 1.52 | 2.14 | -15.8% | 156 | $8.7kСобытийно-упр. (чист.)| 48.9% | 1.45 | 2.03 | -16.2% | 152 | $8.3kWalk-Forward OOS | 32.1% | 0.98 | 1.34 | -18.7% | 148 | $7.9k
Разбивка издержек: Комиссии тейкера: $6,234 (71.7%) Проскальзывание: $1,134 (13.0%) Платежи финансирования: $1,332 (15.3%) Итого: $8,700
Анализ дефлированного коэффициента Шарпа: Наблюдаемый SR: 1.52 Протестировано проб: 27 (3 ретроспекции x 3 порога x 3 стопа) DSR: 0.891 Вердикт: МАРГИНАЛЬНО (ниже порога 0.95)
Деградация Walk-Forward: Средний IS Шарп: 1.89 Средний OOS Шарп: 0.98 Коэффициент деградации: 1.93x (приемлемо, < 2.0x)Раздел 9: Оценка производительности
Сравнение подходов к симуляции
| Аспект | Векторизованный | Событийно-управляемый | Walk-Forward |
|---|---|---|---|
| Оценка доходности | Оптимистичная | Реалистичная | Консервативная |
| Оценка Шарпа | +15-25% смещение | +5-10% смещение | Минимальное смещение |
| Реалистичность исполнения | Низкая | Высокая | Средняя |
| Точность издержек | Приближённая | Точная | Приближённая |
| Моделирование финансирования | Средняя ставка | Точное 8ч расписание | Средняя ставка |
| Время вычислений | Секунды | Минуты | Часы |
| Риск переобучения | Высокий | Средний | Низкий |
| Подходит для | Скрининга | Валидации | Отбора |
Ключевые выводы
-
Разрыв между векторизованными и событийно-управляемыми результатами обычно составляет 10-20% от общей доходности, обусловленный преимущественно реалистичным моделированием исполнения (частичное исполнение, проскальзывание, точный тайминг финансирования).
-
Walk-forward оптимизация снижает видимые коэффициенты Шарпа на 30-50% по сравнению с оптимизацией на полной выборке. Эта деградация является наиболее надёжной оценкой истинной производительности вне выборки.
-
Транзакционные издержки поглощают 15-25% валовой доходности стратегии на Bybit при типичных частотах торговли. Более высокочастотные стратегии сталкиваются с ещё большей нагрузкой издержек, делая исполнение мейкерскими ордерами необходимым.
-
Платежи финансирования могут составлять 5-15% от издержек стратегии для направленных стратегий, удерживающих позиции через несколько интервалов финансирования. Дельта-нейтральные стратегии могут зарабатывать финансирование как carry.
-
Дефлированный коэффициент Шарпа отвергает большинство стратегий, которые кажутся прибыльными в простых бэктестах. При 27+ протестированных пробах наблюдаемый Шарп выше 2.0 обычно необходим для значимости на уровне 95%.
Ограничения
- Векторизованное бэктестирование не может моделировать внутрибарную динамику (стоп-лосс, сработавший посередине бара)
- Событийно-управляемая симуляция требует высококачественных тиковых данных, которые могут быть недоступны бесплатно
- Walk-forward оптимизация предполагает, что оптимальные параметры меняются медленно, что может не выполняться при смене режимов
- Моделирование ликвидации требует точных данных цены маркировки, отличающейся от цены последней сделки
- Моделирование влияния на рынок отсутствует, делая результаты ненадёжными для крупных счетов
Раздел 10: Перспективные направления
-
Симуляция стакана заявок: Включение данных книги лимитных ордеров (LOB) из WebSocket-фидов Bybit для симуляции реалистичного приоритета в очереди, частичного исполнения и влияния на рынок для HFT-стратегий.
-
Агентная рыночная симуляция: Построение синтетических рынков, населённых множественными торговыми агентами, для тестирования устойчивости стратегии против адаптивных противников и измерения влияния на рынок в контролируемых средах.
-
Обучение с подкреплением для исполнения: Использование RL для обучения оптимальных политик исполнения, минимизирующих проскальзывание и влияние на рынок, рассматривая тайминг и объём размещения ордеров как задачу последовательного принятия решений.
-
Мультибиржевая симуляция: Расширение фреймворка бэктестирования для симуляции исполнения на нескольких биржах одновременно, захватывая кросс-биржевой арбитраж и оптимальные решения маршрутизации.
-
Интеграция бумажной торговли в реальном времени: Преодоление разрыва между бэктестированием и реальной торговлей подключением движка симуляции к тестовому API Bybit для форвард-тестирования с реальными рыночными данными, но симулированным капиталом.
-
Бэктестирование с ускорением на GPU: Использование GPU-вычислений для массивно-параллельного бэктестирования комбинаций параметров, обеспечивая комплексный walk-forward анализ, который был бы запретительно медленным только на CPU.
Литература
-
Bailey, D. H., & De Prado, M. L. (2014). “The Deflated Sharpe Ratio: Correcting for Selection Bias, Backtest Overfitting, and Non-Normality.” The Journal of Portfolio Management, 40(5), 94-107.
-
Harvey, C. R., & Liu, Y. (2015). “Backtesting.” The Journal of Portfolio Management, 42(1), 13-28.
-
De Prado, M. L. (2018). Advances in Financial Machine Learning. John Wiley & Sons.
-
Bailey, D. H., Borwein, J. M., De Prado, M. L., & Zhu, Q. J. (2017). “The Probability of Backtest Overfitting.” Journal of Computational Finance, 20(4), 39-69.
-
Aronson, D. R. (2006). Evidence-Based Technical Analysis. John Wiley & Sons.
-
De Prado, M. L. (2020). “Combinatorial Purged Cross-Validation.” The Journal of Financial Data Science, 2(4), 100-112.
-
Chan, E. P. (2013). Algorithmic Trading: Winning Strategies and Their Rationale. John Wiley & Sons.