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

от автора

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

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

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

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

пример трендовой линии

пример трендовой линии

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

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

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

  • минимум 3 пивота на линии (включая якоря) с допуском — линия должна реально проходить через несколько подтверждённых экстремумов;

  • минимум 3 раздельных касания, причём соседние касания сливаются в одно (об этом ниже);

  • уровень недавно уважаем ценой — последнее касание не старше N баров, а не «археология» месячной давности;

  • ни одна свеча не закрывалась за линией дальше маленького допуска с момента первого якоря — иначе линия уже пробита;

  • линия не параболическая — слишком крутые уровни (|угол| больше порога) не берём.

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

score = (pivot_touches, touches, i2 - i1, i2)if best is None or score > best_score:    best, best_score = tl, score

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

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

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

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)

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

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


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

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

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

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

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

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

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

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

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


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

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

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

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

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)))

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

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

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


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

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

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]   # насколько цена сейчас выше линии

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

  • валидностьnp.any(closes < line − tol) мгновенно отсекает линию, если хоть одно закрытие ушло за неё дальше допуска (проколы тенями при этом разрешены — это нормальные касания);

  • касания — маска low − line <= tol, дальше кластеризация из прошлого раздела;

  • свежесть — последнее касание не старше last_touch_max_age баров;

  • актуальность — линия дальше max_dist_atr · ATR от текущей цены отбрасывается;

  • пивоты на линии — считаем, сколько подтверждённых пивотов реально лежит на прямой (отклонение ≤ pivot_tol), и требуем минимум три:

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

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


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

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

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

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

  • тело ≥ 0.35 ATR — импульсный пробой, а не вялое сползание сквозь линию;

  • глубина закрытия ≤ 1.2 ATR за линией — не сигналим по уже улетевшей цене (заодно это держит стоп адекватным);

  • закрытие в крайних 40% диапазона свечи (шорт — у low, лонг — у high) — сильное закрытие без длинного хвоста возврата.

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

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


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

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

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

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

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

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

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

if min_rr > 0 and rr < min_rr:    return Noneif 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

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

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

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

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}")

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

📌 Сюда — пример скрина сигнала (нарисованная линия + вход/стоп/тейк).


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

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

Модуль

Ответственность

trendlines.py

Детекция линий, пивотов, пробоев, структурных стопов, RR

chart.py

Рендер скрина сигнала (matplotlib)

data_feed.py

Публичные свечи BingX (без ключей API)

breakout_bot.py

Цикл, отбор сигналов, отправка в Telegram, состояние

config.py

Все параметры стратегии

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

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

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

self.state.consumed_lines.add(line.signature())self.state.cooldown_until_ms[symbol] = now_ms + cfg.SIGNAL_COOLDOWN_MIN * 60_000

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

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

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

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

Что в итоге

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

  • линия как набор жёстких условий — ≥3 пивота на прямой, ≥3 раздельных касания, недавняя уважаемость, отсутствие закрытий за линией;

  • фрактальные пивоты с подтверждением и построение линий без свечей-пробоев — причинность по построению, без заглядывания в будущее;

  • нормированный через ATR угол — классификация наклона, не зависящая от масштаба цены и графика;

  • кластеризация касаний — считаем подходы к уровню, а не отдельные бары;

  • пробой телом с фильтрами качества — импульс вместо сползания, без догоняющих входов и хвостов возврата;

  • структурный стоп + лимиты RR и рендер скрина — сигнал, который видно и который отбракован по матожиданию.

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

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

ссылка на оригинал статьи https://habr.com/ru/articles/1065724/