Поиск по материалам

Бэктест торговой стратегии на Python: с нуля до полноценного отчёта

·

Бэктест — проверка торговой стратегии на исторических данных. Без него любая идея — гипотеза, после него — статистика. Разбираем по шагам, как сделать полноценный бэктест на Python: подготовить данные с MOEX, реализовать стратегию на pandas, рассчитать P&L с учётом комиссий, посчитать ключевые метрики (Sharpe, max drawdown, profit factor), сделать walk-forward анализ для защиты от overfit и когда вместо «голого» pandas лучше использовать backtrader или vectorbt.

Что такое бэктест и зачем он нужен

Бэктест (backtest) — это симуляция торговли по заранее заданной стратегии на исторических данных. Берём цены за последние N месяцев или лет, прогоняем правила «когда покупать/продавать», получаем кривую капитала и статистику сделок.

Цель — отсеять заведомо проигрышные стратегии до того, как они начнут терять реальные деньги. Бэктест не гарантирует, что стратегия заработает в будущем, но если она не работает на истории — она с очень высокой вероятностью не сработает и вперёд.

Что должен показать хороший бэктест:

  • Cumulative return — суммарная доходность за период.
  • Sharpe ratio — отношение доходности к волатильности.
  • Maximum drawdown — максимальная просадка от пика.
  • Win rate — процент прибыльных сделок.
  • Profit factor — отношение суммы прибылей к сумме убытков.
  • Calmar ratio — годовая доходность / max drawdown.
  • Кривая капитала — визуальное отображение результата.

Без этих метрик «стратегия зарабатывает» — пустые слова. Любая дисциплинированная разработка алгоритма опирается на цифры.

Шаг 1: Подготовка данных

Источник для российского рынка — ISS MOEX API. Загружаем дневные свечи на SBER за последние 3 года:

import pandas as pd
import requests
from datetime import datetime

def load_candles(secid="SBER", interval=24, from_date="2023-01-01"):
    """Загружает свечи с MOEX ISS с пагинацией."""
    url = (f"https://iss.moex.com/iss/engines/stock/markets/shares/"
           f"securities/{secid}/candles.json")
    all_rows = []
    start = 0
    while True:
        r = requests.get(url, params={
            "from": from_date,
            "interval": interval,
            "start": start,
        }, timeout=30)
        r.raise_for_status()
        page = r.json()["candles"]
        rows = [dict(zip(page["columns"], row)) for row in page["data"]]
        if not rows:
            break
        all_rows.extend(rows)
        start += len(rows)
        if len(rows) < 500:
            break

    df = pd.DataFrame(all_rows)
    df["begin"] = pd.to_datetime(df["begin"])
    df = df.set_index("begin")
    return df[["open", "close", "high", "low", "value", "volume"]]

df = load_candles("SBER", interval=24)
print(f"Загружено: {len(df)} свечей, с {df.index[0]} по {df.index[-1]}")

Проверьте данные на пропуски и аномалии — иногда ISS возвращает «нулевые» свечи на праздники. Дропните их:

df = df[df["volume"] > 0]

Шаг 2: Реализация стратегии на pandas

Возьмём простую стратегию: лонг, когда цена закрытия выше SMA(50), выход — когда уходит ниже SMA(50).

def add_signals(df, sma_period=50):
    """Сигналы стратегии: 1 = в позиции, 0 = вне."""
    df = df.copy()
    df["sma"] = df["close"].rolling(sma_period).mean()
    # Сигнал на закрытие свечи
    df["signal"] = (df["close"] > df["sma"]).astype(int)
    # Позиция со сдвигом на 1 — торгуем на открытии следующего бара
    df["position"] = df["signal"].shift(1).fillna(0)
    return df

df = add_signals(df, sma_period=50)
print(df.tail()[["close", "sma", "signal", "position"]])

Ключевой момент: shift(1) — обязателен. Без него вы используете цену закрытия текущего бара для расчёта сигнала и тут же по нему «торгуете» — это look-ahead bias (заглядывание в будущее). Реальная торговля так не работает: сигнал на закрытии бара даёт вход на следующем баре.

Шаг 3: Расчёт сигналов и позиций

Доходность стратегии:

def compute_returns(df, commission=0.0005):
    """Доходность с учётом комиссии при смене позиции."""
    df = df.copy()
    df["returns"] = df["close"].pct_change()
    df["strategy_returns"] = df["position"] * df["returns"]
    # Комиссия при изменении позиции (на вход + выход)
    df["trades"] = df["position"].diff().abs()
    df["strategy_returns"] -= df["trades"] * commission
    df["cumulative"] = (1 + df["strategy_returns"]).cumprod()
    return df

