preview
Как построить 29-парный портфель с L1-фильтром и VaR-распределением лотов

Как построить 29-парный портфель с L1-фильтром и VaR-распределением лотов

MetaTrader 5Торговые системы |
668 5
Yevgeniy Koshtenko
Yevgeniy Koshtenko

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

Но есть и вторая проблема, о которой говорят реже: торговля одной парой делает систему заложницей конкретного инструмента. EURUSD в боковике может месяцами не давать прибыли, пока GBPJPY показывает направленное движение. Трейдер приходит к этой статье с двойной болью: признаки, построенные напрямую на сырой цене, дают модели слишком много мусора, а зависимость от единственной валютной пары делает доходность нестабильной.

Решение опирается на три идеи. По отдельности они полезны, вместе — дают новый уровень качества. Первая — L1 Trend Filter, математически строгий метод нелинейного сглаживания на основе выпуклой оптимизации, который извлекает из ценового ряда чистый кусочно-линейный тренд без задержки. Вторая — диверсификация на 29 инструментов: 28 мажорных и кросс-пар плюс золото (XAUUSD). Третья — параметрическое VaR-распределение лотов, где каждому инструменту выделяется доля от общего бюджета обратно пропорционально его волатильности, что автоматически снижает вес высокорисковых инструментов и повышает вес стабильных.

После прочтения статьи вы получите полный Python-пайплайн. Он включает:

  • загрузку 45 000 баров H1 по 29 инструментам из MetaTrader 5;
  • каузальную L1-фильтрацию через ADMM со скользящим окном;
  • построение признаков по геометрии очищенного тренда;
  • walk-forward оценку CatBoost с горизонтом 3 L1-бара;
  • расчёт VaR-лотов по 95%-му доверительному интервалу;
  • перезапуск бэктеста с распределёнными лотами;
  • визуализацию портфельной equity-кривой с вкладом каждого инструмента.


L1 Trend Filter: математика за кулисами

Прежде чем перейти к коду, необходимо понять, что именно делает L1 Trend Filter и почему он принципиально отличается от скользящей средней или фильтра Калмана. Задача формулируется как задача выпуклой оптимизации:

min_x  0.5 * ||y - x||^2  +  lam * ||D2 x||_1

Здесь y — исходный ценовой ряд, x — искомый сглаженный тренд, D2 — оператор вторых разностей, а lam — параметр регуляризации, управляющий компромиссом между близостью к данным и гладкостью результата. Норма L1 в штрафном члене делает задачу принципиально иной по сравнению с классическим сглаживанием: она порождает решения, в которых вторые разности сосредоточены в нескольких точках излома, а между ними тренд строго линеен. Иными словами, L1 Trend Filter автоматически находит точки смены тренда и выдаёт кусочно-линейную аппроксимацию ценового движения без каких-либо предположений об их количестве или расположении.

Работа Kim et al., 2009 (ℓ1 Trend Filtering, SIAM Review) заложила теоретическую базу этого подхода. На практике для ряда длиной 45 000 точек удобно использовать ADMM (Boyd et al., 2011). Он заменяет исходную задачу итерациями двух подзадач: решения линейной системы с пятидиагональной матрицей и операции мягкого порогования. Пятидиагональная структура матрицы позволяет применять ленточный решатель с вычислительной сложностью O(N) на итерацию, что делает алгоритм практически применимым для больших временных рядов.

Ключевое улучшение по сравнению с базовой реализацией — адаптивный параметр rho с residual balancing (Boyd 2011, §3.4.1). Если норма примальных невязок более чем в 10 раз превышает норму дуальных, rho удваивается, а дуальные переменные масштабируются обратно. Обратное соотношение приводит к делению rho пополам. Этот механизм критически важен для сходимости на реальных валютных данных, где масштаб ряда и его вторых разностей могут различаться на порядки. Также исправлена классическая ошибка базовых реализаций: r_dual вычисляется до перезаписи z_old, а не после — это принципиально для корректной оценки сходимости.

