Прогнозирование динамики фьючерсов с XGBoost

Градиентный бустинг XGBoost занял устойчивое место в арсенале квант-аналитиков не случайно. На табличных данных с нелинейными зависимостями он стабильно превосходит линейные модели, а по скорости обучения и интерпретируемости выигрывает у нейросетей. Фьючерсные данные — именно тот тип данных, где эти преимущества реализуются в полной мере: высокая размерность признаков, нелинейные взаимодействия между ними, шум, превышающий сигнал в разы.

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

Почему ML-модель XGBoost подходит для прогнозирования динамики фьючерсов

XGBoost (eXtreme Gradient Boosting) — реализация градиентного бустинга с регуляризацией, разработанная Тяньци Ченом в 2016 году. Алгоритм строит ансамбль деревьев решений последовательно: каждое новое дерево обучается на остатках предыдущего, минимизируя дифференцируемую функцию потерь. Регуляризация L1 и L2 по весам листьев контролирует сложность модели и снижает переобучение.

Преимущества перед линейными моделями и нейросетями

Линейные модели (такие как Ridge и Lasso) предполагают, что связь между признаками и целевой переменной является аддитивной — то есть вклад каждого признака независим и просто складывается. Финансовые временные ряды это допущение нарушают. Здесь признаки взаимодействуют друг с другом: волатильность зависит от объема торгов, спред bid-ask по-разному влияет на движение цены в разное время суток, а режим рынка (тренд или флэт) определяет, какие признаки вообще имеют предсказательную силу.

XGBoost способен учитывать такие взаимодействия автоматически — за счет последовательных разбиений в деревьях, без необходимости явно задавать перекрестные признаки (interaction terms).

Сравнение подходов:

  • Линейные модели — быстрые, интерпретируемые, но не улавливают нелинейности и взаимодействия признаков;
  • Нейросети (LSTM, Transformer) — гибкие, но требуют большого объема данных, сложной настройки и склонны к переобучению на коротких финансовых рядах;
  • Random Forest — хороши в качестве бейзлайна, но уступают XGBoost по качеству на большинстве финансовых датасетов из-за отсутствия бустинга;
  • XGBoost — баланс между гибкостью, скоростью и устойчивостью к переобучению при правильной регуляризации.

На практике XGBoost показывает стабильный результат при объеме обучающей выборки от 2000–3000 наблюдений — это примерно 2–3 года дневных баров или 3–6 месяцев часовых.

Ограничения модели или где XGBoost не сработает

Модель машинного обучения XGBoost выдает зачастую качественные прогнозы, однако не является универсальным решением.

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

Второе ограничение — отсутствие встроенной работы с последовательностями. XGBoost не учитывает порядок наблюдений напрямую. Зависимость от предыдущих состояний нужно кодировать вручную через лаговые признаки и скользящие (rolling) статистики — это и есть основная часть инжиниринга признаков для временных рядов.

Инжиниринг признаков для фьючерсных данных

Качество признаков определяет результат в большей степени, чем выбор гиперпараметров модели. Для фьючерсов ключевые источники информации — ценовая динамика, объем торгов, структура спреда между контрактами (контанго / бэквордация) и межрыночные взаимосвязи.

👉🏻  Методы анализа финансовых деривативов

Структура входных данных: OHLCV и производные признаки

Сырые OHLCV-данные как признаки использовать нецелесообразно: абсолютные цены нестационарны и не несут предиктивной информации в кросс-секционном смысле. Модель, обученная на ценах 2022 года, не обобщается на 2025-й. Стандартная трансформация — переход к относительным изменениям:

  • логарифмические доходности: log(Close_t / Close_{t-1});
  • нормализованный объем: (Volume_t — mean) / std по скользящему окну;
  • внутридневной диапазон: (High — Low) / Close — прокси волатильности;
  • разрыв открытия (gap): (Open_t — Close_{t-1}) / Close_{t-1}.

Эти четыре группы признаков формируют базовый набор. Дополнительно для фьючерсов имеет смысл включать открытый интерес (open interest) и его изменение — рост открытых позиций при движении цены подтверждает силу тренда, снижение при движении сигнализирует о его слабости.

Лаговые переменные и скользящие статистики

Для передачи временной зависимости в модель XGBoost используются лаговые признаки — значения целевой или других переменных на предыдущих шагах. Выбор количества лагов зависит от таймфрейма: для дневных баров обычно достаточно 5–20 лагов (1–4 торговые недели), для часовых — 24–48 (1–2 торговых дня).

