Блог им. RomellaAkumov

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

 

Трендовая линия — самый субъективный инструмент технического анализа. Дайте одному графику десять трейдеров — получите десять разных линий. Кто-то ведёт по теням, кто-то по телам, кто-то «на глаз подгоняет» так, чтобы линия подтверждала уже открытую позицию. Это не анализ, это гадание с линейкой.

А раз линию рисует человек — её нельзя ни протестировать, ни автоматизировать. Каждый раз получается новая.

Интересная инженерная задача — формализовать «настоящую» трендовую линию в строгую геометрию: набор чётких математических условий, по которым машина строит линию объективно, одинаково и, главное, честно — не заглядывая в будущее. Никаких индикаторов, только цена, экстремумы и ATR.

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

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

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


Что делает линию «настоящей»

Большинство рисует трендовую по двум точкам: соединил два минимума — вот и линия. Но линия по двум случайным экстремумам не значит ничего — через любые две точки можно провести прямую.

Поэтому в детекторе к линии предъявляются жёсткие требования — уровня настоящей трендовой, а не случайной прямой:

  • минимум 3 пивота на линии (включая якоря) с допуском — линия должна реально проходить через несколько подтверждённых экстремумов;
  • минимум 3 раздельных касания, причём соседние касания сливаются в одно (об этом ниже);
  • уровень недавно уважаем ценой — последнее касание не старше N баров, а не «археология» месячной давности;
  • ни одна свеча не закрывалась за линией дальше маленького допуска с момента первого якоря — иначе линия уже пробита;
  • линия не параболическая — слишком крутые уровни (|угол| больше порога) не берём.

Из всех кандидатов выбирается лучший по приоритету: больше пивотов → больше касаний → длиннее → свежее.

<code class="language-python">score = (pivot_touches, touches, i2 - i1, i2)
if best is None or score > best_score:
    best, best_score = tl, score
</code>

Дальше — как каждое из этих условий превращается в код.


Фрактальные пивоты — и почему без заглядывания в будущее

Всё строится на фрактальных пивотах: локальных минимумах (для поддержек) и максимумах (для сопротивлений). Пивот подтверждён, только когда справа от него закрылось right баров:

<code class="language-python">for i in range(left, n - right):
    window = values[i - left : i + right + 1]
    if kind == "low":
        m = window.min()
        if values[i] == m and int(np.sum(window == m)) == 1:
            out.append(i)
</code>

Здесь принципиальный момент, о котором постоянно забывают новички. Пивот нельзя считать подтверждённым в реальном времени — в момент формирования свечи вы ещё не знаете, что она окажется локальным минимумом. Требование «справа закрылось right баров» гарантирует, что мы работаем только с уже подтверждёнными экстремумами. Никакого заглядывания в будущее.

Более того, вся детекция линий идёт по данным без последних свечей-подтверждений пробоя. Линия строится на df[:-nconf], а пробой проверяется на полном df. Отсюда важное следствие: любой найденный уровень гарантированно не был пробит закрытием ранее — иначе он бы не прошёл валидацию. Причинность соблюдена по построению, а не «вроде бы учтена».


Нормированный угол: масштаб-инвариантность

Вот здесь — самая элегантная деталь.

Нам нужно отличать плоский уровень от наклонного. Наивно — взять наклон линии в цене за бар. Но сырой наклон бессмысленен: 0.5 доллара за бар — это круто для монеты по $3 и незаметно для монеты по $60000. Классификация «плоский/наклонный» поедет от одного лишь масштаба цены.

Решение — нормировать наклон на волатильность через ATR:

angle = \arctan\!\left(\dfrac{\text{slope}}{ATR}\right)

где slope — наклон в цене за бар, а ATR — средний истинный диапазон. Смысл простой и красивый: 45° соответствует движению линии ровно на 1 ATR за бар. Такое определение угла не зависит ни от цены актива, ни от масштаба графика, ни от единиц измерения.

<code class="language-python">angle = math.degrees(math.atan(slope / atr_now))
if abs(angle) > max_angle_deg:
    continue                      # параболу не торгуем
is_flat = abs(angle) <= flat_angle_max_deg
</code>

