Алготрейдинг постепенно вырастает из эпохи «пересечений скользящих средних». Классические индикаторы вроде RSI, MACD и EMA — это производные от цены, и они почти всегда запаздывают. Эти сигналы видят все: их обрабатывают маркет-мейкеры, HFT-фонды и десятки тысяч розничных ботов. Преимущество, которое размазано на всех, перестаёт быть преимуществом.
Гораздо интереснее выглядят системы, работающие с самой структурой рынка: объёмом, ликвидностью, зонами принятия и отвержения цены. Именно к этому классу относится аукционная теория — взгляд на рынок как на непрерывный аукцион, где цена всё время ищет справедливый уровень.
В этой статье разберём полноценного торгового бота, построенного вокруг этой идеи. Система строит объёмный профиль (Volume Profile), находит POC в консолидациях, ждёт подтверждающего импульса, торгует ретесты уровней, а перед этим ещё и сама раз в сутки прогоняет walk-forward отбор монет — торгует только то, что было прибыльным на недавней истории.
Отдельно подчеркну то, ради чего половина этой архитектуры вообще существует: бэктест здесь устроен так, чтобы ему можно было верить. Тем же кодом, что считает историю, работает и live. Заглядывание в будущее исключено по построению, а не «вроде бы учтено».
Весь код выложен на GitHub — как обычно, крайне советую читать статью параллельно с исходниками, здесь я разбираю в основном логику и нюансы, а не каждую строчку.
Почему аукционная теория, а не «линии на графике»
Рынок — это аукцион. В каждый момент покупатели и продавцы торгуются за цену, и объём показывает, где именно они согласились на сделку. Там, где объём концентрируется, находится зона принятой стоимости. Там, где объёма почти нет, — зона, которую рынок отверг и быстро проскочил.
Центральная величина здесь — POC (Point of Control), ценовой уровень с максимальным наторгованным объёмом. Это самая «справедливая» цена внутри рассматриваемого участка, к которой рынок тянется снова и снова.
Логика движения в аукционной теории простая:
-
цена накапливается в зоне (консолидация вокруг POC);
-
затем происходит выход и импульс — рынок ищет ликвидность выше или ниже;
-
после чего часто возвращается к своему POC, чтобы перепроверить его, — и вот здесь появляется точка входа.
Ключевое отличие от рисования уровней вручную в том, что POC — это не субъективная линия, а статистика реального объёма. Мы не гадаем, «где сильный уровень», мы его вычисляем.
Общая архитектура системы
Логически конвейер представляет собой каскад фильтров: сырые свечи на входе, торговый сигнал на выходе. Каждая ступень отсекает лишнее, и до генерации сигнала доживают только по-настоящему качественные структуры.
ВСЕ СВЕЧИ СИМВОЛА ↓Понятная консолидация (узкий диапазон + концентрированный POC) ↓Валидация импульсом (выход из диапазона на >= высоту диапазона) ↓Ретест уровня + реакция (возврат к POC и подтверждение) ↓Фильтр качества (RR) (цель — ближайший сильный непротестированный POC) ↓СИГНАЛ (long / short)
Код разбит на независимые модули — каждый отвечает за свой слой:
|
Файл |
Назначение |
|---|---|
|
Объёмный профиль: POC, Value Area (VAL/VAH) |
|
|
Каузальный движок стратегии (общий для бэктеста и live) |
|
|
|
Свечи Binance / BingX, вселенная символов, кэш CSV |
|
|
Подпись запросов BingX, баланс, плечо, маркет-ордера с TP/SL |
|
Симуляция, портфель, метрики, отчёты (CSV/JSON/график) |
|
|
Ежедневный отбор: бэктест топ-100 CMC → прибыльные монеты |
|
|
Live: сканирование альтов, исполнение, риск-контроль |
|
|
|
Офлайн-проверка всего конвейера на синтетике |
Такое разделение — не эстетика ради эстетики. Оно позволяет прогонять одну и ту же стратегию в трёх режимах (smoke-тест, бэктест, live), не дублируя логику. А значит, то, что вы протестировали, и то, что торгует бот, — это буквально один и тот же код.
Ядро: объёмный профиль
Всё начинается с vprofile.py. Здесь объём каждой свечи распределяется по ценовым бинам пропорционально тому, какую часть бина перекрывает диапазон свечи [low, high].
Это честнее, чем «весь объём свечи в цену закрытия»: широкая свеча размазывает объём по всему своему ходу, а не сваливает его в одну точку.
Реализовано векторно, без циклов по свечам:
left = np.maximum(edges[None, :-1], lo_n)right = np.minimum(edges[None, 1:], hi_n)w = np.clip(right - left, 0.0, None) / (hi_n - lo_n)hist += (w * volume[norm][:, None]).sum(axis=0)
Дальше всё просто. POC — это бин с максимальным объёмом:
p = int(hist.argmax())poc = 0.5 * (edges[p] + edges[p + 1])
А Value Area (по умолчанию 70% объёма) набирается расширением от POC в обе стороны, каждый раз добавляя более «тяжёлый» соседний бин, пока не наберётся нужная доля. Границы этой области — VAL и VAH.
Отдельно храним долю POC в общем объёме — она пригодится, чтобы отличать «понятный» профиль от размазанного.
Понятная консолидация
Не всякий POC достоин внимания. Нам нужна не просто область с объёмом, а чёткое накопление — то, что в аукционной теории и является базой для будущего движения.
Движок сканирует историю скользящими окнами трёх длин — 24, 48 и 96 баров (на часовом таймфрейме это примерно 1, 2 и 4 дня). Окно проходит отбор только если выполняются все условия сразу:
-
диапазон узкий — высота окна
<= max_range_atr · ATRи<= max_range_pctот цены (по умолчанию 6·ATR и 10%); -
внутри нет тренда —
|close_end − close_start| / range <= max_net_ratio(0.35), иначе это уже движение, а не накопление; -
POC концентрированный — объём POC-бина как минимум в
min_concentrationраз больше, чем при равномерном распределении; -
POC не у края — он должен быть в середине диапазона, а не прижат к границе окна.
Метрика концентрации здесь элегантная. Если бы объём был размазан равномерно, доля каждого бина была бы 1/bins, и произведение poc_share · bins равнялось бы единице. Поэтому:
conc = poc\_share \times bins
Значение conc = 2.5 буквально означает «в POC-бине в 2.5 раза больше объёма, чем было бы при равномерном распределении». Просто, наглядно и не зависит от числа бинов.
conc = prof.poc_share * binsif conc < cfg.min_concentration: continuepos = (prof.poc - win_lo) / rngif pos < cfg.poc_edge_margin or pos > 1 - cfg.poc_edge_margin: continue
Прошедшее окно живёт как кандидат и обновляется, пока условие выполняется. Как только цена уходит из диапазона — кандидат «замораживается» и переходит на следующую стадию.
Валидация импульсом
И вот здесь принципиальный момент, который отличает эту систему от наивного «нашли зону — торгуем зону».
Сама по себе консолидация — это ещё не сигнал. Мало ли где рынок потоптался. Нам нужно доказательство, что этот уровень действительно значим для крупного капитала.
Таким доказательством выступает импульс. После выхода цены из диапазона у системы есть impulse_horizon баров (12), чтобы увидеть, как экстремум уходит от границы диапазона на величину не меньше высоты самого диапазона:
extreme\_move \geq impulse\_mult \times range
Только если это произошло — POC превращается в валидный уровень с зафиксированным направлением импульса. Если рынок вяло выполз за границу и завис — уровень не рождается, кандидат просто отбрасывается.
thr = cfg.impulse_mult * cand.rngup = w.hi_ext >= thrdn = w.lo_ext >= thrif up: self._create_level(cand, +1, i)
Фактически мы отбираем только те зоны, из которых рынок ушёл резко и осознанно. Слабые, «ренджевые» POC отсеиваются на этой ступени.
Ретест и реакция: движок входа
Теперь у нас есть валидный уровень, и мы ждём его перепроверки. Вся логика реализована как небольшой конечный автомат:
Кандидат → Импульс → Уровень → Ретест → Реакция → Сигнал
Когда цена возвращается в зону уровня (± touch_tolerance_atr · ATR), запускается тест. Направление сделки определяется стороной подхода — тем, откуда пришла цена по последним закрытиям: подошли сверху → ищем лонг, подошли снизу → шорт.
Дальше уровень должен себя проявить. Возможны три исхода:
-
реакция подтверждена — в течение
reaction_windowбаров закрытие уходит от уровня в сторону сделки на>= reaction_buffer_atr · ATR. Это наш вход; -
тест провален — закрытие уходит против на
>= invalidation_atr · ATR(0.9·ATR). Уровень пробит, ликвидность забрана, сигнал неактуален; -
истёк срок — реакции так и не случилось.
failed = (lvl.price - c) * d >= cfg.invalidation_atr * atrsuccess = (c - lvl.price) * d >= cfg.reaction_buffer_atr * atrexpired = (i - p.start) >= cfg.reaction_window
Уровень не бессмертен. Он отрабатывает максимум max_touches тестов (2), живёт ограниченное число баров (level_ttl), а если цену вокруг него «пилит» — при max_crosses пересечениях уровень объявляется мёртвым. Логика та же, что и у трейдера за терминалом: уровень, который перестал держать, торговать бессмысленно.
Отдельно система различает два типа сетапа. Если ретест случился в первые local_retest_window баров после импульса (выход из накопления → сразу ретест POC) — это kind = local, самый «свежий» и, как правило, самый качественный вход. Поздние возвраты помечаются kind = retest. Какие из них торговать — задаётся в конфиге, можно оставить только локальные.
Стоп, цель и фильтр качества
Здесь система ведёт себя как дисциплинированный трейдер, а не как генератор случайных входов.
Стоп ставится за экстремум ретеста с запасом stop_pad_atr · ATR — то есть за ту точку, где рынок в последний раз отказался идти дальше.
Цель выбирается не «на глаз» и не фиксированным процентом. Система ищет ближайший по направлению сделки сильный (концентрация >= strong_concentration) и ещё не протестированный POC — то есть следующую значимую зону ликвидности, к которой рынок логично потянется.
cands = [L2 for L2 in self.levels if L2 is not lvl and L2.alive and not L2.tested and L2.conc >= cfg.strong_concentration and (L2.price - entry) * d > 0.1 * atr]
И только теперь — фильтр качества сделки. Считаем соотношение риск/прибыль:
RR = \frac{(target - entry)\cdot dir}{(entry - stop)\cdot dir}
Если ближайшая сильная цель даёт RR < min_rr (1.5) — сделка пропускается, даже при идеальном во всём остальном сетапе. Если подходящего непротестированного POC по направлению нет — цель выставляется по fallback_rr (2.5). Слишком «узкие» сделки, где риск меньше min_risk_atr · ATR, тоже отсекаются.
Это критично. Огромная часть сигналов у новичков убыточна не потому, что вход плохой, а потому, что соотношение риск/прибыль изначально отрицательное. Здесь система физически не даст себе войти в плохую по RR сделку.
Почему этому бэктесту можно верить
А теперь — главное. То, из-за чего большинство «прибыльных» стратегий из интернета умирают в реальной торговле.
Самая страшная ошибка алготрейдинга — заглядывание в будущее (looking forward). Алгоритм принимает решение, используя данные, которых в реальном времени у него быть не могло. На истории такая система выглядит как грааль. В реальном рынке — сливает депозит.
Обычно эта ошибка прячется в мелочах: человек считает сигнал по незакрытой свече, или индикатор подсматривает значение на баре вперёд. Достаточно одного такого места, чтобы весь бэктест стал фальшивкой.
В этой архитектуре проблема решена радикально — на уровне дизайна. Движок каузальный: метод on_bar() получает бары строго по одному и ничего не знает о будущем.
def on_bar(self, o, h, l, c, v, ts) -> List[Signal]: """Обработать ЗАКРЫТЫЙ бар. Возвращает список сигналов (обычно 0..1).""" self.i += 1 ...
Обратите внимание на комментарий: движок работает только с закрытыми барами. Никакой текущей, ещё формирующейся свечи. Именно тут новички чаще всего и ломают достоверность, торгуя -1 вместо -2.
Но самое важное даже не это. Самое важное — что бэктест и live используют один и тот же движок:
# backtest.pysignals = eng.on_bar(o[i], h[i], l[i], c[i], v[i], int(ts[i]))# main.pysigs = eng.on_bar(b["open"], b["high"], b["low"], b["close"], b["volume"], b["ts"])
Это значит, что расхождение между «бэктест показал» и «бот наторговал» сведено к минимуму по построению. Нельзя случайно протестировать одну логику, а торговать другую — логика физически одна.
Данные: объёмы с Binance, исполнение на BingX
Небольшая, но важная деталь. Объёмный профиль хорош ровно настолько, насколько честны объёмы, которые в него кладут.
Поэтому система берёт свечи и объёмы с Binance USDT-M futures — самого ликвидного рынка с наиболее «правильной» картиной объёма. А исполняет сделки на BingX perpetual. Если же монеты на Binance нет, свечи берутся с BingX, и такой символ помечается source='bingx'.
source = "binance" if b_sym in b_syms else "bingx"
И вот тонкость риск-менеджмента: для BingX-данных риск на сделку снижается (0.5% вместо 1%). Логика простая — картина объёма там менее надёжна, значит, и ставку на такой сигнал делаем меньше.
Слой данных умеет всё, что нужно долгоживущей системе: кэширование свечей в CSV (повторные запуски докачивают только хвост), троттлинг запросов, ретраи с бэк-оффом на ошибках 429/5xx. Это не мелочь — без этого любой сканер сотен монет быстро упрётся в rate limits биржи.
Честный бэктест: комиссии, проскальзывание, пессимизм
Вторая причина, по которой красивые бэктесты не переживают реальность, — комиссии и проскальзывание. Многие искренне считают, что 0.05% — это ноль. На низких таймфреймах именно эти «сотые процента» и съедают всю прибыль, ведь платишь ты и на входе, и на выходе, да ещё с плечом.
В симуляции всё это учтено. Комиссия и проскальзывание закладываются в R каждой сделки:
exit_px = px_raw * (1 - d * slip)r_gross = (exit_px - pos["entry"]) * d / risk_psfee_r = fee * (pos["entry"] + exit_px) / risk_psr_net = r_gross - fee_r
Более того, бэктест пессимистичен: если на одном баре задеты и стоп, и тейк, засчитывается стоп. Мы не знаем, что внутри бара сработало раньше, поэтому считаем в худшую для себя сторону — это честнее, чем раздувать статистику оптимистичным допущением.
Дальше сделки всех символов сводятся в портфель: сортируются по времени входа, риск на сделку берётся в процентах от текущего эквити, действует лимит одновременных позиций (max_concurrent). На выходе — полный набор метрик и готовый отчёт:
=== Портфель ==================================================Сделок: 148 Winrate: 41.9% PF: 1.63 avg R: 0.34 sum R: 50.9 local n=92 WR=45.7% avgR=0.48 retest n=56 WR=35.7% avgR=0.11Эквити: 10,000 -> 14,720 USDT (+47.2%)Max drawdown: 18.3% Пропущено из-за лимита позиций: 12
📌 Сюда — скриншот реальной equity curve из вашего прогона (
bt_results/<дата>/equity.png) и таблица по символам. Живые цифры вашей системы будут убедительнее любого примера.
Честно оговорюсь, что бэктест не моделирует: funding-платежи, реальную глубину стакана и частичные исполнения. На низколиквидных альтах под это стоит закладывать запас. Идеальных симуляций не бывает — важно понимать их границы.
Walk-forward отбор: боремся с маленькой выборкой
Третья классическая ловушка — тестирование на одном рыночном режиме. Прогнали алгоритм на паре месяцев бычьего рынка, увидели красивые цифры и поверили, что нашли рабочую модель. А потом приходит боковик или каскад ликвидаций — и стратегия рассыпается.
Эту проблему система решает через ежедневный walk-forward отбор монет.
Раз в сутки, в 12:00 UTC, бот сам берёт топ-100 монет по капитализации (CoinMarketCap или CoinGecko), прогоняет по каждой бэктест за последние lookback_days (120 дней) — и торгует в realtime только те монеты, что оказались прибыльными по чётким критериям:
-
сделок
>= min_trades(3); -
суммарный R
> min_sum_r(0); -
profit factor
>= min_profit_factor(1.0).
def select_profitable(per_symbol, scfg): rows = [] for sym, d in per_symbol.items(): m = d["metrics"] if m["n"] < scfg.min_trades: continue if m["sum_r"] <= scfg.min_sum_r: continue if m["profit_factor"] < scfg.min_profit_factor: continue rows.append(...) rows.sort(key=lambda r: -r["sum_r"]) return rows[:scfg.max_selected]
Лучшие max_selected (15) монет попадают в selection.json, и до следующего отбора входы разрешены только по ним. Стейблкоины и «обёртки» (WBTC, stETH и т.п.) из рейтинга выкидываются автоматически.
Это принципиально иной подход, чем «подобрал параметры один раз и молюсь». Система постоянно переспрашивает рынок: что работает прямо сейчас? — и адаптирует список инструментов под текущий режим, а не под давно ушедший исторический участок. А чтобы не упереться в лимиты API, отбор идёт с паузами между запросами и кэшированием — первый прогон занимает несколько минут, последующие докачивают только сутки истории.
Live: риск-контроль и отказоустойчивость
В боевом режиме на первый план выходит уже не поиск сигнала, а выживание системы и депозита.
Вход исполняется маркет-ордером в hedge-режиме, причём стоп и тейк прикрепляются прямо к ордеру (STOP_MARKET / TAKE_PROFIT_MARKET по mark price). Даже если бот упадёт или потеряет связь — позицию закроет сама биржа. Это важнейшая страховка.
Поверх этого — несколько уровней защиты риска:
-
дневной kill-switch: при просадке
-max_daily_loss(−5%) за день новые входы останавливаются до следующих суток UTC; -
cooldown на символ после закрытия позиции — не входим в ту же монету сразу;
-
изолированная маржа и плечо из конфига, контроль свободной маржи перед входом;
-
лимит одновременных позиций и минимальный размер сделки.
if start > 0 and eq <= start * (1 - self.cfg.risk.max_daily_loss): log.error("KILL-SWITCH: эквити %.2f <= %.2f ...") self.state["halted"] = True
Отдельно продумана отказоустойчивость. Ошибка данных по одному символу не роняет весь цикл — она логируется, и цикл идёт дальше. Сбой ежедневного отбора не останавливает торговлю: используется предыдущий список. Состояние (открытые позиции, cooldown’ы, бумажный эквити) сохраняется в live_state.json, так что перезапуск не теряет контекст.
И, конечно, безопасная лестница внедрения — от бумаги к бою:
бэктест → demo (VST) → DRY-RUN на реальных данных → боевой счёт с минимальным риском
Боевой режим ещё и переспрашивает подтверждение вручную, чтобы случайно не запустить реальную торговлю.
Проверка конвейера без сети
Приятная инженерная деталь — smoke_test.py. Он собирает синтетический ряд с заранее заложенной структурой (консолидация → импульс → ретест POC → отработка) и прогоняет через него весь конвейер: движок, бэктест, портфель, фильтр отбора.
assert eng.stats.get("cand_created", 0) >= 1, "консолидация не найдена"assert eng.stats.get("level_created", 0) >= 1, "уровень не валидирован"assert len(sigs) >= 1, "нет ни одного сигнала ретеста"
Это позволяет за секунды убедиться, что после правок логика не сломалась, — вообще без обращения к биржам. Для торговой системы, которую вы будете дорабатывать, такой офлайн-тест экономит массу нервов.
Заключение
Мы собрали систему, которая работает не с производными от цены, а с самой структурой рынка. По шагам:
-
объёмный профиль и POC вместо субъективных линий на графике;
-
понятная консолидация с концентрированным объёмом как база сетапа;
-
валидация импульсом — торгуем только значимые уровни;
-
ретест + реакция как триггер входа, с чётким разделением local / retest;
-
цель по ближайшему сильному непротестированному POC и жёсткий фильтр по RR;
-
walk-forward отбор монет каждый день — адаптация под текущий режим рынка;
-
риск-контроль: kill-switch, cooldown, изолированная маржа, TP/SL на стороне биржи.
Но главная ценность этой архитектуры даже не в самой стратегии. Она в том, что вокруг стратегии выстроена честная инфраструктура проверки: каузальный движок, единый код для бэктеста и live, пессимистичная симуляция с комиссиями и проскальзыванием, отбор на скользящем окне. Именно этого не хватает большинству «граалей» из интернета — и именно поэтому они не переживают встречу с реальным рынком.
Красивый бэктест почти ничего не значит без адекватной проверки. Рынок очень быстро наказывает за самообман. Эта система хотя бы честна сама с собой — а это уже половина дела.
Спойлер: идеальных систем всё равно не существует. Но система, которая не врёт себе на истории, имеет куда больше шансов, чем та, что рисует красивую кривую заглядыванием в будущее.
Это программный инструмент и разбор архитектуры, а не инвестиционная рекомендация. Торговля с плечом может привести к потере всего депозита; результаты бэктеста не гарантируют будущую доходность. Все решения и риск-менеджмент остаются за вами. Перед боевым запуском обязательно сверьте эндпоинты BingX с актуальной документацией и прогоните всё на демо-контуре (VST). API-ключ создавайте без права вывода средств и с ограничением по IP.
ссылка на оригинал статьи https://habr.com/ru/articles/1059808/