Rolling-статистики добавляют контекст о текущем рыночном режиме:

  • скользящее стандартное отклонение доходностей (реализованная волатильность);
  • скользящий коэффициент асимметрии (skewness) — сигнализирует о хвостовых рисках;
  • автокорреляция доходностей на горизонте 5–10 баров — характеризует инерцию или возврат к среднему;
  • z-score цены закрытия относительно скользящего окна — позиция цены внутри исторического диапазона.

Размер окна для скользящих статистик — отдельный вопрос. Узкие окна (5–10 баров) захватывают краткосрочные режимы, широкие (50–200 баров) — долгосрочный контекст. Рекомендую включать признаки с несколькими размерами окон и давать модели самой определить, какой масштаб информативен.

Ниже — код генерации признакового пространства для фьючерса на золото (GC=F):

import pandas as pd
import numpy as np
import yfinance as yf
import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec
from xgboost import XGBRegressor
from sklearn.metrics import mean_absolute_error, r2_score
from sklearn.inspection import permutation_importance


def load_futures_data(ticker: str, start: str, end: str) -> pd.DataFrame:
    df = yf.download(ticker, start=start, end=end, auto_adjust=False)
    if isinstance(df.columns, pd.MultiIndex):
        df.columns = df.columns.droplevel(1)
    return df[["Open", "High", "Low", "Close", "Volume"]].dropna()


def make_features(df: pd.DataFrame, lags: list, windows: list) -> pd.DataFrame:
    feat = pd.DataFrame(index=df.index)

    # Базовые трансформации
    feat["log_ret"] = np.log(df["Close"] / df["Close"].shift(1))
    feat["gap"] = (df["Open"] - df["Close"].shift(1)) / df["Close"].shift(1)
    feat["hl_range"] = (df["High"] - df["Low"]) / df["Close"]
    feat["vol_norm"] = (df["Volume"] - df["Volume"].rolling(20).mean()) / df["Volume"].rolling(20).std()

    # Лаговые признаки
    for lag in lags:
        feat[f"ret_lag_{lag}"] = feat["log_ret"].shift(lag)
        feat[f"range_lag_{lag}"] = feat["hl_range"].shift(lag)

    # Rolling-статистики
    for w in windows:
        ret = feat["log_ret"]
        feat[f"vol_{w}"] = ret.shift(1).rolling(w).std()
        feat[f"skew_{w}"] = ret.shift(1).rolling(w).skew()
        feat[f"autocorr_{w}"] = ret.shift(1).rolling(w).apply(
            lambda x: x.autocorr(lag=1) if len(x) > 1 else np.nan, raw=False
        )
        feat[f"zscore_{w}"] = (
            (df["Close"] - df["Close"].shift(1).rolling(w).mean())
            / df["Close"].shift(1).rolling(w).std()
        )

    return feat


def make_target(df: pd.DataFrame, horizon: int = 1) -> pd.Series:
    return np.log(df["Close"].shift(-horizon) / df["Close"]).rename("target")


# Загрузка данных
raw = load_futures_data("GC=F", start="2018-01-01", end="2024-01-01")

lags = [1, 2, 3, 5, 10]
windows = [5, 10, 20, 60]

features = make_features(raw, lags=lags, windows=windows)
target = make_target(raw, horizon=1)

data = features.join(target).dropna()

X = data.drop(columns=["target"])
y = data["target"]

Функция make_features строит полное признаковое пространство в одном проходе. Все rolling-статистики сдвинуты на .shift(1) — это исключает использование текущего бара при расчете признаков. Функция make_target формирует логарифмическую доходность на заданный горизонт вперед с автоматическим сдвигом.

Устранение риска подглядывания в будущее (look-ahead bias)

Биас подглядывания в будущее — наиболее распространенная причина завышенных результатов бэктестинга. Он возникает, когда признак на момент времени t содержит информацию из будущего относительно t. Для скользящих статистик это означает: вычисление должно происходить только по данным до t-1 включительно, то есть с .shift(1) перед .rolling().

👉🏻  Классические методы предиктивной аналитики

Отдельный источник утечки — целевая переменная. При горизонте прогноза h=1 целевая переменная — это доходность следующего дня. Все признаки должны быть доступны на момент закрытия текущего бара, до открытия следующего. Объем текущего бара допустим, объем следующего — нет.