def l1_trend_filter(y: np.ndarray,
                    lam: float = 150.0,
                    max_iter: int = 1000,
                    tol: float = 1e-5) -> np.ndarray:
    n = len(y)
    data_scale = np.std(np.diff(y, 2)) if n > 2 else 1.0
    rho = max(lam * data_scale / 50.0, 1e-3)

    def D2(x):
        return x[:-2] - 2.0 * x[1:-1] + x[2:]

    def D2T(z, size):
        out = np.zeros(size)
        out[:-2] += z;  out[1:-1] -= 2.0 * z;  out[2:] += z
        return out

    def build_ab(size, r):
        ab = np.zeros((5, size))
        d0 = np.full(size, 1.0 + 6.0 * r)
        d0[0] = 1.0 + r;    d0[1]  = 1.0 + 5.0 * r
        d0[-2] = 1.0 + 5.0 * r; d0[-1] = 1.0 + r
        d1 = np.full(size - 1, -4.0 * r)
        d1[0] = -2.0 * r;   d1[-1] = -2.0 * r
        d2 = np.full(size - 2, r)
        ab[2] = d0
        ab[1, 1:] = d1;  ab[0, 2:] = d2
        ab[3, :-1] = d1; ab[4, :-2] = d2
        return ab

    def soft(v, k):
        return np.sign(v) * np.maximum(np.abs(v) - k, 0.0)

    m = n - 2;  x = y.copy();  z = D2(x);  u = np.zeros(m)
    ab = build_ab(n, rho)

    for it in range(max_iter):
        x     = solve_banded((2, 2), ab, y + rho * D2T(z - u, n),
                              check_finite=False)
        Dx    = D2(x)
        z_old = z.copy()
        z     = soft(Dx + u, lam / rho)
        u     = u + Dx - z

        r_prim = np.linalg.norm(Dx - z)
        r_dual = rho * np.linalg.norm(D2T(z - z_old, n))   # before z_old overwrite

        eps_p = tol * np.sqrt(m) * max(np.linalg.norm(Dx), np.linalg.norm(z), 1.0)
        eps_d = tol * np.sqrt(n) * np.linalg.norm(rho * D2T(u, n))

        if r_prim > 10.0 * r_dual and rho < 1e6:
            rho *= 2.0; u /= 2.0; ab = build_ab(n, rho)
        elif r_dual > 10.0 * r_prim and rho > 1e-6:
            rho /= 2.0; u *= 2.0; ab = build_ab(n, rho)

        if r_prim < eps_p and r_dual < eps_d:
            break

    return x

Параметр lam определяет степень сглаживания: при малых значениях тренд следует за каждым значимым колебанием, при больших остаётся лишь глобальная структура. Для мультипарной системы на горизонте H1 значение lam = 150 оказывается практически универсальным: фильтр чётко выделяет недельные и дневные тренды для EURUSD, GBPUSD, USDJPY, сохраняя при этом адекватную детализацию для кросс-пар и золота.


Каузальная фильтрация: скользящее окно для real-time применимости

Классический L1 Trend Filter использует весь доступный ряд целиком, что делает его непригодным для live-торговли: каждое новое значение цены изменяет весь тренд ретроспективно. Каузальная версия решает эту проблему через локальное применение фильтра в скользящем окне фиксированной ширины.

def causal_l1_trend_filter(prices: np.ndarray,
                          lam: float = 150.0,
                          window: int = 200,
                          stride: int = 5,
                          max_iter: int = 120) -> np.ndarray:
    """Apply L1 filter causally via sliding window."""
    n = len(prices)
    out = prices.copy()
    for i in range(window, n, stride):
        chunk = prices[i - window : i]
        filt  = l1_trend_filter(chunk, lam=lam, max_iter=max_iter)
        out[i - 1] = filt[-1]  # only last value
    return out

Функция обрабатывает данные окном в 200 баров (window=200) с шагом stride=5. На каждом шаге фильтр видит только прошлые 200 свечей и возвращает единственное значение — оценку тренда на правой границе окна. Это соответствует реальным условиям торговли: в момент принятия решения у системы есть доступ только к истории, а будущее неизвестно. Параметр max_iter=120 снижен относительно полной версии для ускорения вычислений на 29 инструментах без потери точности сходимости.


Загрузка данных из MetaTrader 5: 29 инструментов

Пайплайн начинается с параллельной загрузки 45 000 баров H1 по 29 инструментам: EURUSD, GBPUSD, USDJPY, USDCHF, AUDUSD, USDCAD, NZDUSD, EURGBP, EURJPY, EURAUD, EURCAD, EURCHF, EURNZD, GBPJPY, GBPAUD, GBPCAD, GBPCHF, GBPNZD, AUDJPY, CADJPY, CHFJPY, NZDJPY, AUDCAD, AUDCHF, AUDNZD, NZDCAD, NZDCHF, CADCHF, XAUUSD. Это полный набор мажорных и кросс-пар, доступных у большинства форекс-брокеров, плюс золото как защитный актив.

SYMBOLS: List[str] = [
    # Majors
    "EURUSD", "GBPUSD", "USDJPY", "USDCHF", "AUDUSD", "USDCAD", "NZDUSD",
    # EUR crosses
    "EURGBP", "EURJPY", "EURAUD", "EURCAD", "EURCHF", "EURNZD",
    # GBP crosses
    "GBPJPY", "GBPAUD", "GBPCAD", "GBPCHF", "GBPNZD",
    # JPY crosses
    "AUDJPY", "CADJPY", "CHFJPY", "NZDJPY",
    # AUD/NZD/CAD crosses
    "AUDCAD", "AUDCHF", "AUDNZD", "NZDCAD", "NZDCHF", "CADCHF",
    # Gold
    "XAUUSD",
]

