Зоны спроса и предложения (supply/demand) — один из самых сильных концептов в трейдинге и один из самых субъективно рисуемых. Идея красивая: цена уходит из области, где крупный игрок набрал позицию, а потом возвращается «добрать» — и от этой области снова разворачивается.
В этой статье разберём сканер, который делает именно это. Он проходит по всему рынку BingX, находит зоны спроса и предложения на старшем таймфрейме, оценивает каждую зону скорингом, ждёт возврата цены и подтверждающего пинбара на 5-минутках — и присылает сигнал в Telegram с нарисованным графиком: свечи, зоны, выделенная сигнальная область, уровни входа и стопа.
Весь код выложен на GitHub — советую читать статью параллельно с исходниками.

Что такое зона — и почему её оцифровать сложно
Зона спроса/предложения — это область, из которой начался сильный импульс. Логика простая: если из какого-то ценового кармана рынок резко улетел, значит, там был дисбаланс — крупные заявки, которые не успели исполниться целиком. Когда цена возвращается в этот карман, оставшиеся заявки доисполняются, и рынок часто снова разворачивается.
То есть зона — это «база» импульса: то место, откуда он стартовал.
Но чтобы это работало в коде, на каждый расплывчатый термин нужно жёсткое определение. Из какой свечи строить зону? Какой у неё размер? Насколько сильным должен быть импульс, чтобы зона считалась настоящей? Дальше — как раз про это.
Зона рождается из разворотной свечи
Первый вопрос — где искать базу импульса. Ответ: в точке разворота. А разворот на графике выражается характерными свечами — пинбаром или поглощением (engulfing).
Пинбар («свеча-молот») — это длинный хвост в сторону, откуда цену вынесли, и маленькое тело. Но «длинный хвост» — снова вкусовщина, поэтому его нужно формализовать точными условиями:
def is_bullish_pinbar_df(df, i): ... body = abs(c - o) total_range = h - l if body > 0.3 * total_range: # тело <= 30% диапазона свечи return False upper_wick = h - max(o, c) lower_wick = min(o, c) - l if c <= o: # для demand — свеча бычья return False if lower_wick >= body * 2 and upper_wick <= body * 0.5: return True # длинный нижний хвост, короткий верхний return False
Три чётких условия вместо «на глаз»: тело не больше 30% диапазона, нижний хвост минимум вдвое длиннее тела, верхний хвост не длиннее половины тела. Для медвежьего пинбара всё зеркально. Поглощение проверяется классически — тело текущей свечи перекрывает тело предыдущей противоположного цвета.
Именно эти свечи и становятся кандидатами на базу зоны: бычий пинбар или бычье поглощение → зона спроса, медвежьи → зона предложения.
Геометрия зоны
Свеча-кандидат найдена. Теперь из неё нужно построить сам прямоугольник. Зона берётся не по всей свече, а от экстремума до основания тела — то есть область хвоста, где и произошёл выкуп/слив:
if zone_type == 'demand': low = l # низ — минимум свечи (конец хвоста) high = min(o, c) # верх — основание тела if (high - low) > max_size: # ограничение размера high = low + max_size
Для зоны предложения зеркально: от основания тела (max(open, close)) до максимума свечи.
Важная деталь — ограничение размера зоны: high - low <= MAX_ZONE_ATR · ATR (по умолчанию 4 ATR). Слишком широкая зона бесполезна — вход в неё означал бы гигантский стоп, а «сработать» она может где угодно внутри. Ограничение через ATR делает максимальный размер зоны адаптивным к волатильности конкретного инструмента, а не фиксированным в долларах.
Скоринг: превращаем «сильную зону» в число
Вот главная техническая идея. Вместо интуитивного «эта зона выглядит мощно» каждой зоне присваивается числовой скор, и в работу идут только те, что набрали порог.
Скор складывается из трёх компонент:
score = \min(3,\ \text{impulse}) + 2\cdot\dfrac{body}{range} + 2\cdot[\,\text{swing}\,]
Разберём по частям.
Сила импульса. Смотрим вперёд на LOOKAHEAD (10) баров после зоны и меряем, как далеко ушла цена, нормируя на ATR:
if zone_type == "demand": future_high = df['high'].iloc[i+1:i+1+lookahead].max() move = future_high - zone['high'] else: future_low = df['low'].iloc[i+1:i+1+lookahead].min() move = zone['low'] - future_low impulse_strength = move / atr_val score += min(3, impulse_strength) if impulse_strength < IMPULSE_MIN_ATR: # слабый импульс — зона отбраковывается return 0
impulse = move / ATR — это и есть измерение силы движения в единицах волатильности. Если импульс слабее IMPULSE_MIN_ATR (2 ATR), зона отбрасывается сразу: не было настоящего дисбаланса — нет и зоны. Это ключевой фильтр.
Тело свечи-базы. Чем плотнее свеча (больше тело относительно диапазона), тем решительнее был разворот: score += 2 · body/range.
Swing-подтверждение. Если база зоны совпала с локальным экстремумом (swing low для спроса, swing high для предложения) — ещё +2.
Порог MIN_ZONE_SCORE = 4. Максимум ~7. Понижаете порог — больше зон и больше шума, повышаете — меньше зон, но качественнее. Один параметр рулит всей строгостью отбора.
И вот важный момент про причинность. Скор смотрит на бары после формирования зоны — значит, зону можно оценить только когда импульс уже случился. Поэтому детекция сознательно останавливается за LOOKAHEAD баров до текущего момента: мы доверяем зоне, только когда она уже доказала себя реальным движением, а затем ждём свежего возврата к ней. Это не заглядывание в будущее — это отложенная торговля проверенной зоны.
Жизненный цикл зоны
Найти зону мало — ею нужно управлять. В системе три механизма.
Инвалидация. Пробитая зона больше не работает. Проверяем, не зашла ли цена после формирования зоны за её границу — по закрытию или по тени (настраивается):
if zone['type'] == 'supply': break_level = slice_df['close'].max() # или 'high' для проверки по тени return break_level > zone['high']
Пробитые зоны выкидываются из списка сразу.
Одноразовость. Как только зона дала сигнал, она помечается использованной и повторно не торгуется:
used_zones.setdefault(symbol, set()).add((zone['high'], zone['low']))
Логика та же, что у трейдера: один раз отработали уровень — второй раз он уже «поюзан» и менее надёжен.
Только релевантные зоны. Из всех найденных берутся лишь две ближайшие к текущей цене зоны каждого типа — остальные сейчас неактуальны, цена до них далеко. Плюс анти-дубли по свече, чтобы один и тот же сетап не улетел в Telegram дважды.
Оставшиеся зоны сортируются по скору и свежести, и берётся топ:
supply_zones = sorted(supply_zones, key=lambda z: (z["score"], z["start_bar"]), reverse=True)[:MAX_ZONES]
Мультитаймфрейм: зона на старшем ТФ, вход на 5m
А это — самое сильное архитектурное решение системы.
Зоны ищутся на старшем таймфрейме (ZONE_TF, в примере на скрине — 4h): там видна значимая структура, крупные области дисбаланса. Но входить по 4h-свече поздно и грубо — сигнал придёт, когда цена уже в глубине зоны, а стоп получится огромным.
Поэтому вход выносится на младший таймфрейм. Когда цена возвращается в старшую зону, система переключается на 5-минутки и ждёт там подтверждающий пинбар прямо внутри зоны:
def check_short_signal(symbol, df_5m, zones): last = df_5m.iloc[-2] # последняя закрытая 5m свеча price = last["close"] if not is_bearish_pinbar_df(df_5m, len(df_5m) - 2): return None # нет пинбара — нет входа for zone in zones: if price_in_zone(price, zone): # цена внутри зоны (с допуском) return zone return None
Смысл связки: старший ТФ отвечает за «где» (значимая зона), младший — за «когда» (точный момент разворота). Макроконтекст плюс микроточность. Пинбар на 5m внутри 4h-зоны — это уже не грубое «цена коснулась box», а зафиксированный отказ рынка идти дальше именно там, где мы его ждали. Ложных входов такой двойной фильтр отсекает заметно больше, чем вход по одному таймфрейму.
Стоп ставится за экстремум сигнальной 5m-свечи (за её high для шорта, за low для лонга) — то есть за точку, где рынок в последний раз отказался идти дальше. Вход — по текущей цене возврата в зону.
Async-сканер всего рынка
Ловить такие сетапы надо по всему рынку и часто, иначе большинство возвратов в зону просто пропустишь. Поэтому сканер асинхронный.
Каждый цикл система берёт все тикеры BingX и запускает обработку параллельно, ограничивая одновременные задачи семафором:
async def main_loop(): symbols = bx.get_all_tikers() semaphore = asyncio.Semaphore(MAX_CONCURRENT) # не больше 10 разом while True: tasks = [process_symbol(symbol, semaphore) for symbol in symbols] await asyncio.gather(*tasks) await asyncio.sleep(SCAN_INTERVAL_SEC) # пауза между полными сканами
Семафор здесь принципиален: сотни монет нельзя запрашивать одновременно — упрёшься в лимиты биржи и получишь шквал ошибок. Semaphore(10) держит ровно десять параллельных обработок, а gather дожидается всех. Ошибка по одной монете изолирована в try/except внутри process_symbol и не роняет весь скан — остальные пары обрабатываются как ни в чём не бывало.
На каждый символ — минимум запросов: одна пачка свечей старшего ТФ для зон и две последние 5m-свечи для проверки входа, и то лишь если рядом вообще есть актуальная зона.
Рендер сигнала
Сигнал по зоне без картинки почти бесполезен — зону надо видеть. Поэтому к каждому сигналу рисуется график: свечи старшего ТФ, все ближайшие зоны (предложение — красным, спрос — синим, полупрозрачной заливкой) и сигнальная зона жирной обводкой.
# сигнальную зону выделяем жирной рамкой ax.add_patch(patches.Rectangle( (rel_start, signal_zone['low']), len(df) - rel_start, signal_zone['high'] - signal_zone['low'], facecolor='none', edgecolor=color, linewidth=3))
Свечи рисуются вручную прямоугольниками тела и линиями теней, готовый PNG стримится в память (io.BytesIO) и уходит в Telegram через sendPhoto — без сохранения на диск. В подписи — символ, тип сигнала, границы зоны, цена входа и стоп.