Еще один нетривиальный случай: нормализация признаков через глобальные статистики (mean/std по всей выборке) создает утечку, поскольку включает будущие данные. Нормализацию нужно выполнять в рамках walk-forward окна — только по обучающей части.

Ниже — визуализация признакового пространства: распределение доходностей и ключевые rolling-признаки.

fig = plt.figure(figsize=(14, 8), facecolor="white")
gs = gridspec.GridSpec(2, 3, figure=fig, hspace=0.45, wspace=0.35)

# 1. Доходности
ax1 = fig.add_subplot(gs[0, 0])
ax1.hist(data["log_ret"], bins=60, color="black", alpha=0.8, edgecolor="white", linewidth=0.3)
ax1.axvline(data["log_ret"].mean(), color="#e07b39", linestyle="--", linewidth=1.2, label="Mean")
ax1.set_title("Распределение доходностей", fontsize=10)
ax1.set_xlabel("log return")
ax1.set_ylabel("Частота")
ax1.legend(fontsize=9)

# 2. Реализованная волатильность (vol_20)
ax2 = fig.add_subplot(gs[0, 1])
ax2.plot(data.index, data["vol_20"], color="black", linewidth=0.8)
ax2.set_title("Реализованная волатильность (20d)", fontsize=10)
ax2.set_xlabel("Дата")
ax2.set_ylabel("std доходностей")

# 3. Z-score цены (zscore_60)
ax3 = fig.add_subplot(gs[0, 2])
ax3.plot(data.index, data["zscore_60"], color="black", linewidth=0.8)
ax3.axhline(0, color="#aaaaaa", linewidth=0.8, linestyle="--")
ax3.axhline(2, color="#e07b39", linewidth=0.8, linestyle=":")
ax3.axhline(-2, color="#e07b39", linewidth=0.8, linestyle=":")
ax3.set_title("Z-score цены (60d)", fontsize=10)
ax3.set_xlabel("Дата")
ax3.set_ylabel("z-score")

# 4. Асимметрия (skew_20)
ax4 = fig.add_subplot(gs[1, 0])
ax4.plot(data.index, data["skew_20"], color="black", linewidth=0.8)
ax4.axhline(0, color="#aaaaaa", linewidth=0.8, linestyle="--")
ax4.set_title("Скользящая асимметрия (20d)", fontsize=10)
ax4.set_xlabel("Дата")
ax4.set_ylabel("skewness")

# 5. Автокорреляция (autocorr_10)
ax5 = fig.add_subplot(gs[1, 1])
ax5.plot(data.index, data["autocorr_10"], color="black", linewidth=0.8)
ax5.axhline(0, color="#aaaaaa", linewidth=0.8, linestyle="--")
ax5.set_title("Автокорреляция доходностей (10d)", fontsize=10)
ax5.set_xlabel("Дата")
ax5.set_ylabel("autocorr lag=1")

# 6. Scatter: vol_20 vs |target|
ax6 = fig.add_subplot(gs[1, 2])
ax6.scatter(data["vol_20"], data["target"].abs(), alpha=0.2, s=8, color="black")
ax6.set_title("|target| vs волатильность", fontsize=10)
ax6.set_xlabel("vol_20")
ax6.set_ylabel("|log return target|")

plt.suptitle("Признаки фьючерса GC=F (золото, дневные бары)", fontsize=12, fontweight="bold", y=1.01)
plt.savefig("features_overview.png", dpi=150, bbox_inches="tight", facecolor="white")
plt.show()

Обзор признакового пространства для фьючерса на золото. Верхний ряд: гистограмма логарифмических доходностей с выраженными тяжелыми хвостами, реализованная волатильность с кластеризацией в периоды рыночного стресса (2020, 2022), z-score цены — сигнал отклонения от скользящей нормы. Нижний ряд: скользящая асимметрия сигнализирует о периодах накопления хвостовых рисков, автокорреляция нестабильна и меняет знак (смена режимов тренд/возврат), следующий график скатер-плот подтверждает: в периоды высокой волатильности абсолютный размер движений растет — важно учитывать при постановке задачи

Рис. 1: Обзор признакового пространства для фьючерса на золото. Верхний ряд: гистограмма логарифмических доходностей с выраженными тяжелыми хвостами, реализованная волатильность с кластеризацией в периоды рыночного стресса (2020, 2022), z-score цены — сигнал отклонения от скользящей нормы. Нижний ряд: скользящая асимметрия сигнализирует о периодах накопления хвостовых рисков, автокорреляция нестабильна и меняет знак (смена режимов тренд/возврат), следующий график скатер-плот подтверждает: в периоды высокой волатильности абсолютный размер движений растет — важно учитывать при постановке задачи

