Кто написал этот код и зачем
Начнём с двух вещей, которые нужно отметить сразу.
Весь код для этой статьи специально написан искусственным интеллектом. Конкретно — моделью Claude Opus 5 от компании Anthropic, работающей в среде Claude Code. Мы уже обсуждали в первой статье, что сейчас ИИ справляется с кодом довольно хорошо, и даже человек, не умеющий писать код, может создать своего бота для трейдинга, поэтому мы намеренно использовали ИИ для этой цели. Мы ставили задачу, задавали ограничения, анализировали результат и проверяли цифры; строки кода писала модель. И это не ради красного словца: дальше будет раздел о том, как та же модель нашла в этом коде настоящую ошибку исполнения, которую человек не заметил бы никогда.
Этот бот не предназначен для торговли. Он написан, чтобы показать устройство: как выглядит логика сигнала, как считается риск, где бэктест обычно привирает и как это ловится тестами. Доходность целью не была, и это видно по цифрам — без плеча бот даёт около 6% годовых, что, скорее всего, меньше вашей ставки по вкладу. Мы намеренно не стали ни выжимать из него проценты, ни писать блок отправки заявок брокеру: этот код физически не умеет торговать, и это сделано специально. Разбирать устройство на живом боевом роботе — плохая идея по двум причинам: работающий эдж от публикации умирает (об этом была первая статья), а читатель, скопировавший чужой боевой код, теряет деньги первым.
Так что относитесь к нему как к разрезанному пополам двигателю в музее: ездить нельзя, а вот анализировать устройство и учиться приветствуется.
Весь проект — 1598 строк: 915 строк кода, 375 строк комментариев и пояснений, остальное — пустые строки между блоками.
Весь код бота
# ==========================================================================
# breakout/core.py
# ==========================================================================
# -*- coding: utf-8 -*-
"""
Базовые типы: свеча, сигнал, позиция, сделка.
Здесь намеренно нет ни pandas, ни numpy. Всё, что относится к принятию
решения, написано на чистом Python — ровно тот же код исполнялся бы и в бою.
Это защита от классической ошибки: «протестировали одно, а торгует бот другое».
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
LONG = 1
SHORT = -1
@dataclass(frozen=True)
class Bar:
"""Одна ЗАКРЫТАЯ свеча. Незакрытые свечи в стратегию не попадают никогда."""
time: datetime
open: float
high: float
low: float
close: float
volume: float = 0.0
def __post_init__(self) -> None:
if not (self.low <= self.open <= self.high and self.low <= self.close <= self.high):
raise ValueError(f"битая свеча {self.time}: O/H/L/C = "
f"{self.open}/{self.high}/{self.low}/{self.close}")
@dataclass(frozen=True)
class Signal:
"""
Что стратегия решила на закрытии бара.
Обратите внимание: здесь НЕТ цены входа. Стратегия её знать не может —
сделка исполнится только на следующем баре, и по какой цене, заранее
неизвестно. Стратегия сообщает УРОВЕНЬ стопа (он привязан к бару-сигналу,
а не к цене входа) и множитель тейка. Точные цены посчитает исполнение,
уже зная реальную цену входа.
"""
side: int
stop_price: float
take_rr: float
class Exit(str, Enum):
STOP = "стоп"
TAKE = "тейк"
TIME = "время"
GAP_STOP = "стоп с разрывом"
END = "конец истории"
@dataclass(frozen=True)
class Position:
"""Открытая позиция. Направление — в side, цены — абсолютные."""
side: int
entry_price: float
entry_time: datetime
entry_index: int
stop_price: float
take_price: float
@dataclass(frozen=True)
class Trade:
"""Закрытая сделка — единица, на которой считается вся посделочная статистика."""
instrument: str
side: int
entry_time: datetime
entry_price: float
exit_time: datetime
exit_price: float
bars_held: int
exit_reason: str
gross_return: float # ход цены в нашу сторону, доля от цены входа
costs: float # издержки круга, доля
risk_fraction: float # плановый риск сделки (расстояние до стопа), доля
@property
def net_return(self) -> float:
return self.gross_return - self.costs
@property
def r_multiple(self) -> float:
"""Результат в единицах начального риска: −1.0 значит «вышли ровно по стопу»."""
return self.net_return / self.risk_fraction if self.risk_fraction > 0 else 0.0
# ==========================================================================
# breakout/indicators.py
# ==========================================================================
# -*- coding: utf-8 -*-
"""
Индикаторы, посчитанные ИНКРЕМЕНТАЛЬНО — бар за баром, как в бою.
Почему не «взять весь массив и посчитать скользящее»: именно так в бэктест
и просачивается заглядывание в будущее. Когда индикатор считается по всему
массиву сразу, очень легко случайно захватить бар, которого в момент решения
ещё не существовало.
Здесь такой возможности нет физически: объект видит бары строго по одному
и в том порядке, в котором они приходили.
"""
from __future__ import annotations
from collections import deque
from .core import Bar
class DonchianChannel:
"""
Максимум и минимум за последние N баров.
Ключевая деталь — levels() возвращает уровни ДО того, как в окно добавлен
текущий бар. Иначе цена сравнивалась бы с каналом, который она сама только
что и раздвинула: пробой стал бы невозможен математически.
"""
def __init__(self, length: int) -> None:
if length < 1:
raise ValueError("длина канала должна быть не меньше 1")
self.length = length
self._highs: deque[float] = deque(maxlen=length)
self._lows: deque[float] = deque(maxlen=length)
@property
def ready(self) -> bool:
return len(self._highs) == self.length
def levels(self) -> tuple[float, float] | None:
"""Верхняя и нижняя границы по УЖЕ ЗАКРЫТЫМ предыдущим барам."""
if not self.ready:
return None
return max(self._highs), min(self._lows)
def update(self, bar: Bar) -> None:
self._highs.append(bar.high)
self._lows.append(bar.low)
class AverageTrueRange:
"""
ATR — средний истинный размах бара, мера обычного размера движения.
Нужен, чтобы измерять глубину пробоя в единицах нормального движения рынка:
выход за уровень на копейку и выход на половину дневного размаха — разные
события, и мерить их в рублях нельзя, иначе порог придётся подбирать
заново для каждого инструмента.
Считаем простым скользящим средним — так проще проверить руками, чем
сглаживание Уайлдера, а на результат это влияет слабо.
"""
def __init__(self, length: int = 14) -> None:
if length < 1:
raise ValueError("длина ATR должна быть не меньше 1")
self.length = length
self._values: deque[float] = deque(maxlen=length)
self._prev_close: float | None = None
@property
def ready(self) -> bool:
return len(self._values) == self.length
def value(self) -> float | None:
if not self.ready:
return None
return sum(self._values) / len(self._values)
def update(self, bar: Bar) -> None:
if self._prev_close is None:
true_range = bar.high - bar.low
else:
# Истинный размах учитывает разрыв между барами: движение могло
# произойти ночью, и для оценки риска оно ничем не хуже внутрибарного.
true_range = max(
bar.high - bar.low,
abs(bar.high - self._prev_close),
abs(bar.low - self._prev_close),
)
self._values.append(true_range)
self._prev_close = bar.close
# ==========================================================================
# breakout/strategy.py
# ==========================================================================
# -*- coding: utf-8 -*-
"""
Логика сигнала — сердце бота и самая короткая его часть.
ПРАВИЛО ЦЕЛИКОМ
1. Смотрим на максимум и минимум последних N закрытых баров.
2. Если бар закрылся ВЫШЕ максимума, причём не на копейку, а с запасом
в pierce средних размахов (ATR), — это сигнал на покупку.
Симметрично вниз — сигнал на продажу.
3. Входим по открытию СЛЕДУЮЩЕГО бара: увидеть закрытие и купить по нему нельзя.
4. Стоп кладём за противоположный край БАРА-СИГНАЛА плюс маленький буфер.
Не «два ATR от входа», а именно за экстремум: если пробой настоящий,
цена не должна возвращаться туда, откуда рванула.
5. Тейк ставим на расстоянии take_rr таких же стопов от входа.
При take_rr = 2 это значит: рискуем одним рублём, чтобы заработать два.
ЗАЧЕМ НУЖЕН ЗАПАС В ПОЛ-ATR
Без него сигналом становится любое касание уровня. Рынок целыми днями болтается
вокруг круглых чисел и вчерашних максимумов, и бот, реагирующий на касание,
покупает шум. Запас требует, чтобы цена ушла за уровень на заметную для этого
инструмента величину — измеренную в его собственных размахах, а не в рублях.
Это единственный параметр, который здесь по-настоящему что-то решает.
ЧТО ЭТО ЗА МЕХАНИЗМ И КТО ПЛАТИТ
Это трендследящая система: она ставит на то, что начавшееся движение
продолжится. Платят за это две группы. Первая — те, кто держал позицию против
движения и вынужден закрываться по стопу, разгоняя цену дальше. Вторая — сами
трендследящие: система ошибается чаще, чем угадывает (прибыльных сделок около
40 процентов), и почти весь доход приносят несколько крупных движений в год.
Большинство такой профиль не выдерживает и уходит; те, кто остаётся, получают
премию за терпение. Это не гарантия — это ответ на вопрос, откуда берутся деньги.
ЧЕГО ЗДЕСЬ НЕТ
Секрета. Пробой канала опубликован в 1983 году (система черепах Ричарда
Денниса) и с тех пор напечатан тысячи раз. Стратегия здесь как честный и
проверяемый образец устройства бота, а не как способ разбогатеть.
"""
from __future__ import annotations
from dataclasses import dataclass
from .core import LONG, SHORT, Bar, Signal
from .indicators import AverageTrueRange, DonchianChannel
@dataclass(frozen=True)
class StrategyParams:
channel: int = 10 # длина канала, баров
pierce_atr: float = 0.5 # требуемый запас за уровень, в ATR
atr_length: int = 14 # окно ATR
take_rr: float = 2.0 # тейк = take_rr на расстояние до стопа
stop_buffer: float = 0.0005 # буфер за экстремум бара-сигнала, доля цены
allow_short: bool = True
def __post_init__(self) -> None:
if self.channel < 2:
raise ValueError("канал короче двух баров не имеет смысла")
if self.pierce_atr < 0:
raise ValueError("запас за уровень не может быть отрицательным")
if self.take_rr <= 0:
raise ValueError("множитель тейка должен быть положительным")
class BreakoutStrategy:
"""
Принимает закрытые бары по одному, возвращает сигнал или None.
О позиции стратегия не помнит ничего: ей просто не задают вопрос, пока
позиция открыта. В бою источником истины о позиции является брокер, а не
память бота — бот, который верит своей памяти больше, чем брокеру, рано
или поздно торгует позицию, которой у него нет.
"""
def __init__(self, params: StrategyParams | None = None) -> None:
self.params = params or StrategyParams()
self._channel = DonchianChannel(self.params.channel)
self._atr = AverageTrueRange(self.params.atr_length)
def decide(self, bar: Bar) -> Signal | None:
"""
Решение по ЗАКРЫТОМУ бару. Исполнено оно будет на следующем.
Порядок действий здесь — не стилистика, а защита от ошибки: сначала
читаем уровни, посчитанные по ПРЕДЫДУЩИМ барам, и только после этого
добавляем текущий бар в индикаторы. Поменяйте шаги местами — и цена
начнёт сравниваться с каналом, который она сама только что раздвинула,
а бэктест нарисует прибыль из ничего.
"""
signal = self._evaluate(bar)
# Текущий бар входит в историю ТОЛЬКО после того, как решение принято.
self._channel.update(bar)
self._atr.update(bar)
return signal
def _evaluate(self, bar: Bar) -> Signal | None:
levels = self._channel.levels()
atr = self._atr.value()
if levels is None or atr is None or atr <= 0:
return None
p = self.params
high, low = levels
margin = p.pierce_atr * atr
if bar.close > high + margin:
return Signal(LONG, bar.low * (1.0 - p.stop_buffer), p.take_rr)
if p.allow_short and bar.close < low - margin:
return Signal(SHORT, bar.high * (1.0 + p.stop_buffer), p.take_rr)
return None
# ==========================================================================
# breakout/engine.py
# ==========================================================================
# -*- coding: utf-8 -*-
"""
Исполнение и прогон по истории.
Стратегия говорит «покупай». Между этим словом и деньгами на счёте лежит всё
то, что обычно и превращает прибыльный бэктест в убыточного робота.
ШЕСТЬ ПРАВИЛ, НА КОТОРЫХ ПОСТРОЕН ЭТОТ ДВИЖОК
1. Решение принимается по ЗАКРЫТОМУ бару, а исполняется по цене ОТКРЫТИЯ
следующего. Так устроена жизнь: увидеть закрытие и купить по нему нельзя.
2. Позиция может быть закрыта на том же баре, на котором открыта. Это не
редкость, а обычное дело, и бэктест, который так не умеет, систематически
завышает результат.
3. Разрыв цены исполняет стоп ХУЖЕ, чем он стоит. Если рынок открылся ниже
вашего стопа на лонг, вы выйдете по открытию, а не по стопу. Именно поэтому
реальный риск сделки всегда больше запланированного. Движок, который
заполняет стоп по цене стопа даже при разрыве, рисует лишние проценты
там, где в реальности был бы худший день года.
4. Если рынок открылся уже ЗА стопом, сделки не будет. Защищать нечего: цена
вернулась туда, откуда мы собирались рвануть, ещё до того, как мы вошли.
Движок без этой проверки открывает позицию со стопом с неправильной
стороны от цены и записывает её как ПРИБЫЛЬНЫЙ стоп — событие,
невозможное на реальном счёте.
5. Если стоп и тейк задеты внутри ОДНОГО бара, считаем, что сработал стоп.
По минутным данным можно было бы восстановить, что случилось раньше, но
у нас их нет, а угадывать в свою пользу — самый дешёвый способ обмануть
себя. Правило намеренно пессимистичное.
6. Одна позиция на инструмент за раз. Сигнал, пришедший, пока позиция открыта,
пропускается — иначе размер позиции становится случайной величиной,
а вместе с ним и весь учёт риска.
ГДЕ ДВИЖОК НАМЕРЕННО ЗАНИЖАЕТ РЕЗУЛЬТАТ
Если рынок разрывом ушёл ЗА тейк, выход считается по цене тейка, хотя реальная
лимитная заявка исполнилась бы по открытию, то есть лучше. Ошибка в свою
пользу опаснее ошибки против себя, поэтому она оставлена как есть.
ЧЕГО ЭТОТ ДВИЖОК НЕ УМЕЕТ
Он не отправляет заявок и не обрабатывает отказ брокера. В бою между решением
и позицией лежит сеть, и ответ может не прийти вообще — это отдельная большая
тема, и в этот файл она не помещается. Но стратегия, риск и порядок действий
здесь те же, что были бы в живом цикле.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date
from .core import LONG, Bar, Exit, Position, Signal, Trade
from .strategy import BreakoutStrategy, StrategyParams
@dataclass(frozen=True)
class Costs:
"""
Издержки круга: комиссия плюс проскальзывание, обе стороны.
Задаются в базисных пунктах от цены (1 б.п. = 0.01 процента). Так их можно
применять к любому инструменту, не пересчитывая в рубли для каждого.
По умолчанию 4 б.п. — ориентир для ликвидного фьючерса; для тонкого
инструмента это занижено вдвое-втрое, и проверять чувствительность
к этому числу обязательно (run_portfolio.py --costs).
"""
round_trip_bp: float = 4.0
@property
def fraction(self) -> float:
return self.round_trip_bp / 10_000.0
@dataclass(frozen=True)
class EngineParams:
max_bars_in_trade: int = 60 # аварийный выход по времени
costs: Costs = field(default_factory=Costs)
max_risk_fraction: float = 0.10 # сигнал со стопом дальше 10 процентов цены пропускаем
@dataclass
class RunResult:
instrument: str
trades: list[Trade] = field(default_factory=list)
daily: dict[date, float] = field(default_factory=dict)
bars: int = 0
signals: int = 0
skipped_in_position: int = 0 # позиция уже открыта
skipped_stop_passed: int = 0 # на открытии цена уже за стопом
skipped_wide_stop: int = 0 # стоп дальше max_risk_fraction
class Engine:
"""Прогоняет стратегию по потоку баров одного инструмента."""
def __init__(self, instrument: str,
strategy_params: StrategyParams | None = None,
engine_params: EngineParams | None = None) -> None:
self.instrument = instrument
self.strategy = BreakoutStrategy(strategy_params)
self.params = engine_params or EngineParams()
self._position: Position | None = None
self._pending: Signal | None = None
self._mark: float = 0.0 # цена, по которой позиция переоценена в прошлый раз
self.result = RunResult(instrument)
# ------------------------------------------------------------- переоценка
def _revalue(self, price: float, day: date) -> None:
"""
Переоценка открытой позиции по текущей цене.
Без неё доход сделки записался бы в день выхода целиком, и просадка
получилась бы мягче настоящей: сделка, которая три недели шла против
нас, а потом вышла в плюс по тейку, выглядела бы как ровный плюс
в один день. Здесь капитал переоценивается по закрытию каждого бара,
поэтому кривая показывает то, что вы реально видели бы на счёте.
"""
p = self._position
if p is None:
return
change = p.side * (price - self._mark) / p.entry_price
self.result.daily[day] = self.result.daily.get(day, 0.0) + change
self._mark = price
# ------------------------------------------------------------- исполнение
def _open(self, signal: Signal, bar: Bar, index: int) -> None:
entry = bar.open
risk = signal.side * (entry - signal.stop_price)
if risk <= 0:
# Правило 4: цена уже за стопом — сетап умер, входить некуда.
self.result.skipped_stop_passed += 1
return
if risk / entry > self.params.max_risk_fraction:
self.result.skipped_wide_stop += 1
return
self._position = Position(
side=signal.side, entry_price=entry, entry_time=bar.time, entry_index=index,
stop_price=signal.stop_price,
take_price=entry + signal.side * signal.take_rr * risk,
)
self._mark = entry
def _close(self, price: float, bar: Bar, index: int, reason: Exit) -> None:
p = self._position
if p is None:
return
day = bar.time.date()
self._revalue(price, day)
# Издержки круга списываем в день выхода — одной строкой за обе стороны.
self.result.daily[day] = self.result.daily.get(day, 0.0) - self.params.costs.fraction
self.result.trades.append(Trade(
instrument=self.instrument, side=p.side,
entry_time=p.entry_time, entry_price=p.entry_price,
exit_time=bar.time, exit_price=price,
bars_held=index - p.entry_index, exit_reason=reason.value,
gross_return=p.side * (price - p.entry_price) / p.entry_price,
costs=self.params.costs.fraction,
risk_fraction=abs(p.entry_price - p.stop_price) / p.entry_price,
))
self._position = None
def _check_exit(self, bar: Bar, index: int, entry_bar: bool) -> None:
"""
Проверяет, вышли ли мы на этом баре и по какой цене.
Порядок проверок здесь — то самое место, где бэктест чаще всего
привирает в свою пользу, поэтому он расписан явно.
"""
p = self._position
if p is None:
return
if p.side == LONG:
stop_hit, take_hit = bar.low <= p.stop_price, bar.high >= p.take_price
gapped = bar.open < p.stop_price
else:
stop_hit, take_hit = bar.high >= p.stop_price, bar.low <= p.take_price
gapped = bar.open > p.stop_price
if stop_hit:
# На баре входа разрыва быть не может: вход был по этому же открытию,
# и правильная сторона от стопа проверена в _open.
if gapped and not entry_bar:
self._close(bar.open, bar, index, Exit.GAP_STOP)
else:
self._close(p.stop_price, bar, index, Exit.STOP)
return
if take_hit:
self._close(p.take_price, bar, index, Exit.TAKE)
return
if index - p.entry_index >= self.params.max_bars_in_trade:
self._close(bar.close, bar, index, Exit.TIME)
# ------------------------------------------------------------------- цикл
def process(self, bar: Bar, index: int) -> None:
entered_now = False
if self._pending is not None:
self._open(self._pending, bar, index)
self._pending = None
entered_now = self._position is not None
self._check_exit(bar, index, entry_bar=entered_now)
# Позиция пережила бар — переоцениваем её по закрытию.
self._revalue(bar.close, bar.time.date())
self.result.daily.setdefault(bar.time.date(), 0.0)
self.result.bars += 1
signal = self.strategy.decide(bar)
if signal is None:
return
self.result.signals += 1
if self._position is not None:
self.result.skipped_in_position += 1
return
self._pending = signal
def run(self, bars: list[Bar]) -> RunResult:
for i, bar in enumerate(bars):
self.process(bar, i)
# Позицию, открытую на последнем баре истории, закрываем: её
# незафиксированная прибыль не должна попасть в статистику подарком.
if self._position is not None:
self._close(bars[-1].close, bars[-1], len(bars) - 1, Exit.END)
return self.result
def run_instrument(instrument: str, bars: list[Bar],
strategy_params: StrategyParams | None = None,
engine_params: EngineParams | None = None) -> RunResult:
return Engine(instrument, strategy_params, engine_params).run(bars)
# ==========================================================================
# breakout/portfolio.py
# ==========================================================================
# -*- coding: utf-8 -*-
"""
Корзина: как из нескольких инструментов собрать одну кривую капитала.
ЗАЧЕМ ВООБЩЕ КОРЗИНА
Трендследящая стратегия на одном инструменте — лотерея. Она зарабатывает
на нескольких крупных движениях в год, а будут ли они в этом году именно
в золоте, заранее неизвестно. Шесть инструментов вместо одного не улучшают
стратегию — они превращают лотерею в статистику.
КАК СКЛАДЫВАЕМ
Не поровну по деньгам, а поровну по РИСКУ. Если сложить поровну по деньгам,
самый дёрганый инструмент принесёт и почти весь доход, и почти всю просадку,
а спокойные будут стоять мёртвым грузом. Поэтому каждая нога сначала
приводится к одной и той же целевой волатильности (по умолчанию 10 процентов
годовых), и только потом ноги усредняются.
ДВЕ ЛОВУШКИ ЭТОГО ФАЙЛА
Первая: волатильность ноги надо считать по КАЛЕНДАРЮ, а не по дням, когда была
сделка. Инструмент, который стоит в позиции 20 процентов времени, на своих
активных днях колеблется примерно вдвое сильнее, чем в среднем по календарю.
Померив волатильность по активным дням, вы решите, что нога дёрганее, чем
она есть, дадите ей вдвое меньший размер — а потом, посчитав Шарп тоже
по активным дням, получите цифру процентов на тридцать выше правды.
Вторая: у разных инструментов разное число торговых дней в году. Биткойн
торгуется 365 дней, фьючерс на золото — около 250. Пересчитывая дневную
волатильность в годовую по одной константе для всех, вы дадите биткойну
примерно на 20 процентов больше риска, чем остальным, и корзина перестанет
быть равновзвешенной ровно в той ноге, которая заработала больше всех.
Поэтому число дней в году считается для каждой ноги по её собственному
календарю.
МАСШТАБ РИСКА
Множитель leverage применяется к готовой кривой корзины. Он не улучшает
стратегию ни на копейку: доход и просадка растут ровно в одинаковое число раз.
Это ручка «сколько я готов терять», а не «сколько я хочу зарабатывать».
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from datetime import date
@dataclass(frozen=True)
class PortfolioParams:
target_leg_vol: float = 0.10 # целевая годовая волатильность одной ноги
leverage: float = 1.0 # общий масштаб риска корзины
max_leg_scale: float = 20.0 # потолок на множитель ноги (защита от деления на шум)
def _std(values: list[float]) -> float:
if len(values) < 2:
return 0.0
mean = sum(values) / len(values)
return math.sqrt(sum((v - mean) ** 2 for v in values) / (len(values) - 1))
def _periods_per_year(days: list[date]) -> float:
"""Сколько торговых дней в году У ЭТОГО инструмента — по факту, а не по константе."""
span = (days[-1] - days[0]).days / 365.25
return len(days) / span if span > 0 else 0.0
def build(legs: dict[str, dict[date, float]],
params: PortfolioParams | None = None,
scale_until: date | None = None) -> tuple[dict[date, float], dict[str, float]]:
"""
Складывает ноги в корзину. Возвращает (кривая корзины, множители ног).
`scale_until` ограничивает период, по которому меряется волатильность ноги.
Нужен для честной проверки на поздних годах: масштаб обязан считаться
только по тем данным, которые были бы доступны на момент решения.
"""
p = params or PortfolioParams()
scales: dict[str, float] = {}
for name, series in legs.items():
days = sorted(d for d in series if scale_until is None or d <= scale_until)
sd = _std([series[d] for d in days])
ppy = _periods_per_year(days) if len(days) > 1 else 0.0
if sd <= 0 or ppy <= 0:
scales[name] = 0.0
continue
target_daily = p.target_leg_vol / math.sqrt(ppy)
scales[name] = min(target_daily / sd, p.max_leg_scale)
# Делим на ПОЛНОЕ число ног, а не на число живых в этот день: пока у ноги
# нет истории, её доля риска просто стоит в деньгах. Это занижает результат
# на первом отрезке, где торгует не вся корзина, — и пусть занижает.
divisor = len([s for s in scales.values() if s > 0]) or 1
all_days = sorted({d for series in legs.values() for d in series})
basket = {
d: sum(legs[name].get(d, 0.0) * scales[name] for name in legs) / divisor * p.leverage
for d in all_days
}
first = next((d for d in all_days if basket[d] != 0.0), None)
if first is not None:
basket = {d: v for d, v in basket.items() if d >= first}
return basket, scales
# ==========================================================================
# breakout/metrics.py
# ==========================================================================
# -*- coding: utf-8 -*-
"""
Метрики: язык, на котором разговаривают о стратегиях.
Почти каждую из них можно обмануть, если смотреть на неё одну.
Доля прибыльных — самая переоценённая метрика. Трендовая система с долей
35 процентов бывает отличной, а система с долей 95 —
смертельной, если редкий убыток съедает сотню прибылей.
Профит-фактор — во сколько раз заработанное больше потерянного.
Меньше 1 — стратегия теряет. 1.2 у трендовой системы
это норма, а 3.0 почти всегда значит, что вы что-то
посчитали неправильно.
Матожидание — сколько в среднем приносит одна сделка. Вот ради этой
цифры всё и считается.
Просадка — сколько теряли по дороге. Единственный честный
ограничитель доходности: доход можно удвоить плечом,
но просадка удвоится вместе с ним.
Коэффициент Шарпа — доход на единицу колебаний. Главная ловушка здесь не
в формуле, а в знаменателе: если считать его только по
дням, когда была сделка, а годовой множитель брать как
для всех дней подряд, Шарп раздувается на ровном месте.
Поэтому ниже он считается по КАЛЕНДАРЮ: дни без позиции
входят в расчёт нулями, как и положено.
Отдельная строка «доля прибыли из 5 лучших сделок»: если убрать пять лучших
и стратегия становится убыточной, значит вы измерили не преимущество,
а пять удачных случаев.
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
from datetime import date
from .core import Trade
@dataclass
class TradeStats:
trades: int = 0
win_rate: float = 0.0
profit_factor: float = 0.0
expectancy: float = 0.0
avg_win: float = 0.0
avg_loss: float = 0.0
payoff: float = 0.0
avg_r: float = 0.0
best: float = 0.0
worst: float = 0.0
max_losing_streak: int = 0
top5_share: float = 0.0
avg_bars: float = 0.0
exits: dict[str, float] = field(default_factory=dict)
@dataclass
class CurveStats:
years: float = 0.0
cagr: float = 0.0
volatility: float = 0.0
sharpe: float = 0.0
max_drawdown: float = 0.0
max_drawdown_days: int = 0
calmar: float = 0.0
worst_day: float = 0.0
worst_month: float = 0.0
positive_months: float = 0.0
by_year: dict[int, float] = field(default_factory=dict)
days: int = 0
def trade_stats(trades: list[Trade]) -> TradeStats:
s = TradeStats()
if not trades:
return s
pnls = [t.net_return for t in trades]
wins = [p for p in pnls if p > 0]
losses = [p for p in pnls if p <= 0]
s.trades = len(pnls)
s.win_rate = len(wins) / len(pnls)
s.expectancy = sum(pnls) / len(pnls)
s.avg_win = sum(wins) / len(wins) if wins else 0.0
s.avg_loss = sum(losses) / len(losses) if losses else 0.0
s.payoff = abs(s.avg_win / s.avg_loss) if s.avg_loss else math.inf
gross_loss = abs(sum(losses))
s.profit_factor = (sum(wins) / gross_loss) if gross_loss > 0 else math.inf
s.avg_r = sum(t.r_multiple for t in trades) / len(trades)
s.best, s.worst = max(pnls), min(pnls)
s.avg_bars = sum(t.bars_held for t in trades) / len(trades)
streak = worst = 0
for p in pnls:
streak = streak + 1 if p <= 0 else 0
worst = max(worst, streak)
s.max_losing_streak = worst
total = sum(pnls)
if total > 0:
s.top5_share = sum(sorted(pnls, reverse=True)[:5]) / total
counts: dict[str, int] = {}
for t in trades:
counts[t.exit_reason] = counts.get(t.exit_reason, 0) + 1
s.exits = {k: v / len(trades) for k, v in sorted(counts.items(), key=lambda kv: -kv[1])}
return s
def curve_stats(daily: dict[date, float]) -> CurveStats:
"""
Метрики по КАЛЕНДАРНОМУ ряду дневных доходностей.
На вход подаются все торговые дни периода, включая те, когда позиции
не было (значение 0.0). Это принципиально: см. комментарий про Шарп выше.
"""
days = sorted(daily)
if len(days) < 2:
raise ValueError("для метрик нужно хотя бы два дня")
rets = [daily[d] for d in days]
s = CurveStats()
s.days = len(rets)
# Длину периода берём из КАЛЕНДАРЯ, а частоту наблюдений — по факту.
# Жёсткая константа «252 дня в году» здесь была бы ошибкой: криптовалюта
# торгуется без выходных, и в общем календаре корзины дней заметно больше.
# Подставив 252, вы растянули бы семь лет истории до десяти и занизили
# и доходность, и волатильность.
s.years = max((days[-1] - days[0]).days / 365.25, 1e-9)
periods_per_year = len(rets) / s.years
# Просадка меряется от старта, а не от первого значения кривой: если бот
# начал с убытка, эта просадка тоже была на счёте и обязана попасть в отчёт.
equity = peak = 1.0
peak_day = days[0]
for d, r in zip(days, rets):
equity *= 1.0 + r
if equity > peak:
peak, peak_day = equity, d
elif equity / peak - 1.0 < s.max_drawdown:
s.max_drawdown = equity / peak - 1.0
s.max_drawdown_days = (d - peak_day).days
s.cagr = equity ** (1.0 / s.years) - 1.0 if equity > 0 else -1.0
mean = sum(rets) / len(rets)
sd = math.sqrt(sum((r - mean) ** 2 for r in rets) / (len(rets) - 1))
s.volatility = sd * math.sqrt(periods_per_year)
s.sharpe = (mean / sd * math.sqrt(periods_per_year)) if sd > 0 else 0.0
s.calmar = s.cagr / abs(s.max_drawdown) if s.max_drawdown < 0 else 0.0
s.worst_day = min(rets)
months: dict[tuple[int, int], float] = {}
years: dict[int, float] = {}
for d, r in zip(days, rets):
key = (d.year, d.month)
months[key] = (1.0 + months.get(key, 0.0)) * (1.0 + r) - 1.0
years[d.year] = (1.0 + years.get(d.year, 0.0)) * (1.0 + r) - 1.0
s.worst_month = min(months.values()) if months else 0.0
s.positive_months = sum(1 for v in months.values() if v > 0) / len(months) if months else 0.0
s.by_year = dict(sorted(years.items()))
return s
# ==========================================================================
# breakout/data.py
# ==========================================================================
# -*- coding: utf-8 -*-
"""
Загрузка котировок и проверка их на вменяемость.
Данные — фундамент: на кривых данных получается кривой результат, причём
чаще всего слишком красивый, а не слишком плохой. Поэтому загрузчик здесь
не просто читает файл, но и проверяет то, что обычно никто не проверяет:
идут ли бары по возрастанию времени, нет ли дубликатов, не сломана ли свеча
(максимум ниже минимума), нет ли нулевых и отрицательных цен.
Дыры в календаре загрузчик НЕ считает ошибкой и не заполняет: выходные,
праздники и остановки торгов — часть реальности, и придумывать за рынок
цены, которых не было, гораздо опаснее, чем оставить пропуск.
"""
from __future__ import annotations
import csv
from datetime import date, datetime
from pathlib import Path
from .core import Bar
REQUIRED_COLUMNS = ("datetime", "open", "high", "low", "close")
class DataError(ValueError):
"""Данные не годятся для бэктеста. Лучше упасть здесь, чем торговать мусор."""
def _parse_time(value: str) -> datetime:
text = value.strip()
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d"):
try:
return datetime.strptime(text, fmt)
except ValueError:
continue
raise DataError(f"не могу разобрать дату: {value!r}")
def load_csv(path: str | Path) -> list[Bar]:
"""
Читает CSV с колонками datetime,open,high,low,close[,volume].
Возвращает список, а не генератор, намеренно: бары нужно проверить целиком
до того, как по ним побежит стратегия, иначе бот успеет наторговать
на первой половине файла и только потом упадёт на битой строке.
"""
path = Path(path)
if not path.exists():
raise DataError(f"файл не найден: {path}")
bars: list[Bar] = []
with path.open("r", encoding="utf-8", newline="") as handle:
reader = csv.DictReader(handle)
missing = [c for c in REQUIRED_COLUMNS if c not in (reader.fieldnames or [])]
if missing:
raise DataError(f"в файле нет колонок: {', '.join(missing)}")
for line_no, row in enumerate(reader, start=2):
try:
bar = Bar(
time=_parse_time(row["datetime"]),
open=float(row["open"]),
high=float(row["high"]),
low=float(row["low"]),
close=float(row["close"]),
volume=float(row.get("volume") or 0.0),
)
except (ValueError, TypeError) as exc:
raise DataError(f"строка {line_no}: {exc}") from exc
if min(bar.open, bar.high, bar.low, bar.close) <= 0:
raise DataError(f"строка {line_no}: неположительная цена")
bars.append(bar)
validate(bars)
return bars
def validate(bars: list[Bar]) -> None:
if len(bars) < 2:
raise DataError("для бэктеста нужно хотя бы два бара")
for previous, current in zip(bars, bars[1:]):
if current.time == previous.time:
raise DataError(f"дубликат времени: {current.time}")
if current.time < previous.time:
raise DataError(f"бары идут не по порядку: {previous.time} и {current.time}")
def trading_days(bars: list[Bar]) -> list[date]:
"""Календарь торговых дней инструмента — по нему считаются метрики."""
seen: dict[date, None] = {}
for bar in bars:
seen.setdefault(bar.time.date(), None)
return sorted(seen)
def describe(bars: list[Bar]) -> str:
span_days = (bars[-1].time - bars[0].time).days
return (f"баров {len(bars):,}, дней {len(trading_days(bars)):,}, "
f"период {bars[0].time:%Y-%m-%d} — {bars[-1].time:%Y-%m-%d} "
f"({span_days / 365.25:.1f} лет), цена {bars[0].close:.2f} — {bars[-1].close:.2f}")Это весь пакет breakout — семь модулей в том порядке, в котором их удобно читать: типы, индикаторы, стратегия, движок, корзина, метрики, загрузка котировок. Русские комментарии внутри не мусор, а часть материала: в них записано, почему сделано именно так. Свои комментарии вы тоже можете оставлять при разработке собственных ботов, они будут удобны для вас и абсолютно не помешают работе бота.
Проверьте сами. Нужен Python 3.10 или новее; ставить ничего не надо — весь расчёт идёт на стандартной библиотеке. Скачайте архив с проектом, в нём код, тесты и все котировки; распакуйте и запустите из его папки:
| команда | что покажет |
|---|---|
python run_portfolio.py | все цифры из этой статьи |
python run_portfolio.py --leverage 3 | таблицу плеча |
python run_portfolio.py --split 2023-12-31 | проверку на поздних годах |
python run_portfolio.py --costs 8 | что будет при вдвое больших издержках |
python run_backtest.py --instrument gold | журнал сделок по золоту: вход, стоп, выход |
python tests/run_all.py | 21 проверку, на которой держится бэктест |
Считается за несколько секунд. В этом и смысл публикации: любую цифру ниже можно не принимать на веру, а перепроверить одной командой.
Правило, по которому бот торгует
Бот торгует пробой канала Дончиана. Разберём по частям.
Donchian channel (канал Дончиана) — максимум и минимум цены за последние N баров. Назван по имени Ричарда Дончиана, применявшего его ещё в середине XX века. Верхняя граница канала — самая высокая цена за взятый вами период, нижняя — самая низкая. У нас период N = 10, то есть канал охватывает последние десять четырёхчасовых баров, а это примерно двое суток торгов.
Breakout (пробой) — выход цены за границу канала. Идея простая: пока цена ходит внутри диапазона, ничего не происходит; выход за границу канала означает, что цена, вероятно, продолжит своё движение по направлению пробоя.
Проблема в том, что цена может несколько раз за день подходить и задевать границы канала, и реагировать на каждое касание — значит терять деньги. Поэтому одного выхода за границу мало: нужно, чтобы цена ушла за неё с запасом.
Но в чём мерить этот запас? «Цена ушла за границу на 3 рубля» — фраза ни о чём: для газа три рубля огромный ход, для золота шум. Значит, мерка нужна своя у каждого инструмента, и считать её надо по нему самому.
ATR (Average True Range) — обычный ход инструмента за один бар. Сколько цена в среднем проходит за одну свечу; у нас — по последним четырнадцати барам. Четырнадцать здесь не подбиралось: это классическое значение, с которым ATR и придумали. С каналом в десять баров оно не спорит — канал задаёт уровень, ATR меряет шум вокруг него. Ночной разрыв тоже идёт в счёт: открылся рынок на 2% ниже вчерашнего закрытия — движение было, просто торгов в этот момент не шло. Вот этой меркой запас и задаётся: половина ATR на золоте и половина ATR на биткойне — разные рубли, но одинаковый по смыслу отступ.
Правило целиком, одной фразой: если бар закрылся выше максимума последних десяти баров, причём с запасом не меньше половины ATR, — покупаем на открытии следующего бара. Симметрично вниз для продажи.
Вот как это выглядит в коде — это функция _evaluate из файла breakout/strategy.py, и это вся торговая идея целиком:
def _evaluate(self, bar: Bar) -> Signal | None:
levels = self._channel.levels()
atr = self._atr.value()
if levels is None or atr is None or atr <= 0:
return None
p = self.params
high, low = levels
margin = p.pierce_atr * atr
if bar.close > high + margin:
return Signal(LONG, bar.low * (1.0 - p.stop_buffer), p.take_rr)
if p.allow_short and bar.close < low - margin:
return Signal(SHORT, bar.high * (1.0 + p.stop_buffer), p.take_rr)
return NoneЧитается почти как русский текст: взять границы канала, взять ATR, посчитать запас, сравнить. Если закрытие выше верхней границы плюс запас — вернуть сигнал на покупку. Обратите внимание на первые четыре строки: пока индикаторы не набрали историю, функция возвращает «ничего». Бот, который начинает торговать на третьем баре своей жизни, торгует по индикаторам, посчитанным по трём числам.
Никакого секрета в этом правиле нет. Пробой канала — одна из самых известных механических систем в истории рынка; на её основе в 1983 году Ричард Деннис обучал группу трейдеров, вошедшую в историю под названием «черепахи». За сорок лет её напечатали тысячи раз. Это ровно тот случай из первой статьи: опубликованная стратегия, эдж в которой в основном уже выбран.
Карта кода: где живёт идея
Проект разложен по файлам так, чтобы каждый отвечал ровно за одну задачу.
| файл | что внутри | строк кода |
|---|---|---|
breakout/core.py | типы: свеча, сигнал, позиция, сделка | 56 |
breakout/indicators.py | канал и ATR, посчитанные бар за баром | 45 |
breakout/strategy.py | правило входа — вся торговая идея | 42 |
breakout/engine.py | исполнение: разрывы, приоритет стопа, переоценка | 125 |
breakout/portfolio.py | сборка корзины по равному риску | 41 |
breakout/metrics.py | метрики: посделочные и календарные | 101 |
breakout/data.py | загрузка и проверка котировок | 60 |
tests/ | 21 проверка | 266 |
Здесь стоит остановиться, потому что эта таблица — главный практический вывод статьи.
Торговая идея занимает 42 строки из 915 — меньше пяти процентов кода. Исполнение — втрое больше идеи. Тесты — в шесть раз больше идеи. То, ради чего всё затевалось, оказывается самой короткой частью программы, и так бывает почти всегда.
Новичок обычно представляет себе работу над ботом так: придумать гениальное правило, записать его — и всё. На практике правило записывается за вечер, а основное время уходит на вопросы вроде «что делать, если рынок открылся ниже стопа» и «как убедиться, что бэктест не врёт». Именно эти вопросы отделяют работающего робота от красивой картинки, и именно им посвящена вся оставшаяся часть статьи.
Язык, на котором говорят о результате
Прежде чем смотреть на цифры, надо договориться о словах. Каждую метрику ниже можно обмануть, если смотреть на неё одну, — поэтому смотреть надо на все сразу.
Win rate (доля прибыльных сделок) — сколько процентов сделок закрылись в плюс. Самая переоценённая метрика в трейдинге. У нашего бота она 39,8%: из 2182 сделок прибыльными оказались 869. Это нормально для трендовой системы и ничего плохого не означает. Многие даже самые успешные трейдеры часто имеют процент прибыльных сделок ниже 50%, но прибыльные сделки приносят суммарно больше дохода, чем убыточные. Обратный пример нагляднее: стратегия с долей прибыльных сделок 95% может разорить за один день, если те самые 5% убытков окажутся в двадцать раз крупнее прибыли. Доля прибыльных сделок ничего не говорит о деньгах, пока вы не знаете размер средней прибыли и среднего убытка.
Expectancy (математическое ожидание сделки) — сколько в среднем приносит одна сделка. Вот ради этой цифры всё и считается. У нас:
доля прибыльных 39,8%, средняя прибыль +4,26%
доля убыточных 60,2%, средний убыток −2,34%
0,398 × 4,26% − 0,602 × 2,34% = +0,29% на сделкуНаш бот ошибается довольно часто, но всё равно зарабатывает — потому что средняя прибыль почти вдвое больше среднего убытка. Это и есть портрет трендследящей системы: много мелких убытков и редкие прибыльные сделки.
Profit factor (профит-фактор) — во сколько раз сумма всех прибылей больше суммы всех убытков. Меньше 1 — стратегия теряет деньги. У нас 1,21: на каждый потерянный рубль бот зарабатывает 1,21 рубля. Для трендовой системы это нормальная величина. А вот профит-фактор 3,0 в бэктесте почти всегда означает не гениальную стратегию, а ошибку в расчёте.
R-multiple (R-мультипл) — результат сделки, измеренный в её собственном начальном риске. Если вы рисковали 1000 рублей и заработали 2000, это +2R. Если потеряли ровно свой запланированный риск — это −1R. Метрика удобна тем, что сравнивает сделки на разных инструментах и в разных объёмах: −1R на золоте и −1R на биткойне — одно и то же событие. Средний R нашего бота +0,12.
Volatility (волатильность) — насколько тряской была поездка. Формально: разброс доходности вокруг среднего, приведённый к году, — чтобы число можно было ставить рядом с самой доходностью. У нашей корзины 5,8% годовых при волатильности 4,2%.
Maximum drawdown (максимальная просадка) — самое глубокое падение счёта от предыдущего максимума до последующего минимума. Единственный честный ограничитель доходности: доход можно удвоить плечом, но просадка удвоится вместе с ним. У нашей корзины без плеча она −4,0%, и длилась она 72 дня. Длительность здесь важна не меньше глубины: именно два с половиной месяца сидения в минусе, а не сама цифра, обычно и решает, доживёт ли человек до конца эксперимента или остановит бота, тем самым зафиксировав убыток до того, как бот реализует свой потенциал.
CAGR (Compound Annual Growth Rate) — среднегодовая доходность с учётом сложного процента. Не среднее арифметическое по годам: если счёт за год вырос на 50%, а на следующий упал на 50%, среднее арифметическое покажет ноль, а CAGR — минус 13,4% годовых. Именно столько и осталось на счёте.
Sharpe ratio (коэффициент Шарпа) — доход на единицу колебаний. Главная метрика качества стратегии: она отвечает на вопрос «сколько тряски пришлось вытерпеть за этот доход». Грубый ориентир: ниже 0,5 — плохо, около 1,0 — рабочая система, выше 2,0 в бэктесте — повод не радоваться, а искать ошибку в коде: такого результата очень трудно добиться честно, и чаще всего он означает заглядывание в будущее или слишком удобное исполнение заявок.
Главная ловушка Шарпа не в формуле, а в знаменателе. Если считать колебания только по дням, когда была сделка, а годовой множитель брать как для всех дней подряд — Шарп раздувается на ровном месте процентов на тридцать. Поэтому в нашем коде он считается по календарю: дни без позиции входят в расчёт нулями, как и положено.
Calmar ratio (коэффициент Кальмара) — доходность, делённая на максимальную просадку. Отвечает на вопрос «сколько я зарабатываю за каждый процент боли». У нас 1,45.
Теперь можно смотреть на результат. Вот что получилось по каждому инструменту в отдельности:
| Instrument | Trades | Win rate | Profit factor | Sharpe | CAGR | Max drawdown |
|---|---|---|---|---|---|---|
| NASDAQ | 377 | 41,4% | 1,24 | 0,70 | +10,8% | −22,1% |
| Gold | 322 | 41,3% | 1,26 | 0,65 | +6,6% | −15,9% |
| Silver | 361 | 42,7% | 1,46 | 1,18 | +33,0% | −29,4% |
| Natural gas | 407 | 33,7% | 0,95 | −0,16 | −15,9% | −81,7% |
| RTS | 239 | 38,9% | 1,26 | 0,52 | +9,8% | −32,7% |
| Bitcoin | 476 | 41,2% | 1,32 | 1,01 | +40,5% | −41,4% |
Обратите внимание на газ: профит-фактор 0,95, Шарп отрицательный, просадка 81,7%. Это не недосмотр и не забытая строка — это честный результат, который мы оставили в таблице специально. Ниже, в разделе про корзину, будет видно, зачем.
Три цены одной сделки
Сигнал — это ещё не сделка. Чтобы получилась сделка, нужны три цены: где входим, где признаём ошибку и где забираем прибыль.
Цена входа. Бот входит по цене открытия следующего бара. Не по закрытию того бара, который дал сигнал. Это принципиально: чтобы узнать цену закрытия свечи, надо дождаться, пока свеча закроется, — а в этот момент по ней уже нельзя купить. Разница кажется мелочью, но именно на ней бэктесты рисуют половину своей несуществующей прибыли.
Stop-loss (стоп-лосс) — «фиксация убытка» — заявка, автоматически закрывающая позицию при движении цены против вас. Место, где вы заранее согласились признать, что ошиблись.
Наш бот ставит стоп за минимум бара, который дал сигнал (для покупки; для продажи — за максимум). Логика содержательная, а не арифметическая: если пробой настоящий, цена не должна возвращаться туда, откуда начала движение после пробоя. Если вернулась — значит, пробой, вероятнее всего, ложный, и следует выйти из сделки. Это лучше, чем популярное «стоп на 2% ниже входа»: 2% — число, взятое из воздуха, а минимум бара-сигнала — уровень, который поставил сам рынок.
Take-profit (тейк-профит) — «фиксация прибыли» — заявка, закрывающая позицию при движении в вашу сторону. Наш тейк стоит на расстоянии двух стопов от входа: рискуем одним рублём, чтобы заработать два. Это и есть тот самый множитель, из-за которого система с долей прибыльных 40% остаётся в плюсе.
Вот весь этот расчёт в коде, функция _open из breakout/engine.py:
def _open(self, signal: Signal, bar: Bar, index: int) -> None:
entry = bar.open
risk = signal.side * (entry - signal.stop_price)
if risk <= 0:
# Правило 4: цена уже за стопом — сетап умер, входить некуда.
self.result.skipped_stop_passed += 1
return
if risk / entry > self.params.max_risk_fraction:
self.result.skipped_wide_stop += 1
return
self._position = Position(
side=signal.side, entry_price=entry, entry_time=bar.time, entry_index=index,
stop_price=signal.stop_price,
take_price=entry + signal.side * signal.take_rr * risk,
)
self._mark = entryПервая строка — entry = bar.open — это и есть вход по открытию. Дальше считается риск (расстояние от входа до стопа), и только потом от него — цена тейка. Две проверки в середине отсекают сделки, которые не надо совершать; к первой из них мы ещё вернёмся: это место заслуживает отдельного разговора.
Отметьте, чего здесь нет: стратегия не знает цену входа. Она сообщает только уровень стопа — он привязан к бару-сигналу и известен заранее. Точную цену считает исполнение, уже увидев реальное открытие. Разделение не косметическое: как только стратегия начинает рассуждать о цене, по которой она «войдёт», в код открывается дверь для заглядывания в будущее (look-ahead bias).
Исполнение: место, где бэктест врёт
Чаще всего бэктест обманывает не ошибкой в формуле, а тем, как он представляет себе исполнение заявок. Программа считает всё правильно — просто по слишком удобным правилам. Вот три места, где это происходит.
Случай первый: цена перепрыгнула ваш стоп. Gap (разрыв) — скачок цены между закрытием одного бара и открытием следующего: была ночь, выходные или остановка биржи, торги не шли. Ваш стоп стоял на 95, а рынок открылся сразу по 90. Продать по 95 некому — такой цены в этот день просто не существовало.
Значит, выход надо считать по 90, а не по 95. На одной сделке разница копеечная, но она копится: разрывы приходятся как раз на самые нервные дни. Бэктест, который в такой ситуации запишет выход по 95, дорисует прибыль ровно в даты худших убытков. У нашего бота на такие выходы приходится 1% всех сделок.
Случай второй: за один бар цена дошла и до стопа, и до тейка. Бар четырёхчасовой. Внутри него цена спускалась к стопу и поднималась к тейку — но что случилось раньше, четырёхчасовые данные не скажут: в баре записаны только максимум и минимум, а не порядок, в котором они произошли.
Выбор тут из двух вариантов, и он определяет честность всего бэктеста. Решите, что сработал тейк, — получите красивую кривую. Мы решаем, что сработал стоп, то есть засчитываем себе худший из двух возможных исходов. Угадывать в свою пользу — самый дешёвый способ обмануть себя.
Случай третий: сделка умерла на том же баре, где родилась. Вошли на открытии, через полчаса выбило стопом. Бэктест, который ищет выход только начиная со следующего бара, такую сделку вовремя не закроет и завысит результат. У нас они есть и видны в журнале как сделки длиной ноль баров.
И одно место, где мы занижаем результат намеренно. Если рынок разрывом ушёл за тейк — тейк стоял на 110, а бар открылся сразу на 120, — мы записываем выход по 110, хотя настоящая заявка исполнилась бы по 120, то есть лучше для нас. Таких сделок на всей истории 17. Ошибка в свою пользу опаснее ошибки против себя, поэтому эту мы оставили как есть.
Заглядывание в будущее: как оно попадает в код
Look-ahead bias (заглядывание в будущее) — расчёт, использующий информацию, которой в момент принятия решения ещё не существовало. Мы называли её самой опасной ошибкой бэктеста ещё в первой статье об устройстве торгового бота. Опасна она тем, что не проявляет себя никак: программа не падает, в логи ничего не пишет, всё работает идеально — просто кривая доходности выходит слишком красивой.
Покажем на самом типичном примере, как она пролезает в код. Наш бот на каждом баре делает две вещи: принимает решение и обновляет индикаторы. Вопрос — в каком порядке.
signal = self._evaluate(bar)
# Текущий бар входит в историю ТОЛЬКО после того, как решение принято.
self._channel.update(bar)
self._atr.update(bar)
return signalСначала решение, потом обновление. Поменяйте две строки местами — и код останется рабочим, а бэктест сломается. Причём сломается неочевидно: канал Дончиана начнёт включать в себя текущий бар, то есть цена будет сравниваться с максимумом, который она сама только что и поставила. Пробой станет математически невозможен: чтобы закрыться выше максимума последних десяти баров, включая себя, надо закрыться выше собственного максимума. В нашем случае это означает невозможность торговать, но чаще всего такая перестановка даёт подозрительно прибыльный результат.
Второй заслон от той же ошибки — способ, которым посчитаны индикаторы. Их можно считать двумя способами: взять весь массив истории и посчитать скользящее одной операцией, или скармливать бары по одному. Первый способ быстрее и короче, второй — безопаснее, потому что при нём заглянуть в будущее физически невозможно: объект видит бары строго по одному и в том порядке, в котором они приходили. Мы выбрали второй, и по этой же причине во всём проекте нет ни pandas, ни numpy: те же самые строки кода, которые считают индикатор в бэктесте, работали бы и в бою.
Это отвечает на ещё одну проблему из первой статьи — «бот торгует не то, что вы тестировали». Когда бэктест и боевой робот написаны разными кусками кода, они рано или поздно разъезжаются. Когда это один и тот же код — не разъедутся.
Ошибка, которую ИИ нашёл в собственном коде
Теперь обещанная история.
Код был написан, бэктест считал, тесты проходили. Затем модели дали новую задачу — не «улучши», а «найди в этом коде ошибки: заглядывание в будущее, неправильное исполнение, всё, что рисует несуществующую прибыль». Формулировка важна, к ней вернёмся.
Модель написала проверку, устроенную непривычно. Обычный тест берёт придуманную ситуацию и сверяет ответ с ожидаемым. Этот тест не проверяет ситуацию — он прогоняет бота по всей истории и проверяет invariant (инвариант) — утверждение, которое обязано быть верным всегда:
> Выход по стоп-лоссу не может быть прибыльным.
Утверждение звучит банально до неловкости. Стоп-лосс — это место, где вы признаёте ошибку; выйти по нему в плюс невозможно по определению.
Тест упал. На истории нашлось восемь прибыльных стопов.
Механизм оказался такой. Бот получает сигнал на покупку, стоп ставит под минимум бара-сигнала — скажем, на 3401. Следующий бар открывается разрывом вниз, на 3391, то есть уже ниже стопа. Старый код всё равно открывал позицию, и получалась конструкция, невозможная на реальном счёте: покупка по 3391 со стоп-лоссом на 3401, то есть со стопом выше цены входа. На следующей же проверке бот видел, что «стоп задет», закрывал позицию по 3401 — и записывал прибыль.
Вот как это выглядело в журнале сделок:
natgas 2022-10-28 SHRT вход 5.848 выход 5.441 стоп +6.92% R=+0.99
natgas 2023-01-30 LONG вход 2.720 выход 2.867 стоп +5.35% R=+0.99
gold 2024-12-19 SHRT вход 2685.4 выход 2641.5 стоп +1.59% R=+0.98Восемь сделок, каждая приносит ровно свой запланированный риск со знаком плюс, суммарно +22,35% несуществующей прибыли. Каждая держалась ноль баров — родилась и умерла на одном открытии.
Лечится это тремя строками, которые вы уже видели выше: если цена на открытии оказалась за стопом, сделки не будет. Защищать нечего — цена вернулась туда, откуда мы собирались идти, ещё до того, как мы вошли. Сетап умер.
После починки цифры упали: Шарп 1,43 → 1,37, доходность 6,4% → 5,8%. Это правильное направление. Починка честного бэктеста почти всегда ухудшает результат — если ваши правки его улучшают, вы, скорее всего, чините не то.
Какой отсюда практический вывод про работу с ИИ. Их два, и оба важнее самой истории.
Первый: ценность модели не только в том, что она пишет код, но и в том, что она его вычитывает и исправляет ошибки. Написать 900 строк она может за десять минут, и в них будет ошибка — как была бы у человека. А вот перечитать эти 900 строк с конкретным вопросом «где здесь может протечь будущее» человек ленится, а модель делает это ровно так же внимательно на девятисотой строке, как на первой.
Второй: формулировка запроса решает всё. В первой статье мы писали, что главная опасность работы с ИИ — услужливость: попросите «улучши стратегию», и модель улучшит, подобрав параметры покрасивее. Здесь просьба была противоположной по знаку: не «сделай лучше», а «найди, где я себе вру». Разница в одном предложении, а результат — минус 22% нарисованной прибыли из отчёта.
Тест, который не падает, ничего не доказывает
В проекте 21 проверка, и кода в них больше, чем в стратегии, индикаторах и движке вместе взятых — 266 строк против 212. Возникает резонный вопрос: а откуда мы знаем, что сами тесты не бесполезны?
Тест, который проходит на правильном коде, не доказывает ничего. Доказательство — это тест, который падает на сломанном. Проверяется это методом, который в разработке называется мутационным тестированием.
Mutation testing (мутационное тестирование) — способ проверить сами тесты: в рабочий код намеренно вносят ошибку и смотрят, заметят ли её тесты. Не заметили — тест декоративный.
Мы подсадили в движок три ошибки, каждая из которых встречается в реальных бэктестах:
| подсаженная ошибка | сколько тестов упало |
|---|---|
| стоп при разрыве исполняется по цене стопа | 2 |
| вход разрешён, когда цена уже ушла за стоп | 3 |
| индикаторы обновляются до принятия решения | 5 |
Все три пойманы. После этого тесты можно считать чем-то большим, чем украшением репозитория.
Отдельно стоит сказать про два теста-инварианта, потому что это самый полезный приём из всей статьи. Обычный тест ловит ту ошибку, которую вы придумали заранее. Инвариант ловит любую, включая те, о которых вы не подумали. У нас их два:
- выход по стопу не может быть прибыльным;
- цена выхода не может быть лучше той, что дал рынок.
Первый нашёл настоящий баг. Второй заодно доказал, что все 17 «странных» выходов из раздела про исполнение записаны в невыгодную нам сторону — то есть движок ошибается только против себя.
Корзина: почему шесть инструментов, а не один
Вернёмся к таблице по инструментам. Серебро дало Шарп 1,18, газ — минус 0,16. Возникает естественная мысль: выкинуть газ и торговать серебро.
Мысль неверная, и понять почему — важнее, чем всё остальное в этой статье.
Трендследящая стратегия на одном инструменте — лотерея. Она зарабатывает на нескольких крупных движениях в год, а будут ли они в этом году именно в серебре, заранее неизвестно. Посмотрите на просадки в той таблице: 22%, 16%, 29%, 82%, 33%, 41%. Ни один из этих инструментов в одиночку торговать невозможно. А теперь корзина из всех шести, включая убыточный газ:
среднегодовая доходность +5,8%
годовая волатильность 4,2%
коэффициент Шарпа (календ.) +1,37
максимальная просадка −4,0% (от пика до дна 72 дня)
худший день / худший месяц −1,6% / −2,2%
месяцев в плюсе 71%Просадка упала до 4,0% — против 29,4% у серебра, лучшей одиночной ноги по Шарпу. Шарп вырос с 1,18 до 1,37 — выше, чем у любого отдельного инструмента. Шесть инструментов не улучшают стратегию — они превращают лотерею в статистику.
Про газ. Он остаётся в корзине, потому что решение выкинуть его было бы принято задним числом: в 2019 году никто не знал, что именно газ окажется худшим. Выкидывая из бэктеста то, что не сработало, вы получаете survivorship bias (ошибку выжившего) — систематическое завышение результата за счёт того, что неудачники исключены из выборки. Классический пример вне трейдинга: «средний срок жизни компаний из списка растёт» — потому что умершие из списка выбывают. В корзине всегда есть балласт; смысл диверсификации в том, что вы не знаете заранее, какая именно нога им окажется.
Теперь как складывать. Risk parity (равный риск) — складываем не поровну по деньгам, а поровну по риску. Если сложить поровну по деньгам, самый дёрганый инструмент принесёт и почти весь доход, и почти всю просадку, а спокойные будут стоять мёртвым грузом. Поэтому каждая нога сначала приводится к одинаковой целевой волатильности — у нас 10% годовых, — и только потом ноги усредняются.
Здесь прячется ловушка, стоившая нам отдельной правки в коде. У разных инструментов разное число торговых дней в году: биткойн торгуется 365 дней, фьючерс на золото — около 250. Если пересчитывать дневные колебания в годовые по одной константе для всех, биткойн получит примерно на 20% больше риска, чем остальные, — и «равный риск» перестанет быть равным ровно в той ноге, которая заработала больше всех. В breakout/portfolio.py число дней в году считается для каждой ноги по её собственному календарю.
Плечо: ручка, которая ничего не улучшает
Шесть процентов годовых — результат, ради которого не стоит вставать с дивана. Разберёмся, что с этим можно сделать, потому что это самый недопонятый вопрос в алготрейдинге.
Leverage (плечо) — торговля на сумму больше собственных денег. На фьючерсах плечо встроено в сам инструмент: вы вносите не полную стоимость контракта, а margin (гарантийное обеспечение, ГО) — лишь часть его стоимости. То есть, торгуя фьючерсами, вы пользуетесь плечом всегда, вопрос только в его величине.
Вот та же корзина, тот же код, разный масштаб риска:
| Leverage | CAGR | Volatility | Max drawdown | Worst month | Worst day | Sharpe |
|---|---|---|---|---|---|---|
| x1 | +5,8% | 4,2% | −4,0% | −2,2% | −1,6% | 1,37 |
| x2 | +11,7% | 8,4% | −7,9% | −4,3% | −3,3% | 1,37 |
| x3 | +17,8% | 12,5% | −11,7% | −6,4% | −4,9% | 1,37 |
| x4 | +24,0% | 16,7% | −15,5% | −8,6% | −6,6% | 1,37 |
| x5 | +30,2% | 20,9% | −19,1% | −10,7% | −8,2% | 1,37 |
Смотрите на последний столбец. Шарп не меняется. Совсем. Доход вырос в пять раз, просадка выросла в пять раз, качество системы не изменилось ни на сотую.
Это и есть главное, что надо понимать про плечо: оно не улучшает стратегию, оно её масштабирует. Плечо — ручка «сколько я готов терять», а не «сколько я хочу зарабатывать». Продавец бота, обещающий «100% годовых», сообщает вам не о качестве системы, а о выбранном им размере позиции; ту же цифру можно получить из чего угодно, включая заведомо убыточную стратегию, — просто крутанув ручку.
(Мелкая деталь для внимательных: доход растёт чуть быстрее линейного, просадка — чуть медленнее. Пять раз по 5,8% дали бы 29,0%, а получилось 30,2%; пять раз по 4,0% дали бы 20,0% просадки, а получилось 19,1%. Это сложный процент, работающий в обе стороны. На дневных доходностях, по которым считается Шарп, масштабирование строго линейное — поэтому он и стоит на месте.)
А теперь — зачем всё это нужно, и почему просадка важнее доходности.
Просадка — жёсткий потолок масштабирования. Вернитесь к таблице по инструментам: у биткойна доходность 40,5% в год, но просадка 41,4%. Умножить его на два нельзя — просадка уйдёт за 80%, а счёт с плечом до этого не доживёт. У корзины доходность втрое меньше, зато просадка 4,0%, и вот её умножать можно.
Отсюда практический вывод: настоящий продукт стратегии — не доходность, а доходность на единицу просадки. Стратегия с доходом 6% и просадкой 4% лучше стратегии с доходом 40% и просадкой 41%, потому что первую вы можете масштабировать до нужного вам уровня риска, а вторую — нет. Именно поэтому бот с маленькой просадкой ценнее, чем кажется по его скромным процентам: маленькая просадка — это не робость, это запас, который конвертируется в доход поворотом ручки.
Где предел. Ограничителей три, и все жёсткие.
Переподгонка. Наши цифры получены на той же истории, на которой подбирались параметры. Реальную просадку надо закладывать глубже исторической — мы считаем, что в полтора-два раза. То есть x3 с историческими 11,7% реалистично означает 18–23%, а x4 с историческими 15,5% — уже 23–31%.
Гарантийное обеспечение. Если просадка съест буфер свободных средств, брокер закроет позиции принудительно — по рыночной цене, в худший момент, не спросив. Это называется margin call (маржин-колл), и он превращает временную просадку в окончательный убыток. Предел плеча — не только вопрос выдержки.
Выдержка. На x4 худший месяц исторически −8,6%, а реалистично около −13%. Просадка длиной 72 дня превращается в два с половиной месяца, когда вы каждый день видите минус и каждый день думаете, не выключить ли бота.
Для этого бота разумный рабочий масштаб — x3, потолок — x4. Но повторим ещё раз то, с чего начали: приведённые цифры — иллюстрация к разговору о плече, а не рекомендация к запуску.
Как мы проверяли, что это не подгонка
Overfitting (переподгонка) — подбор параметров под конкретную историю, при котором система запоминает прошлое вместо того, чтобы улавливать закономерность. Главный враг бэктеста и самый быстрый способ потерять деньги.
Проверок было три.
Train/test split (разделение выборки). Данные делятся на две части: на ранней система настраивается, на поздней — проверяется, причём поздняя не участвует в настройке вообще. У нас размер каждой ноги посчитан только по 2019–2023, после чего бот прогнан по 2024–2026. Шарп 1,39 на ранних годах против 1,36 на поздних — система не рассыпалась при встрече с невиданными данными.
Чувствительность к издержкам. Издержки — комиссия плюс проскальзывание — заданы у нас в 4 базисных пункта на круг. Basis point (базисный пункт, б.п.) — это сотая доля процента. Проверяем, что будет при других значениях:
2 б.п. → Шарп 1,47
4 б.п. → Шарп 1,37
8 б.п. → Шарп 1,16
12 б.п. → Шарп 0,95
16 б.п. → Шарп 0,74Деградация плавная, обрыва нет. Плохой признак выглядел бы иначе: 1,37 при четырёх пунктах и 0,2 при восьми — это значит, что вся прибыль держится на предположении об идеальном исполнении.
Устойчивость по годам. 2019: +0,9%, 2020: +11,8%, 2021: +5,4%, 2022: +4,8%, 2023: +5,2%, 2024: +7,8%, 2025: +5,9%, 2026: +1,3%. Все годы положительные, но две оговорки. 2026-й неполный. А в 2019-м в корзине была одна нога: история по золоту начинается с января 2019-го, по остальным пяти инструментам — только с января 2020-го. Полноценных лет корзины, таким образом, шесть, и ни один из них не вытягивает всё остальное.
И честная оговорка, которую положено делать вслух. Параметры — канал 10, запас 0,5 ATR, тейк 2R — найдены перебором на этой же истории. Соседние наборы работают заметно хуже: канал 50 с тейком 1,5R даёт на ранних годах Шарп 1,18 против наших 1,39, а на поздних — 0,21 против 1,36, то есть разваливается полностью. Значит, часть результата — премия за знание ответа. Честное ожидание от такой системы вслепую — Шарп ближе к 1,0, чем к 1,37.
Отдельно — про данные, потому что в них тоже есть подвох. Ряды по фьючерсам склеены из последовательных контрактов без поправки на разницу цен между ними, и в момент склейки в данных возникает разрыв, которого на реальном счёте не было. Хуже всего это в газе: 97 межбаровых скачков больше 3%, часть из них приходится на смену контракта. Мы упоминали эту ошибку в первой статье — «стратегия зарабатывает на скачке, которого не было». Здесь она осталась в данных сознательно: убирать её — отдельная работа, а газ в корзине и без того убыточен.
Сами котировки качаются и по отдельности — если захотите проверить на них свою идею, а не нашу:
| Instrument | File | Bars | Period |
|---|---|---|---|
| Gold | gold_240m.csv | 7 882 | 01.2019 — 06.2026 |
| Silver | silver_240m.csv | 10 051 | 01.2020 — 06.2026 |
| Natural gas | natgas_240m.csv | 9 333 | 01.2020 — 06.2026 |
| NASDAQ | nasdaq_240m.csv | 9 765 | 01.2020 — 04.2026 |
| RTS | rts_240m.csv | 6 321 | 01.2020 — 04.2026 |
| Bitcoin | btc_240m.csv | 13 872 | 01.2020 — 04.2026 |
Формат простой: datetime,open,high,low,close,volume, бары четырёхчасовые. Те же шесть файлов лежат и внутри архива с кодом.
Почему этот бот не годится для торговли
Соберём в одном месте всё, что делает этот код образцом, а не торговой системой.
Он не умеет отправлять заявки. В нём нет ни одной строки, обращающейся к брокеру. Между решением «покупай» и реальной позицией лежит сеть, отказ, частичное исполнение и ответ, который может не прийти вообще, — и ничего из этого здесь не реализовано. Это сделано намеренно. Да, вы можете сами или с использованием ИИ реализовать это для данного кода, но повторимся — смысла нет, так как эдж в данной стратегии практически выработан.
Его доходность без плеча, скорее всего, ниже безрисковой ставки. Помните вывод первой статьи: ваш ноль — не ноль, а доходность банковского вклада. Сравните 5,8% с текущей ставкой по вкладу. Если бот даёт меньше, торговать его незачем — вы берёте на себя риск бесплатно.
Его стратегия опубликована сорок лет назад. Пробой канала известен всем, кто хоть немного интересовался темой. Заметного эджа в нём давно нет — и это ровно то, о чём мы предупреждали в разговоре про эдж, говоря, что опубликованные стратегии уже выбраны.
Часть его результата — премия за знание ответа, потому что параметры подобраны на той же истории, на которой измерены. Реальные цифры вслепую были бы скромнее.
Он собран, чтобы объяснять, а не чтобы зарабатывать. Каждая четвёртая строка — комментарий или пояснение; на каждую строку торговой логики приходится шесть строк тестов. В боевом коде такие пропорции не встречаются.
Что в нём при этом настоящее и что стоит забрать себе: разделение решения и исполнения, вход по открытию следующего бара, пессимистичное разрешение неоднозначностей, ежедневная переоценка позиции, тесты-инварианты и мутационная проверка самих тестов. Это переносится на любую стратегию, и это — единственное, ради чего писался этот код.
Что дальше
Третья статья серии об алготрейдинге идёт дальше по цепочке: от бэктеста к бою. Что меняется, когда между вашим решением и позицией появляется брокер; почему боевой бот обязан считать источником истины ответ брокера, а не собственную память; как устроен запуск на сервере, что делать при обрыве связи и как выглядит аварийное отключение.
Если после этой статьи вы уходите с одной мыслью, пусть это будет вот эта: код бота — не то место, где решается, заработаете ли вы. Правило торговли заняло здесь 42 строки, и написать их может кто угодно, в том числе за вас — ИИ. Остальные 873 строки существуют ради одного: чтобы результат, который покажет бэктест, оказался правдой. Вот на это и уходит работа.
Материал носит информационный характер и не является индивидуальной инвестиционной рекомендацией. Торговля на финансовых рынках связана с риском потери капитала. Приведённый код опубликован как учебный пример и не предназначен для использования в реальной торговле.
Статью подготовила редакция Tradeonia — команда практикующих трейдеров, которая сама пишет и запускает торговых роботов. Мы рассказываем только о том, что делаем и проверяем сами.
© 2026 Tradeonia. Полное или частичное копирование материала, его переработка и публикация на других ресурсах без письменного разрешения правообладателя запрещены. Цитирование допускается в объёме, оправданном целью цитирования, с указанием автора и активной гиперссылкой на эту страницу.
Источники цифр, приведённых в статье
- Все метрики, таблицы и журналы сделок получены прогоном приложенного кода на приложенных данных. Воспроизводятся командой
python run_portfolio.py; таблица масштабов риска — ключом--leverage, проверка на поздних годах — ключом--split 2023-12-31, чувствительность к издержкам — ключом--costs. - Период: январь 2020 — июнь 2026 по пяти инструментам и январь 2019 — июнь 2026 по золоту; всего шесть инструментов (фьючерсы на NASDAQ, золото, серебро, природный газ, индекс РТС и бессрочный фьючерс BTCUSDT), 240-минутные бары, издержки 4 базисных пункта на круг.
- Код написан моделью Claude Opus 5 (Anthropic) в среде Claude Code.