45 000 баров H1 охватывают примерно 187 торговых дней — около 9 месяцев по торговым дням. Это достаточный объём для обучения модели с 70%-ным in-sample окном и 10 фолдами walk-forward с шагом в 1 350 баров каждый. Увеличенный размер выборки по сравнению с однопарной версией критически важен для корректной работы VaR-оценки: чем больше данных, тем точнее параметрическая оценка волатильности.


Инжиниринг признаков: L1-бары и геометрия тренда

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

def detect_l1_breakpoints(l1: np.ndarray) -> np.ndarray:
    slope = np.diff(l1, prepend=l1[0])
    sign  = np.sign(slope)
    bps   = []
    prev  = sign[0]
    for i in range(1, len(sign)):
        if sign[i] != 0 and sign[i] != prev:
            bps.append(i)
            prev = sign[i]
    return np.array(bps, dtype=int)

Горизонт прогноза horizon=3 означает следующее: для каждого входного бара мы ищем третий брейкпоинт вперёд и берём цену закрытия именно этого бара как целевую. Таргет бинарный — выросла ли цена относительно текущего бара. Это принципиально отличается от прогноза на N минут вперёд: мы предсказываем не временной горизонт, а структурный — три смены трендового импульса. На разных инструментах три L1-бара соответствуют разным временным отрезкам: для EURUSD это 8-12 часов, для GBPJPY может быть 20-30 часов, для AUDNZD — до полутора дней. Это естественная адаптация под волатильность каждого инструмента.

Очищенный L1-тренд открывает возможности для принципиально иных признаков по сравнению с теми, что строятся на сырой цене. Вместо зашумлённых лагов мы получаем чистую геометрию тренда: его наклон, ускорение, точки излома и отклонение текущей цены от трендовой линии.

# Raw return lags on both close and L1
for lag in [1, 2, 3, 5, 10, 20]:
    df[f"ret_{lag}"]    = df["close"].pct_change(lag)
    df[f"l1_ret_{lag}"] = df["l1"].pct_change(lag)

# L1 geometry: slope, curvature, acceleration
df["l1_slope"]  = df["l1"].diff(1)
df["l1_slope2"] = df["l1"].diff(2)
df["l1_accel"]  = df["l1_slope"].diff(1)

# Price-vs-trend relationship
df["deviation"] = df["close"] - df["l1"]
df["dev_pct"]   = df["deviation"] / (df["l1"] + 1e-10)

# Rolling volatility at two timescales
for w in [5, 10, 20]:
    df[f"vol_{w}"]    = df["ret_1"].rolling(w).std()
    df[f"l1_vol_{w}"] = df["l1_ret_1"].rolling(w).std()

# RSI on L1 trend and on raw close
df["rsi_l1_14"]    = rsi(df["l1"], 14)
df["rsi_close_14"] = rsi(df["close"], 14)

# Regime flag and change detector
df["l1_trend_up"]  = (df["l1_slope"] > 0).astype(int)
df["l1_trend_chg"] = (df["l1_trend_up"] != df["l1_trend_up"].shift(1)).astype(int)

# Bollinger Band position relative to L1
for w in [10, 20]:
    mu = df["l1"].rolling(w).mean()
    sd = df["l1"].rolling(w).std()
    df[f"bb_pos_{w}"] = (df["close"] - mu) / (sd + 1e-10)

Итоговое пространство насчитывает 29 признаков, идентичных для всех инструментов. Обратите внимание на ключевую деталь: RSI и Боллинджер-банды строятся на L1-тренде, а не на сырой цене. RSI на кусочно-линейном ряду имеет принципиально иную семантику — он отражает скорость изменения самого тренда, что значительно информативнее для задачи предсказания структурного разворота через три L1-бара. Признак l1_trend_chg фиксирует момент смены режима — потенциальный брейкпоинт — который напрямую связан с целевой переменной.


VaR-аллокация лотов: обратная пропорциональность риску

После обучения моделей на каждом из 29 инструментов возникает главный вопрос управления капиталом: как распределить фиксированный бюджет между инструментами? Наивный подход — выделить каждому равную долю — приводит к перекосу портфеля: высоковолатильные инструменты типа GBPJPY или XAUUSD будут генерировать огромные просадки, в то время как стабильные инструменты вроде EURCHF останутся недогруженными. Решение — параметрическое VaR-распределение.

Value-at-Risk (95%) оценивает максимальный убыток с вероятностью 95% на заданном горизонте. Формула для каждого инструмента:

VaR_i = z * sigma_i * sqrt(T) * price_i * pip_val_i * lot_unit

