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

Зона спроса/предложения — это область, из которой начался сильный импульс. Логика простая: если из какого-то ценового кармана рынок резко улетел, значит, там был дисбаланс — крупные заявки, которые не успели исполниться целиком. Когда цена возвращается в этот карман, оставшиеся заявки доисполняются, и рынок часто снова разворачивается.
То есть зона — это «база» импульса: то место, откуда он стартовал.
Но чтобы это работало в коде, на каждый расплывчатый термин нужно жёсткое определение. Из какой свечи строить зону? Какой у неё размер? Насколько сильным должен быть импульс, чтобы зона считалась настоящей? Дальше — как раз про это.
Первый вопрос — где искать базу импульса. Ответ: в точке разворота. А разворот на графике выражается характерными свечами — пинбаром или поглощением (engulfing).
Пинбар («свеча-молот») — это длинный хвост в сторону, откуда цену вынесли, и маленькое тело. Но «длинный хвост» — снова вкусовщина, поэтому его нужно формализовать точными условиями:
<code class="language-python">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
</code>Три чётких условия вместо «на глаз»: тело не больше 30% диапазона, нижний хвост минимум вдвое длиннее тела, верхний хвост не длиннее половины тела. Для медвежьего пинбара всё зеркально. Поглощение проверяется классически — тело текущей свечи перекрывает тело предыдущей противоположного цвета.
Именно эти свечи и становятся кандидатами на базу зоны: бычий пинбар или бычье поглощение → зона спроса, медвежьи → зона предложения.
Свеча-кандидат найдена. Теперь из неё нужно построить сам прямоугольник. Зона берётся не по всей свече, а от экстремума до основания тела — то есть область хвоста, где и произошёл выкуп/слив:
<code class="language-python">if zone_type == 'demand':
low = l # низ — минимум свечи (конец хвоста)
high = min(o, c) # верх — основание тела
if (high - low) > max_size: # ограничение размера
high = low + max_size
</code>Для зоны предложения зеркально: от основания тела (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:
<code class="language-python">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
</code>impulse = move / ATR — это и есть измерение силы движения в единицах волатильности. Если импульс слабее IMPULSE_MIN_ATR (2 ATR), зона отбрасывается сразу: не было настоящего дисбаланса — нет и зоны. Это ключевой фильтр.
Тело свечи-базы. Чем плотнее свеча (больше тело относительно диапазона), тем решительнее был разворот: score += 2 · body/range.
Swing-подтверждение. Если база зоны совпала с локальным экстремумом (swing low для спроса, swing high для предложения) — ещё +2.
Порог MIN_ZONE_SCORE = 4. Максимум ~7. Понижаете порог — больше зон и больше шума, повышаете — меньше зон, но качественнее. Один параметр рулит всей строгостью отбора.
И вот важный момент про причинность. Скор смотрит на бары после формирования зоны — значит, зону можно оценить только когда импульс уже случился. Поэтому детекция сознательно останавливается за LOOKAHEAD баров до текущего момента: мы доверяем зоне, только когда она уже доказала себя реальным движением, а затем ждём свежего возврата к ней. Это не заглядывание в будущее — это отложенная торговля проверенной зоны.
Найти зону мало — ею нужно управлять. В системе три механизма.
Инвалидация. Пробитая зона больше не работает. Проверяем, не зашла ли цена после формирования зоны за её границу — по закрытию или по тени (настраивается):
<code class="language-python">if zone['type'] == 'supply':
break_level = slice_df['close'].max() # или 'high' для проверки по тени
return break_level > zone['high']
</code>Пробитые зоны выкидываются из списка сразу.
Одноразовость. Как только зона дала сигнал, она помечается использованной и повторно не торгуется:
<code class="language-python">used_zones.setdefault(symbol, set()).add((zone['high'], zone['low'])) </code>
Логика та же, что у трейдера: один раз отработали уровень — второй раз он уже «поюзан» и менее надёжен.
Только релевантные зоны. Из всех найденных берутся лишь две ближайшие к текущей цене зоны каждого типа — остальные сейчас неактуальны, цена до них далеко. Плюс анти-дубли по свече, чтобы один и тот же сетап не улетел в Telegram дважды.
Оставшиеся зоны сортируются по скору и свежести, и берётся топ:
<code class="language-python">supply_zones = sorted(supply_zones, key=lambda z: (z["score"], z["start_bar"]),
reverse=True)[:MAX_ZONES]
</code>А это — самое сильное архитектурное решение системы.
Зоны ищутся на старшем таймфрейме (ZONE_TF, в примере на скрине — 4h): там видна значимая структура, крупные области дисбаланса. Но входить по 4h-свече поздно и грубо — сигнал придёт, когда цена уже в глубине зоны, а стоп получится огромным.
Поэтому вход выносится на младший таймфрейм. Когда цена возвращается в старшую зону, система переключается на 5-минутки и ждёт там подтверждающий пинбар прямо внутри зоны:
<code class="language-python">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
</code>Смысл связки: старший ТФ отвечает за «где» (значимая зона), младший — за «когда» (точный момент разворота). Макроконтекст плюс микроточность. Пинбар на 5m внутри 4h-зоны — это уже не грубое «цена коснулась box», а зафиксированный отказ рынка идти дальше именно там, где мы его ждали. Ложных входов такой двойной фильтр отсекает заметно больше, чем вход по одному таймфрейму.
Стоп ставится за экстремум сигнальной 5m-свечи (за её high для шорта, за low для лонга) — то есть за точку, где рынок в последний раз отказался идти дальше. Вход — по текущей цене возврата в зону.
Ловить такие сетапы надо по всему рынку и часто, иначе большинство возвратов в зону просто пропустишь. Поэтому сканер асинхронный.
Каждый цикл система берёт все тикеры BingX и запускает обработку параллельно, ограничивая одновременные задачи семафором:
<code class="language-python">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) # пауза между полными сканами
</code>Семафор здесь принципиален: сотни монет нельзя запрашивать одновременно — упрёшься в лимиты биржи и получишь шквал ошибок. Semaphore(10) держит ровно десять параллельных обработок, а gather дожидается всех. Ошибка по одной монете изолирована в try/except внутри process_symbol и не роняет весь скан — остальные пары обрабатываются как ни в чём не бывало.
На каждый символ — минимум запросов: одна пачка свечей старшего ТФ для зон и две последние 5m-свечи для проверки входа, и то лишь если рядом вообще есть актуальная зона.
Сигнал по зоне без картинки почти бесполезен — зону надо видеть. Поэтому к каждому сигналу рисуется график: свечи старшего ТФ, все ближайшие зоны (предложение — красным, спрос — синим, полупрозрачной заливкой) и сигнальная зона жирной обводкой.
<code class="language-python"># сигнальную зону выделяем жирной рамкой
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))
</code>Свечи рисуются вручную прямоугольниками тела и линиями теней, готовый PNG стримится в память (io.BytesIO) и уходит в Telegram через sendPhoto — без сохранения на диск. В подписи — символ, тип сигнала, границы зоны, цена входа и стоп.
Оговорюсь честно: сигнал даёт вход и стоп, но не тейк-профит — цель вы определяете сами по ближайшей противоположной зоне или структуре. И направление у сигнала есть, но пробой/возврат зоны всё равно может не отработать: инструмент показывает качественный сетап, а не гарантию.
Мы оцифровали ещё один субъективный концепт трейдинга. Инженерно самое ценное здесь:
gather, изоляция ошибок по символу, рендер сигнала прямо в память.И про главное — честно. Оцифровка не равна прибыли. Красиво посчитанная зона со скором 6.5 — это всё ещё зона, и возвраты к ней регулярно пробивают её насквозь. Алгоритм не предсказывает рынок; он убирает субъективность и ручной труд «пересмотреть весь рынок глазами», рисуя и оценивая зоны одинаково и по правилам. Что делать с сигналом — решаете вы, глядя на скрин и контекст.
Спойлер: грааля в supply/demand-зонах нет — как и в любом другом одиночном инструменте. Но зона, найденная по чётким правилам, оценённая числом и подтверждённая разворотом на младшем ТФ, — это куда более честная отправная точка, чем прямоугольник, пририсованный на глаз к уже открытой позиции.
Это сигнальный инструмент и разбор архитектуры, а не инвестиционная рекомендация. Возврат к зоне — не гарантия разворота; торговля фьючерсами с плечом сопряжена с риском потери средств. Решения и риск-менеджмент остаются за вами. Токен Telegram-бота держите в секрете; публичные эндпоинты BingX со временем меняются — сверяйтесь с актуальной документацией.