ORB на QQQ в PyBroker: бэктест пробоя открывающего диапазона без заглядывания вперёд

13 минут чтения •


Оглавление

Пробой открывающего диапазона (ORB) — одна из самых старых идей в интрадей-трейдинге: если первые минуты торговли прошли выше цены открытия дня, покупаем, ожидая продолжения импульса. Идея настолько проста, что кажется, будто её нельзя испортить реализацией. На практике всё наоборот: почти вся разница между «стратегия работает» и «стратегия съедает депозит» умещается в детали исполнения бэктеста — на каком баре исполняется сигнал, как считается размер позиции и какие издержки вообще не попали в модель.

Разберём конкретную реализацию: пятиминутный ORB на QQQ в open-source библиотеке PyBroker 2.x — с бар-за-бару исполнением, состоянием в ctx.session, стопом на противоположной границе диапазона и размером позиции из фиксированного риска. Отдельно посчитаем то, что обычно остаётся за кадром: какое плечо неявно создаёт «риск 1% на сделку» и почему 2026 год ломает кривую доходности при растущем индексе.

Отправная точка: видео PyBroker: Backtesting 5 Minute ORB Strategy on QQQ канала Part Time Larry. Числа прогонов ниже — из этого разбора; код и расчёты издержек — авторские, приведены как проверяемая схема, а не как воспроизведение исходного репозитория.

Что именно проверяется

Формализуем гипотезу, иначе бэктест превращается в подгонку:

H1: знак первых пяти минут торговли на QQQ предсказывает знак оставшейся части сессии с вероятностью, достаточной для положительного математического ожидания после издержек.

Гипотеза не про «магию первого бара», а про продолжение импульса: у крупных участников после открытия остаётся неисполненный объём, и они доносят его в первые часы. Если это верно, распределение доходностей остатка дня должно зависеть от направления первого импульса. Если не верно — мы получаем симметричную лотерею с отрицательным ожиданием после комиссий.

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

Математика до кода: чего стоит 23% winrate

Пусть убыток ограничен дистанцией стопа и равен 1R, средний выигрыш равен k в тех же единицах R, а доля прибыльных сделок — p. Ожидание на сделку в единицах риска:

$$ E = p \cdot k - (1 - p) $$

Безубыточный порог при заданном k:

$$ p^{*} = \frac{1}{1 + k} $$

При k = 10 (тейк в десять размеров диапазона) порог — 9.1%: достаточно угадывать меньше одной сделки из десяти, чтобы не терять деньги. Это и есть экономический смысл стратегии с тейком «в десять раз больше риска»: она продаёт частоту за амплитуду.

Средний выигрыш kПорог p*Ожидание при p = 0.23
3R25.0%−0.08R
5R16.7%+0.38R
8R11.1%+1.07R
10R9.1%+1.53R

Таблица показывает главную уязвимость конструкции: при k = 3 стратегия с тем же winrate уже убыточна. Значит, вся прибыль держится не на проценте прибыльных сделок, а на том, что редкие выигрыши действительно дотягивают до десяти R. Любое событие, срезающее хвост выигрышей — выход в конце дня, сдвиг тейка, ранний стоп, — уничтожает ожидание быстрее, чем рост winrate способен его восстановить.

Контракт исполнения важнее сигнала

В векторном бэктесте («посчитать доходность по колонкам pandas») позиция на бар t часто формируется по данным бара t и заполняется по цене t. Это заглядывание вперёд: в реальной торговле на закрытии бара вы уже не купите по его же цене. Разница в несколько базисных пунктов на сделку при 250 сделках в год превращается в разницу между «работает» и «не работает».

PyBroker решает это архитектурно: функция исполнения вызывается на каждом баре и получает контекст, в котором доступны только текущий и прошлые бары (ctx.close[-1], ctx.high[-2]), а заявка, выставленная на баре t, исполняется на баре t+1. Заглядывание вперёд становится технически невозможным, а не «делом дисциплины».