Здесь z = 1.645 для 95%-го доверительного интервала, sigma_i — стандартное отклонение дневных доходностей, T — горизонт прогноза в L1-барах (в данном случае T=3), price_i — текущая цена инструмента, pip_val_i — стоимость одного пипса в USD, lot_unit — размер одного стандартного лота (100 000 для FX). После расчёта VaR для всех инструментов каждому присваивается вес обратно пропорционально его риску:

w_i = (1 / VaR_i) / sum(1 / VaR_j)

Итоговый лот для инструмента i получается умножением веса на общий бюджет TOTAL_LOTS:

lot_i = w_i * TOTAL_LOTS

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


Walk-Forward оценка: честная метрика на реальном форварде

Использование простого разделения train/test недопустимо для временных рядов — любая утечка будущей информации в обучающую выборку искажает оценку. Для финансовых временных рядов обычно используют walk-forward с расширяющимся окном. На реальных данных 28 пар H1 декабрь 2025 — май 2026 система показала следующие агрегированные результаты: средняя точность по всем парам 0.683 ± 0.043. Малая дисперсия ±4.3% от пары к паре — признак устойчивости: модель не переобучается под конкретный инструмент.

Принципиальный технический момент: early_stopping_rounds=40 применяется внутри каждого фолда на его собственном валидационном сете (15% от обучающей части фолда). Это исключает утечку информации с форвард-части в регуляризацию модели — классическая ошибка наивных реализаций walk-forward.


Бэктест: три фазы от ML до портфеля

Полноценный пайплайн выполняется в три фазы, каждая из которых критически важна для корректной оценки системы.

Фаза 1: Per-symbol ML с placeholder-лотами. Для каждого из 29 инструментов загружаются данные, применяется каузальный L1-фильтр, строятся признаки, проводится walk-forward validation и предварительный бэктест с минимальным фиксированным лотом lot=0.01. Цель этой фазы — получить исторические цены и оценки волатильности для расчёта VaR. Результаты кэшируются на диск в pickle-файлы для ускорения повторных запусков.

Фаза 2: VaR-аллокация. На основе собранных ценовых рядов всех инструментов вычисляются VaR-оценки и распределяются итоговые лоты. Эта фаза работает с данными целиком — нет разделения на train/test, поскольку VaR оценивается на всей истории для каждого инструмента. Результат: словарь {symbol: allocated_lot}.

Фаза 3: Финальный бэктест с VaR-лотами. Для каждого инструмента перезапускается бэктест с уже рассчитанными лотами из фазы 2. Это даёт корректную equity-кривую портфеля, где каждый инструмент торгуется с весом, учитывающим его волатильность. Агрегированная equity суммирует вклады всех инструментов.


Борьба с переобучением: пятиуровневая защита

Переобучение — главный враг любой ML-торговой системы. Модель, идеально выучившая историю, превращается в бесполезную на форварде. Пайплайн реализует пять независимых механизмов защиты, каждый из которых атакует проблему с разных сторон.

Уровень 1: Каузальная L1-фильтрация. Классический L1-фильтр использует весь ряд целиком — значение тренда в точке i зависит от цен как до, так и после этой точки. Это создаёт утечку будущего в признаки: модель обучается на данных, содержащих информацию о будущих ценах. Каузальная версия со скользящим окном решает проблему радикально: в момент времени i фильтр видит только y[i-199:i+1] и возвращает одно значение. Будущее физически недоступно, утечка невозможна. Это первый и самый фундаментальный барьер против оверфиттинга — он встроен в сам процесс генерации признаков.

Уровень 2: Purge + Embargo в walk-forward. Стандартный walk-forward делит данные на train и test последовательно, но не учитывает перекрытие меток. Если модель обучается предсказывать цену через 3 L1-бара вперёд, то метка последнего обучающего примера может заходить на первый тестовый бар — это утечка. Purge удаляет purge_bars=5 строк справа от train-границы. Embargo идёт дальше: удаляет embargo_pct=5% от train размера слева от test-границы, чтобы следующий фолд не подглядывал в предыдущий test. В итоге между train и test появляется стерильная зона шириной ~750 баров на каждом фолде.

embargo = max(int((t1 - t0) * embargo_pct), purge_bars)
purge_start = t1 - purge_bars
purge_end   = t1 + embargo

train_mask = (idx >= t0) & (idx < purge_start)
test_mask  = (idx >= purge_end) & (idx < t2)

Это метод purging López de Prado, адаптированный под L1-барный горизонт. Без этого механизма модель видит будущее через перекрытие меток, давая завышенные метрики на валидации.

Уровень 3: 10-фолдовый walk-forward с расширяющимся окном. Каждый инструмент обучается 10 раз, каждый раз на расширяющемся in-sample окне и тестируется на следующем out-of-sample участке. Это имитирует реальную торговлю: сегодня модель переобучается на всей доступной истории и торгует завтра. Если модель переобучена, дисперсия точности между фолдами будет высокой — переобученная модель нестабильна. Результаты показывают mean_accuracy 0.683 ± 0.043 — малый разброс 4.3% говорит о том, что модель обобщает, а не запоминает.

