Код торгового бота на Python: разбор с бэктестом - Tradeonia
TradeoniaTM
ГлавнаяСтатьиРазбор кода торгового бота: как он устроен изнутри

Разбор кода торгового бота: как он устроен изнутри

В первой статье мы разбирали устройство бота на уровне схемы. Здесь — конкретный работающий бот, весь его код и все его цифры. Заодно посмотрим, как выглядит бэктест, который не врёт, и как ИИ находит ошибки в коде, который сам же и написал.

·23 минуты чтения·Редакция Tradeonia

Вторая статья из серии об алготрейдинге и торговых роботах.

Робот-андроид работает за компьютером в тёмной комнате, рядом экран с графиком японских свечей
Код этого бота написан моделью Claude Opus 5. Она же нашла в нём ошибку исполнения, которую человек не заметил бы никогда.

Кто написал этот код и зачем

Начнём с двух вещей, которые нужно отметить сразу.

Весь код для этой статьи специально написан искусственным интеллектом. Конкретно — моделью Claude Opus 5 от компании Anthropic, работающей в среде Claude Code. Мы уже обсуждали в первой статье, что сейчас ИИ справляется с кодом довольно хорошо, и даже человек, не умеющий писать код, может создать своего бота для трейдинга, поэтому мы намеренно использовали ИИ для этой цели. Мы ставили задачу, задавали ограничения, анализировали результат и проверяли цифры; строки кода писала модель. И это не ради красного словца: дальше будет раздел о том, как та же модель нашла в этом коде настоящую ошибку исполнения, которую человек не заметил бы никогда.

Этот бот не предназначен для торговли. Он написан, чтобы показать устройство: как выглядит логика сигнала, как считается риск, где бэктест обычно привирает и как это ловится тестами. Доходность целью не была, и это видно по цифрам — без плеча бот даёт около 6% годовых, что, скорее всего, меньше вашей ставки по вкладу. Мы намеренно не стали ни выжимать из него проценты, ни писать блок отправки заявок брокеру: этот код физически не умеет торговать, и это сделано специально. Разбирать устройство на живом боевом роботе — плохая идея по двум причинам: работающий эдж от публикации умирает (об этом была первая статья), а читатель, скопировавший чужой боевой код, теряет деньги первым.

Так что относитесь к нему как к разрезанному пополам двигателю в музее: ездить нельзя, а вот анализировать устройство и учиться приветствуется.

Весь проект — 1598 строк: 915 строк кода, 375 строк комментариев и пояснений, остальное — пустые строки между блоками.

Весь код бота

Пакет breakout · 7 модулей, 947 строкСкачать · 1,0 МБ
# ==========================================================================
# 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.py21 проверку, на которой держится бэктест

Считается за несколько секунд. В этом и смысл публикации: любую цифру ниже можно не принимать на веру, а перепроверить одной командой.


Правило, по которому бот торгует

Бот торгует пробой канала Дончиана. Разберём по частям.

Donchian channel (канал Дончиана) — максимум и минимум цены за последние N баров. Назван по имени Ричарда Дончиана, применявшего его ещё в середине XX века. Верхняя граница канала — самая высокая цена за взятый вами период, нижняя — самая низкая. У нас период N = 10, то есть канал охватывает последние десять четырёхчасовых баров, а это примерно двое суток торгов.

Breakout (пробой) — выход цены за границу канала. Идея простая: пока цена ходит внутри диапазона, ничего не происходит; выход за границу канала означает, что цена, вероятно, продолжит своё движение по направлению пробоя.

Проблема в том, что цена может несколько раз за день подходить и задевать границы канала, и реагировать на каждое касание — значит терять деньги. Поэтому одного выхода за границу мало: нужно, чтобы цена ушла за неё с запасом.

Но в чём мерить этот запас? «Цена ушла за границу на 3 рубля» — фраза ни о чём: для газа три рубля огромный ход, для золота шум. Значит, мерка нужна своя у каждого инструмента, и считать её надо по нему самому.

