Скользящие оконные функции решают фундаментальную задачу анализа временных рядов: извлечение локальных паттернов из последовательных данных. Метод основан на применении агрегирующих операций к подмножествам наблюдений фиксированного размера, которые последовательно сдвигаются вдоль временной оси.
Библиотека Pandas предоставляет три типа окон для работы с временными рядами:
- rolling — фиксированный размер;
- expanding — растущее от начала;
- exponentially weighted — экспоненциальное взвешивание.
Скользящие окна в Pandas представлены классом rolling() (от англ. rolling windows). Они применяются для расчета локальной волатильности, выявления краткосрочных трендов, детекции структурных сдвигов в данных и построения предиктивных фич для машинного обучения. В количественных стратегиях скользящие окна используются для нормализации цен, расчета z-scores, определения режимов рынка и генерации торговых сигналов на основе статистических аномалий.
Основы работы с rolling()
Метод rolling() создает объект Rolling, который применяет агрегирующие функции к последовательным подмножествам данных. Базовый синтаксис требует указания размера окна и опционально параметров обработки граничных значений.
Синтаксис и ключевые параметры
Основные параметры rolling() определяют поведение окна и условия расчета:
- window: размер окна в количестве наблюдений или временной интервал (например ’30D’);
- min_periods: минимальное количество наблюдений для расчета, по умолчанию равно window;
- center: если True, метка окна располагается в центре вместо правого края;
- win_type: тип взвешивания (boxcar, triang, blackman и другие из scipy.signal);
- closed: какая граница окна включается (‘right’, ‘left’, ‘both’, ‘neither’).
Параметр min_periods контролирует обработку начальных значений ряда. При min_periods=1 расчет начинается с первого наблюдения, при min_periods=window первые (window-1) значений будут NaN. Это важная настройка для стратегий с реинвестированием, где пропуски в начале ряда искажают накопленную доходность.
import pandas as pd
import yfinance as yf
import numpy as np
# Загрузка данных
ticker = yf.download('TSM', start='2023-11-01', end='2025-11-01', progress=False)
prices = ticker['Close']
# Разные варианты min_periods
rolling_strict = prices.rolling(window=20, min_periods=20).mean()
rolling_partial = prices.rolling(window=20, min_periods=1).mean()
print(f"NaN в strict: {rolling_strict.isna().sum()}")
print(f"NaN в partial: {rolling_partial.isna().sum()}")
NaN в strict: Ticker
TSM 19
NaN в partial: Ticker
TSM 0
Код демонстрирует влияние min_periods на количество пропущенных значений. При min_periods=20 первые 19 наблюдений будут NaN, при min_periods=1 расчет начинается с первого значения. Выбор зависит от задачи: для бэктестинга предпочтителен strict режим (избегаем возможной утечки данных из будущего), для визуализации допустим partial.
Базовые агрегирующие функции
Rolling объект поддерживает стандартные статистические методы pandas. Наиболее используемые в количественном анализе:
- mean(): простое скользящее среднее для определения локального тренда;
- std(): скользящее стандартное отклонение для оценки волатильности;
- var(): дисперсия, альтернатива std() когда нужна квадратичная метрика;
- min(), max(): экстремумы в окне для расчета диапазонов;
- median(): робастная альтернатива mean() при наличии выбросов;
- quantile(q): произвольные квантили для построения доверительных интервалов;
- sum(): накопление значений в окне;
- count(): количество ненулевых наблюдений.
# Расчет ключевых метрик
returns = prices.pct_change()
window = 20
rolling_mean = returns.rolling(window).mean()
rolling_std = returns.rolling(window).std()
rolling_sharpe = rolling_mean / rolling_std * np.sqrt(252)
# Z-score для детекции аномалий
z_score = (returns - rolling_mean) / rolling_std
print(f"Периоды с |z-score| > 3: {(abs(z_score) > 3).sum()}")
Периоды с |z-score| > 3: Ticker
TSM 4
Представленный выше код вычисляет скользящий коэффициент Шарпа (Sharpe ratio) и z-score для доходностей. Sharpe показывает отношение доходности к риску в локальном окне, что позволяет идентифицировать периоды эффективности актива. Z-score выше 3 по модулю указывает на статистически значимое отклонение доходности от локального среднего — потенциальный сигнал для стратегии возврата к среднему (mean reversion).
Временные окна вместо фиксированного размера
Метод rolling() принимает строковые обозначения временных интервалов: ‘5D’ (5 дней), ‘2W’ (2 недели), ‘3M’ (3 месяца). Этот подход корректно обрабатывает нерегулярные временные ряды с пропусками торговых дней.
# Временное окно 30 дней
prices_indexed = prices.copy()
rolling_30d = prices_indexed.rolling('30D').mean()
# Сравнение с фиксированным окном 30 наблюдений
rolling_30obs = prices.rolling(30).mean()
# Проверка различий
diff = (rolling_30d - rolling_30obs).dropna()
print(f"Средняя разница: {diff.mean().item():.4f}")
print(f"Макс разница: {diff.abs().max().item():.4f}")
Средняя разница: 1.9131
Макс разница: 10.9375
Временные окна автоматически адаптируются к календарным особенностям: выходные, праздники, приостановки торгов. Фиксированное окно в 30 наблюдений может охватывать разные временные периоды в зависимости от плотности данных. Для стратегий, чувствительных к календарному времени (например, ежемесячная ребалансировка портфеля), временные окна обеспечивают консистентность расчетов.
Продвинутые техники
Библиотека Pandas предоставляет расширенные возможности для кастомных вычислений на скользящих окнах, экспоненциального взвешивания и кумулятивных расчетов. Эти методы необходимы для реализации сложных количественных метрик и стратегий.
Кастомные функции через apply()
Метод apply() позволяет применять произвольные функции к каждому окну. Функция получает на вход numpy array или pandas Series (в зависимости от параметра raw) и должна возвращать скалярное значение.
def calculate_sharpe(window_returns, risk_free_rate=0.02):
"""Sharpe ratio с учетом безрисковой ставки"""
if len(window_returns) < 2:
return np.nan
excess_returns = window_returns - risk_free_rate/252
if excess_returns.std() == 0:
return 0
return excess_returns.mean() / excess_returns.std() * np.sqrt(252)
def sortino_ratio(window_returns, risk_free_rate=0.02):
"""Sortino ratio - учитывает только downside волатильность"""
if len(window_returns) < 2:
return np.nan
excess_returns = window_returns - risk_free_rate/252
downside_returns = excess_returns[excess_returns < 0]
if len(downside_returns) == 0 or downside_returns.std() == 0:
return 0
return excess_returns.mean() / downside_returns.std() * np.sqrt(252)
# Применение кастомных функций
window = 60
sharpe_rolling = returns.rolling(window).apply(calculate_sharpe, raw=False)
sortino_rolling = returns.rolling(window).apply(sortino_ratio, raw=False)
print(f"Средний Sharpe: {sharpe_rolling.mean().item():.3f}")
print(f"Средний Sortino: {sortino_rolling.mean().item():.3f}")
Средний Sharpe: 1.805
Средний Sortino: 3.769
Код реализует две метрики доходности с поправками на риск. Коэффициент Шарпа использует полную волатильность, коэффициент Сортино учитывает только негативные отклонения. Параметр raw=False передает в функцию pandas серию данных (Series) с сохранением индекса, что удобно для работы с датами. Параметр raw=True передает массив в формате Numpy (numpy array) — быстрее, но без метаданных.
Коэффициент Сортино предпочтительнее для асимметричных распределений доходности. В периоды высокой положительной волатильности (резкий рост) показатель Шарпа может давать заниженную оценку, тогда как Сортино корректно фокусируется на риске просадок.
Экспоненциальное взвешивание через ewm()
Метод ewm() применяет экспоненциальное взвешивание, где более свежие наблюдения получают больший вес. В отличие от rolling() с равными весами, ewm() обеспечивает плавную адаптацию к изменениям без резких скачков на границах окна.
Ключевые параметры экспоненциального взвешивания:
- span: количество периодов для decay, эквивалент N в формуле alpha=2/(N+1);
- halflife: период полураспада весов в единицах времени;
- alpha: коэффициент сглаживания напрямую (0 < alpha ≤ 1);
- adjust: если True, использует корректировку для начальных наблюдений.
# Сравнение rolling и ewm для волатильности
window = 20
span = 20
rolling_vol = returns.rolling(window).std() * np.sqrt(252)
ewm_vol = returns.ewm(span=span, adjust=False).std() * np.sqrt(252)
# GARCH-подобная волатильность: взвешенная сумма квадратов доходностей
returns_squared = returns ** 2
garch_like_vol = returns_squared.ewm(span=span).mean().apply(np.sqrt) * np.sqrt(252)
print(f"Rolling vol mean: {rolling_vol.mean().item():.4f}")
print(f"EWM vol mean: {ewm_vol.mean().item():.4f}")
print(f"GARCH-like vol mean: {garch_like_vol.mean().item():.4f}")
Rolling vol mean: 0.3957
EWM vol mean: 0.3936
GARCH-like vol mean: 0.3934
В примере выше мы сравниваем три подхода к оценке волатильности:
- Rolling дает одинаковый вес всем наблюдениям в окне и скачет при входе/выходе экстремумов;
- EWM плавно адаптируется к изменениям волатильности;
- GARCH-like использует экспоненциальное взвешивание квадратов доходностей — упрощенная версия GARCH(1,1), которая быстрее реагирует на волатильные периоды.
Параметр adjust=False использует рекурсивную формулу без корректировки начальных значений, что предпочтительнее для онлайн-вычислений и соответствует классическому EWMA. Параметр adjust=True применяет нормализацию весов для точности на коротких рядах.
Расширяющиеся окна для кумулятивных расчетов
Метод expanding() создает окно растущего размера от начала ряда до текущего наблюдения. Это эквивалентно rolling() с window равным позиции элемента в ряде.
# Кумулятивные метрики производительности
cumulative_return = (1 + returns).cumprod() - 1
cumulative_mean = returns.expanding().mean()
cumulative_std = returns.expanding().std()
cumulative_sharpe = cumulative_mean / cumulative_std * np.sqrt(252)
# Running maximum для расчета drawdown
running_max = (1 + returns).cumprod().expanding().max()
drawdown = (1 + returns).cumprod() / running_max - 1
print(f"Максимальная просадка: {drawdown.min().item():.2%}")
print(f"Итоговый Sharpe: {cumulative_sharpe.iloc[-1].item():.3f}")
Максимальная просадка: -36.82%
Итоговый Sharpe: 1.765
Расширяющиеся окна используются для отслеживания метрик от начала торговли. Накопленный коэффциент Шарпа (Cumulative Sharpe) показывает эволюцию риск скорректированной доходности стратегии. Оценка просадки через running maximum — стандартный способ расчета просадки: отношение текущей стоимости портфеля к историческому максимуму.
Отличие от rolling: expanding учитывает всю историю, что стабилизирует метрики на длинных периодах, но медленнее реагирует на структурные изменения в данных. Rolling ограничивает память окном, обеспечивая локальную адаптивность.
Оптимизация производительности
Скорость вычислений на скользящих окнах критична при работе с высокочастотными данными или при обучении моделей машинного обучения на множестве фич. Pandas предоставляет параметры для выбора вычислительного бэкенда и режима обработки данных.
Параметры engine и raw
Параметр engine определяет библиотеку для выполнения вычислений:
- cython (по умолчанию): оптимизированные Cython-функции для стандартных агрегаций;
- numba: JIT-компиляция для кастомных функций через apply().
Параметр raw контролирует формат входных данных в apply():
- raw=True: передает numpy array, быстрее на 2-5x;
- raw=False: передает pandas Series с индексом, медленнее но часто удобнее.
import time
import numpy as np
import yfinance as yf
def custom_metric_series(x):
"""Функция работает с pandas Series"""
return (x - x.mean()).abs().sum()
def custom_metric_array(x):
"""Функция работает с numpy array"""
return np.abs(x - x.mean()).sum()
# Бенчмарк на больших данных
ticker_large = yf.download(
'TSM',
start='2015-11-01',
end='2025-11-01',
progress=False
)
prices_large = ticker_large['Close']
returns_large = prices_large.pct_change().dropna()
window = 60
iterations = 3
# Тест с raw=False
start = time.time()
for _ in range(iterations):
result_series = returns_large.rolling(window).apply(
custom_metric_series,
raw=False
)
time_series = (time.time() - start) / iterations
# Тест с raw=True
start = time.time()
for _ in range(iterations):
result_array = returns_large.rolling(window).apply(
custom_metric_array,
raw=True
)
time_array = (time.time() - start) / iterations
# Тест с numba engine
start = time.time()
for _ in range(iterations):
result_numba = returns_large.rolling(window).apply(
custom_metric_array,
raw=True,
engine='numba'
)
time_numba = (time.time() - start) / iterations
print(f"raw=False: {time_series:.3f}s")
print(f"raw=True: {time_array:.3f}s")
print(f"numba: {time_numba:.3f}s")
print(f"Ускорение raw: {time_series / time_array:.1f}x")
print(f"Ускорение numba: {time_series / time_numba:.1f}x")
raw=False: 0.562s
raw=True: 0.033s
numba: 1.559s
Ускорение raw: 16.8x
Ускорение numba: 0.4x
Код сравнивает три режима вычислений на кастомной метрике. Параметр raw=True дает ускорение в несколько раз за счет отсутствия дополнительного компьюта на создание Series. Numba engine компилирует функцию при первом запуске и обеспечивает ускорение 5-10x на последующих вызовах. Для разовых расчетов компиляция может занять больше времени чем экономия от JIT.
Numba требует ограничений на код функции: только numpy операции, никаких pandas методов, типы должны быть совместимы с nopython режимом. Для сложной бизнес-логики с условиями и множественными ветвлениями raw=True остается оптимальным выбором.
Практические кейсы
Скользящие окна применяются для детекции аномалий, построения торговых сигналов и расчета динамических корреляций между активами. Рассмотрим несколько прикладных сценариев с полной реализацией.
Детекция аномалий через z-score
Z-score на скользящем окне выявляет статистически значимые отклонения от локального среднего. Метод применим для идентификации экстремальных движений цены, которые часто предшествуют развороту.
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# Приведение данных к безопасному виду: prices и returns должны быть Series
if isinstance(prices, pd.DataFrame):
prices = prices.iloc[:, 0]
if isinstance(returns, pd.DataFrame):
returns = returns.iloc[:, 0]
# Выравнивание индексов
common_index = prices.index.intersection(returns.index)
prices = prices.loc[common_index]
returns = returns.loc[common_index]
# Rolling Z-score
window = 30
rolling_mean = returns.rolling(window).mean()
rolling_std = returns.rolling(window).std(ddof=0)
# Защита от деления на 0
z_score = (returns - rolling_mean) / rolling_std.replace(0, np.nan)
# Детекция аномалий
threshold = 2.5
anomalies = z_score.abs() > threshold
# Визуализация
fig, (ax1, ax2, ax3) = plt.subplots(3, 1, figsize=(14, 10), sharex=True)
# Цены + аномалии
ax1.plot(prices.index, prices.values, color='black', linewidth=0.8, label='TSM Price')
anomaly_prices = prices.loc[anomalies]
ax1.scatter(
anomaly_prices.index,
anomaly_prices.values,
color='red',
s=50,
alpha=0.6,
label='Anomalies',
zorder=5
)
ax1.set_ylabel('Price ($)')
ax1.legend()
ax1.grid(alpha=0.3)
# Z-score
ax2.plot(z_score.index, z_score.values, color='#2C3E50', linewidth=0.8)
ax2.axhline(threshold, color='red', linestyle='--', linewidth=1, alpha=0.7, label=f'±{threshold}')
ax2.axhline(-threshold, color='red', linestyle='--', linewidth=1, alpha=0.7)
ax2.axhline(0, color='black', linewidth=0.5, alpha=0.5)
ylim = (-5, 5)
ax2.set_ylim(ylim)
ax2.fill_between(z_score.index, threshold, ylim[1], color='red', alpha=0.1)
ax2.fill_between(z_score.index, -threshold, ylim[0], color='red', alpha=0.1)
ax2.set_ylabel('Z-score')
ax2.legend()
ax2.grid(alpha=0.3)
# Скользящая волатильность
rolling_vol = rolling_std * np.sqrt(252) * 100
ax3.plot(
rolling_vol.index,
rolling_vol.values,
color='#34495E',
linewidth=1
)
ax3.set_ylabel('Rolling Vol (%)')
ax3.set_xlabel('Date')
ax3.grid(alpha=0.3)
plt.tight_layout()
plt.show()
# Статистика аномалий
num_anomalies = anomalies.sum()
freq_anomalies = num_anomalies / len(anomalies) * 100
print(f"Всего аномалий: {int(num_anomalies)}")
print(f"Частота аномалий: {freq_anomalies:.2f}%")
print("\nТоп-5 положительных аномалий:")
print(z_score.dropna().nlargest(5))
print("\nТоп-5 отрицательных аномалий:")
print(z_score.dropna().nsmallest(5))
Всего аномалий: 10
Частота аномалий: 1.99%
Топ-5 положительных аномалий:
Date
2024-01-18 4.295813
2025-04-09 3.323819
2024-10-17 3.109780
2024-06-05 2.791702
2025-10-13 2.694060
Name: TSM, dtype: float64
Топ-5 отрицательных аномалий:
Date
2025-01-27 -3.834212
2024-07-17 -3.033414
2025-10-10 -2.986711
2023-12-20 -2.390125
2025-04-03 -2.349212