df = compute_returns(df, commission=0.0005)  # 0.05% за сделку
print(f"Итоговая доходность: {(df['cumulative'].iloc[-1] - 1) * 100:.1f}%")

Комиссия 0.0005 — это 0.05% за сделку (1 сторона). На Тинькофф, БКС, Сбере она примерно такая для розничных клиентов. Полный круг (вход + выход) = 0.1%.

Шаг 4: Расчёт P&L с учётом проскальзывания

В реальной торговле сделка исполняется не точно по цене закрытия — есть проскальзывание (slippage). На ликвидных бумагах оно мало (0.01–0.05%), на менее ликвидных — больше.

Добавим slippage в расчёт:

def compute_returns_with_slippage(df, commission=0.0005, slippage=0.0003):
    df = df.copy()
    df["returns"] = df["close"].pct_change()
    df["strategy_returns"] = df["position"] * df["returns"]
    df["trades"] = df["position"].diff().abs()
    # Комиссия + проскальзывание на каждый вход и выход
    df["strategy_returns"] -= df["trades"] * (commission + slippage)
    df["cumulative"] = (1 + df["strategy_returns"]).cumprod()
    return df

df = compute_returns_with_slippage(df)

На M1–M5 проскальзывание может быть в 2–5 раз выше, чем на дневных свечах. Игнорировать его — главная причина, по которой бэктесты «показывают +200%», а реальная торговля сливает.

Шаг 5: Ключевые метрики

import numpy as np

def compute_metrics(df, risk_free=0.05):
    """Метрики по результату бэктеста."""
    r = df["strategy_returns"].dropna()

    total_return = df["cumulative"].iloc[-1] - 1
    n_days = (df.index[-1] - df.index[0]).days
    annual_return = (1 + total_return) ** (365 / n_days) - 1

    sharpe = (r.mean() - risk_free / 252) / r.std() * np.sqrt(252)

    cumulative = df["cumulative"]
    drawdown = cumulative / cumulative.cummax() - 1
    max_dd = drawdown.min()

    # Сделки: по сменам позиции
    trades_close = (df["position"].diff().abs() > 0)
    n_trades = trades_close.sum()

    # P&L по сделке: считаем как доходность между входом и выходом
    in_position = df["position"] == 1
    pnl_per_trade = []
    entry_price = None
    for i, row in df.iterrows():
        if row["position"] == 1 and entry_price is None:
            entry_price = row["close"]
        elif row["position"] == 0 and entry_price is not None:
            pnl_per_trade.append(row["close"] / entry_price - 1)
            entry_price = None

    if pnl_per_trade:
        wins = [p for p in pnl_per_trade if p > 0]
        losses = [p for p in pnl_per_trade if p <= 0]
        win_rate = len(wins) / len(pnl_per_trade) if pnl_per_trade else 0
        profit_factor = (
            sum(wins) / abs(sum(losses)) if losses else float("inf")
        )
    else:
        win_rate, profit_factor = 0, 0

    calmar = annual_return / abs(max_dd) if max_dd else 0

    return {
        "total_return": total_return,
        "annual_return": annual_return,
        "sharpe": sharpe,
        "max_drawdown": max_dd,
        "win_rate": win_rate,
        "profit_factor": profit_factor,
        "calmar": calmar,
        "n_trades": n_trades,
    }

metrics = compute_metrics(df)
for k, v in metrics.items():
    print(f"{k:20s} {v:.4f}")

Какие значения считать «хорошими»:

Метрика Плохо Средне Хорошо
Sharpe < 0.5 0.5–1.0 > 1.0
Max Drawdown > 30% 20–30% < 20%
Win Rate < 35% 35–50% > 50%
Profit Factor < 1.2 1.2–1.8 > 1.8
Calmar < 0.3 0.3–0.7 > 0.7

Win rate не самый важный показатель — стратегии trend-following дают win rate 30–40%, но profit factor 1.5–2.0 за счёт крупных выигрышных сделок.

Шаг 6: Визуализация

import matplotlib.pyplot as plt

