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

В этой статье разберём сканер, который делает именно это. Он проходит по всему рынку BingX, находит зоны спроса и предложения на старшем таймфрейме, оценивает каждую зону скорингом, ждёт возврата цены и подтверждающего пинбара на 5-минутках — и присылает сигнал в Telegram с нарисованным графиком: свечи, зоны, выделенная сигнальная область, уровни входа и стопа.

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

пример supply/demand зон
пример supply/demand зон

Что такое зона — и почему её оцифровать сложно

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

То есть зона — это «база» импульса: то место, откуда он стартовал.

Но чтобы это работало в коде, на каждый расплывчатый термин нужно жёсткое определение. Из какой свечи строить зону? Какой у неё размер? Насколько сильным должен быть импульс, чтобы зона считалась настоящей? Дальше — как раз про это.


Зона рождается из разворотной свечи

Первый вопрос — где искать базу импульса. Ответ: в точке разворота. А разворот на графике выражается характерными свечами — пинбаром или поглощением (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-зонах нет — как и в любом другом одиночном инструменте. Но зона, найденная по чётким правилам, оценённая числом и подтверждённая разворотом на младшем ТФ, — это куда более честная отправная точка, чем прямоугольник, пририсованный на глаз к уже открытой позиции.

Комментарии (0)