Рис. 1: Детекция аномалий в ценах акций TSM через z-score. Верхняя панель показывает цены с красными маркерами аномальных движений. Средняя панель отображает z-score с пороговыми уровнями ±2.5 — зоны за пределами порогов выделены красным, указывая на статистически значимые отклонения. Нижняя панель демонстрирует динамику скользящей волатильности, которая влияет на чувствительность детекции. Z-score выше 2.5 означает что доходность отклонилась от 30-дневного среднего более чем на 2.5 стандартных отклонения — событие с вероятностью менее 1% при нормальном распределении
Представленный выше подход к обнаружению аномалий эффективен для стратегий возврата к среднему: вход в позицию при экстремальных z-score с ожиданием возврата к среднему. При этом важно выбирать порог с учетом специфики актива — для волатильных криптовалют порог 3-4, для ликвидных акций 2-2.5. Ложные сигналы возникают при структурных сдвигах (смена режима волатильности), когда историческое среднее перестает быть валидным ориентиром.
Стратегия возврата к среднему на основе скользящих окон
Стратегия возврата к среднему использует отклонение цены от скользящего среднего как сигнал для открытия позиции. Стратегия предполагает что экстремальные движения корректируются возвратом к равновесию.
import yfinance as yf
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
# Загрузка котировок EUR/GBP за последние 2 года
ticker_symbol = 'EURGBP=X'
prices = yf.download(ticker_symbol,
period='2y',
interval='1d',
progress=False,
auto_adjust=False)['Close'].dropna()
prices = prices.squeeze() # превращаем в Series
# Стратегия mean reversion (Long & Short)
def mean_reversion_strategy(prices, short_window=7, long_window=28, entry_threshold=1.57, exit_threshold=0.25):
returns = prices.pct_change()
ma_long = prices.rolling(long_window).mean()
std_long = prices.rolling(long_window).std()
z_score = (prices - ma_long) / std_long
signals = pd.DataFrame(index=prices.index)
signals['price'] = prices
signals['ma_long'] = ma_long
signals['z_score'] = z_score
signals['position'] = 0
position = 0
positions = []
for i in range(len(signals)):
if i < long_window:
positions.append(0)
continue
current_z = signals['z_score'].iloc[i]
# Long & Short стратегия
if position == 0:
if current_z < -entry_threshold:
position = 1 # Long
elif current_z > entry_threshold:
position = -1 # Short
elif position == 1 and current_z > -exit_threshold:
position = 0 # Закрываем Long
elif position == -1 and current_z < exit_threshold:
position = 0 # Закрываем Short
positions.append(position)
signals['position'] = positions
signals['returns'] = returns
signals['strategy_returns'] = signals['position'].shift(1) * signals['returns']
# Метрики
total_return = (1 + signals['strategy_returns'].dropna()).prod() - 1
sharpe = signals['strategy_returns'].mean() / signals['strategy_returns'].std() * np.sqrt(252)
cumulative = (1 + signals['strategy_returns'].fillna(0)).cumprod()
running_max = cumulative.expanding().max()
drawdown = (cumulative - running_max) / running_max
max_drawdown = drawdown.min()
trades = (signals['position'].diff() != 0).sum()
return signals, {
'total_return': total_return,
'sharpe_ratio': sharpe,
'max_drawdown': max_drawdown,
'num_trades': trades
}
# Применение стратегии
signals, metrics = mean_reversion_strategy(prices, short_window=7, long_window=28,
entry_threshold=1.57, exit_threshold=0.25)
print("Метрики стратегии:")
print(f"Общая доходность: {metrics['total_return']:.2%}")
print(f"Sharpe ratio: {metrics['sharpe_ratio']:.3f}")
print(f"Максимальная просадка: {metrics['max_drawdown']:.2%}")
print(f"Количество сделок: {metrics['num_trades']}")
# Buy-and-hold доходность
buy_hold_return = (prices.iloc[-1] / prices.iloc[0]) - 1
print(f"\nBuy-and-hold доходность: {buy_hold_return:.2%}")
# Вычисление short MA
ma_short = prices.rolling(7).mean()
# Визуализация
fig, (ax1, ax2, ax3) = plt.subplots(3, 1, figsize=(14, 10), sharex=True)
# Цена и сигналы
ax1.plot(signals.index, signals['price'], color='black', linewidth=0.8, label='EUR/GBP Price', alpha=0.7)
ax1.plot(signals.index, signals['ma_long'], color='blue', linewidth=1, label=f'{28}D MA', alpha=0.6)
ax1.plot(signals.index, ma_short, color='purple', linewidth=1, label='7D Short MA', alpha=0.6)
long_entries = signals[signals['position'].diff() == 1].index
short_entries = signals[signals['position'].diff() == -1].index
ax1.scatter(long_entries, signals.loc[long_entries, 'price'], color='green', marker='^', s=100, label='Long Entry', zorder=5)
ax1.scatter(short_entries, signals.loc[short_entries, 'price'], color='red', marker='v', s=100, label='Short Entry', zorder=5)
ax1.set_ylabel('Price')
ax1.legend(loc='upper left')
ax1.grid(alpha=0.3)
# Z-score с порогами
ax2.plot(signals.index, signals['z_score'], color='#2C3E50', linewidth=0.8)
ax2.axhline(y=1.57, color='red', linestyle='--', linewidth=1, alpha=0.7, label='Entry threshold')
ax2.axhline(y=-1.57, color='green', linestyle='--', linewidth=1, alpha=0.7)
ax2.axhline(y=0.25, color='orange', linestyle=':', linewidth=1, alpha=0.7, label='Exit threshold')
ax2.axhline(y=-0.25, color='orange', linestyle=':', linewidth=1, alpha=0.7)
ax2.axhline(y=0, color='black', linestyle='-', linewidth=0.5, alpha=0.5)
ax2.fill_between(signals.index, 1.57, 3, alpha=0.1, color='red')
ax2.fill_between(signals.index, -1.57, -3, alpha=0.1, color='green')
ax2.set_ylabel('Z-score')
ax2.set_ylim(-3, 3)
ax2.legend()
ax2.grid(alpha=0.3)
# Кумулятивная доходность
strategy_cumulative = (1 + signals['strategy_returns'].fillna(0)).cumprod()
bh_cumulative = prices / prices.iloc[0]
ax3.plot(signals.index, strategy_cumulative, color='#27AE60', linewidth=1.5, label='Mean Reversion Strategy')
ax3.plot(signals.index, bh_cumulative, color='#95A5A6', linewidth=1, label='Buy & Hold', alpha=0.7)
ax3.set_ylabel('Cumulative Return')
ax3.set_xlabel('Date')
ax3.legend()
ax3.grid(alpha=0.3)
plt.tight_layout()
plt.show()