Формулировка задачи: регрессия vs классификация

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

Целевая переменная: возврат, направление или квантиль

Регрессия на логарифмическую доходность — это, пожалуй, наиболее информативный подход: модель предсказывает и направление, и величину движения. Минус подхода — высокий шум и низкий R² даже у хороших моделей (0.02–0.05 для дневных баров норма). Не следует интерпретировать низкий R² как признак плохой модели: в финансовых рядах это ожидаемо.

Бинарная классификация (рост/падение) упрощает задачу, но теряет информацию о размере движения. Метрики precision/recall здесь приоритетнее accuracy: дисбаланс классов незначителен для ликвидных фьючерсов, но стоимость ошибок разного типа может отличаться.

Квантильная регрессия — третий вариант: модель предсказывает не точечное значение, а квантиль распределения будущих доходностей. Это позволяет оценивать хвостовые риски непосредственно в структуре предсказания. XGBoost поддерживает квантильную регрессию через objective=’reg:quantileerror’ с параметром quantile_alpha.

Для большинства задач рекомендую начинать с регрессии на логарифмическую доходность: такой подход сохраняет больше информации о динамике цен фьючерса и позволяет строить сигнал с учетом уверенности модели через размер предсказанного возврата.

👉🏻  Долгосрочное прогнозирование динамики облигаций с помощью ансамбля статистических моделей

Горизонт прогноза и его влияние на качество модели

Горизонт прогноза h — один из ключевых параметров задачи. Зависимость нелинейная:

  • h=1 (следующий бар) — максимальный шум, минимальная предсказуемость, но высокая частота сигналов;
  • h=5–10 (неделя для дневных баров) — баланс между предсказуемостью и торговыми издержками;
  • h=20+ — сигнал более устойчив, но реагирование на него запаздывает, что критично для фьючерсов с высокой beta к макро-событиям.

При увеличении горизонта перекрывающиеся наблюдения (overlapping returns) вводят автокорреляцию в целевую переменную, что завышает метрики на стандартной валидации. Для h>1 рекомендую использовать неперекрывающую выборку или скорректировать оценку ковариации по методу Newey-West.

Валидация временных рядов

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

Walk-forward валидация в сравнении с k-fold

Walk-forward validation (скользящая валидация) воспроизводит реальные условия торговли: модель обучается на данных до момента t, тестируется на периоде [t, t+h], затем окно сдвигается вперед. У нее есть два ключевых режима:

  • Expanding window — обучающая выборка растет с каждым шагом, тест всегда на новых данных;
  • Rolling window — фиксированный размер обучающей выборки, окно сдвигается целиком.

Метод Expanding window предпочтительнее, если рыночный режим стабилен и данные с 2010 года релевантны для прогноза в 2023-м. Метод Rolling window как правило применяют, если есть основания считать старые данные нерелевантными — например, после структурного сдвига (смена режима ставок, изменение микроструктуры рынка).

Purging и embargo для устранения утечки данных

Даже при валидации методом walk-forward иногда возникает утечка. Так, например, если горизонт прогноза h=5, наблюдение t=100 в обучающей выборке имеет целевую переменную, вычисленную по ценам t=101..105. Наблюдение t=103 в тестовой выборке содержит признаки, пересекающиеся по времени с целевой переменной обучающего наблюдения.

Purging — удаление из обучающей выборки наблюдений, целевые переменные которых перекрываются по времени с тестовым периодом. Embargo — дополнительный буфер между обучающей и тестовой выборкой размером в несколько баров (обычно равен горизонту h или размеру rolling-окна признаков).

Ниже код построения визуализации схемы walk-forward валидации с purging и embargo:

fig, ax = plt.subplots(figsize=(13, 5), facecolor="white")

n_splits = 5
total_bars = 100
train_size = 50
test_size = 10
embargo = 3

colors = {"train": "#222222", "test": "#e07b39", "embargo": "#cccccc", "purge": "#999999"}