Из этого следуют два неочевидных следствия, которые ломают наивную реализацию:

flowchart TD
    A[Минутные OHLCV: 09:30 - 15:59 NY] --> B[Бар 1-5: накопление H и L диапазона]
    B --> C{Бар 09:34: C сравнить с открытием дня O}
    C -- C больше O --> D[Лонг на открытии бара 09:35]
    C -- C меньше O --> E[Шорт на открытии бара 09:35]
    C -- C равно O --> F[Сигнала нет, день пропущен]
    D --> G[Дистанция стопа D = C минус L]
    E --> G
    G --> H[Размер позиции q = floor R делить на D]
    H --> I{Следующие бары: касание стопа или тейка}
    I -- Стоп --> J[Выход, убыток около 1R]
    I -- Тейк --> K[Выход, прибыль около k R]
    I -- Ни то, ни другое --> L[Бар 15:58: принудительное закрытие]

Механика диапазона и границ

Открывающий диапазон — это максимум и минимум первых пяти минут:

$$ H = \max_{t \in [1,5]} \text{high}_t, \qquad L = \min_{t \in [1,5]} \text{low}_t $$

Ширина диапазона w = H − L — одновременно мера волатильности утра и масштаб всей сделки: от неё считаются и стоп, и тейк, и размер позиции. Сигнал:

$$ \text{signal} = \begin{cases} +1, & C > O \ -1, & C < O \ 0, & C = O \end{cases} $$

где C — закрытие пятого бара, O — цена открытия дня. Случай равенства — не мелочь: в узких диапазонах он встречается регулярно, и молчаливая трактовка «равно нулю = лонг» добавляет к выборке сделки, которых стратегия не предполагала.

Дистанция стопа до противоположной границы диапазона:

$$ D = \begin{cases} C - L, & \text{лонг} \ H - C, & \text{шорт} \end{cases} $$

и тейк на расстоянии k ширин диапазона от входа:

$$ T = C + k \cdot w \cdot \text{signal} $$

Обратите внимание: стоп задаётся как дистанция от цены входа, а не как абсолютная цена. Это принципиально для корректного расчёта размера позиции и для переноса логики на другие таймфреймы: при переходе с пятиминутного диапазона на пятнадцатиминутный меняется только константа, а не структура кода.

Скрытое плечо, которое создаёт «риск 1% на сделку»

Стандартная формула размера позиции из риска:

$$ q = \left\lfloor \frac{R}{D} \right\rfloor, \qquad R = 0.01 \cdot \text{equity} $$

Выглядит консервативно: теряем не больше 1% счёта на сделку. Но посчитаем номинал. Подставив q, получим:

$$ \text{notional} = q \cdot C \approx R \cdot \frac{C}{D} $$

Отношение C / D — это рычаг, который вы не выбирали: он целиком определяется шириной утреннего диапазона. На QQQ по цене около 109 долларов при ширине диапазона 0.40 доллара отношение равно примерно 272. Умноженное на риск 1%, оно даёт около 2.7× счёта в номинале.

Именно поэтому в разборе прогон с фиксированными 250 акциями дал +9% за январь 2016, а прогон с риском 1% — +25%: разница не в «более умной» стратегии, а в том, что risk-based сайзинг на узком стопе вложил втрое больше капитала. Это не обязательно плохо, но это надо знать: стратегия масштабируется не риском на сделку, а плечом, и на кэш-счёте она просто не помещается в капитал — отсюда урезание 250 акций до 229 из-за нехватки средств.

Второе следствие опаснее. Стоп — это заявка, а не гарантия. Если цена проскакивает уровень на величину g, реальный убыток:

$$ \text{loss} = q \cdot (D + g) = R\left(1 + \frac{g}{D}\right) $$