Рис. 2: Визуализация стратегии возврата к среднему на котировках EUR/GBP. Верхняя панель показывает цены со скользящими средними и маркерами входа в позицию — зеленые треугольники для long (покупка на просадках), красные для short (продажа на росте). Средняя панель отображает z-score с пороговыми уровнями: ±1.57 для входа, ±0.25 для выхода. Нижняя панель сравнивает кумулятивную доходность стратегии (зеленая линия) со стратегией buy-and-hold (серая линия). Стратегия генерирует доходность через множественные короткие позиции при отклонениях от равновесной цены
Метрики стратегии:
Общая доходность: 11.19%
Sharpe ratio: 1.554
Максимальная просадка: -2.74%
Количество сделок: 50
Buy-and-hold доходность: 0.73%
В представленном примере кода мы загружаем исторические котировки валютной пары EUR/GBP за последние 2 года и применяем стратегию mean reversion, которая открывает длинные (long) или короткие (short) позиции в зависимости от того, насколько цена отклоняется от своей долгосрочной средней.
Для расчета сигналов используется Z-score: разница между текущей ценой и скользящей средней по длинному окну (28 дней) делится на стандартное отклонение. Если Z-score превышает порог входа, открывается позиция: положительная — long, отрицательная — short. Закрытие позиции происходит, когда Z-score возвращается к уровню выхода (exit threshold = 0.25), что позволяет фиксировать прибыль или ограничивать убыток.
Стратегия ведет подсчет доходности для каждой сделки, рассчитывает кумулятивную доходность, Sharpe ratio, максимальную просадку и количество сделок.
В приведенном примере стратегия принесла 11,19% доходности за 2 года при хорошем коэффициенте Шарпа 1,55 и максимальной просадке -2,74%. Это очень хорошие результаты. Однако такие показатели - нечастый случай.
Главное ограничение стратегии mean reversion заключается в том, что она плохо работает на трендовых рынках, где цена долго движется в одном направлении и не возвращается к среднему. Данная стратегия показывает наилучший результат на валютных парах и на рынках с диапазонным движением, где есть выраженный уровень равновесия цены. В нашем случае это и валютная пара и валюты соседних стран с похожими экономиками, поэтому они не могут длительно уходить в тренды друг от друга и стремятся к равновесию. Что, в общем-то, идеально ложится в парадигму стратегии.
Динамический корреляционный анализ
Еще один метод библиотеки Pandas под названием rolling correlation выявляет изменения взаимосвязи между активами во времени. Метод применяется для диверсификации портфеля, хеджирования и отбора пар для трейдинга.
import yfinance as yf
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from scipy import stats
# Загрузка котировок Visa и Mastercard
ticker1 = yf.download('V', start='2023-11-01', end='2025-11-01', progress=False, auto_adjust=False)
ticker2 = yf.download('MA', start='2023-11-01', end='2025-11-01', progress=False, auto_adjust=False)
# Извлекаем Close с учетом возможного MultiIndex
if isinstance(ticker1.columns, pd.MultiIndex):
prices1 = ticker1['Close'].iloc[:, 0] # Берем первую колонку
else:
prices1 = ticker1['Close']
if isinstance(ticker2.columns, pd.MultiIndex):
prices2 = ticker2['Close'].iloc[:, 0]
else:
prices2 = ticker2['Close']
# Доходности
returns1 = prices1.pct_change().dropna()
returns2 = prices2.pct_change().dropna()
# Выравнивание индексов
common_index = returns1.index.intersection(returns2.index)
returns1 = returns1.loc[common_index]
returns2 = returns2.loc[common_index]
# Размер окна
window = 60
# Скользящая корреляция
rolling_corr = returns1.rolling(window).corr(returns2)
# Скользящая бета
rolling_cov = returns1.rolling(window).cov(returns2)
rolling_var = returns2.rolling(window).var()
rolling_beta = rolling_cov / rolling_var
# Средние значения
overall_corr_mean = np.nanmean(rolling_corr.values)
overall_beta_mean = np.nanmean(rolling_beta.values)
# Визуализации
fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2, figsize=(15, 10))
# Нормализованные цены
norm_prices1 = prices1.loc[common_index] / prices1.loc[common_index].iloc[0]
norm_prices2 = prices2.loc[common_index] / prices2.loc[common_index].iloc[0]
ax1.plot(common_index, norm_prices1, color='#2C3E50', linewidth=1, label='Visa', alpha=0.8)
ax1.plot(common_index, norm_prices2, color='#E74C3C', linewidth=1, label='Mastercard', alpha=0.8)
ax1.set_ylabel('Normalized Price')
ax1.set_title('Price Dynamics (Visa vs Mastercard)')
ax1.legend()
ax1.grid(alpha=0.3)
# Скользящая корреляция
# Убеждаемся что это 1D массив
corr_values = rolling_corr.values.flatten() if rolling_corr.values.ndim > 1 else rolling_corr.values
ax2.plot(rolling_corr.index, corr_values, color='#3498DB', linewidth=1.2)
ax2.axhline(y=0, color='black', linestyle='-', linewidth=0.5, alpha=0.5)
ax2.axhline(y=overall_corr_mean, color='red', linestyle='--', linewidth=1, alpha=0.7,
label=f'Mean: {overall_corr_mean:.3f}')
ax2.fill_between(rolling_corr.index, 0, corr_values,
where=(corr_values > 0), alpha=0.2, color='green', label='Positive')
ax2.fill_between(rolling_corr.index, 0, corr_values,
where=(corr_values < 0), alpha=0.2, color='red', label='Negative') ax2.set_ylabel('Correlation') ax2.set_title(f'{window}-Day Rolling Correlation') ax2.set_ylim(-1, 1) ax2.legend() ax2.grid(alpha=0.3) # Скользящая бета beta_values = rolling_beta.values.flatten() if rolling_beta.values.ndim > 1 else rolling_beta.values
ax3.plot(rolling_beta.index, beta_values, color='#9B59B6', linewidth=1.2)
ax3.axhline(y=overall_beta_mean, color='red', linestyle='--', linewidth=1, alpha=0.7,
label=f'Mean Beta: {overall_beta_mean:.3f}')
ax3.axhline(y=1, color='black', linestyle=':', linewidth=1, alpha=0.5, label='Beta = 1')
ax3.set_ylabel('Beta (Visa vs Mastercard)')
ax3.set_title(f'{window}-Day Rolling Beta')
ax3.set_xlabel('Date')
ax3.legend()
ax3.grid(alpha=0.3)
# Скатерплот последних доходностей
recent_returns1 = returns1.iloc[-window:]
recent_returns2 = returns2.iloc[-window:]
ax4.scatter(recent_returns2, recent_returns1, alpha=0.5, s=30, color='#34495E')
# Линия регрессии
slope, intercept, r_value, p_value, std_err = stats.linregress(recent_returns2, recent_returns1)
line_x = np.array([recent_returns2.min(), recent_returns2.max()])
line_y = slope * line_x + intercept
ax4.plot(line_x, line_y, color='red', linewidth=2,
label=f'Beta: {slope:.3f}\nR²: {r_value**2:.3f}')
ax4.set_xlabel('Mastercard Returns')
ax4.set_ylabel('Visa Returns')
ax4.set_title(f'Returns Relationship (Last {window} Days)')
ax4.axhline(y=0, color='black', linestyle='-', linewidth=0.5, alpha=0.3)
ax4.axvline(x=0, color='black', linestyle='-', linewidth=0.5, alpha=0.3)
ax4.legend()
ax4.grid(alpha=0.3)
plt.tight_layout()
plt.show()
# Статистика
print(f"Средняя корреляция: {overall_corr_mean:.3f}")
print(f"Минимальная корреляция: {np.nanmin(corr_values):.3f}")
print(f"Максимальная корреляция: {np.nanmax(corr_values):.3f}")
print(f"Стандартное отклонение: {np.nanstd(corr_values):.3f}")
print(f"\nСредняя бета: {overall_beta_mean:.3f}")
print(f"Текущая бета: {rolling_beta.iloc[-1]:.3f}")