def plot_results(df):
    fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 8))

    # Кривая капитала
    ax1.plot(df.index, df["cumulative"], label="Strategy")
    ax1.plot(df.index, df["close"] / df["close"].iloc[0],
             label="Buy & Hold", alpha=0.5)
    ax1.set_ylabel("Cumulative Return")
    ax1.legend()
    ax1.grid(True)

    # Drawdown
    cumulative = df["cumulative"]
    drawdown = (cumulative / cumulative.cummax() - 1) * 100
    ax2.fill_between(df.index, drawdown, 0, alpha=0.5, color="red")
    ax2.set_ylabel("Drawdown %")
    ax2.grid(True)

    plt.tight_layout()
    plt.show()

plot_results(df)

Кривую сравнивайте с buy-and-hold того же актива — если стратегия не обыгрывает «просто купил и держал», стратегия не нужна.

Шаг 7: Walk-forward анализ

Главная защита от overfit: walk-forward. Параметры стратегии подбираются на одном окне, проверяются на следующем — и так скользящим окном по всей истории.

def walk_forward(df, train_size=252, test_size=63, param_grid=None):
    """Walk-forward: train_size дней обучение, test_size дней тест."""
    if param_grid is None:
        param_grid = range(20, 200, 10)  # период SMA

    results = []
    for start in range(0, len(df) - train_size - test_size, test_size):
        train = df.iloc[start:start + train_size]
        test = df.iloc[start + train_size:start + train_size + test_size]

        # Подбираем лучший параметр на train
        best_param, best_score = None, -float("inf")
        for p in param_grid:
            t = add_signals(train, sma_period=p)
            t = compute_returns(t)
            score = compute_metrics(t)["sharpe"]
            if score > best_score:
                best_score = score
                best_param = p

        # Применяем на test
        t = add_signals(test, sma_period=best_param)
        t = compute_returns(t)
        results.append({
            "test_start": test.index[0],
            "test_end": test.index[-1],
            "param": best_param,
            "test_return": t["cumulative"].iloc[-1] - 1,
            "test_sharpe": compute_metrics(t)["sharpe"],
        })

    return pd.DataFrame(results)

wf = walk_forward(df)
print(wf)
print(f"Средний Sharpe on out-of-sample: {wf['test_sharpe'].mean():.2f}")

Если средний Sharpe на out-of-sample заметно ниже, чем на in-sample — стратегия переподогнана, использовать её нельзя.

Альтернативы: backtrader и vectorbt

Для серьёзных бэктестов «голый pandas» становится громоздким. Готовые фреймворки:

backtrader

import backtrader as bt

class SmaStrategy(bt.Strategy):
    params = (("period", 50),)

    def __init__(self):
        self.sma = bt.indicators.SimpleMovingAverage(
            self.data.close, period=self.p.period
        )

    def next(self):
        if not self.position and self.data.close[0] > self.sma[0]:
            self.buy()
        elif self.position and self.data.close[0] < self.sma[0]:
            self.close()

cerebro = bt.Cerebro()
cerebro.addstrategy(SmaStrategy)
data = bt.feeds.PandasData(dataname=df)
cerebro.adddata(data)
cerebro.broker.set_cash(100_000)
cerebro.broker.setcommission(commission=0.0005)
cerebro.run()
cerebro.plot()

backtrader — самый зрелый Python-фреймворк для бэктеста, но синтаксис тяжеловесный.

vectorbt

import vectorbt as vbt

sma = df["close"].rolling(50).mean()
entries = df["close"] > sma
exits = df["close"] < sma

pf = vbt.Portfolio.from_signals(
    df["close"], entries, exits,
    fees=0.0005, slippage=0.0003,
)
print(pf.stats())
pf.plot().show()

vectorbt — векторизованный, быстрый, отлично подходит для оптимизации параметров и портфельного бэктеста.

backtesting.py

from backtesting import Backtest, Strategy
from backtesting.lib import crossover

class SmaCross(Strategy):
    def init(self):
        self.sma = self.I(lambda x: pd.Series(x).rolling(50).mean(), self.data.Close)

    def next(self):
        if self.data.Close[-1] > self.sma[-1] and not self.position:
            self.buy()
        elif self.data.Close[-1] < self.sma[-1] and self.position:
            self.position.close()

bt = Backtest(df, SmaCross, cash=100_000, commission=0.0005)
stats = bt.run()
print(stats)
bt.plot()

backtesting.py — простой, отличный для образования и быстрых проверок.

Выбор зависит от задачи: pandas — для понимания механики; backtrader — для production; vectorbt — для оптимизации; backtesting.py — для быстрых пробов.

Шаг 8: Out-of-sample проверка