ATR (Average True Range) — обычный ход инструмента за один бар. Сколько цена в среднем проходит за одну свечу; у нас — по последним четырнадцати барам. Четырнадцать здесь не подбиралось: это классическое значение, с которым ATR и придумали. С каналом в десять баров оно не спорит — канал задаёт уровень, ATR меряет шум вокруг него. Ночной разрыв тоже идёт в счёт: открылся рынок на 2% ниже вчерашнего закрытия — движение было, просто торгов в этот момент не шло. Вот этой меркой запас и задаётся: половина ATR на золоте и половина ATR на биткойне — разные рубли, но одинаковый по смыслу отступ.

Правило целиком, одной фразой: если бар закрылся выше максимума последних десяти баров, причём с запасом не меньше половины ATR, — покупаем на открытии следующего бара. Симметрично вниз для продажи.

Вот как это выглядит в коде — это функция _evaluate из файла breakout/strategy.py, и это вся торговая идея целиком:

breakout/strategy.py · функция _evaluate — вся торговая идея
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.

Теперь можно смотреть на результат. Вот что получилось по каждому инструменту в отдельности:

InstrumentTradesWin rateProfit factorSharpeCAGRMax drawdown
NASDAQ37741,4%1,240,70+10,8%−22,1%
Gold32241,3%1,260,65+6,6%−15,9%
Silver36142,7%1,461,18+33,0%−29,4%
Natural gas40733,7%0,95−0,16−15,9%−81,7%
RTS23938,9%1,260,52+9,8%−32,7%
Bitcoin47641,2%1,321,01+40,5%−41,4%

Обратите внимание на газ: профит-фактор 0,95, Шарп отрицательный, просадка 81,7%. Это не недосмотр и не забытая строка — это честный результат, который мы оставили в таблице специально. Ниже, в разделе про корзину, будет видно, зачем.


Три цены одной сделки

Сигнал — это ещё не сделка. Чтобы получилась сделка, нужны три цены: где входим, где признаём ошибку и где забираем прибыль.

Цена входа. Бот входит по цене открытия следующего бара. Не по закрытию того бара, который дал сигнал. Это принципиально: чтобы узнать цену закрытия свечи, надо дождаться, пока свеча закроется, — а в этот момент по ней уже нельзя купить. Разница кажется мелочью, но именно на ней бэктесты рисуют половину своей несуществующей прибыли.

Stop-loss (стоп-лосс) — «фиксация убытка» — заявка, автоматически закрывающая позицию при движении цены против вас. Место, где вы заранее согласились признать, что ошиблись.

Наш бот ставит стоп за минимум бара, который дал сигнал (для покупки; для продажи — за максимум). Логика содержательная, а не арифметическая: если пробой настоящий, цена не должна возвращаться туда, откуда начала движение после пробоя. Если вернулась — значит, пробой, вероятнее всего, ложный, и следует выйти из сделки. Это лучше, чем популярное «стоп на 2% ниже входа»: 2% — число, взятое из воздуха, а минимум бара-сигнала — уровень, который поставил сам рынок.

Take-profit (тейк-профит) — «фиксация прибыли» — заявка, закрывающая позицию при движении в вашу сторону. Наш тейк стоит на расстоянии двух стопов от входа: рискуем одним рублём, чтобы заработать два. Это и есть тот самый множитель, из-за которого система с долей прибыльных 40% остаётся в плюсе.

Вот весь этот расчёт в коде, функция _open из breakout/engine.py:

breakout/engine.py · функция _open — три цены сделки
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 (заглядывание в будущее) — расчёт, использующий информацию, которой в момент принятия решения ещё не существовало. Мы называли её самой опасной ошибкой бэктеста ещё в первой статье об устройстве торгового бота. Опасна она тем, что не проявляет себя никак: программа не падает, в логи ничего не пишет, всё работает идеально — просто кривая доходности выходит слишком красивой.

Покажем на самом типичном примере, как она пролезает в код. Наш бот на каждом баре делает две вещи: принимает решение и обновляет индикаторы. Вопрос — в каком порядке.

breakout/strategy.py · решение принимается до обновления индикаторов
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%. Ни один из этих инструментов в одиночку торговать невозможно. А теперь корзина из всех шести, включая убыточный газ:

корзина из шести инструментов, масштаб риска x1
среднегодовая доходность      +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 (гарантийное обеспечение, ГО) — лишь часть его стоимости. То есть, торгуя фьючерсами, вы пользуетесь плечом всегда, вопрос только в его величине.