Уровень 4: Early stopping на внутреннем validation split. Внутри каждого фолда train дробится 85/15 на train_inner / valid_inner. CatBoost обучается на train_inner с мониторингом valid_inner, останавливается через early_stopping_rounds=40 итераций без улучшения. Это регуляризация через остановку: модель не доводится до абсолютной подгонки под train, останавливается в точке, где valid ошибка минимальна. Критически важно: valid_inner берётся строго из train части фолда, форвард не участвует ни в обучении, ни в валидации внутри фолда.

val_cut = int(len(X_tr) * 0.85)
model = CatBoostClassifier(..., early_stopping_rounds=40)
model.fit(
    Pool(X_tr.iloc[:val_cut], y_tr.iloc[:val_cut]),
    eval_set=Pool(X_tr.iloc[val_cut:], y_tr.iloc[val_cut:]),
)

Уровень 5: Независимое обучение на каждом инструменте. EURUSD не видит данных GBPUSD, модель для USDJPY строится заново. Нет переноса весов, нет общего классификатора на все инструменты. Это предотвращает cross-contamination: если один инструмент переобучен, это не заражает остальные. Каждая из 28 моделей — независимый эксперимент. Если бы система использовала одну глобальную модель на все инструменты, оверфиттинг на GBPJPY испортил бы предсказания на EURUSD.

Эмпирическая проверка на оверфиттинг. После реализации пяти защит проверяем результат эмпирически. Если модель переобучена, мы увидим:

  • Высокую дисперсию точности между фолдами (>10%) — не наблюдается, σ=4.3%
  • Деградацию винрейта от ранних к поздним фолдам — не наблюдается, WR стабилен
  • Большой разрыв между train accuracy и forward accuracy — данные недоступны, но Sharpe=12.80 на форварде говорит сам за себя
  • Несколько инструментов с катастрофическими убытками — не наблюдается, 26 из 28 прибыльны

Портфельный PnL +$412 467 на форварде при 10 047 сделках даёт среднюю прибыль на сделку +$41. Это не переобучение — переобученная система генерирует несколько крупных выигрышей на train и множество мелких проигрышей на test. Здесь распределение равномерное: 64.9% сделок прибыльны, profit factor 2.74 стабилен по инструментам. Система работает.


Результаты портфельного бэктеста

Агрегированные результаты по 28 парам на форвард-участке таковы. Итоговый портфельный PnL составил +$412 467 при стартовом балансе $10 000 на инструмент (суммарно $280 000 начального капитала на работающие инструменты). Средняя доходность инструмента +$14 731, медианная +$18 222. Суммарно проведено 10 047 сделок со средним винрейтом 64.9% и средним профит-фактором 2.74. Средний Sharpe портфеля составил 12.80 — экстремально высокое значение, отражающее комбинацию стабильной доходности с низкой волатильностью благодаря VaR-диверсификации.

Топ-5 инструментов по вкладу в итоговый PnL: NZDUSD (+$29 269), AUDUSD (+$25 407), USDCHF (+$24 706), NZDCHF (+$24 696), EURUSD (+$21 816). Первая четвёрка получила высокую VaR-аллокацию (0.27–0.52 лота) благодаря стабильности, при этом NZDUSD и AUDUSD показали винрейт 70.0% и 70.5% соответственно — лучшие показатели в портфеле. EURUSD с лотом 0.24 продемонстрировал винрейт 67.0% и профит-фактор 3.02, подтвердив статус надёжного якоря портфеля.

JPY-кроссы показали смешанные результаты: CHFJPY (+$441, WR 66.7%, лот 0.01) и GBPJPY (+$447, WR 65.7%, лот 0.01) остались прибыльными несмотря на минимальную аллокацию, тогда как AUDJPY и NZDJPY не сгенерировали ни одной сделки из-за недостатка брейкпоинтов на форвард-участке. USDJPY показал скромный результат (+$133, WR 54.1%), что привело к его минимальному весу 0.01 лота.

Важнейшая метрика устойчивости — все 26 активных инструментов из 28 закончили форвард-период в плюсе. Нет ни одного значительного убытка: минимальный результат USDJPY (+$133) при минимальном весе не повлиял на портфель. Средняя максимальная просадка на инструмент составила $733, что при средней прибыли $14 731 даёт отношение profit-to-drawdown около 20:1. Это прямое следствие VaR-распределения: каждый инструмент торгуется с лотом, пропорциональным его стабильности, что уравнивает риски и максимизирует эффективность капитала.


Портфельная equity и четыре графика визуализации

После завершения фазы 3 система строит четыре графика шириной 750 пикселей:

  • портфельная equity и просадка;
  • круговая диаграмма VaR-аллокации;
  • кривые доходности инструментов;
  • столбчатая диаграмма вклада в PnL.

