Трендовая линия — самый субъективный инструмент технического анализа. Дайте одному графику десять трейдеров — получите десять разных линий. Кто-то ведёт по теням, кто-то по телам, кто-то «на глаз подгоняет» так, чтобы линия подтверждала уже открытую позицию. Это не анализ, это гадание с линейкой.
Интересная инженерная задача — формализовать «настоящую» трендовую линию в строгую геометрию: набор чётких математических условий, по которым машина строит линию объективно, одинаково и, главное, честно — не заглядывая в будущее. Никаких индикаторов, только цена, экстремумы и 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 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
Скрин сигнала: линия, которую видно
Текстовый сигнал по трендовой почти бесполезен — уровень надо видеть. Поэтому к каждому сигналу рисуется график в тёмном стиле 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}")
Прямо в легенде — паспорт линии: нормированный угол, число пивотов и касаний. Видно не только «где», но и почему это уровень.

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