for i in range(n_splits):
    train_start = 0 if i == 0 else i * test_size
    train_end = train_size + i * test_size
    test_start = train_end + embargo
    test_end = test_start + test_size
    purge_start = train_end - test_size
    purge_end = train_end

    # Train
    ax.barh(i, train_end - train_start, left=train_start, height=0.5,
            color=colors["train"], label="Train" if i == 0 else "")
    # Purge zone
    ax.barh(i, purge_end - purge_start, left=purge_start, height=0.5,
            color=colors["purge"], label="Purge" if i == 0 else "")
    # Embargo
    ax.barh(i, embargo, left=train_end, height=0.5,
            color=colors["embargo"], label="Embargo" if i == 0 else "")
    # Test
    ax.barh(i, test_size, left=test_start, height=0.5,
            color=colors["test"], label="Test" if i == 0 else "")

ax.set_yticks(range(n_splits))
ax.set_yticklabels([f"Fold {i+1}" for i in range(n_splits)])
ax.set_xlabel("Индекс бара")
ax.set_title("Walk-forward валидация с purging и embargo", fontsize=12, fontweight="bold")
ax.legend(loc="lower right", fontsize=9)
ax.set_facecolor("white")
plt.tight_layout()
plt.savefig("walkforward_schema.png", dpi=150, bbox_inches="tight", facecolor="white")
plt.show()

Схема walk-forward валидации с purging и embargo. Разными цветами представлены фолды: черный — обучающая выборка, серый — зона purging (удаляется из обучения, так как целевые переменные перекрываются с тестом), светло-серый — embargo (буфер без данных), оранжевый — тестовая выборка. Тест каждого следующего фолда начинается ровно там, где закончился предыдущий — это гарантирует полное покрытие без пересечений

Рис. 2: Схема walk-forward валидации с purging и embargo. Разными цветами представлены фолды: черный — обучающая выборка, серый — зона purging (удаляется из обучения, так как целевые переменные перекрываются с тестом), светло-серый — embargo (буфер без данных), оранжевый — тестовая выборка. Тест каждого следующего фолда начинается ровно там, где закончился предыдущий — это гарантирует полное покрытие без пересечений

Подбор гиперпараметров XGBoost для финансовых данных

Модель машинного обучения XGBoost имеет около 30 настраиваемых параметров, однако реально влияют на качество 6–8. Перебор по всей сетке на финансовых данных избыточен: walk-forward валидация уже ограничивает размер выборки, поэтому поиск нужно сделать целенаправленным.

👉🏻  Что такое регрессионный анализ и как он работает?

Ключевые параметры и их влияние на переобучение

  • n_estimators — количество деревьев. Большие значения без ранней остановки приводят к переобучению. Рекомендую использовать early_stopping_rounds=50 на валидационном сете;
  • max_depth — глубина дерева. Для финансовых данных оптимальный диапазон 3–6: глубокие деревья запоминают шум. При работе с малыми выборками (<3000 баров) лучше ограничиться показателем до 3–4;
  • learning_rate (eta) — шаг градиентного спуска. Малые значения (0.01–0.05) в сочетании с большим числом деревьев дают лучшую обобщаемость, однако увеличивают время обучения;
  • subsample — доля наблюдений для обучения каждого дерева. Значения 0.6–0.8 добавляют стохастичность и снижают переобучение аналогично дропауту (dropout) в нейросетях;
  • colsample_bytree — доля признаков для каждого дерева. Параметр особенно важен при большом числе коррелированных признаков (типично для rolling-статистик с разными окнами). Рекомендуемый диапазон 0.5–0.8;
  • min_child_weight — минимальная сумма весов в листе. Увеличение до 5–10 предотвращает деления по малому числу наблюдений — ключевой параметр для шумных финансовых данных;
  • reg_alpha, reg_lambda — L1 и L2 регуляризация. Значение reg_lambda=1.0 (по дефолту) обычно достаточно; reg_alpha>0 добавляет разреженность весов при большом числе признаков.

Практические диапазоны значений

Для дневных фьючерсных данных (1000–5000 баров) рекомендуемый стартовый набор:

  • max_depth: 3–5;
  • learning_rate: 0.02–0.05;
  • n_estimators: 300–1000 (с early stopping);
  • subsample: 0.7;
  • colsample_bytree: 0.6–0.7;
  • min_child_weight: 5–10;
  • reg_lambda: 1.0–2.0.

Для подбора гиперпараметров лучше всего использовать Optuna с TPE-сэмплером — он эффективнее случайного поиска при ограниченном бюджете итераций. Также я рекомендую запускать оптимизацию только на первых N фолдах walk-forward, а не на всех — иначе гиперпараметры подстраиваются под будущие данные.