Вот та же корзина, тот же код, разный масштаб риска:

LeverageCAGRVolatilityMax drawdownWorst monthWorst daySharpe
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%, часть из них приходится на смену контракта. Мы упоминали эту ошибку в первой статье — «стратегия зарабатывает на скачке, которого не было». Здесь она осталась в данных сознательно: убирать её — отдельная работа, а газ в корзине и без того убыточен.

Сами котировки качаются и по отдельности — если захотите проверить на них свою идею, а не нашу:

InstrumentFileBarsPeriod
Goldgold_240m.csv7 88201.2019 — 06.2026
Silversilver_240m.csv10 05101.2020 — 06.2026
Natural gasnatgas_240m.csv9 33301.2020 — 06.2026
NASDAQnasdaq_240m.csv9 76501.2020 — 04.2026
RTSrts_240m.csv6 32101.2020 — 04.2026
Bitcoinbtc_240m.csv13 87201.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.

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

Сколько строк кода в торговом боте?
В нашем — 915 строк, и сама торговая идея занимает из них 42, то есть меньше пяти процентов. Исполнение заявок втрое больше идеи, тесты — в шесть раз больше. Это обычная пропорция: правило торговли пишется за вечер, а остальное уходит на вопросы вроде «что делать, если рынок открылся ниже стопа» и «как убедиться, что бэктест не врёт».
Что такое заглядывание в будущее в бэктесте?
Это расчёт, использующий данные, которых в момент принятия решения ещё не существовало. Опасна эта ошибка тем, что никак себя не проявляет: программа не падает, в логи ничего не пишет — просто кривая доходности выходит слишком красивой. Самый частый источник — индикатор, в который текущий бар попал до того, как по нему принято решение.
Почему бот должен входить по открытию следующего бара, а не по закрытию текущего?
Потому что цену закрытия свечи вы узнаёте в тот момент, когда по ней уже нельзя купить. Сигнал даёт закрытая свеча, сделка возможна только на следующей. Разница кажется мелочью, но именно на ней бэктесты рисуют значительную часть несуществующей прибыли.
Увеличивает ли плечо доходность торговой стратегии?
Плечо масштабирует стратегию, а не улучшает её. В нашем бэктесте от x1 до x5 доходность выросла с 5,8% до 30,2% годовых, просадка — с 4,0% до 19,1%, а коэффициент Шарпа остался ровно 1,37 на всех пяти уровнях. Поэтому обещание «100% годовых» говорит не о качестве системы, а о выбранном размере позиции: ту же цифру можно получить из чего угодно, включая убыточную стратегию.
Как проверить, что бэктест не врёт?
Двумя приёмами. Тест-инвариант прогоняет бота по всей истории и требует утверждения, которое обязано быть верным всегда, — например, «выход по стоп-лоссу не может быть прибыльным»; такой тест ловит и те ошибки, о которых вы не подумали. Мутационное тестирование проверяет сами тесты: в рабочий код намеренно вносят ошибку и смотрят, заметят ли её. Не заметили — тест декоративный.
Может ли ИИ найти ошибку в коде, который сам написал?
Да, и в этой статье разобран такой случай: модель нашла в собственном коде восемь прибыльных выходов по стоп-лоссу — 22,35% несуществующей прибыли. Решает формулировка запроса. На просьбу «улучши стратегию» модель услужливо подгонит параметры под историю; сработала просьба противоположная по знаку — «найди, где этот код рисует прибыль, которой не было».
Можно ли торговать этим ботом?
Нет, и он для этого не написан. В нём нет ни одной строки, обращающейся к брокеру, — отправлять заявки он физически не умеет, и это сделано намеренно. Плюс его доходность без плеча — 5,8% годовых, что, скорее всего, ниже ставки по вкладу, а стратегия «пробой канала» опубликована сорок лет назад и заметного преимущества давно не даёт. Код выложен как образец устройства, а не как торговая система.
Материалы сайта носят информационный характер и не являются индивидуальной инвестиционной рекомендацией. Статьи отражают опыт и мнение редакции; примеры кода публикуются в учебных целях, предоставляются «как есть» и не гарантируют какого-либо результата. Торговля на бирже связана с риском потери вложенных средств.
© 2026 Tradeonia. Все права защищены.