При D = 0.35 и проскальзывании 0.10 доллара потеря составит 1.29R. При узком диапазоне D = 0.10 и том же проскальзывании — 2R, то есть вдвое больше заявленного риска. Узкие диапазоны одновременно дают максимальное плечо и худшее исполнение стопа: двойная асимметрия против стратегии, и её не видно ни в одной метрике, кроме распределения фактических убытков.

Код: состояние между барами

Ключевая деталь PyBroker — словарь ctx.session, переживающий бары внутри дня. В нём живёт накопитель диапазона; при смене даты он сбрасывается. Ниже скелет, адаптированный под API PyBroker 2.x (синтаксис проверен, прогон на данных не выполнялся — числа в статье взяты из первоисточника):

from datetime import datetime
import pandas as pd
from pybroker import Strategy, StrategyConfig, ExecContext

OPEN_BARS = 5            # первые 5 минут: 09:30 - 09:34
LAST_OR_MINUTE = 34      # бар, на котором фиксируется диапазон
FLAT_MINUTE = 58         # 15:58: заявка исполнится на 15:59
RISK_PCT = 0.01
TAKE_MULT = 10.0
MAX_LEVERAGE = 1.0       # жёсткий потолок номинала к капиталу

def orb_exec(ctx: ExecContext):
    ts: datetime = ctx.date
    minute = ts.hour * 60 + ts.minute

    # сброс состояния при смене торгового дня
    if ctx.session.get("day") != ts.date():
        ctx.session.update(
            day=ts.date(), or_high=None, or_low=None,
            day_open=ctx.open[-1], entered=False,
        )

    if minute < 570 or minute >= 960:          # вне 09:30 - 16:00
        return

    if ctx.session["or_high"] is None:
        ctx.session["or_high"] = ctx.high[-1]
        ctx.session["or_low"] = ctx.low[-1]
    else:
        ctx.session["or_high"] = max(ctx.session["or_high"], ctx.high[-1])
        ctx.session["or_low"] = min(ctx.session["or_low"], ctx.low[-1])

    # закрытие всех позиций: заявка уйдёт на следующий бар
    if minute == FLAT_MINUTE:
        ctx.sell_all_shares()
        return

    if minute != 570 + LAST_OR_MINUTE or ctx.session["entered"]:
        return

    high, low = ctx.session["or_high"], ctx.session["or_low"]
    width = high - low
    close, day_open = ctx.close[-1], ctx.session["day_open"]
    if width <= 0 or close == day_open:
        return

    side = 1 if close > day_open else -1
    stop_distance = close - low if side > 0 else high - close

    equity = ctx.calc_total_equity()
    risk_cash = equity * RISK_PCT
    qty = int(risk_cash / stop_distance)
    qty = min(qty, int(equity * MAX_LEVERAGE / close))   # потолок плеча
    if qty <= 0:
        return

    if side > 0:
        ctx.buy_shares = qty
        ctx.stop_loss = close - stop_distance
        ctx.take_profit = close + TAKE_MULT * width
    else:
        ctx.sell_shares = qty
        ctx.stop_loss = close + stop_distance
        ctx.take_profit = close - TAKE_MULT * width

    ctx.session["entered"] = True


config = StrategyConfig(initial_cash=25_000)
df = pd.read_csv("qqq_1min.csv", parse_dates=["date"])
df["symbol"] = "QQQ"

strategy = Strategy(df, "2016-01-01", "2023-12-31", config=config)
strategy.add_execution(orb_exec, ["QQQ"])
result = strategy.backtest()
print(result.orders.tail())

Три строки в этом коде важнее остальных. Строка с MAX_LEVERAGE превращает неявное плечо из предыдущего раздела в явный, проверяемый параметр. Строка ctx.stop_loss = ... ставит стоп как цену, вычисленную от фактической дистанции, — иначе риск на сделку перестаёт совпадать с RISK_PCT. Строка с sell_all_shares() на 58-й минуте закрывает позицию до конца сессии, а не на утро следующего дня.

Издержки: считаем, а не предполагаем