Плоский уровень (по умолчанию |угол| < 7.5°) и наклонный обрабатываются по-разному: у плоского тейк и стоп фиксированы от ATR(1h), у наклонного — стоп за структурой, тейк на якоре линии. Но сама граница между ними теперь объективна и одинакова для любого инструмента.

Тот же нормированный порог задаёт и допустимое направление наклона: восходящая поддержка (или плоская) — да, падающая круче плоского — нет, потому что её логика пробоя уже другая.


Кластеризация касаний: одно касание вместо десяти

Три касания — это требование. Но что считать касанием?

Наивный подсчёт «сколько баров подошло к линии» ломается моментально: если цена десять баров подряд ползёт вдоль линии, это одно касание, а не десять. Иначе любое залипание у линии накрутит счётчик и пропустит мусорный уровень.

Поэтому касания кластеризуются: серия соседних баров у линии — одно касание, а кластеры ближе min_gap баров сливаются в один:

<code class="language-python">def _count_touch_clusters(mask: np.ndarray, min_gap: int = 1) -> int:
    idx = np.flatnonzero(mask)
    if idx.size == 0:
        return 0
    gaps = np.diff(idx)
    return 1 + int(np.sum(gaps > max(1, min_gap)))
</code>

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

\text{касания} = 1 + \sum_i [\, gap_i > gap_{min}\,]

Просто, векторно и ровно то, что нужно: считаем раздельные подходы к уровню, а не отдельные бары.


Валидация линии: чистая геометрия, векторно

Теперь собираем всё вместе. Для каждой пары пивотов строим линию-кандидата и прогоняем через фильтры — весь горячий путь на numpy, без циклов по свечам.

<code class="language-python">idx = np.arange(i1, n)
line = p1 + slope * (idx - i1)

if kind == "support":
    if np.any(closes[i1:n] < line - violation_tol):
        continue                       # закрытие уже пробило линию — не уровень
    rel = lows[i1:n] - line            # <= tol -> касание/прокол тенью
    touch_mask = rel <= touch_tol
    dist_now = closes[-1] - line[-1]   # насколько цена сейчас выше линии
</code>

Логика по шагам:

  • валидностьnp.any(closes < line − tol) мгновенно отсекает линию, если хоть одно закрытие ушло за неё дальше допуска (проколы тенями при этом разрешены — это нормальные касания);
  • касания — маска low − line <= tol, дальше кластеризация из прошлого раздела;
  • свежесть — последнее касание не старше last_touch_max_age баров;
  • актуальность — линия дальше max_dist_atr · ATR от текущей цены отбрасывается;
  • пивоты на линии — считаем, сколько подтверждённых пивотов реально лежит на прямой (отклонение ≤ pivot_tol), и требуем минимум три:
<code class="language-python">in_span = piv_arr[(piv_arr >= i1)]
devs = np.abs(anchor_vals[in_span] - (p1 + slope * (in_span - i1)))
pivot_touches = int(np.sum(devs <= pivot_tol))
if pivot_touches < min_pivot_touches:
    continue
</code>

Именно проверка «через сколько пивотов проходит линия» и отделяет настоящую трендовую от прямой по двум случайным точкам. Это, по сути, сердце всей детекции.


Пробой = закреп телом, а не касание тенью

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

Правильный пробой — закреп телом: закрытие свечи за линией, а не касание. Причём подтверждающих свечей может быть несколько (по умолчанию две): обе должны закрыться за линией, вход на второй, возврат второй свечи отменяет сетап.

Но одного закрепа мало. К пробойной свече применяются фильтры качества — чистая математика от цены и ATR:

  • тело ≥ 0.35 ATR — импульсный пробой, а не вялое сползание сквозь линию;
  • глубина закрытия ≤ 1.2 ATR за линией — не сигналим по уже улетевшей цене (заодно это держит стоп адекватным);
  • закрытие в крайних 40% диапазона свечи (шорт — у low, лонг — у high) — сильное закрытие без длинного хвоста возврата.
<code class="language-python">body = abs(closes[i] - opens[i])
if min_body_atr > 0 and body < min_body_atr * atr_now:
    return None                        # сползание, а не импульс

