Аукционная теория в коде: торговый бот на Volume Profile, POC и walk-forward отборе монет

от автора

Алготрейдинг постепенно вырастает из эпохи «пересечений скользящих средних». Классические индикаторы вроде RSI, MACD и EMA — это производные от цены, и они почти всегда запаздывают. Эти сигналы видят все: их обрабатывают маркет-мейкеры, HFT-фонды и десятки тысяч розничных ботов. Преимущество, которое размазано на всех, перестаёт быть преимуществом.

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

В этой статье разберём полноценного торгового бота, построенного вокруг этой идеи. Система строит объёмный профиль (Volume Profile), находит POC в консолидациях, ждёт подтверждающего импульса, торгует ретесты уровней, а перед этим ещё и сама раз в сутки прогоняет walk-forward отбор монет — торгует только то, что было прибыльным на недавней истории.

Отдельно подчеркну то, ради чего половина этой архитектуры вообще существует: бэктест здесь устроен так, чтобы ему можно было верить. Тем же кодом, что считает историю, работает и live. Заглядывание в будущее исключено по построению, а не «вроде бы учтено».

Весь код выложен на GitHub — как обычно, крайне советую читать статью параллельно с исходниками, здесь я разбираю в основном логику и нюансы, а не каждую строчку.


Почему аукционная теория, а не «линии на графике»

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

Центральная величина здесь — POC (Point of Control), ценовой уровень с максимальным наторгованным объёмом. Это самая «справедливая» цена внутри рассматриваемого участка, к которой рынок тянется снова и снова.

Логика движения в аукционной теории простая:

  • цена накапливается в зоне (консолидация вокруг POC);

  • затем происходит выход и импульс — рынок ищет ликвидность выше или ниже;

  • после чего часто возвращается к своему POC, чтобы перепроверить его, — и вот здесь появляется точка входа.

Ключевое отличие от рисования уровней вручную в том, что POC — это не субъективная линия, а статистика реального объёма. Мы не гадаем, «где сильный уровень», мы его вычисляем.


Общая архитектура системы

Логически конвейер представляет собой каскад фильтров: сырые свечи на входе, торговый сигнал на выходе. Каждая ступень отсекает лишнее, и до генерации сигнала доживают только по-настоящему качественные структуры.

ВСЕ СВЕЧИ СИМВОЛА      ↓Понятная консолидация      (узкий диапазон + концентрированный POC)      ↓Валидация импульсом        (выход из диапазона на >= высоту диапазона)      ↓Ретест уровня + реакция    (возврат к POC и подтверждение)      ↓Фильтр качества (RR)       (цель — ближайший сильный непротестированный POC)      ↓СИГНАЛ (long / short)

Код разбит на независимые модули — каждый отвечает за свой слой:

Файл

Назначение

vprofile.py

Объёмный профиль: POC, Value Area (VAL/VAH)

strategy.py

Каузальный движок стратегии (общий для бэктеста и live)

data_feed.py

Свечи Binance / BingX, вселенная символов, кэш CSV

bingx_client.py

Подпись запросов BingX, баланс, плечо, маркет-ордера с TP/SL

backtest.py

Симуляция, портфель, метрики, отчёты (CSV/JSON/график)

selector.py

Ежедневный отбор: бэктест топ-100 CMC → прибыльные монеты

main.py

Live: сканирование альтов, исполнение, риск-контроль

smoke_test.py

Офлайн-проверка всего конвейера на синтетике

Такое разделение — не эстетика ради эстетики. Оно позволяет прогонять одну и ту же стратегию в трёх режимах (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/