В разборе прогоны сделаны без комиссий и проскальзывания — автор обещает отдельный выпуск. Пока этого не произошло, оценим порядок сами, на тех же числах: счёт 25 000, риск 1% ($250), дистанция стопа 0.35, около 714 акций.

Итого $20–35 на сделку, то есть 0.08–0.14R. При 23 сделках в месяц это 2–3R вычитаемых из валового ожидания. Само по себе терпимо — но это ровно тот порядок, который отделяет «+9% за месяц на фиксированном размере» от результата, который покажет честная модель.

Инструментирование: измерить ожидание, а не любоваться кривой

Кривая доходности — самая плохая диагностика бэктеста: она суммирует всё и не отвечает ни на один вопрос. Минимальный набор измерений, который нужно снимать с того же прогона, — три величины на сделку: причина выхода, фактический результат в R и экстремумы хода цены до выхода (MAE и MFE). Причина выхода отделяет стратегию от её хвоста: если 70% прибыли приходит из сделок, закрытых по тайм-ауту в 15:58, — вы торгуете не ORB, а интрадей-лонг с фильтром.

import numpy as np
import pandas as pd

def trade_r(orders: pd.DataFrame) -> pd.DataFrame:
    """Приводит сделки PyBroker к единицам риска R."""
    df = orders.copy()
    entry = df["entry_price"].astype(float)
    stop_dist = (entry - df["stop_price"]).abs()
    direction = np.where(df["type"].str.startswith("long"), 1.0, -1.0)
    df["r"] = (df["exit_price"] - entry) * direction / stop_dist
    df["exit_reason"] = np.select(
        [df["bars"] <= 1, df["r"] > 1.0, df["r"] < -0.9],
        ["immediate", "take_or_trail", "stop"],
        default="time_exit",
    )
    return df[["symbol", "entry_date", "r", "exit_reason"]]

def expectancy_ci(r: pd.Series, z: float = 1.96):
    n = len(r)
    mean, sd = r.mean(), r.std(ddof=1)
    half = z * sd / np.sqrt(n)
    return mean, mean - half, mean + half, sd

trades = trade_r(result.orders)
mean_r, lo, hi, sd = expectancy_ci(trades["r"])
print(f"E = {mean_r:+.3f}R  95% CI [{lo:+.3f}, {hi:+.3f}]  sd = {sd:.3f}  n = {len(trades)}")
print(trades.groupby("exit_reason")["r"].agg(["count", "mean", "sum"]))

Доверительный интервал здесь важнее самого числа:

$$ E \pm z \cdot \frac{\sigma}{\sqrt{n}} $$

При 23 сделках в месяц и σ ≈ 3R полуширина интервала равна 1.96 · 3 / √23 ≈ 1.23R. Иначе говоря, за месяц вы не отличите ожидание 1R от нуля — и любой вывод по одному месяцу, включая январь 2016 с его +9% и +25%, статистически пуст. Для разрешения 0.3R при том же разбросе нужно:

$$ n = \left(\frac{z \cdot \sigma}{\Delta}\right)^{2} \approx \left(\frac{1.96 \cdot 3}{0.3}\right)^{2} \approx 384 $$

около 384 сделок, то есть больше полутора лет при 23 сделках в месяц. Это и есть честный горизонт проверки гипотезы H1 — и причина, по которой разбивка по годам из восьми лет in-sample выглядит убедительнее, чем она есть.

Что показывают числа и чего они не показывают

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

Сводка прогонов из первоисточника:

ПрогонРезультат
Январь 2016, фиксированные 250 акций$25 000 → $27 360 (+9%), 23 сделки
Январь 2016, риск 1% на сделку$25 000 → $31 257 (+25%)
In-sample 2016–2023$25 000 → около $200 000, winrate 23%
Out-of-sample 2024–2025Кривая растёт, обгоняет индекс
2026 (до 27 октября)−8% при +16% у QQQ