Практическая реализация: пайплайн от данных до сигнала

Давайте теперь соберем все компоненты в единый пайплайн: walk-forward валидация с purging, обучение XGBoost, оценка качества и интерпретация важности признаков.

Walk-forward обучение и оценка качества

def walk_forward_split(
    n: int,
    train_size: int,
    test_size: int,
    embargo: int,
    expanding: bool = True
):
    """
    Функция генерирует индексы для walk-forward разбиений с опциональным расширением обучающего окна
    и эмбарго между train и test.
    """
    splits = []
    start = 0

    while True:
        # Определяем конец train: либо скользящее окно, либо расширяющееся
        train_end = (
            start + train_size
            if not expanding
            else train_size + len(splits) * test_size
        )

        # Проверяем, не выйдем ли за пределы данных
        if train_end + embargo + test_size > n:
            break

        # Определяем тестовый период с учетом эмбарго
        test_start = train_end + embargo
        test_end = test_start + test_size

        # Индексы для train и test
        train_idx = list(range(start if not expanding else 0, train_end))
        test_idx = list(range(test_start, test_end))

        splits.append((train_idx, test_idx))

        # Сдвигаем окно для следующего прохода
        start += test_size

    return splits


def run_walk_forward(
    X: pd.DataFrame,
    y: pd.Series,
    train_size: int = 500,
    test_size: int = 63,
    embargo: int = 5,
    xgb_params: dict = None
) -> pd.DataFrame:
    """
    Запуск walk-forward валидации для XGBRegressor с нормализацией признаков
    и early stopping на последней части train.
    """
    if xgb_params is None:
        # Параметры по умолчанию для XGB
        xgb_params = {
            "n_estimators": 500,
            "max_depth": 4,
            "learning_rate": 0.03,
            "subsample": 0.7,
            "colsample_bytree": 0.65,
            "min_child_weight": 7,
            "reg_lambda": 1.5,
            "early_stopping_rounds": 50,
            "eval_metric": "mae",
            "random_state": 42,
            "verbosity": 0
        }

    # Получаем разбиения
    splits = walk_forward_split(
        n=len(X),
        train_size=train_size,
        test_size=test_size,
        embargo=embargo,
        expanding=True
    )

    results = []

    for fold_idx, (train_idx, test_idx) in enumerate(splits):
        X_tr, y_tr = X.iloc[train_idx], y.iloc[train_idx]
        X_te, y_te = X.iloc[test_idx], y.iloc[test_idx]

        # Нормализация признаков по обучающему множеству
        feat_mean = X_tr.mean()
        feat_std = X_tr.std().replace(0, 1)  # чтобы избежать деления на 0

        X_tr_scaled = (X_tr - feat_mean) / feat_std
        X_te_scaled = (X_te - feat_mean) / feat_std

        # Validation set для early stopping: последние 15% train
        val_cutoff = int(len(X_tr_scaled) * 0.85)
        X_val = X_tr_scaled.iloc[val_cutoff:]
        y_val = y_tr.iloc[val_cutoff:]
        X_tr_fit = X_tr_scaled.iloc[:val_cutoff]
        y_tr_fit = y_tr.iloc[:val_cutoff]

        # Инициализация и обучение модели
        model = XGBRegressor(**xgb_params)
        model.fit(
            X_tr_fit,
            y_tr_fit,
            eval_set=[(X_val, y_val)],
            verbose=False
        )

        # Предсказания на тесте
        preds = model.predict(X_te_scaled)

        # Сохраняем результаты fold
        fold_result = pd.DataFrame({
            "date": X_te.index,
            "y_true": y_te.values,
            "y_pred": preds,
            "fold": fold_idx
        })
        results.append(fold_result)

    return pd.concat(results, ignore_index=True)


# Запуск
oof_results = run_walk_forward(
    X, y,
    train_size=500,
    test_size=63,
    embargo=5
)

# Метрики качества
mae = mean_absolute_error(oof_results["y_true"], oof_results["y_pred"])
r2 = r2_score(oof_results["y_true"], oof_results["y_pred"])
ic = oof_results[["y_true", "y_pred"]].corr().iloc[0, 1]

print(f"MAE: {mae:.5f} | R²: {r2:.4f} | IC: {ic:.4f}")
MAE: 0.00712 | R²: -0.0009 | IC: 0.0243