depth = abs(closes[i] - line.value_at(i))
if max_depth_atr > 0 and depth > max_depth_atr * atr_now:
    return None                        # не догоняем улетевшую цену

rng = highs[i] - lows[i]
if close_pos_max < 1.0 and rng > 0:
    pos = ((closes[i] - lows[i]) / rng if direction == "short"
           else (highs[i] - closes[i]) / rng)
    if pos > close_pos_max:            # закрытие слабое, с хвостом возврата
        return None
</code>

Совокупно эти фильтры существенно режут число сигналов, оставляя только структурно качественные пробои. Пробой поддержки — шорт, пробой сопротивления — лонг.


Стоп, тейк и риск-ревард

Уровни сигнала считаются не «на глаз». Для наклонного пробоя стоп ставится за структурой — за последним подтверждённым свингом младшего таймфрейма (swing high для шорта, swing low для лонга) с небольшим запасом:

<code class="language-python">for i in reversed(find_pivots(highs, pivot_left, pivot_right, "high")):
    if highs[i] > entry_ref:
        return float(highs[i] + buffer_abs)   # ближайший свинг выше входа
</code>

Тейк — на цене первого пивота-якоря, от которого началась линия: логичная цель, к которой рынок уже ходил.

И финальный фильтр — риск-ревард:

RR = \dfrac{|TP - entry|}{|entry - SL|}

Если RR < 1 — сетап отбраковывается, сигнала нет. Если RR > 5 — стоп в сигнале отодвигается, пока RR не станет ровно 5 (структурный стоп при этом остаётся внутри нового). Так в сигнал не попадают ни сделки с отрицательным матожиданием, ни абсурдно широкие цели.

<code class="language-python">if min_rr > 0 and rr < min_rr:
    return None
if max_rr > 0 and rr > max_rr:
    new_risk = reward / max_rr
    sl = entry + new_risk if side == "short" else entry - new_risk
    rr = max_rr
</code>

Скрин сигнала: линия, которую видно

Текстовый сигнал по трендовой почти бесполезен — уровень надо видеть. Поэтому к каждому сигналу рисуется график в тёмном стиле TradingView: свечи рабочего ТФ, сама наклонная с якорями, свеча-закреп со стрелкой и пунктирные линии входа, стопа и тейка.

Рендерит matplotlib в файл (Agg, без дисплея), затем PNG уходит в Telegram через sendPhoto. Если matplotlib не установлен или рендер упал — сигнал уходит текстом, бот не останавливается.

<code class="language-python">ax.plot([x1, x2], [line.value_at(line.i1), line.value_at(i_last + 3)],
        color=LINE_C, linewidth=2.2,
        label=f"наклонная {line.angle_deg:+.1f}°, "
              f"пивотов {line.pivot_touches}, касаний {line.touches}")
</code>

Прямо в легенде — паспорт линии: нормированный угол, число пивотов и касаний. Видно не только «где», но и почему это уровень.
Трендовая линия как алгоритм: фрактальные пивоты, нормированный угол и пробой телом (Python, без индикаторов)


Архитектура сигнального бота

Вокруг детектора — минимальная, но живучая обвязка. Код разложен по ответственностям:

Модуль Ответственность
trendlines.py Детекция линий, пивотов, пробоев, структурных стопов, RR
chart.py Рендер скрина сигнала (matplotlib)
data_feed.py Публичные свечи BingX (без ключей API)
breakout_bot.py Цикл, отбор сигналов, отправка в Telegram, состояние
config.py Все параметры стратегии

Несколько инженерных решений, которые делают бота пригодным для «поставил и забыл»:

Только публичные данные. Свечи берутся с публичного эндпоинта BingX — ключи API не нужны вообще. Бот ничего не отправляет на биржу, он только читает рынок и присылает сигналы. Из данных сразу выбрасывается текущая (незакрытая) свеча — работаем строго по закрытым барам.

Никаких повторных сигналов. Отработанная линия помечается по её сигнатуре, и это состояние переживает рестарт (атомарная запись в JSON). После перезапуска бот не пришлёт сигнал по той же линии второй раз. Плюс кулдаун на актив после сигнала.