Оговорюсь честно: сигнал даёт вход и стоп, но не тейк-профит — цель вы определяете сами по ближайшей противоположной зоне или структуре. И направление у сигнала есть, но пробой/возврат зоны всё равно может не отработать: инструмент показывает качественный сетап, а не гарантию.
Что в итоге
Мы оцифровали ещё один субъективный концепт трейдинга. Инженерно самое ценное здесь:
зона как формализованный объект — база импульса из пинбара/поглощения, с чёткой геометрией и адаптивным к ATR размером;
скоринг зоны числом — сила импульса в единицах ATR, плотность свечи, swing-подтверждение и один порог, рулящий строгостью;
отложенная торговля проверенной зоны — доверяем только зоне, уже доказавшей себя импульсом, без заглядывания в будущее;
мультитаймфрейм — зона на старшем ТФ («где»), вход по пинбару на 5m («когда»): макроконтекст плюс микроточность;
жизненный цикл зоны — инвалидация по пробою, одноразовость, только ближайшие релевантные зоны;
async-сканер всего рынка — семафор,
gather, изоляция ошибок по символу, рендер сигнала прямо в память.
И про главное — честно. Оцифровка не равна прибыли. Красиво посчитанная зона со скором 6.5 — это всё ещё зона, и возвраты к ней регулярно пробивают её насквозь. Алгоритм не предсказывает рынок; он убирает субъективность и ручной труд «пересмотреть весь рынок глазами», рисуя и оценивая зоны одинаково и по правилам. Что делать с сигналом — решаете вы, глядя на скрин и контекст.
Спойлер: грааля в supply/demand-зонах нет — как и в любом другом одиночном инструменте. Но зона, найденная по чётким правилам, оценённая числом и подтверждённая разворотом на младшем ТФ, — это куда более честная отправная точка, чем прямоугольник, пририсованный на глаз к уже открытой позиции.