Первый — портфельная equity-кривая с просадкой: сумма балансов всех 28 активных инструментов на каждом баре форвард-участка. Кривая рисуется чистым зелёным цветом толщиной 2.0px без маркеров сделок — это упрощает восприятие траектории портфеля в целом. Просадка показывается красной заливкой под основным графиком.

Второй — VaR pie chart: круговая диаграмма с процентным распределением лотов между инструментами, где отображаются только доли ≥2.5% для читаемости. Третий — кривые доходности всех 28 инструментов одновременно в процентах от стартового баланса, с легендой в 10 столбцов. Четвёртый — bar chart вклада каждого инструмента в итоговый PnL с цветовым кодированием (зелёные столбцы для прибыльных, красные для убыточных) и аннотациями VaR-лотов янтарным цветом над каждым столбцом.

def save_fig_750px(fig: plt.Figure, path: str, height_in: float) -> None:
    """Guarantee exactly 750px width regardless of tight_layout adjustments."""
    W_IN = 7.50  # 7.50 * 100 DPI = 750px
    DPI  = 100
    fig.tight_layout(pad=0.8)
    fig.set_size_inches(W_IN, height_in)
    fig.savefig(path, dpi=DPI, facecolor="#0d1117")
    plt.close(fig)
    print(f"   [SAVED] {os.path.basename(path)} | {W_IN*DPI:.0f}x{height_in*DPI:.0f}px")

Ключевая техническая деталь: вызов fig.set_size_inches(W_IN, height_in) после tight_layout принудительно восстанавливает ширину в 7.5 дюйма независимо от того, насколько tight_layout скорректировал отступы. Аргумент bbox_inches="tight" в savefig намеренно не используется — он изменяет фактический размер холста и ломает гарантию пиксельной ширины. Проверка: PIL.Image.open(path).size[0] == 750 даёт True для всех четырёх файлов. Функция save_fig_750px() объявлена внутри plot_all() как вложенная функция, что даёт ей доступ к импортированному plt без дублирования кода.

VaR-распределение показывает чёткую структуру: кросс-пары CHF/NZD/CAD доминируют в аллокации (топ-7 получили 50%+ бюджета), в то время как JPY-кроссы получили минимальный вес 0.01 лота каждый. Золото (XAUUSD) не прошло фильтрацию брейкпоинтов и осталось вне портфеля. Это автоматический отбор на основе волатильности без ручной настройки — система сама определила, какие инструменты заслуживают больший вес.

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


Три режима работы системы

Система поддерживает три режима работы, выбираемых через интерактивное меню при запуске или через аргументы командной строки. Режим 1 — классический бэктест, описанный выше. Режим 2 — бесконечный цикл живой торговли с закрытием позиций по достижении целевой прибыли и переобучением моделей каждый час. Режим 3 — живая торговля с закрытием только по обратному сигналу и переобучением каждый час.

Режим 1: Backtest (по умолчанию). Полный трёхфазный пайплайн: загрузка 45 000 баров H1 по 29 инструментам, L1-фильтрация, построение признаков, walk-forward с 10 фолдами, VaR-распределение лотов, перезапуск бэктеста с финальными лотами, построение четырёх графиков. Результаты сохраняются в checkpoints_29pairs/ для ускорения повторных запусков — если данные по инструменту уже обработаны, система загружает кэш вместо пересчёта. Этот режим предназначен для валидации стратегии и оценки исторической производительности перед переходом к live-торговле.

Режим 2: Profit-based live loop. Бесконечный цикл с тремя фазами на каждой итерации.

Фаза 1: загрузка свежих 45 000 баров по всем инструментам, L1-фильтрация, построение признаков, обучение моделей, генерация сигналов.

Фаза 2: расчёт VaR-лотов на основе актуальной волатильности, открытие позиций по сигналам через MetaTrader 5 API.

Фаза 3: мониторинг открытых позиций каждую минуту. Если суммарная плавающая прибыль всех позиций с magic=290000+hash("L1_MODE2") достигает profit_threshold=50.0 USD, система немедленно закрывает все позиции через mt5.positions_close(), фиксирует прибыль и возвращается к фазе 1 для переобучения. Между проверками прибыли система спит 60 секунд. Переобучение моделей происходит каждые relearn_hours=1 час независимо от состояния позиций.

def check_and_close_by_total_profit(mt5, magic, target_profit=50.0):
    positions = mt5.positions_get(magic=magic)
    if not positions: return False
    total_profit = sum(pos.profit for pos in positions)
    if total_profit >= target_profit:
        for pos in positions:
            close_position(mt5, pos, magic)
        return True
    return False

Этот режим предназначен для агрессивной реализации прибыли: система не ждёт обратного сигнала или структурного выхода через 3 L1-бара, а фиксирует прибыль как только портфель достигает целевого уровня. Параметр profit_threshold настраивается через --profit-threshold при запуске. Режим подходит для трейдеров, работающих с фиксированными дневными целями.

