Бэктест торговой стратегии на Python: пошаговое руководство
Бэктестирование — это процесс проверки торговой стратегии на исторических данных для оценки ее потенциальной эффективности. Это фундаментальный этап в разработке любого торгового алгоритма, от простого скрипта до полноценного trading bot. Без количественной оценки идеи на прошлом рынке переход к реальной торговле сопряжен с неоправданно высоким риском. Основная задача бэктеста — не предсказать будущее, а получить объективные метрики производительности стратегии в прошлом, такие как доходность, уровень риска и стабильность.
Проведение бэктеста позволяет ответить на ключевые вопросы: является ли торговая идея в принципе прибыльной? Насколько велик риск, связанный с ней? Как стратегия ведет себя в различных рыночных условиях (тренд, флэт, высокая волатильность)? Результаты, выраженные в виде конкретных цифр — общего PnL, максимальной просадки, коэффициента Шарпа — превращают абстрактную гипотезу в объект для инженерного анализа и дальнейшей оптимизации.
Данное руководство представляет собой пошаговый процесс создания простого, но функционального бэктестера на Python с использованием стандартных библиотек анализа данных. Мы пройдем все этапы: от загрузки данных до реализации логики, расчета метрик и визуализации результатов. Этот python backtest пример станет основой, которую можно расширять и адаптировать для более сложных систем.
H2: Подготовка данных и окружения
Основа любого бэктеста — качественные исторические данные. Как правило, используются котировки в формате OHLCV (Open, High, Low, Close, Volume) для определенного таймфрейма (например, 1 час, 1 день). Данные можно получить через API криптовалютных бирж (Binance, Bybit), от брокеров или из открытых источников, таких как Yahoo Finance. Для воспроизводимости и скорости работы удобнее всего сохранить данные в локальный CSV-файл.
Для работы нам понадобятся три основные библиотеки Python:
- Pandas: для работы с временными рядами и табличными данными.
- NumPy: для математических вычислений.
- Matplotlib: для визуализации результатов.
Установим их, если они отсутствуют:
pip install pandas numpy matplotlib
Предположим, у нас есть CSV-файл btc_usdt_1h.csv со следующей структурой: timestamp,open,high,low,close,volume. Загрузим и подготовим данные.
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
# Загрузка данных из CSV-файла
# parse_dates=['timestamp'] преобразует колонку с датой в формат datetime
# index_col='timestamp' делает дату индексом DataFrame для удобной работы с временными рядами
try:
df = pd.read_csv('btc_usdt_1h.csv', parse_dates=['timestamp'], index_col='timestamp')
except FileNotFoundError:
# Создадим пример DataFrame, если файл не найден, для демонстрации
print("Файл не найден. Создание демонстрационного набора данных.")
dates = pd.to_datetime(pd.date_range(start='2023-01-01', periods=1000, freq='H'))
price = 100 + np.random.randn(1000).cumsum()
df = pd.DataFrame({
'open': price - np.random.uniform(0, 1, 1000),
'high': price + np.random.uniform(0, 1, 1000),
'low': price - np.random.uniform(1, 2, 1000),
'close': price,
'volume': np.random.uniform(100, 500, 1000)
}, index=dates)
# Убедимся, что данные отсортированы по дате
df.sort_index(inplace=True)
# Выведем первые несколько строк для проверки
print(df.head())
На этом этапе мы имеем готовый к анализу DataFrame, где каждая строка представляет один временной интервал (свечу) с необходимыми ценовыми данными.
H2: Формулирование и векторизация стратегии
Выберем простую, но популярную трендовую стратегию — пересечение двух простых скользящих средних (Simple Moving Average, SMA).
Логика стратегии:
1. Сигнал на покупку (Long): Короткая SMA пересекает длинную SMA снизу вверх. Это указывает на возможное начало восходящего тренда.
2. Сигнал на продажу (Short/Close Long): Короткая SMA пересекает длинную SMA сверху вниз. Это указывает на возможное начало нисходящего тренда или окончание восходящего.
Для примера возьмем 50-периодную SMA как короткую и 200-периодную как длинную. Pandas позволяет вычислить эти значения векторизованно, то есть без использования медленных циклов.
# Определяем периоды для скользящих средних
short_window = 50
long_window = 200
# Рассчитываем SMA
df['SMA_short'] = df['close'].rolling(window=short_window, min_periods=1).mean()
df['SMA_long'] = df['close'].rolling(window=long_window, min_periods=1).mean()
# Генерация сигналов
# Создаем колонку 'signal', где 1 - покупка, -1 - продажа
df['signal'] = 0
# Условие для сигнала на покупку
df.loc[df['SMA_short'] > df['SMA_long'], 'signal'] = 1
# Условие для сигнала на продажу
df.loc[df['SMA_short'] < df['SMA_long'], 'signal'] = -1
# Создаем колонку 'position', которая отражает фактическое действие
# Мы входим в позицию на СЛЕДУЮЩЕЙ свече после появления сигнала,
# чтобы избежать "заглядывания в будущее" (lookahead bias).
# shift(1) сдвигает все значения на одну строку вниз.
df['position'] = df['signal'].diff()
# Оставляем только фактические точки входа/выхода
# Покупка: было 0 или -1, стало 1 (diff будет 1 или 2) -> ставим 1
# Продажа: была 1, стало 0 или -1 (diff будет -1 или -2) -> ставим -1
df['position'] = np.where(df['position'] > 0, 1, np.where(df['position'] < 0, -1, 0))
print(df.tail(10))
# Проверим, сколько у нас сигналов на покупку и продажу
print("Сигналы на покупку:", (df['position'] == 1).sum())
print("Сигналы на продажу:", (df['position'] == -1).sum())
В колонке position теперь содержатся значения 1 (купить), -1 (продать) и 0 (ничего не делать) в те моменты времени, когда стратегия предписывает совершить сделку. Использование .diff() и .shift() — стандартная практика для корректной генерации торговых сигналов без заглядывания в данные текущей свечи для принятия решения.
H2: Реализация бэктест-цикла
Теперь, когда у нас есть сигналы, необходимо симулировать торговлю. Мы создадим цикл, который будет итерироваться по нашему DataFrame и исполнять сделки, обновляя состояние портфеля. Этот подход, хоть и не самый быстрый (в сравнении с полностью векторизованными бэктестами), является наиболее наглядным и позволяет легко добавлять сложную логику, такую как комиссии, проскальзывание и управление размером позиции.
Параметры симуляции:
- Начальный капитал: 10,000 у.е.
- Размер сделки: весь доступный капитал.
- Комиссия: 0.1% за сделку.
initial_cash = 10000.0
cash = initial_cash
position_size = 0 # Количество актива в портфеле
commission_rate = 0.001 # 0.1%
# Списки для хранения истории
portfolio_history = []
trades = []
for i in range(len(df)):
current_price = df['close'].iloc[i]
# Логика покупки
if df['position'].iloc[i] == 1 and cash > 0:
# Покупаем на все доступные средства
investment = cash
position_size = (investment * (1 - commission_rate)) / current_price
cash = 0
trades.append({'type': 'BUY', 'price': current_price, 'date': df.index[i]})
# print(f"{df.index[i]} BUY: Price={current_price:.2f}, Size={position_size:.4f}")
# Логика продажи
elif df['position'].iloc[i] == -1 and position_size > 0:
# Продаем весь имеющийся актив
revenue = position_size * current_price
cash = revenue * (1 - commission_rate)
position_size = 0
trades.append({'type': 'SELL', 'price': current_price, 'date': df.index[i]})
# print(f"{df.index[i]} SELL: Price={current_price:.2f}, Revenue={revenue:.2f}")
# Рассчитываем текущую стоимость портфеля
current_portfolio_value = cash + position_size * current_price
portfolio_history.append(current_portfolio_value)
# Добавляем историю стоимости портфеля в основной DataFrame
df['portfolio_value'] = portfolio_history
# Преобразуем список сделок в DataFrame для удобства анализа
trades_df = pd.DataFrame(trades)
print("Бэктест завершен.")
print(f"Начальный капитал: {initial_cash:.2f}")
print(f"Конечный капитал: {df['portfolio_value'].iloc[-1]:.2f}")
print(trades_df)
Этот цикл — ядро нашего бэктест trading bot. Он последовательно проходит по временному ряду, имитируя поведение трейдера: проверяет наличие сигнала, исполняет ордер с учетом комиссии и пересчитывает стоимость портфеля на каждом шаге.
H2: Анализ результатов и ключевые метрики
Простого значения конечного капитала недостаточно для оценки стратегии. Необходимо рассчитать набор стандартных метрик, которые комплексно описывают ее доходность и риск.
1. Общая доходность (Total Return): Процентное изменение капитала от начала до конца периода.
- Формула:
(Конечный капитал / Начальный капитал - 1) * 100%
2. Максимальная просадка (Maximum Drawdown, MDD): Максимальное падение капитала от локального пика до последующего минимума. Это ключевой показатель риска, показывающий, какой максимальный убыток мог понести инвестор.
- Расчет: Для каждой точки на кривой капитала вычисляется падение относительно предыдущего максимального значения. MDD — это максимум из этих падений.
3. Коэффициент Шарпа (Sharpe Ratio): Показывает, какую доходность приносит стратегия на единицу принятого риска (волатильности). Чем выше коэффициент, тем лучше доходность с поправкой на риск.
- Упрощенная формула:
Средняя доходность периода / Стандартное отклонение доходности периода. Для более точного расчета требуется безрисковая ставка, но для сравнения стратегий часто достаточно упрощенной версии.
# 1. Общая доходность
final_capital = df['portfolio_value'].iloc[-1]
total_return = (final_capital / initial_cash - 1) * 100
print(f"Общая доходность: {total_return:.2f}%")
# 2. Максимальная просадка
# Рассчитываем кумулятивный максимум капитала
df['peak'] = df['portfolio_value'].cummax()
# Рассчитываем просадку
df['drawdown'] = (df['portfolio_value'] - df['peak']) / df['peak']
max_drawdown = df['drawdown'].min() * 100
print(f"Максимальная просадка: {max_drawdown:.2f}%")
# 3. Коэффициент Шарпа
# Рассчитываем дневную доходность портфеля
df['daily_return'] = df['portfolio_value'].pct_change()
# Годовая доходность (предполагаем 252 торговых дня в году, для крипто 365)
# Для часовых данных: 24 * 365 свечей в году
annual_factor = 24 * 365
avg_annual_return = df['daily_return'].mean() * annual_factor
annual_volatility = df['daily_return'].std() * np.sqrt(annual_factor)
# Предполагаем безрисковую ставку = 0
sharpe_ratio = avg_annual_return / annual_volatility if annual_volatility != 0 else 0
print(f"Коэффициент Шарпа (годовой): {sharpe_ratio:.2f}")
# Дополнительные метрики
num_trades = len(trades_df)
# Для расчета win rate нужно сгруппировать сделки по парам buy/sell
wins = 0
total_pnl_trades = 0
for i in range(0, len(trades_df) - 1, 2):
buy_trade = trades_df.iloc[i]
sell_trade = trades_df.iloc[i+1]
if sell_trade['price'] > buy_trade['price']:
wins += 1
total_pnl_trades +=1
win_rate = (wins / total_pnl_trades) * 100 if total_pnl_trades > 0 else 0
print(f"Количество сделок: {num_trades}")
print(f"Процент прибыльных сделок (Win Rate): {win_rate:.2f}%")
H2: Визуализация результатов
Графики помогают наглядно оценить поведение стратегии. Два основных графика — это кривая капитала (equity curve) и график цены с отмеченными точками входа и выхода.
1. Кривая капитала
Показывает, как изменялась стоимость портфеля с течением времени. Идеальная кривая — плавно растущая вверх линия. Резкие провалы на графике соответствуют периодам просадки.
2. График цены со сделками
Позволяет визуально проверить, в каких рыночных ситуациях стратегия открывала и закрывала позиции.
plt.style.use('seaborn-v0_8-darkgrid')
# График 1: Кривая капитала
plt.figure(figsize=(14, 7))
plt.plot(df.index, df['portfolio_value'], label='Стоимость портфеля')
plt.title('Кривая капитала (Equity Curve)')
plt.xlabel('Дата')
plt.ylabel('Стоимость портфеля, $')
plt.legend()
plt.show()
# График 2: Цена и точки входа/выхода
plt.figure(figsize=(14, 7))
plt.plot(df.index, df['close'], label='Цена Close', alpha=0.5)
plt.plot(df.index, df['SMA_short'], label=f'SMA {short_window}', linestyle='--')
plt.plot(df.index, df['SMA_long'], label=f'SMA {long_window}', linestyle='--')
# Отмечаем точки покупки и продажи
buy_signals = trades_df[trades_df['type'] == 'BUY']
sell_signals = trades_df[trades_df['type'] == 'SELL']
plt.scatter(buy_signals['date'], buy_signals['price'], marker='^', color='green', s=100, label='Покупка')
plt.scatter(sell_signals['date'], sell_signals['price'], marker='v', color='red', s=100, label='Продажа')
plt.title('Цена актива и сигналы стратегии')
plt.xlabel('Дата')
plt.ylabel('Цена, $')
plt.legend()
plt.show()
Визуальный анализ позволяет быстро выявить очевидные проблемы, например, если стратегия часто торгует во флэте, генерируя множество убыточных сделок, или пропускает сильные трендовые движения.
H2: Заключение и следующие шаги
Мы создали с нуля и провели полный цикл бэктестирования простой торговой стратегии на Python. Процесс включает четыре основных этапа: получение данных, определение логики стратегии, симуляция торговли и анализ метрик. Полученные результаты (доходность, просадка, коэффициент Шарпа) дают объективную оценку исторической эффективности и рисков.
Важно понимать ограничения данного подхода. Ручная реализация бэктеста в цикле, хотя и наглядна, может быть медленной на больших объемах данных и содержит риск допущения ошибок, таких как lookahead bias. Кроме того, мы не учитывали проскальзывание (slippage), которое может существенно влиять на результаты высокочастотных стратегий.
Следующие шаги для развития:
1. Использование специализированных библиотек. Фреймворки, такие как backtesting.py, VectorBT или Zipline, предоставляют оптимизированные и проверенные движки для бэктестинга. Они быстрее, содержат встроенные расчеты множества метрик и помогают избежать распространенных ошибок. VectorBT особенно эффективен для сверхбыстрых векторизованных бэктестов.
2. Параметрическая оптимизация. Исследование того, как изменяются результаты стратегии при изменении ее параметров (например, периодов SMA). Этот процесс требует осторожности, чтобы избежать подгонки под данные (overfitting).
3. Управление риском. Внедрение в логику стоп-лоссов, тейк-профитов и более сложных моделей управления размером позиции (position sizing).
4. Тестирование на данных вне выборки (Out-of-Sample Testing). Проведение бэктеста на одном периоде данных, а затем проверка его работоспособности на другом, который не использовался при разработке, для подтверждения робастности стратегии.
Создание и бэктестирование — итеративный процесс. Каждый python backtest пример — это шаг к построению более надежного и потенциально прибыльного trading bot.