Блог им. RomellaAkumov

Зоны спроса и предложения как алгоритм: скоринг зон, мультитаймфрейм и вход по пинбару (Python, asyncio)

 

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

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

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

Зоны спроса и предложения как алгоритм: скоринг зон, мультитаймфрейм и вход по пинбару (Python, asyncio)


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

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

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

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


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

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

Мультитаймфрейм: зона на старшем ТФ, вход на 5m

А это — самое сильное архитектурное решение системы.

Зоны ищутся на старшем таймфрейме (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 для лонга) — то есть за точку, где рынок в последний раз отказался идти дальше. Вход — по текущей цене возврата в зону.


Async-сканер всего рынка

Ловить такие сетапы надо по всему рынку и часто, иначе большинство возвратов в зону просто пропустишь. Поэтому сканер асинхронный.

Каждый цикл система берёт все тикеры 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 — без сохранения на диск. В подписи — символ, тип сигнала, границы зоны, цена входа и стоп.

Зоны спроса и предложения как алгоритм: скоринг зон, мультитаймфрейм и вход по пинбару (Python, asyncio)

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


Что в итоге

Мы оцифровали ещё один субъективный концепт трейдинга. Инженерно самое ценное здесь:

  • зона как формализованный объект — база импульса из пинбара/поглощения, с чёткой геометрией и адаптивным к ATR размером;
  • скоринг зоны числом — сила импульса в единицах ATR, плотность свечи, swing-подтверждение и один порог, рулящий строгостью;
  • отложенная торговля проверенной зоны — доверяем только зоне, уже доказавшей себя импульсом, без заглядывания в будущее;
  • мультитаймфрейм — зона на старшем ТФ («где»), вход по пинбару на 5m («когда»): макроконтекст плюс микроточность;
  • жизненный цикл зоны — инвалидация по пробою, одноразовость, только ближайшие релевантные зоны;
  • async-сканер всего рынка — семафор, gather, изоляция ошибок по символу, рендер сигнала прямо в память.

И про главное — честно. Оцифровка не равна прибыли. Красиво посчитанная зона со скором 6.5 — это всё ещё зона, и возвраты к ней регулярно пробивают её насквозь. Алгоритм не предсказывает рынок; он убирает субъективность и ручной труд «пересмотреть весь рынок глазами», рисуя и оценивая зоны одинаково и по правилам. Что делать с сигналом — решаете вы, глядя на скрин и контекст.

Спойлер: грааля в supply/demand-зонах нет — как и в любом другом одиночном инструменте. Но зона, найденная по чётким правилам, оценённая числом и подтверждённая разворотом на младшем ТФ, — это куда более честная отправная точка, чем прямоугольник, пририсованный на глаз к уже открытой позиции.


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

Данная публикация является личным мнением автора. Мнение владельца сайта может не совпадать с мнением автора.
583
1 комментарий

Читайте на SMART-LAB:
Фото
NZD/USD: Покупатели «киви» свили гнездо на прочном фундаменте
Новозеландский доллар уже вторую неделю подряд упорно «трамбует» сильный уровень поддержки 0.5577 и нижнюю границу нисходящего канала. Четверг...
❗️Каждый второй заемщик МФО моложе 35 лет
На пользователей от 18 до 34 лет пришлось 59% микрозаймов, выданных через маркетплейс Банки.ру. Они же подали 63% всех заявок.   🔶 Чаще всего...
Фото
Lumentum: почему акции производителя лазеров выросли почти в 7 раз за год
Павел Гаврилов После нескольких лет тишины российским инвесторам вновь доступен рынок США через вечные фьючерсы. Продолжаем цикл материалов...
Фото
Обсуждаем стратегию Элвиса по рынку акций
На этой неделе Элвис выложил видео со своими идеями по рынку. Предлагаю его обсудить и поспорить с ним.

теги блога Roman crypto_maniac

....все тэги



UPDONW
Новый дизайн