Блог им. RomellaAkumov
Трендовая линия — самый субъективный инструмент технического анализа. Дайте одному графику десять трейдеров — получите десять разных линий. Кто-то ведёт по теням, кто-то по телам, кто-то «на глаз подгоняет» так, чтобы линия подтверждала уже открытую позицию. Это не анализ, это гадание с линейкой.
А раз линию рисует человек — её нельзя ни протестировать, ни автоматизировать. Каждый раз получается новая.
Интересная инженерная задача — формализовать «настоящую» трендовую линию в строгую геометрию: набор чётких математических условий, по которым машина строит линию объективно, одинаково и, главное, честно — не заглядывая в будущее. Никаких индикаторов, только цена, экстремумы и ATR.
В этой статье разберём детектор, который это делает. Он находит трендовые поддержки и сопротивления по фрактальным пивотам, отсеивает всё, что «уровнем» на самом деле не является, ловит пробой телом с фильтрами качества — и присылает сигнал в Telegram с нарисованным скрином: линия, якоря, свеча-пробой, уровни входа, стопа и тейка.
Упор в разборе — на технику: как повторяемо строить линии, как сделать классификацию наклона независимой от масштаба, как не считать одно и то же касание десять раз и как отличить импульсный пробой от вялого сползания сквозь линию. Ордера на бирже бот не выставляет — это чистый сигнальный инструмент, работающий на публичных данных без ключей API.
Весь код выложен на GitHub — советую читать статью параллельно с исходниками.
Большинство рисует трендовую по двум точкам: соединил два минимума — вот и линия. Но линия по двум случайным экстремумам не значит ничего — через любые две точки можно провести прямую.
Поэтому в детекторе к линии предъявляются жёсткие требования — уровня настоящей трендовой, а не случайной прямой:
Из всех кандидатов выбирается лучший по приоритету: больше пивотов → больше касаний → длиннее → свежее.
<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:
<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>Прямо в легенде — паспорт линии: нормированный угол, число пивотов и касаний. Видно не только «где», но и почему это уровень.

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