Представленный выше пайплайн реализует walk-forward валидацию с расширяющимся окном с embargo между фолдами. Нормализация выполняется строго внутри каждого фолда по обучающей части — это предотвращает утечку через глобальные статистики. Ранняя остановка Early stopping использует последние 15% обучающей выборки как валидационный сет, сохраняя временной порядок.

👉🏻  Сезонность временных рядов. В чем отличие аддитивной от мультипликативной?

Ключевая метрика для оценки прогностической силы — IC (информационный коэффициент), корреляция Пирсона между предсказаниями и фактическими доходностями. Значение IC > 0.05 считается значимым для дневных данных; IC > 0.10 — сильный сигнал. R² на финансовых данных закономерно низкий (0.01–0.05), это не проблема модели.

Краткая интерпретация этих метрик:

  • MAE 0.0071 — средняя ошибка небольшая, но сама по себе она мало говорит без масштаба целевой переменной;
  • R² -0.0009 — модель почти не объясняет вариацию целевой переменной; отрицательное значение значит, что прогноз хуже, чем просто среднее;
  • IC 0.0243 — корреляция предсказаний с фактом очень слабая, почти нулевая.

Итог: модель не дает полезного прогноза, практически случайные предсказания.

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

Визуализация результатов и интерпретация сигнала

fig, axes = plt.subplots(2, 2, figsize=(14, 8), facecolor="white")

# 1. Накопленная доходность сигнала vs buy&hold
ax1 = axes[0, 0]
signal_ret = oof_results["y_pred"].values * np.sign(oof_results["y_pred"].values)
strategy_ret = oof_results["y_true"].values * np.sign(oof_results["y_pred"].values)
bh_ret = oof_results["y_true"].values

cumstrat = np.cumsum(strategy_ret)
cumbh = np.cumsum(bh_ret)

ax1.plot(cumstrat, color="black", linewidth=1.2, label="Стратегия (long/short)")
ax1.plot(cumbh, color="#e07b39", linewidth=1.0, linestyle="--", label="Buy & Hold")
ax1.set_title("Накопленная доходность сигнала", fontsize=10)
ax1.set_xlabel("Наблюдение")
ax1.set_ylabel("Cumulative log return")
ax1.legend(fontsize=9)
ax1.set_facecolor("white")

# 2. Scatter: y_pred vs y_true
ax2 = axes[0, 1]
ax2.scatter(oof_results["y_pred"], oof_results["y_true"],
            alpha=0.15, s=6, color="black")
ax2.axhline(0, color="#aaaaaa", linewidth=0.8)
ax2.axvline(0, color="#aaaaaa", linewidth=0.8)
ax2.set_title(f"Прогноз vs факт (IC={ic:.3f})", fontsize=10)
ax2.set_xlabel("y_pred")
ax2.set_ylabel("y_true")
ax2.set_facecolor("white")

# 3. IC по фолдам
ic_by_fold = oof_results.groupby("fold").apply(
    lambda g: g[["y_true", "y_pred"]].corr().iloc[0, 1]
).reset_index()
ic_by_fold.columns = ["fold", "ic"]

ax3 = axes[1, 0]
colors_fold = ["black" if v > 0 else "#e07b39" for v in ic_by_fold["ic"]]
ax3.bar(ic_by_fold["fold"], ic_by_fold["ic"], color=colors_fold)
ax3.axhline(0, color="#aaaaaa", linewidth=0.8)
ax3.set_title("IC по фолдам", fontsize=10)
ax3.set_xlabel("Fold")
ax3.set_ylabel("IC")
ax3.set_facecolor("white")

# 4. Распределение предсказаний
ax4 = axes[1, 1]
ax4.hist(oof_results["y_pred"], bins=60, color="black", alpha=0.8,
         edgecolor="white", linewidth=0.3)
ax4.axvline(0, color="#e07b39", linewidth=1.2, linestyle="--")
ax4.set_title("Распределение предсказаний модели", fontsize=10)
ax4.set_xlabel("y_pred")
ax4.set_ylabel("Частота")
ax4.set_facecolor("white")

plt.suptitle("Оценка качества модели XGBoost (walk-forward OOF)", fontsize=12, fontweight="bold")
plt.tight_layout()
plt.savefig("model_evaluation.png", dpi=150, bbox_inches="tight", facecolor="white")
plt.show()