Восемь лет и восьмикратный рост при winrate 23% выглядят как подтверждение гипотезы продолжения импульса. Но проверка на согласованность даёт важную поправку. Фактический результат января: около $6 257 прибыли на 23 сделки, то есть примерно $272 на сделку ≈ 1.1R. Формула с p = 0.23 и k = 10 обещает 1.53R. Расхождение означает, что средний выигрыш ближе к 8R, а не к 10R: часть прибыльных сделок закрывается по времени в 15:58, не дотягивая до тейка. Распределение выигрышей — не «бимодальное ±k R», а непрерывное, и это надо учитывать при оценке просадок.

Ещё важнее то, чего в таблице нет: результата после издержек, и разбивки по годам. Именно разбивка по годам отвечает на вопрос, заданный 2026-м.

Почему 2026 год ломает кривую

Минус 8% при плюс 16% у индекса — не «неудача», а сигнал о смене режима. Три конкурирующих объяснения, которые можно разделить экспериментом, а не спором:

  1. Режим. Стратегия зарабатывает на продолжении утреннего импульса. Если рынок переходит в режим возврата к среднему внутри дня, пробой открытия становится точкой разворота — и стратегия систематически покупает локальный максимум. Проверка: доля дней, где направление первого импульса совпало с направлением дня, по годам.
  2. Издержки. С ростом волатильности растёт и проскальзывание, а дистанция стопа не меняется пропорционально. Проверка: добавить модель издержек и посмотреть, не исчезает ли преимущество уже в 2024–2025.
  3. Слабость условия. «Закрытие выше открытия дня» — грубый фильтр, не учитывающий ни относительный объём в диапазоне, ни волатильность относительно ATR. Проверка: ожидание по квинтилям ширины диапазона и объёма.

Показательный контр-эксперимент: заменить сигнал на случайный вход в 09:35 с теми же стопом и тейком. Если случайный вход даёт похожую кривую за 2016–2023, стратегия измеряет не импульс, а асимметрию стопа и тейка в трендовом бычьем рынке. Это самый дешёвый способ отличить edge от бета-версии лонга.

Пять инвариантов, которые проверяются до любых выводов

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

  1. Ни одна заявка не исполнена на баре сигнала. Идентификатор бара входа строго больше идентификатора бара, на котором зафиксирован сигнал.
  2. Сумма R сходится с деньгами. Приведённая к R прибыль должна совпадать с фактическим P&L, делённым на риск на сделку; расхождение означает, что часть сделок потеряла привязку к своему стопу.
  3. Касса не уходит в минус ни на одном баре. Отрицательный баланс в кривой — не «плечо», а баг движка или сайзинга.
  4. Число сессий в прогоне равно календарю. Если уникальных дат меньше, чем торговых дней, пропуски в данных выглядят как отсутствие сигналов и завышают winrate.
  5. На каждом дне не больше одной сделки. Двойной вход означает, что состояние в ctx.session сбрасывается не на той границе дня.
def check_invariants(orders: pd.DataFrame, equity: pd.Series, sessions: int, risk_cash: float):
    assert (orders["entry_bar"] > orders["signal_bar"]).all(), "fill on signal bar"
    assert abs(orders["r"].sum() - orders["pnl"].sum() / risk_cash) < 1e-6, "R mismatch"
    assert (equity >= 0).all(), "negative cash"
    assert orders["entry_date"].dt.date.nunique() == sessions, "session count mismatch"
    assert orders.groupby(orders["entry_date"].dt.date).size().le(1).all(), "double entry"

Если в результате strategy.backtest() нет колонок signal_bar и entry_bar, их надо положить в ctx.session в момент выставления заявки: движок отдаёт сделки, но не историю решений, и без этого инвариант №1 проверить нечем.

Чек-лист перед реальными деньгами

Прежде чем переносить такую конструкцию на живой счёт через Interactive Brokers, нужно закрыть четыре разрыва:

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

Выводы

Первоисточник

Связанные разборы