Загрузка данных
Прогнал весь конвейер (Phase 1 → сигналы → бэктест) на 4000 синтетических баров с инженерными режимами и четыре точечных теста. Все инварианты зелёные, и по дороге харнесс нашёл реальный баг — об этом ниже.
Прямой ответ на твой вопрос — хранение ордеров и пересечение сигналов. Состояние — одна переменная : либо , либо единственный объект . Закрытые сделки копятся в . Гонку сигналов снимает не очередь ордеров, а гейт в начале каждого бара: . Новый вход рассматривается только если на старте бара мы были плоскими. Пока позиция открыта, сигнальный слой для входов заглушён полностью — встречный шорт при открытом лонге не разворачивает и не добавляет позицию, он просто игнорируется и считается в (тест это подтвердил: при лонге прилетел шорт → сделок 1, сторона [+1], гашений 1). Это ровно то, что просило ТЗ: «позиция сопровождается по правилам Модуля 5». Если когда-нибудь захочешь stop-and-reverse — это замена одной ветки, но по умолчанию поведение консервативное и однозначное.posNonePositionself.tradeswas_flat = pos is Noneignored_opposite_signals
Архитектура трёх слоёв. берёт обогащённый df, сдвигает VAL/VAH/POC/Market_State/ATR на и отдаёт колонки , плюс сами сдвинутые уровни — чтобы риск-движок считал стоп от тех же значений, что породили сигнал. — три чистые функции без состояния (, , ), поэтому их тривиально тестировать в изоляции. — event-driven цикл: на каждом баре открытие (если были плоскими) → сопровождение позиции по диапазону бара → mark-to-market equity. Порядок «открыть, потом сопроводить тот же бар» важен: позиция, открытая на , проверяется на стоп/тейк уже внутри своего бара входа, иначе быстрый разворот внутри первого бара не стопался бы.AVVMSignalGenerator.shift(1)signalsig_regimeRiskEnginestop_priceposition_sizemath_take_profitAVVMBacktesteropen[t+1]
Временная модель против look-ahead (зафиксирована в docstring явно). Решение о входе на баре t — по сдвинутым уровням (известны на закрытии t-1) против OHLC бара t. Исполнение входа по умолчанию на открытии t+1 () — на цене, которую в момент решения мы видеть не могли. Стопы/тейки/касание POC исполняются внутрибарно по ценам стоявших ордеров — это легитимно, ордер уже висел. Тайм-стоп и смерть тренда читаются на закрытии бара.entry_on='next_open'
Четыре решения, которых в ТЗ не было, но без них бэктест врёт:
Внутрибарный конфликт стоп/тейк — главное. На M5 мы не знаем путь цены внутри бара. Если диапазон одного бара накрывает и стоп, и цель, по умолчанию : считаем, что первым сработал стоп. Тест на одном баре, накрывающем оба уровня, дал −100 в пессимистичном режиме против +250 в оптимистичном () — это и есть классическое смещение бэктеста, и я по умолчанию беру честную пессимистичную сторону. Оптимистичный режим оставлен только флагом.intrabar='worst'tp_first
Сайзинг с потолком. при крошечном ATR даёт абсурдное плечо — одна сделка ломает кривую. Жёсткий (по умолчанию 10×) и пропуск сделки при вырожденной дистанции. Учти асимметрию фильтра стопа: для тренда привязка стопа за POC может только расширить стоп относительно 2.5·ATR, то есть уменьшить размер — это в коде помечено.Position_Size = риск/дистанцияmax_leverage
Просадка по mark-to-market. считается по equity = cash + плавающий P&L открытой позиции на каждом баре, а не по закрытым сделкам — иначе MDD систематически занижается.Max Drawdown
EOD-закрытие. Открытая в конце ряда позиция принудительно закрывается по последней цене и записывается сделкой — иначе её плавающий результат повисает между equity и закрытыми сделками.
Баг, который поймал аудит-харнесс. Я добавил инвариант сохранения капитала (). Он сразу разошёлся на ~324 — комиссия входа вычиталась дважды (при открытии и повторно в расчёте при закрытии). После правки расхождение ровно 0.000000. Это к вопросу, зачем гонять инварианты, а не только смотреть на красивую кривую: ошибка в учёте комиссий тихо искажала бы каждый прогон.cap0 + Σ pnl == final_equity
Числа прогона (−6.6%, MDD −9.7%, PF ~0.6) — это валидация движка на случайных данных, не альфа; на синтетике стратегия и не обязана зарабатывать.
Дальше логично прогнать это на реальном MOEX-ряде с твоей сессионной фильтрацией (07:00 modern era) и сделать walk-forward, чтобы отделить in-sample от forward. Готовить этот слой — или сначала добить что-то в Phase 2?