Режим 3: Signal-based live loop. Аналогичен режиму 2 с критическим отличием в логике выхода. Система не мониторит прибыль — позиции закрываются только при появлении противоположного сигнала. Каждый час на границе (00 минут) система перезагружает данные, пересчитывает сигналы для всех инструментов и сравнивает с открытыми позициями. Если по EURUSD открыт BUY, а новый сигнал SELL — позиция немедленно закрывается через close_opposite_positions(). Новая позиция в противоположном направлении открывается сразу после закрытия старой. Между проверками сигналов система каждую минуту выводит текущий PnL портфеля для мониторинга.

if current_minute == 0 and current_time - last_signal_check > 50:
    new_signals = reload_signals_for_all_symbols()
    for pos in mt5.positions_get(magic=magic):
        current_dir = "BUY" if pos.type == 0 else "SELL"
        new_dir = new_signals.get(pos.symbol, {}).get("direction", "HOLD")
        if new_dir != "HOLD" and new_dir != current_dir:
            close_opposite_positions(mt5, pos.symbol, new_dir, magic)

Этот режим реализует классическую стратегию "держать тренд до разворота". Позиции живут дольше, что снижает количество сделок и транзакционные издержки, но увеличивает экспозицию на отдельные инструменты. Режим подходит для трейдеров, предпочитающих структурные выходы механическим целям прибыли.

Выбор режима при запуске. При запуске без флагов система показывает меню:

L1 PIPELINE 29 PAIRS — MODE SELECTION

Select mode:
  1) Backtest (default)
  2) Infinite live loop — close on profit target, relearn hourly
  3) Infinite live loop — close on reverse signal, relearn hourly

Enter choice (1-3): 

Альтернативно, режимы запускаются через аргументы командной строки: python l1_pipeline_29pairs.py --live-trade для легаси-режима, или выбор в меню для режимов 2/3. Параметры настраиваются через --bars, --timeframe, --horizon, --total-lots, --long-thr, --short-thr. Для режима 2 дополнительно доступен --profit-threshold. Все три режима используют одни и те же модели, признаки, VaR-аллокацию — разница только в логике выхода.


Заключение

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

L1 Trend Filter через ADMM с адаптивным rho и residual balancing даёт математически строгую кусочно-линейную аппроксимацию тренда без задержки. Каузальная версия со скользящим окном делает фильтр применимым в real-time торговле без утечки будущего. Диверсификация на 29 инструментов снижает зависимость от режима конкретного инструмента. Параметрическая VaR-аллокация автоматически распределяет капитал обратно пропорционально риску каждого инструмента, уравнивая вклады в портфельную просадку.

Пятиуровневая защита от переобучения — каузальная фильтрация, purge+embargo, 10-фолдовый walk-forward, early stopping, независимое обучение на каждом инструменте — подтверждена эмпирически: средняя точность 0.683 ± 0.043, винрейт 64.9%, 26 из 28 инструментов прибыльны на форварде. Трёхфазный бэктест с VaR-лотами замкнул цепочку от теории до торгового результата: +$412 467 портфельного PnL при среднем Sharpe 12.80. Весь пайплайн реализован в одном Python-файле и готов к немедленному использованию — терминал MetaTrader 5, запущенный и авторизованный, является единственным внешним требованием. Система поддерживает как классический бэктест, так и два режима живой торговли с автоматическим переобучением каждый час и выбором между profit-based и signal-based логикой выхода.'

Проект поддерживается в AlgoForge.

Прикрепленные файлы |
Последние комментарии | Перейти к обсуждению на форуме трейдеров (5)
Renat Akhtyamov
Renat Akhtyamov | 29 мая 2026 в 07:28

Поразбирался с математикой, интересные идеи. Хорошая и интересная статья.

Однако, система может принести неприятности на разворотах тренда. 

Это в статье не прозвучало.

Aliaksandr Kazunka
Aliaksandr Kazunka | 31 мая 2026 в 13:16
"EURJPY":  1.0 / 157.0,
    "GBPJPY":  1.0 / 157.0,
    "AUDJPY":  1.0 / 157.0,
    "CADJPY":  1.0 / 157.0,
    "CHFJPY":  1.0 / 157.0,
    "NZDJPY":  1.0 / 157.0, и т.д...

а если курс изменился?


L1-фильтр  идеально рисует прямые линии на устоявшемся тренде, игнорируя шум. Но у каузальной версии (которая не заглядывает в будущее) есть неизбежная плата за сглаживание: инерция.

Текущий live-цикл работает по таймеру — он пересчитывает сигналы раз в час или проверяет условия каждые 10-60 секунд. На волатильных инструментах вроде XAUUSD или GBPJPY самые жесткие развороты происходят внутри часа. Если цена резко разворачивается, формируя мощный паттерн, ожидание закрытия H1-бара или срабатывания минутного таймера приведет к огромной просадке по открытой позиции.