Рис. 3: Анализ динамической корреляции между котировками акций Visa и Mastercard. Верхний левый график показывает нормализованные цены обоих активов — синхронность движений визуально подтверждает положительную корреляцию. Верхний правый график отображает 60-дневную скользящую корреляцию с выделением положительных (зеленая заливка) и отрицательных (красная заливка) значений. Нижний левый график показывает скользящую бету с референсным уровнем beta=1 — бета выше 1 означает что акции Visa более волатильны относительно Mastercard. Нижний правый график — график рассеяния доходностей за последние 60 дней с линией регрессии, демонстрирующей текущую силу связи через R²
Средняя корреляция: 0.822
Минимальная корреляция: 0.594
Максимальная корреляция: 0.969
Стандартное отклонение: 0.087
Средняя бета: 0.835
Текущая бета: 0.862
Интерпретация результатов:
- Средняя корреляция 0.822 говорит о том, что акции Visa и Mastercard движутся в одном направлении с высокой согласованностью;
- Минимальная и максимальная корреляция (0.594 – 0.969) показывают, что даже в периоды расхождения связь остается положительной и достаточно сильной;
- Стандартное отклонение 0.087 указывает на умеренную изменчивость корреляции во времени — она стабильна;
- Средняя бета 0.835 означает, что Visa менее волатильна относительно Mastercard (при росте Mastercard на 1%, Visa в среднем растет на 0.835%);
- Текущая бета 0.862 подтверждает, что последняя динамика сохраняет примерно такую же чувствительность к изменениям Mastercard.
Вывод: акции двух компаний сильно коррелированы и могут рассматриваться для парных стратегий или хеджирования, при этом Visa движется чуть мягче, чем Mastercard.
Инжиниринг признаков для машинного обучения прогноза временных рядов
Скользящие окна генерируют фичи для предиктивных моделей: статистические агрегации исторических данных служат входами для алгоритмов классификации и регрессии. Правильная конструкция фич на основе rolling определяет качество прогнозов.
При создании предиктивных фич важно избегать утечек данных из будущего. Метод rolling() естественно соблюдает каузальность, используя только предыдущие наблюдения.
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import TimeSeriesSplit
from sklearn.metrics import classification_report, roc_auc_score
def create_features(prices, windows=[5, 10, 20, 40, 60]):
"""Генерация фич на основе скользящих окон"""
df = pd.DataFrame(index=prices.index)
returns = prices.pct_change()
for window in windows:
# Доходность за период
df[f'return_{window}d'] = prices.pct_change(window)
# Волатильность
df[f'volatility_{window}d'] = returns.rolling(window).std()
# Моментум (текущая цена / MA)
df[f'momentum_{window}d'] = prices / prices.rolling(window).mean() - 1
# Относительная позиция в диапазоне
rolling_min = prices.rolling(window).min()
rolling_max = prices.rolling(window).max()
df[f'range_position_{window}d'] = (prices - rolling_min) / (rolling_max - rolling_min)
# Skewness доходностей
df[f'skew_{window}d'] = returns.rolling(window).skew()
# Kurtosis доходностей
df[f'kurt_{window}d'] = returns.rolling(window).kurt()
return df
# Создание таргета: направление движения через 5 дней
def create_target(prices, horizon=5):
"""Бинарная классификация: рост/падение через horizon дней"""
future_return = prices.shift(-horizon) / prices - 1
target = (future_return > 0).astype(int)
return target
# Подготовка данных
features = create_features(prices1)
target = create_target(prices1, horizon=5)
# Объединение и очистка
data = pd.concat([features, target.rename('target')], axis=1).dropna()
print(f"Размер датасета: {len(data)}")
print(f"Количество фич: {len(features.columns)}")
print(f"Распределение таргета:\n{data['target'].value_counts(normalize=True)}")
print(f"\nДиапазон дат: {data.index[0]} to {data.index[-1]}")
# Разделение на train/test с сохранением временного порядка
# Используем 80% для train, 20% для test
split_idx = int(len(data) * 0.8)
train = data.iloc[:split_idx]
test = data.iloc[split_idx:]
X_train = train.drop('target', axis=1)
y_train = train['target']
X_test = test.drop('target', axis=1)
y_test = test['target']
print(f"\nTrain размер: {len(train)} ({train.index[0]} to {train.index[-1]})")
print(f"Test размер: {len(test)} ({test.index[0]} to {test.index[-1]})")
# Проверка что train не пустой
if len(train) == 0 or len(test) == 0:
print("ОШИБКА: Train или Test набор пустой!")
else:
# Обучение Random Forest
rf = RandomForestClassifier(n_estimators=100, max_depth=10, min_samples_split=20,
random_state=42, n_jobs=-1)
rf.fit(X_train, y_train)
# Предсказание
y_pred = rf.predict(X_test)
y_pred_proba = rf.predict_proba(X_test)[:, 1]
# Метрики
print("\n" + "="*50)
print("РЕЗУЛЬТАТЫ КЛАССИФИКАЦИИ")
print("="*50)
print(classification_report(y_test, y_pred, target_names=['Down', 'Up']))
print(f"ROC AUC: {roc_auc_score(y_test, y_pred_proba):.3f}")
# Feature importance
feature_importance = pd.DataFrame({
'feature': X_train.columns,
'importance': rf.feature_importances_
}).sort_values('importance', ascending=False)
print("\nТоп-10 важных фич:")
print(feature_importance.head(10).to_string(index=False))
Размер датасета: 442
Количество фич: 30
Распределение таргета:
target
1 0.558824
0 0.441176
Name: proportion, dtype: float64
Диапазон дат: 2024-01-30 00:00:00 to 2025-10-31 00:00:00
Train размер: 353 (2024-01-30 00:00:00 to 2025-06-26 00:00:00)
Test размер: 89 (2025-06-27 00:00:00 to 2025-10-31 00:00:00)
==================================================
РЕЗУЛЬТАТЫ КЛАССИФИКАЦИИ
==================================================
precision recall f1-score support
Down 0.69 0.44 0.54 50
Up 0.51 0.74 0.60 39
accuracy 0.57 89
macro avg 0.60 0.59 0.57 89
weighted avg 0.61 0.57 0.57 89
ROC AUC: 0.614
Топ-10 важных фич:
feature importance
volatility_40d 0.070033
kurt_60d 0.058292
return_40d 0.055881
return_10d 0.054677
kurt_40d 0.052024
range_position_60d 0.046882
momentum_20d 0.043195
momentum_60d 0.042244
volatility_60d 0.039244
volatility_20d 0.036533
Код создает 30 фичей на основе скользящих окон разной длины. Каждое окно (5, 10, 20, 40, 60 дней) дает 6 метрик: доходность, волатильность, моментум, позицию в диапазоне, асимметрию (skewness) и эксцесс (kurtosis). Цель (target) — бинарная переменная, показывающая рост или падение через 5 дней.
Анализ важности признаков (feature importance) показывает, какие окна и метрики дают наилучшие прогнозы. Обычно для внутридневных стратегий важнее краткосрочные окна (5–10 дней), а для позиционной торговли — долгосрочные (40–60 дней). Асимметрия и эксцесс помогают видеть распределение доходностей: положительный skew указывает на правые хвосты (редкие большие положительные выбросы), высокий kurtosis — на толстые хвосты (частые экстремальные значения).
Многомерные rolling операции
Применение rolling к DataFrame вместо Series позволяет рассчитывать метрики с учетом взаимосвязей между столбцами. Пример: ковариационная матрица на скользящем окне для портфельной оптимизации.
# Загрузка портфеля активов
tickers = ['TSM', 'ASML', 'NVDA', 'AMD']
portfolio = yf.download(tickers, start='2023-11-01', end='2025-11-01', progress=False)['Close']
# Убираем MultiIndex если он есть
if isinstance(portfolio.columns, pd.MultiIndex):
portfolio.columns = portfolio.columns.droplevel(0)
returns_portfolio = portfolio.pct_change().dropna()
# Скользящая ковариационная матрица
window = 60
def rolling_covariance_matrix(returns, window):
"""Расчет скользящей ковариационной матрицы"""
cov_matrices = []
for i in range(window-1, len(returns)):
window_returns = returns.iloc[i-window+1:i+1]
cov_matrix = window_returns.cov()
cov_matrices.append(cov_matrix)
return cov_matrices
cov_matrices = rolling_covariance_matrix(returns_portfolio, window)
# Извлечение диагональных элементов (дисперсий)
variances = pd.DataFrame(index=returns_portfolio.index[window-1:])
for ticker in tickers:
variances[ticker] = [cov.loc[ticker, ticker] for cov in cov_matrices]
# Визуализация динамики дисперсий
fig, ax = plt.subplots(figsize=(14, 6))
for ticker in tickers:
ax.plot(variances.index, variances[ticker] * 252, label=ticker, linewidth=1.2)
ax.set_ylabel('Annualized Variance')
ax.set_xlabel('Date')
ax.set_title(f'{window}-Day Rolling Variance (Portfolio Components)')
ax.legend()
ax.grid(alpha=0.3)
plt.tight_layout()
plt.show()
# Расчет минимально-вариационного портфеля для последней даты
last_cov = cov_matrices[-1]
n_assets = len(tickers)
# Веса для минимальной дисперсии (аналитическое решение)
inv_cov = np.linalg.inv(last_cov.values)
ones = np.ones(n_assets)
weights_min_var = inv_cov @ ones / (ones @ inv_cov @ ones)
weights_df = pd.DataFrame({
'Ticker': tickers,
'Weight': weights_min_var
}).sort_values('Weight', ascending=False)
print("Веса минимально-вариационного портфеля:")
print(weights_df.to_string(index=False))
print(f"\nОжидаемая дисперсия портфеля: {weights_min_var @ last_cov.values @ weights_min_var:.6f}")