Финальный шаг — отдельная проверка на никогда не виденных данных:

# Откладываем последние 6 месяцев для финальной проверки
oos_split = df.index[-126]
train = df[df.index < oos_split]
oos = df[df.index >= oos_split]

# Подбираем параметры только на train
# ... walk-forward ...

# Применяем найденные параметры на oos
oos_with_signals = add_signals(oos, sma_period=best_param)
oos_with_pnl = compute_returns(oos_with_signals)
print(compute_metrics(oos_with_pnl))

Если на out-of-sample результат катастрофически хуже — стратегия не годится для real-money. Если близок к in-sample — есть основания продолжать.

Подводные камни

  • Look-ahead bias. Использование данных будущего в текущем сигнале. Решение — всегда shift(1) при расчёте позиций.
  • Survivorship bias. Тестируем только на бумагах, существующих сегодня. Не видим обанкротившиеся и делистнутые. Решение — использовать point-in-time датасеты.
  • Overfit. Подбор параметров под историю. Решение — walk-forward + out-of-sample.
  • Игнорирование комиссий и проскальзывания. Решение — закладывать реальные значения для конкретного брокера и таймфрейма.
  • Нереалистичные размеры позиций. Бэктест по 100% капитала на сделку — не отражает риск-менеджмент. Решение — фиксированный риск (0.5–1% депозита на сделку).
  • Бэктест без учёта корпоративных событий. Дивидендные гэпы, сплиты, делистинги ломают цены — на «голых» close-сериях это даёт ложные сигналы.
  • Малое количество сделок. При < 50 сделках статистика недостоверна — большая случайная составляющая.

Частые вопросы

Достаточно ли pandas для бэктеста или нужен фреймворк?

Для первых стратегий и обучения — pandas достаточно. Когда стратегия выходит на 5+ инструментов одновременно, появляется портфельное управление или оптимизация большого числа параметров — лучше переходить на vectorbt или backtrader. Они быстрее и предотвращают типичные ошибки.

Какой минимальный период для бэктеста?

Минимум — 3–5 лет дневных данных, чтобы включить разные режимы рынка (бычий, медвежий, флэт). На более короткой истории легко получить иллюзию работающей стратегии, которая на самом деле подогнана под один режим. Для коротких таймфреймов (M5, M15) можно ограничиться 1–2 годами тиковых данных.

Чем walk-forward отличается от out-of-sample?

Out-of-sample — это отложенная часть данных (обычно последние 20–30%), которая не используется при подборе параметров и проверяется в конце. Walk-forward — скользящее окно: обучаем на 1 год, тестируем на 3 месяца, сдвигаем, повторяем. Walk-forward даёт более точную оценку устойчивости стратегии во времени, out-of-sample — более простую финальную проверку. Хороший процесс включает оба метода.

Какой Sharpe считается хорошим для частной алготорговли?

Для одной стратегии — Sharpe > 1.0 на out-of-sample считается хорошим, > 1.5 — отличным. Sharpe > 2.0 на частной стратегии без специальной инфраструктуры почти всегда означает overfit. Институциональные фонды показывают Sharpe 0.5–1.5 в долгосроке — это уровень профессионалов.

Можно ли доверять бэктесту, если стратегия показывает 200% годовых?

В большинстве случаев нет. Такие результаты обычно объясняются одним из: переподгонкой параметров, игнорированием комиссий и проскальзывания, look-ahead bias, использованием survivorship-biased данных. Реалистичные стратегии для частного капитала дают 15–40% годовых при умеренной просадке. Всё, что выше, требует особо тщательной проверки.

Что запомнить

  • Бэктест — обязательный шаг между гипотезой и реальной торговлей.
  • Минимальные метрики: cumulative return, Sharpe, max drawdown, win rate, profit factor.
  • Защита от overfit: walk-forward анализ + out-of-sample проверка на отложенных данных.
  • Обязательно учитывать комиссии (0.05–0.1%) и проскальзывание (0.01–0.05%) — без них кривая обманчиво красивая.
  • shift(1) при расчёте позиции — защита от look-ahead bias.
  • Pandas — для обучения; backtrader, vectorbt, backtesting.py — для production.
  • Sharpe > 1.0 на out-of-sample — хорошая стратегия; > 2.0 без серьёзной инфраструктуры обычно overfit.
  • Минимум 3–5 лет дневных данных и 50+ сделок для статистически значимого результата.
бэктест Python pandas backtrader стратегии