sindicato
sindicato | 2 июн. 2026 в 07:06

Доброе утро. При запуске программы я получаю ошибку "❌ EURUSD : не удается получить тик". Эта ошибка возникает со всеми символами. Есть ли какие-нибудь решения? В режиме тестирования на исторических данных все работает идеально, и графики в итоге генерируются. Однако в режиме "Бесконечный цикл в реальном времени" я получаю указанную выше ошибку.

<moderator's note: machine translation to the Russian>

saeedsoleimani1371
saeedsoleimani1371 | 26 июн. 2026 в 06:30
Dear  Evgeniy , I really enjoyed your article well written with solid base theory, well done Bro. I ran a couple of tests... Here is my suggestion :
1- change the total lot from static 6 to an output of a function that calculates it based on a multplier of free margin(e.g. 0.2)... then max per symbol would be a multplier of totLots (e.g. 0.1)
2- in many instances after the relearn the code looses contanct with mt5, you may benefit on adding this in send_market order :
if mt5.terminal_info() is None:
print("MT5 disconnected. Reconnecting...")

mt5, terminal = mt5_connect_expected()

saeedsoleimani1371
saeedsoleimani1371 | 26 июн. 2026 в 06:31
syndicato # :

Good morning. When I launch the program, I get the error "❌ EURUSD : Unable to get tick." This error occurs with all symbols. Are there any solutions? In historical data testing mode, everything works perfectly, and charts are generated. However, in "Real-time infinite loop" mode, I get the error mentioned above.

<moderator's note: machine translation to the Russian>

you can solve that using the what I proposed in my comment
Разработка инструментария для анализа Price Action (Часть 51): Инновационная технология поиска свечных паттернов на графике Разработка инструментария для анализа Price Action (Часть 51): Инновационная технология поиска свечных паттернов на графике
Эта статья предназначена для алгоритмических трейдеров, количественных аналитиков и разработчиков MQL5, которые хотят глубже разобраться в распознавании свечных паттернов на практике. В ней подробно рассматривается советник CandlePatternSearch.mq5 – полноценная система для обнаружения, визуализации и отслеживания классических свечных формаций в MetaTrader 5. Помимо детального разбора кода, в статье рассматриваются архитектура решения, логика обнаружения паттернов, интеграция графического интерфейса и механизмы алертов, а также показано, как можно эффективно автоматизировать традиционный анализ Price Action.
Как реализовать R/S-анализ и индикатор Хёрста в MQL5 Как реализовать R/S-анализ и индикатор Хёрста в MQL5
Индикатор показателя Хёрста для MQL5 реализован на основе R/S-анализа с OLS-регрессией в log-log пространстве. Теоретическая опора — результаты Gatheral–Jaisson–Rosenbaum (2014), согласно которым волатильность — дробное броуновское движение с H ≈ 0.10. Индикатор оценивает H в скользящем окне, выделяет антиперсистентный (H < 0.3), нейтральный и трендовый (H > 0.5) режимы, окрашивает линию и подаёт алерт при смене режима, помогая выбирать тип стратегии и управлять риском.
Сеточный советник на дивергенции RSI с риск-менеджером в MQL5 Сеточный советник на дивергенции RSI с риск-менеджером в MQL5
Простая сеточная стратегия превращается в выживающую систему за счёт трёх компонентов: фильтра входа на основе дивергенции RSI, жёсткого ограничения числа уровней в сетке и независимого риск-менеджера, который ежетиково контролирует equity и блокирует торговлю при достижении дневного лимита или максимальной просадки. В статье разобрана математика классической мартингейл-ловушки, реализация поиска pivot-точек и дивергенций RSI на MQL5, полный код класса CRiskManager с защитой от плавающих убытков, а также архитектура самой сетки с TP от средневзвешенной цены входа.
Создание торговой системы (Часть 4): Как случайные выходы из сделок влияют на ожидаемую доходность Создание торговой системы (Часть 4): Как случайные выходы из сделок влияют на ожидаемую доходность
Многие трейдеры сталкивались с подобной ситуацией. Они часто придерживаются своих критериев входа, но испытывают трудности с сопровождением сделок. Даже при корректных торговых сетапах эмоциональное принятие решений, например, панический выход до того, как сделки достигнут уровней тейк-профита или стоп-лосса, - может привести к снижению кривой эквити. Как трейдеры могут преодолеть эту проблему и улучшить свои результаты? В данной статье мы рассмотрим эти вопросы, исследуя случайные винрейты (доля прибыльных сделок) и демонстрируя с помощью моделирования по методу Монте-Карло, как трейдеры могут совершенствовать свои стратегии, фиксируя прибыль на разумных уровнях до достижения первоначальной цели.