Оценка качества модели на отложенной выборке. Верхний левый график: накопленная доходность простой long/short стратегии на основе знака предсказания против buy & hold — показывает, несет ли сигнал торговую ценность независимо от абсолютной точности. Верхний правый график: скатерплот предсказаний и факта, IC отражает линейную согласованность рангов. Нижний левый график: IC по фолдам — нестабильность IC сигнализирует о смене рыночных режимов или переобучении. Нижний правый график: распределение предсказаний — симметрия вокруг нуля и отсутствие выраженных смещений говорят об отсутствии систематической ошибки

Рис. 3: Оценка качества модели на отложенной выборке. Верхний левый график: накопленная доходность простой long/short стратегии на основе знака предсказания против buy & hold — показывает, несет ли сигнал торговую ценность независимо от абсолютной точности. Верхний правый график: скатерплот предсказаний и факта, IC отражает линейную согласованность рангов. Нижний левый график: IC по фолдам — нестабильность IC сигнализирует о смене рыночных режимов или переобучении. Нижний правый график: распределение предсказаний — симметрия вокруг нуля и отсутствие выраженных смещений говорят об отсутствии систематической ошибки

Интерпретация важности признаков

ML-модель XGBoost вычисляет три типа категорий важности признаков:

  • weight — количество разбиений по признаку. Смещено в сторону признаков с большим диапазоном значений;
  • gain — среднее улучшение целевой функции при разбиении по признаку. Более надежная оценка реального вклада;
  • cover — среднее число наблюдений, затронутых разбиением. Интерпретируется как охват.
👉🏻  Анализ акций Tesla с помощью Python

Помимо встроенных метрик, permutation importance (из sklearn) дает более надежную оценку: признак случайно перемешивается, измеряется падение качества. Это прямая оценка зависимости модели от признака на тестовых данных.

# Обучаем финальную модель на всех данных кроме последнего фолда (для демонстрации importance)
last_train_idx = walk_forward_split(len(X), 500, 63, 5)[-1][0]
X_final_tr = X.iloc[last_train_idx]
y_final_tr = y.iloc[last_train_idx]

feat_mean_f = X_final_tr.mean()
feat_std_f = X_final_tr.std().replace(0, 1)
X_scaled_f = (X_final_tr - feat_mean_f) / feat_std_f

final_model = XGBRegressor(
    n_estimators=500, max_depth=4, learning_rate=0.03,
    subsample=0.7, colsample_bytree=0.65, min_child_weight=7,
    reg_lambda=1.5, random_state=42, verbosity=0
)
final_model.fit(X_scaled_f, y_final_tr)

# Gain importance
importance_df = pd.DataFrame({
    "feature": X.columns,
    "gain": final_model.feature_importances_
}).sort_values("gain", ascending=False).head(15)

fig, ax = plt.subplots(figsize=(10, 6), facecolor="white")
ax.barh(importance_df["feature"][::-1], importance_df["gain"][::-1], color="gray")
ax.set_title("Top-15 признаков по gain importance (XGBoost)", fontsize=11, fontweight="bold")
ax.set_xlabel("Gain")
ax.set_facecolor("white")
plt.tight_layout()
plt.savefig("feature_importance.png", dpi=150, bbox_inches="tight", facecolor="white")
plt.show()

Top-15 признаков модели по приросту gain importance. Признаки с высоким gain вносят наибольший вклад в снижение функции потерь при разбиении деревьев. Доминирование скользящей волатильности и z-score над лаговыми доходностями указывает на то, что модель использует информацию о рыночном режиме сильнее, чем краткосрочную инерцию цены — типичный паттерн для дневных фьючерсов

Рис. 4: Top-15 признаков модели по приросту gain importance. Признаки с высоким gain вносят наибольший вклад в снижение функции потерь при разбиении деревьев. Доминирование скользящей волатильности и z-score над лаговыми доходностями указывает на то, что модель использует информацию о рыночном режиме сильнее, чем краткосрочную инерцию цены — типичный паттерн для дневных фьючерсов

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

Заключение

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

Практический вывод: первичная ценность такой модели не в предсказании конкретного движения цены, а в формировании ранжированного сигнала с положительным IC. Даже если получена метрика IC=0.05 на дневных данных — это уже стабильное статистическое преимущество, которое при правильном управлении риском конвертируется в реальный edge (альфу, торговое преимущество). Качество инжиниринга признаков и строгость валидационной схемы определяют, останется ли этот edge устойчивым за пределами обучающей выборки.