Рис. 4: Динамика 60-дневной скользящей дисперсии для портфеля из 4-х акций сектора производства полупроводников. График показывает как волатильность каждого компонента меняется во времени. NVDA демонстрирует наиболее высокую дисперсию — актив с максимальным риском в портфеле
Веса минимально-вариационного портфеля:
Ticker Weight
NVDA 0.481229
ASML 0.379635
AMD 0.101275
TSM 0.037861
Ожидаемая дисперсия портфеля: 0.000281
Скользящая ковариационная матрица используется для адаптивного управления портфелем. Портфель с минимальной дисперсией (Minimum Variance Portfolio) снижает риск при заданном наборе активов. Веса активов пересчитываются каждый период с учетом текущей ковариационной матрицы, что позволяет учитывать изменения корреляций и волатильностей.
Альтернатива — оптимизация по Марковицу с ограничениями на веса (например, запрет на короткие позиции). Она требует оценки ожидаемых доходностей, что добавляет шум в оптимизацию. Портфель с минимальной дисперсией более устойчив, так как зависит только от второго момента распределения доходностей (дисперсии и ковариаций).
Обработка особых случаев и ограничения
Скользящие функции имеют ограничения при работе с нерегулярными данными, пропусками и граничными эффектами. Понимание этих ограничений необходимо для корректного применения метода в продакшен системах.
Обработка пропущенных значений
Pandas rolling() по умолчанию игнорирует NaN в расчетах, но это может привести к неожиданным результатам когда пропусков много. Параметр min_periods контролирует минимальное количество ненулевых наблюдений для расчета.
# Создание ряда с пропусками
series_with_gaps = prices.copy()
# Искусственно добавляем кластерные пропуски
np.random.seed(42)
n_blocks = 10 # количество разрывов
block_size = 2 # длина каждого разрыва
for _ in range(n_blocks):
start = np.random.randint(0, len(series_with_gaps) - block_size)
series_with_gaps.iloc[start:start + block_size] = np.nan
print(f"Пропусков в данных: {series_with_gaps.isna().sum()}")
# Разные стратегии обработки пропусков
window = 15
# 1. По умолчанию
rolling_default = series_with_gaps.rolling(window).mean()
# 2. Строгий режим: требует все наблюдения
rolling_strict = series_with_gaps.rolling(window, min_periods=window).mean()
# 3. Гибкий режим: минимум половина окна
rolling_flexible = series_with_gaps.rolling(window, min_periods=window // 2).mean()
# 4. Forward fill перед rolling
series_ffill = series_with_gaps.ffill()
rolling_ffill = series_ffill.rolling(window).mean()
print(f"\nNaN в rolling_default: {rolling_default.isna().sum()}")
print(f"NaN в rolling_strict: {rolling_strict.isna().sum()}")
print(f"NaN в rolling_flexible: {rolling_flexible.isna().sum()}")
print(f"NaN в rolling_ffill: {rolling_ffill.isna().sum()}")
# Сравнение значений
comparison = pd.DataFrame({
'Original': series_with_gaps,
'Default': rolling_default,
'Strict': rolling_strict,
'Flexible': rolling_flexible,
'Ffill': rolling_ffill
}).iloc[80:100]
print("\nСравнение методов (выборка):")
print(comparison)
Пропусков в данных: 20
NaN в rolling_default: 161
NaN в rolling_strict: 161
NaN в rolling_flexible: 6
NaN в rolling_ffill: 14
Сравнение методов (выборка):
Original Default Strict Flexible Ffill
Date
2024-04-15 0.85440 NaN NaN 0.856373 0.856058
2024-04-16 0.85362 NaN NaN 0.856069 0.855795
2024-04-17 0.85440 NaN NaN 0.855808 0.855568
2024-04-18 0.85669 NaN NaN 0.855785 0.855549
2024-04-19 0.85582 NaN NaN 0.855865 0.855617
2024-04-22 0.86120 NaN NaN 0.856418 0.856097
2024-04-23 0.86260 NaN NaN 0.856859 0.856669
2024-04-24 0.85942 0.857030 0.857030 0.857030 0.857030
2024-04-25 0.85871 0.857168 0.857168 0.857168 0.857168
2024-04-26 0.85760 0.857195 0.857195 0.857195 0.857195
2024-04-29 0.85590 0.857067 0.857067 0.857067 0.857067
2024-04-30 0.85314 0.856739 0.856739 0.856739 0.856739
2024-05-01 0.85397 0.856574 0.856574 0.856574 0.856574
2024-05-02 0.85474 0.856437 0.856437 0.856437 0.856437
2024-05-03 0.85551 0.856515 0.856515 0.856515 0.856515
2024-05-06 0.85780 0.856741 0.856741 0.856741 0.856741
2024-05-07 0.85727 0.856985 0.856985 0.856985 0.856985
2024-05-08 0.85992 0.857353 0.857353 0.857353 0.857353
2024-05-09 0.86016 0.857584 0.857584 0.857584 0.857584
2024-05-10 0.86078 0.857915 0.857915 0.857915 0.857915
Код сравнивает четыре подхода к заполнению пропусков:
- Default (по умолчанию) — считает среднее по доступным значениям. Например, окно из 15 точек с 5 пропусками дает среднее по 15 значениям;
- Strict (строгий) — требует полного окна, иначе возвращает NaN. Консервативно, но теряет данные;
- Flexible (гибкий) — принимает окна, если есть хотя бы половина наблюдений. Компромисс между надежностью и полнотой данных;
- Forward fill (заполнение последним значением) — пропуски заменяются последним известным значением перед скользящим расчетом.
Выбор метода зависит от природы пропусков: Если пропуски случайны (например, технические сбои биржи) — подходят default или flexible. Если пропуски систематичны (например, выходные без торгов) — forward fill допустим, но только при исторически корректном заполнении (без использования будущей информации).
Граничные эффекты и артефакты
Скользящие окна создают искажения на границах ряда. В начале ряда данных меньше, чем размер окна, что влияет на расчеты. В конце ряда результаты могут быть искажены, если данные обрезаны.
Чтобы уменьшить эти эффекты можно:
- Отбрасывать первые (window-1) наблюдений при анализе или явно задавать min_periods=window;
- Заполнение недостающих значений в начале окна средним, медианой или NaN для сохранения длины ряда, если важно сохранять временную ось;
- Для бэктестинга критично начинать стратегию только после формирования полного окна, иначе результаты первых периодов будут искажены;
- Для визуализации допустимо показывать неполные окна, но с пометкой о том что выбран именно такой метод.
Производительность на очень больших данных
Операции скользящего окна имеют временную сложность:
O(N·W)
где:
- N — длина ряда;
- W — размер окна.
Для стандартных агрегатных функций, таких как sum() или mean(), Pandas оптимизирует расчеты до O(N) с помощью инкрементальных обновлений. Но кастомные функции через apply() остаются O(N·W).
Способы оптимизации на больших данных:
- Использовать встроенные методы (mean, std, sum) вместо apply() везде где это возможно;
- Измерять производительность на части данных, чтобы подобрать оптимальные параметры;
- Для кастомных функций применять engine='numba' с raw=True. Numba ускоряет apply() в 5–10 раз, но требует кода, совместимого с режимом nopython;
- Кешировать результаты rolling, если они используются многократно;
- Использовать специализированные библиотеки: Dask, Polars.
Для очень больших временных рядов стоит комбинировать подходы: сначала встроенные агрегаты, затем Numba для кастомных функций, и при необходимости переходить на Dask или Polars.
Заключение
Скользящие оконные функции в Pandas преобразуют временные ряды в набор локальных статистик, открывая возможности для адаптивного анализа и построения предсказательных моделей.
Практическая ценность этих функций раскрывается в сочетании с доменными знаниями: выбор размера окна отражает гипотезу о временном масштабе рыночных паттернов. Краткосрочные окна фиксируют внутринедельные колебания для тактического трейдинга. Долгосрочные окна выявляют структурные сдвиги для стратегического позиционирования.
Для надежной работы со скользящими функциями важно учитывать граничные эффекты, пропуски, мультиколлинеарность признаков и производительность на больших данных. Применение правильных методов обработки и оптимизации позволяет строить более стабильные и интерпретируемые модели, адаптированные к реальным рыночным условиям.