<code class="language-python">self.state.consumed_lines.add(line.signature())
self.state.cooldown_until_ms[symbol] = now_ms + cfg.SIGNAL_COOLDOWN_MIN * 60_000
</code>

Отказоустойчивость. Ошибка по одному активу логируется и уходит в Telegram, но не роняет цикл — остальные пары обрабатываются как ни в чём не бывало. Плюс защита от спама одинаковыми ошибками.

Интерактивность. Команда /menu в Telegram включает фильтр объёма и меняет его коэффициент кнопками — настройки тоже переживают рестарт. Условие фильтра: avg_vol(3 свечей) > avg_vol(40 свечей) × коэффициент.

Один цикл в логах выглядит так:

<code>2026-08-02 10:30:12 | INFO | SOL-USDT: пробой! наклонный уровень (поддержка), угол +12.0 град., пивотов на линии 3, касаний 4 -> SHORT
</code>

Что в итоге

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

  • линия как набор жёстких условий — ≥3 пивота на прямой, ≥3 раздельных касания, недавняя уважаемость, отсутствие закрытий за линией;
  • фрактальные пивоты с подтверждением и построение линий без свечей-пробоев — причинность по построению, без заглядывания в будущее;
  • нормированный через ATR угол — классификация наклона, не зависящая от масштаба цены и графика;
  • кластеризация касаний — считаем подходы к уровню, а не отдельные бары;
  • пробой телом с фильтрами качества — импульс вместо сползания, без догоняющих входов и хвостов возврата;
  • структурный стоп + лимиты RR и рендер скрина — сигнал, который видно и который отбракован по матожиданию.

Но честно про главное. Формализация не равна прибыли. Строгая, красиво посчитанная линия — это всё ещё просто линия, и пробои трендовых регулярно оказываются ложными. Алгоритм не предсказывает будущее — он лишь убирает из процесса субъективность и ручной труд «пересмотреть сотню графиков глазами», рисуя уровни объективно и одинаково. Что делать с сигналом — решаете вы, глядя на скрин и контекст.

Спойлер: грааля в трендовой линии нет — как и в любом другом одиночном инструменте. Но линия, построенная по чётким правилам, которые не подгоняются под уже открытую позицию и не подсматривают вперёд, — это честная отправная точка. А честность к себе в трейдинге стоит дороже, чем кажется.

 

Данная публикация является личным мнением автора. Мнение владельца сайта может не совпадать с мнением автора.
499 | ★1
4 комментария
Зачет! Стоит погружения.
avatar
ATR() на каком периоде считается?
Eugene Bright, 14 (ATR_PERIOD = 14), и считается он по Уайлдеру (RMA, то есть ewm(alpha=1/14, adjust=False) в atr_series)
Roman crypto_maniac, понял, спасибо.
Извечная проблема: что делать, если рынок «растянулся» и на 14 периодах не работает?


Читайте на SMART-LAB:
Фото
Финансовые результаты Аэрофлота по РСБУ за 6 месяцев 2026 года
Аэрофлот опубликовал финансовые результаты по РСБУ за 2 квартал и 6 месяцев 2026 года. ✈️ Выручка во 2 квартале увеличилась на 3,3% год к году и...
Фото
Эмитент допустил дефолт: что делать инвесторам в таком случае?
Дефолт по облигациям не всегда означает полную потерю вложений. Даже если эмитент перестал платить купоны или не погасил долг, инвестор...
Ключевые тезисы менеджмента Норникеля по итогам раскрытия финансовых результатов за 1П 2026 г.
Сегодня мы раскрыли финансовые результаты за 1П 2026 г. по МСФО. Все наши материалы доступны на сайте...
Фото
МД Медикал операционные результаты 2 кв. 2026 г. - темпы роста ускорились, но появился долг
Компания МД Медикал (Мать и дитя) опубликовала операционные результаты за 2 квартал 2026 г. Выручка за 2 квартал выросла на 25,9% до 13 млрд руб....

теги блога Roman crypto_maniac

....все тэги



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