Архитектурная эволюция торговой системы: от монолита к модульному пайплайну
Как моя функция превратилась в Франкенштейна и что я с этим сделал
Жил-был у меня код. Простой такой, милый:
(defn generate-signal [candles price]
(let [levels (calculate-levels candles)
trend (detect-trend candles)]
(if (> price (:level levels))
:buy
:hold)))
Жил не тупил, сигналы генерировал. Но потом пошло-поехало…
Как я своего монстра создавал
Сначала добавил проверку пробития уровней:
;; Окей, добавим мониторинг пробитий...
(let [level-status (monitor-breakthroughs price levels)])
Потом понадобился анализ объема:
;; А Volume глянем, вдруг там дивергенция...
(let [volume-analysis (analyze-volume candles)])
Затем virtual levels подъехали:
;; Тут еще виртуальные уровни проверить надо...
(let [virtual-levels (validate-virtual-levels price levels)])
И понеслась… Функция раздулась до 50+ строк, вложенных условий, и я сам перестал понимать, что где происходит.
(defn generate-signal
[candle-history current-price]
(let [ordered-candles (if (> (:time (first candle-history))
(:time (last candle-history)))
(reverse candle-history)
candle-history)
price (:close (last current-price))
last-timestamp (:time (last candle-history))
candle-count (count candle-history)]
(log/info "Тайм Штамп свечи:" last-timestamp)
(when (should-recalculate-levels? ordered-candles)
(let [extremums (trend-detector/find-extremums ordered-candles)
levels (aggregate-candles ordered-candles price DEFAULT-CONFIG)
trend (trend-detector/determine-trend extremums price DEFAULT-CONFIG)]
;; 1. ОБНОВЛЯЕМ АТОМЫ с новыми уровнями (СНАЧАЛА!)
(swap! levels-state assoc :active-levels levels)
;; 2. BRAIN: мониторинг пробитий (ПОТОМ мониторим)
(let [level-status (smart/monitor-level-breakthrough price levels-state)
;; 3. ВАЛИДАЦИЯ ВИРТУАЛЬНЫХ УРОВНЕЙ (после мониторинга)
validation-result (smart/validate-virtual-levels price levels-state candle-history)]
(when (:virtual-validation? validation-result)
(log/info "✅ Виртуальный уровень подтвержден! Обновляем состояние..."))
;; 4. УМНЫЙ ПЕРЕСЧЕТ (если уровень пробит)
(when-let [recalc-type (smart/recalculation-needed? levels-state)]
(case recalc-type
:recalculate-support
(do
(log/info "🔄 Выполняем пересчет поддержки")
(let [updated-levels (smart/recalculate-support-level levels ordered-candles price)]
(swap! levels-state assoc :active-levels updated-levels)
(log/info "✅ Поддержка пересчитана")))
:recalculate-resistance
(do
(log/info "🔄 Выполняем пересчет сопротивления")
(let [updated-levels (smart/recalculate-resistance-level levels ordered-candles price)]
(swap! levels-state assoc :active-levels updated-levels)
(log/info "✅ Сопротивление пересчитана")))
nil))
;; 5. Если уровень пробит - НЕ генерируем сигналы
(if (:level-breached? level-status)
(do
(log/info "🔴 Уровень пробит, пропускаем генерацию сигналов")
(reset! levels-cache {:timestamp last-timestamp,
:candle-count candle-count,
:levels levels,
:extremums extremums})
nil)
;; 6. FAST КОНТУР: генерация сигналов
(let [current-levels (:active-levels @levels-state)
signal (signal-generator current-levels price trend extremums)]
(reset! levels-cache {:timestamp last-timestamp,
:candle-count candle-count,
:levels current-levels,
:extremums extremums})
(when signal
(let [signal-type (:type signal)]
(when (#{:buy :sell} signal-type)
(log/info "🎯 GENERATED TRADING SIGNAL: " signal-type
"в тренде:" (name trend))
(let [{:keys [support resistance]} (:levels signal)
params {:risk-reward-ratio 2.0,
:support-level (:level support),
:resistance-level (:level resistance),
:current-levels (:levels signal),
:trend trend,
:extremums extremums}]
(reset! last-cache {:timestamp last-timestamp,
:levels current-levels,
:signal (assoc signal :params params),
:trend trend})
(assoc signal :params params))))))))))))
Момент истины
Настал день, когда нужно было добавить анализ свечных паттернов. Я открыл код и понял — это очень сложно. Нужно прилагать усилия, чтобы вникнуть в обстановку и понять куда внедрить новое.
Вот что на самом деле происходило в коде (хоть это и было скрыто в хаосе):
[Уровни] → [Мониторинг] → [Валидация] → [Анализ] → [Сигналы] → [Кеширование]
И все в одной функции! Как в той шутке: “Я не программист, я сборщик кода методом копипасты”.
Что попробовал сделать
Попытка 1: Разделить по смыслу
;; Просто вынес куски в отдельные функции
(defn prepare-data [candles price] ...)
(defn calculate-levels [data] ...)
(defn monitor-breakthroughs [data] ...)
Стало чуть лучше, но все равно каша.
Попытка 2: Сделать как умные ребята советуют
;; Начитался статей, сделал "правильно"
(defmulti process-phase :phase)
(defmethod process-phase :calculation [state] ...)
Получилось красиво, но я сам через неделю не мог понять, как это работает. Выкинул.
Попытка 3: Как заядлый Линуксоид
;; Просто последовательность шагов
(defn process-tick [candles price]
(-> {:candles candles :price price}
prepare-data
find-levels
check-breakthroughs
analyze-patterns
generate-signal))
И вот это сработало! Каждый шаг делает одну понятную вещь, добавить новый функционал — просто вставить еще один шаг в цепочку.
Вместо того чтобы городить сложные архитектуры, просто сделал как в bash:
cat data | prepare | analyze | filter | generate_signal
Только на Clojure это выглядит еще элегантнее:
(defn process-tick [candles price]
(-> {:candles candles :price price} ;; <- как будто cat data
prepare-data ;; <- подготовка
find-levels ;; <- поиск уровней
check-breakthroughs ;; <- мониторинг пробитий
analyze-patterns ;; <- анализ паттернов
generate-signal)) ;; <- финальный результат
Почему пайплайн — это мне было известно:
- вместо сложных интерфейсов — каждый шаг принимает данные и возвращает данные, никаких скрытых состояний
- Можно легко дебажить — вставляешь
tap>
между шагами как будтоtee
в конвейере - Простота добавления нового функционала — хочешь добавить анализ объема? Просто вставляешь еще один шаг:
(-> data
prepare-data
find-levels
check-breakthroughs
analyze-volume ;; <- новый шаг, вставил за 2 секунды
analyze-patterns
generate-signal)
- Логично для торговли — ведь анализ рынка это и есть последовательность шагов: данные → уровни → пробития → сигнал
И главное — никакой магии. Открываешь код через месяц и сразу видишь всю цепочку преобразований. Не нужно вспоминать, какие там были состояния или фазы — просто читаешь сверху вниз, как книгу.
Финальный вариант:
;; ===== ОСНОВНОЙ PIPELINE =====
(defn generate-signal
[candle-history current-price]
(let [pipeline [prepare-market-data
calculate-levels-phase ;; 1. Расчет уровней
monitor-levels-phase ;; 2. Мониторинг пробитий
market-analysis-phase ;; 3. Анализ рынка (флэт/сжатие)
minute-volume-analysis-phase ;; ← НОВАЯ ФАЗА: минутный объем!
smart-recalculation-phase ;; 4. Пересчет
generate-signals-phase ;; 5. Генерация сигналов
update-cache-phase] ;; 6. Кеширование
initial-data {:candle-history candle-history :current-price current-price}]
(try
(let [result (reduce (fn [data phase-fn]
(if-let [result (phase-fn data)]
result
(reduced nil)))
initial-data
pipeline)]
(:signal result))
(catch Exception e
(log/error "Ошибка в торговом пайплайне:" e)
nil))))
Разбор пайплайна
1. pipeline
— это не массив переменных, а вектор функций
pipeline [prepare-market-data ;; <- это функция!
calculate-levels-phase ;; <- и это функция!
monitor-levels-phase] ;; <- и это тоже функция!
Каждая из этих функций определена выше, например:
(defn prepare-market-data [{:keys [candle-history current-price]}]
;; ... логика подготовки
{:candles ordered-candles :price price}) ;; возвращает данные для следующей фазы
(defn calculate-levels-phase [{:keys [candles price] :as data}]
;; ... расчёт уровней
(assoc data :levels calculated-levels)) ;; добавляет уровни к данным
2. reduce
— это конвейерная лента
Представь себе заводской конвейер:
(reduce (fn [data phase-fn] (phase-fn data)) ;; ← рабочий на конвейере
initial-data ;; ← сырьё в начале ленты
pipeline) ;; ← последовательность операций
Как это работает пошагово:
- Начало:
initial-data = {:candle-history [...], :current-price [...]}
- Шаг 1:
(prepare-market-data initial-data)
→ возвращает{:candles [...], :price 43.77, ...}
- Шаг 2:
(calculate-levels-phase результат-шага-1)
→ возвращает данные +:levels
- Шаг 3:
(monitor-levels-phase результат-шага-2)
→ возвращает данные +:level-status
- … и так далее по всему пайплайну
3. reduced
— аварийный останов
(fn [data phase-fn]
(if-let [result (phase-fn data)] ;; пробуем выполнить фазу
result ;; если получилось — идём дальше
(reduced nil))) ;; если фаза вернула nil — останавливаем конвейер
Это как аварийная кнопка на конвейере. Если какая-то фаза возвращает nil
(например, данные невалидные), пайплайн останавливается и сразу возвращает nil
.
4. Всё вместе — это преобразование данных
На входе: сырые свечи и цена
{:candle-history [...], :current-price [...]}
На выходе: готовый торговый сигнал (или nil
)
{:type :buy, :params {...}, :levels {...}}
Что происходит внутри:
Сырые данные
→ prepare-market-data (упорядочивание свечей)
→ calculate-levels-phase (расчёт уровней поддержки/сопротивления)
→ monitor-levels-phase (проверка пробитий уровней)
→ ... и т.д.
→ generate-signals-phase (генерация сигнала buy/sell)
Тоесть, результат работы предыдущей функции передается следующей.
Почему это круто
- Видна вся логика — просто читаешь вектор
pipeline
и понимаешь последовательность - Легко менять — хочешь добавить анализ объема? Просто вставляешь функцию в вектор
- Легко дебажить — можно пройти пайплайн пошагово
- Каждая функция простая — делает одну конкретную вещь
По сути, это функциональный аналог UNIX pipe: cat data | prepare | analyze | generate
, только типобезопасный и на Clojure.
Почему Clojure идеально подходит для пайплайнов
Самое красивое в этом всём — то, что Clojure буквально создан для таких пайплайнов. В отличие от императивных языков, где ты постоянно мучаешься с промежуточными переменными и побочными эффектами, в Clojure пайплайн выглядит как естественный поток данных.
Возьмём тот же пример на Python (условно):
def generate_signal(candle_history, current_price):
data = prepare_market_data(candle_history, current_price) # шаг 1
data = calculate_levels_phase(data) # шаг 2
data = monitor_levels_phase(data) # шаг 3
# ... и так 7 раз с повторяющимся присваиванием
return data['signal']
А в Clojure благодаря threading macro ->
это выглядит элегантно:
(-> {:candle-history candle-history :current-price current-price}
prepare-market-data
calculate-levels-phase
monitor-levels-phase
;; ... и ещё 4 фазы без лишнего шума
:signal)
Что даёт такой подход на практике:
- Неизменяемые данные — каждая фаза получает данные, возвращает новые данные, не ломая исходные. Можно спокойно дебажить каждый шаг.
- Композируемость — функции как кубики Лего. Хочешь добавить анализ VWAP? Просто вставляешь ещё один “кубик” в пайплайн.
- Прозрачность — видишь всю бизнес-логику как на ладони. Не нужно прыгать по классам и интерфейсам.
И самое главное — это идиоматично для Clojure. Threading macros (->
, ->>
) это не какая-то магия, а базовая конструкция языка. Когда пишешь на Clojure, ты просто думаешь в терминах “данные проходят через серию преобразований” — и код естественным образом получается в виде пайплайна.
По сути, я не придумал ничего нового — просто использовал язык так, как он и задумывался. И это оказалось самым эффективным решением.