Скорость и ускорение в последовательностях временных рядов. Методы расчета

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

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

Математические основы производных временных рядов

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

Для непрерывной функции y(t) первая производная определяется как предел отношения приращения функции к приращению аргумента:

dy/dt = lim(Δt→0) [y(t + Δt) — y(t)] / Δt

где:

  • y(t) — значение функции в момент времени t;
  • Δt — приращение времени;
  • dy/dt — скорость изменения функции.

Первая производная показывает направление и интенсивность изменения. Положительные значения указывают на рост, отрицательные — на падение.

Вторая производная характеризует скорость изменения первой производной:

d²y/dt² = lim(Δt→0) [dy/dt(t + Δt) — dy/dt(t)] / Δt

где:

  • d²y/dt² — ускорение изменения функции;
  • dy/dt(t) — первая производная в момент t.

Вторая производная отражает кривизну траектории. Положительное ускорение означает выпуклость вверх, отрицательное — выпуклость вниз.

Дискретная аппроксимация

Временные ряды представляют собой последовательность дискретных наблюдений y₁, y₂, …, yₙ с шагом Δt. Производные аппроксимируются через конечные разности. Простейший вариант — первая прямая разность:

Δyᵢ = (yᵢ₊₁ — yᵢ) / Δt

где:

  • yᵢ — значение ряда в точке i;
  • Δt — шаг дискретизации (обычно равен 1 для равномерных рядов);
  • Δyᵢ — аппроксимация первой производной.

Эта формула дает смещенную оценку, так как использует только правую точку. Центральная разность точнее:

Δyᵢ = (yᵢ₊₁ — yᵢ₋₁) / (2Δt)

Центральная схема симметрична относительно точки i и обеспечивает погрешность второго порядка по Δt.

Методы расчета первой производной

Конечные разности

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

Forward difference использует текущую и следующую точку:

Δyᵢ = (yᵢ₊₁ — yᵢ) / Δt

Backward difference опирается на текущую и предыдущую:

Δyᵢ = (yᵢ — yᵢ₋₁) / Δt

Central difference усредняет информацию с обеих сторон:

Δyᵢ = (yᵢ₊₁ — yᵢ₋₁) / (2Δt)

Методы Forward и backward имеют погрешность O(Δt), central — O(Δt²). На практике central difference предпочтительнее для внутренних точек ряда, forward и backward применяются на границах.

import numpy as np
import matplotlib.pyplot as plt

# Генерация синтетического ряда: тренд + шум
np.random.seed(42)
n = 200
t = np.arange(n)
trend = 0.05 * t + 10 * np.sin(2 * np.pi * t / 50)
noise = np.random.normal(0, 0.5, n)
y = trend + noise

# Конечные разности
forward_diff = np.diff(y, prepend=y[0])
backward_diff = np.diff(y, append=y[-1])
central_diff = (np.roll(y, -1) - np.roll(y, 1)) / 2
central_diff[0] = forward_diff[0]
central_diff[-1] = backward_diff[-1]

# Визуализация
fig, axes = plt.subplots(2, 1, figsize=(12, 8))

axes[0].plot(t, y, color='black', linewidth=1.5, label='Исходный ряд')
axes[0].set_ylabel('Значение', fontsize=11)
axes[0].legend(fontsize=10)
axes[0].grid(alpha=0.3)

axes[1].plot(t, forward_diff, color='#666666', alpha=0.6, label='Forward')
axes[1].plot(t, backward_diff, color='#999999', alpha=0.6, label='Backward')
axes[1].plot(t, central_diff, color='black', linewidth=1.5, label='Central')
axes[1].set_xlabel('Время', fontsize=11)
axes[1].set_ylabel('Первая производная', fontsize=11)
axes[1].legend(fontsize=10)
axes[1].grid(alpha=0.3)

plt.tight_layout()
plt.show()

Сравнение схем конечных разностей. Верхняя панель — анализируем временной ряд с трендом и шумом. Нижняя панель — первая производная, рассчитанная тремя методами. Central difference (черная линия) показывает более гладкую траекторию по сравнению с forward и backward схемами, и более пригодна для использования в торговых стратегиях

Рис. 1: Сравнение схем конечных разностей. Верхняя панель — анализируем временной ряд с трендом и шумом. Нижняя панель — первая производная, рассчитанная тремя методами. Central difference (черная линия) показывает более гладкую траекторию по сравнению с forward и backward схемами, и более пригодна для использования в торговых стратегиях

Представленный выше код генерирует синусоидальный тренд с добавлением гауссовского шума, после чего вычисляет три типа разностных схем. Схемы Forward и backward дают смещенные оценки на границах интервала, central difference обеспечивает симметричную аппроксимацию. Результат показывает, что все три метода улавливают общую динамику, но central difference менее чувствительна к локальным флуктуациям благодаря усреднению.

Выбор шага дискретизации

Шаг Δt определяет временное разрешение производной. Для равномерных рядов с единичным шагом Δt = 1, и формулы упрощаются. Если данные имеют нерегулярный шаг, необходимо использовать фактические временные метки. Уменьшение шага повышает чувствительность к высокочастотным компонентам, однако усиливает влияние шума. Увеличение шага сглаживает результат, однако снижает разрешение по времени.

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

Сглаживание и дифференцирование: фильтр Савицкого-Голея

Фильтр Savitzky-Golay выполняет локальную полиномиальную регрессию в скользящем окне и вычисляет коэффициенты этого полинома для аппроксимации производных. Метод одновременно сглаживает данные и оценивает производную, что снижает влияние шума.

👉🏻  Показатели Альфа, Бета, коэффициент Шарпа: Расчеты в Python

Основные параметры:

  • window_length — ширина окна (нечетное число), определяет степень сглаживания;
  • polyorder — порядок полинома (обычно 2-4), контролирует гибкость аппроксимации;
  • deriv — порядок производной (1 или 2).

Фильтр работает следующим образом:

  1. Для каждой точки ряда строится полином степени polyorder по соседним точкам в окне window_length;
  2. Коэффициенты полинома используются для вычисления производной в центральной точке;
  3. После чего процедура повторяется для всех точек.
from scipy.signal import savgol_filter

# Генерация ряда: экспоненциальный тренд + шум
np.random.seed(123)
n = 300
t = np.linspace(0, 10, n)
y_true = np.exp(0.1 * t) * np.sin(2 * t)
noise = np.random.normal(0, 0.3, n)
y = y_true + noise

# Истинная первая производная
dy_true = 0.1 * np.exp(0.1 * t) * np.sin(2 * t) + np.exp(0.1 * t) * 2 * np.cos(2 * t)

# Прямая разность (без сглаживания)
dy_raw = np.gradient(y, t)

# Savitzky-Golay с разными параметрами
dy_sg_11 = savgol_filter(y, window_length=11, polyorder=3, deriv=1, delta=t[1]-t[0])
dy_sg_21 = savgol_filter(y, window_length=21, polyorder=3, deriv=1, delta=t[1]-t[0])
dy_sg_51 = savgol_filter(y, window_length=51, polyorder=3, deriv=1, delta=t[1]-t[0])

# Визуализация
fig, axes = plt.subplots(2, 1, figsize=(12, 8))

axes[0].plot(t, y, color='#CCCCCC', linewidth=1, label='Зашумленный ряд')
axes[0].plot(t, y_true, color='black', linewidth=2, label='Истинный сигнал')
axes[0].set_ylabel('Значение', fontsize=11)
axes[0].legend(fontsize=10)
axes[0].grid(alpha=0.3)

axes[1].plot(t, dy_true, color='black', linewidth=2, label='Истинная производная', linestyle='--')
axes[1].plot(t, dy_raw, color='#CCCCCC', alpha=0.7, label='Прямая разность')
axes[1].plot(t, dy_sg_11, color='#FF6B6B', linewidth=1.5, label='SG window=11')
axes[1].plot(t, dy_sg_21, color='#4ECDC4', linewidth=1.5, label='SG window=21')
axes[1].plot(t, dy_sg_51, color='#45B7D1', linewidth=1.5, label='SG window=51')
axes[1].set_xlabel('Время', fontsize=11)
axes[1].set_ylabel('Первая производная', fontsize=11)
axes[1].legend(fontsize=10)
axes[1].grid(alpha=0.3)

plt.tight_layout()
plt.show()

Влияние параметров фильтра Савицкого-Голея на оценку 1-й производной. Верхняя панель — исходный сигнал с шумом. Нижняя панель — сравнение методов расчета производной. Прямая разность (светло-серая) непригодна из-за шума. Цветные линии - фильтры с разной шириной окна: чем шире окно, тем более гладкие линии

Рис. 2: Влияние параметров фильтра Савицкого-Голея на оценку 1-й производной. Верхняя панель — исходный сигнал с шумом. Нижняя панель — сравнение методов расчета производной. Прямая разность (светло-серая) непригодна из-за шума. Цветные линии — фильтры с разной шириной окна: чем шире окно, тем более гладкие линии

Код демонстрирует влияние ширины окна на качество оценки производной. Прямая разность сильно зашумлена и непригодна для анализа. Savitzky-Golay с window=11 улавливает быстрые изменения, но сохраняет часть шума. Window=21 обеспечивает баланс между точностью и гладкостью. Window=51 дает самую гладкую траекторию, но начинает терять детали быстрых осцилляций.

Выбор параметров зависит от характеристик данных:

  • Для высокочастотных стратегий с быстрыми изменениями предпочтительны узкие окна (11-21) и низкий polyorder (2-3);
  • Для позиционных стратегий подходят широкие окна (31-51) и polyorder=3-4;
  • Экспериментально window_length ≈ 5-10% от длины ряда обеспечивает хорошие результаты.

Методы на основе сплайнов

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

Кубический сплайн представляет функцию как кусочно-полиномиальную. Между каждой парой точек (xᵢ, yᵢ) и (xᵢ₊₁, yᵢ₊₁) строится полином третьей степени:

Sᵢ(x) = aᵢ + bᵢ(x — xᵢ) + cᵢ(x — xᵢ)² + dᵢ(x — xᵢ)³

где:

  • aᵢ, bᵢ, cᵢ, dᵢ — коэффициенты полинома на i-м интервале;
  • x — точка, в которой вычисляется значение;
  • Sᵢ(x) — значение сплайна на интервале [xᵢ, xᵢ₊₁].

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

S’ᵢ(x) = bᵢ + 2cᵢ(x — xᵢ) + 3dᵢ(x — xᵢ)²

Коэффициенты определяются из условий непрерывности функции, первой и второй производных в узлах интерполяции.

from scipy.interpolate import CubicSpline

# Генерация разреженного ряда с шумом
np.random.seed(456)
n_sparse = 30
t_sparse = np.sort(np.random.uniform(0, 10, n_sparse))
y_sparse = 5 * np.sin(t_sparse) + 0.5 * t_sparse + np.random.normal(0, 0.4, n_sparse)

# Построение кубического сплайна
cs = CubicSpline(t_sparse, y_sparse)

# Плотная сетка для визуализации
t_dense = np.linspace(0, 10, 500)
y_spline = cs(t_dense)
dy_spline = cs(t_dense, 1)  # Первая производная

# Прямая разность для сравнения
dy_direct = np.gradient(y_sparse, t_sparse)

# Визуализация
fig, axes = plt.subplots(2, 1, figsize=(12, 8))

axes[0].scatter(t_sparse, y_sparse, color='black', s=50, zorder=3, label='Исходные точки')
axes[0].plot(t_dense, y_spline, color='#666666', linewidth=1.5, label='Кубический сплайн')
axes[0].set_ylabel('Значение', fontsize=11)
axes[0].legend(fontsize=10)
axes[0].grid(alpha=0.3)

axes[1].scatter(t_sparse, dy_direct, color='#CCCCCC', s=50, label='Прямая разность', zorder=2)
axes[1].plot(t_dense, dy_spline, color='black', linewidth=1.5, label='Производная сплайна')
axes[1].set_xlabel('Время', fontsize=11)
axes[1].set_ylabel('Первая производная', fontsize=11)
axes[1].legend(fontsize=10)
axes[1].grid(alpha=0.3)

plt.tight_layout()
plt.show()

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

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

Интерполяция сплайнами эффективна для разреженных данных или рядов с нерегулярным шагом. Метод создает гладкую функцию, которая точно проходит через исходные точки. Производная сплайна непрерывна и не требует дополнительного сглаживания. В отличие от Savitzky-Golay, сплайн не усредняет локальные флуктуации, а строит гладкую интерполяцию между точками.

Недостаток сплайнов — чувствительность к выбросам. Единичная аномальная точка искажает сплайн в локальной окрестности. Поэтому перед применением метода необходима предобработка: детекция и удаление выбросов или замена их интерполированными значениями.

Методы расчета второй производной

Прямое дифференцирование разностей

Вторая производная аппроксимируется через вторую центральную разность:

d²yᵢ/dt² ≈ (yᵢ₊₁ — 2yᵢ + yᵢ₋₁) / Δt²

где:

  • yᵢ₋₁, yᵢ, yᵢ₊₁ — три последовательные точки ряда;
  • Δt — шаг дискретизации;
  • d²yᵢ/dt² — аппроксимация второй производной в точке i.

Формула получается из двукратного применения центральной разности. Первая производная между точками i-1 и i:

👉🏻  Основы оценки рисков и доходности биржевого портфеля

dy₁ = (yᵢ — yᵢ₋₁) / Δt

Первая производная между точками i и i+1:

dy₂ = (yᵢ₊₁ — yᵢ) / Δt

Вторая производная — разность этих производных:

d²y = (dy₂ — dy₁) / Δt = (yᵢ₊₁ — 2yᵢ + yᵢ₋₁) / Δt²

# Генерация ряда: кусочно-квадратичная функция + шум
np.random.seed(789)
n = 200
t = np.linspace(0, 20, n)
y_clean = np.piecewise(t, 
                       [t < 10, t >= 10],
                       [lambda x: 0.5 * x**2, lambda x: 0.5 * 100 - 0.3 * (x - 10)**2])
noise = np.random.normal(0, 1.5, n)
y = y_clean + noise

# Первая производная: центральная разность
dy = np.gradient(y, t)

# Вторая производная: прямая двойная разность
d2y_raw = np.gradient(dy, t)

# Вторая производная через формулу
dt = t[1] - t[0]
d2y_formula = (np.roll(y, -1) - 2*y + np.roll(y, 1)) / dt**2
d2y_formula[0] = d2y_formula[1]
d2y_formula[-1] = d2y_formula[-2]

# Визуализация
fig, axes = plt.subplots(3, 1, figsize=(12, 10))

axes[0].plot(t, y, color='#999999', linewidth=1, label='Зашумленный ряд')
axes[0].plot(t, y_clean, color='black', linewidth=2, linestyle='--', label='Чистый сигнал')
axes[0].set_ylabel('Значение', fontsize=11)
axes[0].legend(fontsize=10)
axes[0].grid(alpha=0.3)

axes[1].plot(t, dy, color='black', linewidth=1.5, label='Первая производная')
axes[1].axhline(0, color='#CCCCCC', linestyle='--', linewidth=1)
axes[1].set_ylabel('dy/dt', fontsize=11)
axes[1].legend(fontsize=10)
axes[1].grid(alpha=0.3)

axes[2].plot(t, d2y_raw, color='#666666', linewidth=1.5, label='Вторая производная (gradient)')
axes[2].plot(t, d2y_formula, color='black', linewidth=1, alpha=0.7, label='Вторая производная (формула)')
axes[2].axhline(0, color='#CCCCCC', linestyle='--', linewidth=1)
axes[2].set_xlabel('Время', fontsize=11)
axes[2].set_ylabel('d²y/dt²', fontsize=11)
axes[2].legend(fontsize=10)
axes[2].grid(alpha=0.3)

plt.tight_layout()
plt.show()

Усиление шума при расчете 2-й производной. Верхняя панель — исходный сигнал с шумом. Средняя панель — первая производная, где еще виден сигнал смены тренда в точке t=10. Нижняя панель — вторая производная полностью зашумлена, практически непригодна для анализа без дополнительного сглаживания

Рис. 4: Усиление шума при расчете 2-й производной. Верхняя панель — исходный сигнал с шумом. Средняя панель — первая производная, где еще виден сигнал смены тренда в точке t=10. Нижняя панель — вторая производная полностью зашумлена, практически непригодна для анализа без дополнительного сглаживания

Регуляризированные подходы

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

Для сглаживания обычно применяют фильтр Савицкого-Голея. Этот фильтр может напрямую вычислять вторую производную через параметр deriv=2. Альтернативный подход — последовательное сглаживание: сначала применить фильтр с deriv=0 для подавления шума, затем вычислить вторую производную сглаженного ряда.

# Продолжение предыдущего примера с кусочно-квадратичной функцией

# Метод 1: Прямой расчет второй производной через SG
d2y_sg_direct = savgol_filter(y, window_length=31, polyorder=3, deriv=2, delta=dt)

# Метод 2: Двухэтапное сглаживание
y_smoothed = savgol_filter(y, window_length=31, polyorder=3, deriv=0)
d2y_sg_twostep = savgol_filter(y_smoothed, window_length=21, polyorder=3, deriv=2, delta=dt)

# Метод 3: Сглаживание + прямая разность
y_smooth2 = savgol_filter(y, window_length=51, polyorder=3, deriv=0)
d2y_smooth_diff = np.gradient(np.gradient(y_smooth2, t), t)

# Истинная вторая производная (кусочно-постоянная)
d2y_true = np.piecewise(t,
                        [t < 10, t >= 10],
                        [1.0, -0.6])

# Визуализация
fig, axes = plt.subplots(2, 1, figsize=(12, 8))

axes[0].plot(t, y, color='#CCCCCC', linewidth=1, alpha=0.7, label='Зашумленный ряд')
axes[0].plot(t, y_clean, color='black', linewidth=2, label='Чистый сигнал')
axes[0].plot(t, y_smoothed, color='#666666', linewidth=1.5, linestyle='--', label='Сглаженный (SG)')
axes[0].set_ylabel('Значение', fontsize=11)
axes[0].legend(fontsize=10)
axes[0].grid(alpha=0.3)

axes[1].plot(t, d2y_true, color='black', linewidth=2, linestyle='--', label='Истинная d²y/dt²')
axes[1].plot(t, d2y_sg_direct, color='#FF6B6B', linewidth=1.5, label='SG прямой (deriv=2)')
axes[1].plot(t, d2y_sg_twostep, color='#4ECDC4', linewidth=1.5, label='SG двухэтапный')
axes[1].plot(t, d2y_smooth_diff, color='#45B7D1', linewidth=1.5, label='Сглаживание + gradient')
axes[1].axhline(0, color='#CCCCCC', linestyle='--', linewidth=1)
axes[1].set_xlabel('Время', fontsize=11)
axes[1].set_ylabel('d²y/dt²', fontsize=11)
axes[1].legend(fontsize=10)
axes[1].grid(alpha=0.3)

plt.tight_layout()
plt.show()

Сравнение методов регуляризации для 2-й производной. Верхняя панель — исходный, чистый и сглаженный ряды. Нижняя панель — оценки второй производной разными методами. Прямой Savitzky-Golay с deriv=2 наиболее точно восстанавливает структуру (константы 1.0 и -0.6), но размывает точку перелома в t=10. Двухэтапное сглаживание сохраняет резкость перехода ценой большей дисперсии оценки

Рис. 5: Сравнение методов регуляризации для 2-й производной. Верхняя панель — исходный, чистый и сглаженный ряды. Нижняя панель — оценки второй производной разными методами. Прямой Savitzky-Golay с deriv=2 наиболее точно восстанавливает структуру (константы 1.0 и -0.6), но размывает точку перелома в t=10. Двухэтапное сглаживание сохраняет резкость перехода ценой большей дисперсии оценки

При фильтрации нам приходится идти на компромисс между чувствительностью и стабильностью. Прямое вычисление второй производной через Savitzky-Golay (deriv=2) дает наименее зашумленный результат, но сглаживает резкие переходы. Двухэтапное сглаживание обеспечивает баланс: первый этап подавляет высокочастотный шум, второй извлекает кривизну. Метод «сглаживание + gradient» самый консервативный, однако теряет детали.

Другой метод регуляризации Total Variation (TV) минимизирует сумму абсолютных изменений производной при сохранении близости к исходным данным. Метод эффективен для рядов с кусочно-гладкой структурой, где вторая производная имеет разрывы.

Задача оптимизации:

minimize: Σᵢ(yᵢ — xᵢ)² + λ Σᵢ|xᵢ₊₁ — xᵢ|

где:

  • yᵢ — исходные данные;
  • xᵢ — сглаженный ряд;
  • λ — параметр регуляризации, контролирует степень сглаживания.

Первое слагаемое обеспечивает близость к данным, второе подавляет резкие скачки. После получения сглаженного ряда x вычисляется вторая производная стандартными методами.

from scipy.optimize import minimize

def tv_regularization(y, lambda_tv):
    """Total Variation регуляризация"""
    n = len(y)
    
    def objective(x):
        data_term = np.sum((y - x)**2)
        tv_term = np.sum(np.abs(np.diff(x)))
        return data_term + lambda_tv * tv_term
    
    result = minimize(objective, y, method='L-BFGS-B')
    return result.x

# Применение TV регуляризации с разными lambda
y_tv_001 = tv_regularization(y, lambda_tv=1.0)
y_tv_01 = tv_regularization(y, lambda_tv=10.0)
y_tv_1 = tv_regularization(y, lambda_tv=100.0)

# Вторая производная после TV
d2y_tv_001 = np.gradient(np.gradient(y_tv_001, t), t)
d2y_tv_01 = np.gradient(np.gradient(y_tv_01, t), t)
d2y_tv_1 = np.gradient(np.gradient(y_tv_1, t), t)

# Визуализация
fig, axes = plt.subplots(2, 1, figsize=(12, 8))

axes[0].plot(t, y, color='#CCCCCC', linewidth=1, alpha=0.6, label='Зашумленный ряд')
axes[0].plot(t, y_clean, color='black', linewidth=2, linestyle='--', label='Чистый сигнал')
axes[0].plot(t, y_tv_001, color='#FF6B6B', linewidth=1.5, label='TV λ=1.0')
axes[0].plot(t, y_tv_01, color='#4ECDC4', linewidth=1.5, label='TV λ=10.0')
axes[0].plot(t, y_tv_1, color='#45B7D1', linewidth=1.5, label='TV λ=100.0')
axes[0].set_ylabel('Значение', fontsize=11)
axes[0].legend(fontsize=10)
axes[0].grid(alpha=0.3)

axes[1].plot(t, d2y_true, color='black', linewidth=2, linestyle='--', label='Истинная d²y/dt²')
axes[1].plot(t, d2y_tv_001, color='#FF6B6B', linewidth=1.5, label='TV λ=1.0')
axes[1].plot(t, d2y_tv_01, color='#4ECDC4', linewidth=1.5, label='TV λ=10.0')
axes[1].plot(t, d2y_tv_1, color='#45B7D1', linewidth=1.5, label='TV λ=100.0')
axes[1].axhline(0, color='#CCCCCC', linestyle='--', linewidth=1)
axes[1].set_xlabel('Время', fontsize=11)
axes[1].set_ylabel('d²y/dt²', fontsize=11)
axes[1].legend(fontsize=10)
axes[1].grid(alpha=0.3)

plt.tight_layout()
plt.show()

Регуляризация рядов методом Total Variation для сохранения разрывов 2-й производной. Верхняя панель — эффект различных значений параметра λ на сглаживание. Малые λ следуют данным, большие дают сильное сглаживание. Нижняя панель — восстановленная вторая производная. TV с λ=1.0 лучше других сохраняет резкий скачок в точке t=10, характерный для кусочно-гладких функций

Рис. 6: Регуляризация рядов методом Total Variation для сохранения разрывов 2-й производной. Верхняя панель — эффект различных значений параметра λ на сглаживание. Малые λ следуют данным, большие дают сильное сглаживание. Нижняя панель — восстановленная вторая производная. TV с λ=1.0 лучше других сохраняет резкий скачок в точке t=10, характерный для кусочно-гладких функций

TV регуляризация сохраняет резкие переходы лучше, чем полиномиальное сглаживание:

  • При λ=1.00 метод близко следует данным, сохраняя шум;
  • λ=10.0 обеспечивает баланс: подавляет флуктуации, но сохраняет структурные изломы;
  • λ=100.0 дает чрезмерное сглаживание, теряя детали кривизны.
👉🏻  Что такое алгоритмическая торговля и как она работает?

Оптимальное значение λ зависит от соотношения сигнал/шум: для данных с высоким шумом используются большие значения, для относительно чистых рядов — малые.

Практические аспекты и подводные камни

Дифференцирование усиливает высокочастотные компоненты сигнала. Если исходный ряд содержит шум с дисперсией σ², дисперсия первой производной пропорциональна σ²/Δt², второй — σ²/Δt⁴. Это объясняет катастрофическое ухудшение качества при прямом расчете второй производной.

Работа с шумом в производных

Соотношение сигнал/шум (Signal-to-Noise Ratio, SNR) определяет выбор метода. SNR измеряется как отношение мощности сигнала к мощности шума:

SNR = 10 log₁₀(P_signal / P_noise)

где:

  • P_signal — дисперсия полезного сигнала;
  • P_noise — дисперсия шума;
  • SNR — выражается в децибелах (dB).

При SNR > 20 dB прямые методы (центральная разность, Savitzky-Golay с узким окном) дают приемлемые результаты. При SNR < 10 dB необходима агрессивная регуляризация: широкие окна сглаживания (51+), Total Variation, или предварительная фильтрация низких частот.

# Анализ влияния SNR на качество производных
np.random.seed(100)
n = 200
t = np.linspace(0, 10, n)
signal = 5 * np.sin(2 * np.pi * t / 5)

# Генерация рядов с разным SNR
snr_levels = [30, 15, 5]  # dB
noise_levels = []

for snr_db in snr_levels:
    signal_power = np.mean(signal**2)
    noise_power = signal_power / (10**(snr_db / 10))
    noise_std = np.sqrt(noise_power)
    noise_levels.append(noise_std)

# Визуализация
fig, axes = plt.subplots(3, 2, figsize=(14, 10))

for idx, (snr_db, noise_std) in enumerate(zip(snr_levels, noise_levels)):
    y_noisy = signal + np.random.normal(0, noise_std, n)
    
    # Первая производная: прямая и сглаженная
    dy_raw = np.gradient(y_noisy, t)
    dy_smooth = savgol_filter(y_noisy, window_length=31, polyorder=3, deriv=1, delta=t[1]-t[0])
    
    # Истинная производная
    dy_true = (2 * np.pi / 5) * 5 * np.cos(2 * np.pi * t / 5)
    
    # Левая колонка: исходный ряд
    axes[idx, 0].plot(t, signal, color='black', linewidth=2, linestyle='--', label='Сигнал')
    axes[idx, 0].plot(t, y_noisy, color='#999999', linewidth=1, alpha=0.7, label=f'SNR={snr_db} dB')
    axes[idx, 0].set_ylabel('Значение', fontsize=10)
    axes[idx, 0].legend(fontsize=9)
    axes[idx, 0].grid(alpha=0.3)
    
    # Правая колонка: производная
    axes[idx, 1].plot(t, dy_true, color='black', linewidth=2, linestyle='--', label='Истинная')
    axes[idx, 1].plot(t, dy_raw, color='#CCCCCC', linewidth=1, alpha=0.6, label='Прямая')
    axes[idx, 1].plot(t, dy_smooth, color='#666666', linewidth=1.5, label='SG window=31')
    axes[idx, 1].set_ylabel('dy/dt', fontsize=10)
    axes[idx, 1].legend(fontsize=9)
    axes[idx, 1].grid(alpha=0.3)

axes[2, 0].set_xlabel('Время', fontsize=10)
axes[2, 1].set_xlabel('Время', fontsize=10)

plt.tight_layout()
plt.show()

Влияние соотношения сигнал/шум на оценку производной. Три строки соответствуют SNR 30, 15 и 5 dB. Левая колонка — зашумленные ряды. Правая колонка — оценки первой производной. При высоком SNR прямая разность работает удовлетворительно. При низком SNR только агрессивное сглаживание дает приемлемый результат

Рис. 7: Влияние соотношения сигнал/шум на оценку производной. Три строки соответствуют SNR 30, 15 и 5 dB. Левая колонка — зашумленные ряды. Правая колонка — оценки первой производной. При высоком SNR прямая разность работает удовлетворительно. При низком SNR только агрессивное сглаживание дает приемлемый результат

Код демонстрирует деградацию качества производной при снижении SNR:

  • При SNR=30 dB прямая разность все еще улавливает структуру, хотя и зашумлена;
  • При SNR=15 dB прямой метод непригоден, но Savitzky-Golay восстанавливает форму;
  • При SNR=5 dB даже сглаженная оценка отклоняется от истины, требуется более широкое окно или предварительная фильтрация.

Выбор параметров сглаживания

Параметры фильтра определяют компромисс между точностью и стабильностью. Узкие окна сохраняют детали, но пропускают шум. Широкие окна подавляют шум, но размывают быстрые изменения.

Я рекомендую начать со следующих настроек для фильтра Savitzky-Golay:

  • Дневные данные (позиционные стратегии): window_length = 21-51, polyorder = 3;
  • Внутридневные данные (интрадей): window_length = 11-21, polyorder = 2-3;
  • Тиковые данные (HFT): window_length = 5-11, polyorder = 2.

Window_length должен быть нечетным и больше polyorder. Для адаптации к изменяющейся динамике используются скользящие окна переменной ширины: узкие в периоды низкой волатильности, широкие — в периоды высокой.

Обработка пропусков и выбросов

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

Стратегии обработки пропусков:

  • Forward-fill: заполнение последним известным значением. Подходит для краткосрочных пропусков (1-2 точки). Создает плато с нулевой производной;
  • Линейная интерполяция: построение прямой между соседними точками. Предпочтительна для умеренных пропусков (3-5 точек). Производная принимает постоянное значение;
  • Сплайн-интерполяция: гладкое заполнение через кубические сплайны. Оптимальна для длинных пропусков (5+ точек). Производная непрерывна и реалистична.

Выбросы определяются как точки, отклоняющиеся от локального тренда более чем на k стандартных отклонений (обычно k=3-5). Модифицированный Z-score учитывает медиану вместо среднего:

Mᵢ = 0.6745 · (yᵢ — median(y)) / MAD

где:

  • MAD = median(|yᵢ — median(y)|) — медианное абсолютное отклонение;
  • Mᵢ — модифицированный Z-score;
  • Точки с |Mᵢ| > 3.5 считаются выбросами.
👉🏻  Чем отличается финансовый ML от других видов машинного обучения

После обнаружения выбросы заменяются интерполированными значениями или удаляются с последующим заполнением пропусков.

from scipy.interpolate import interp1d

# Генерация ряда с пропусками и выбросами
np.random.seed(200)
n = 150
t_full = np.arange(n)
y_full = 10 + 0.05 * t_full + 3 * np.sin(2 * np.pi * t_full / 30) + np.random.normal(0, 0.3, n)

# Добавление пропусков
missing_idx = np.random.choice(range(20, 130), 15, replace=False)
t_missing = np.delete(t_full, missing_idx)
y_missing = np.delete(y_full, missing_idx)

# Добавление выбросов
outlier_idx = np.random.choice(range(len(y_missing)), 5, replace=False)
y_outliers = y_missing.copy()
y_outliers[outlier_idx] += np.random.choice([-1, 1], 5) * np.random.uniform(5, 8, 5)

# Детекция выбросов через Modified Z-score
def detect_outliers_mod_z(y, threshold=3.5):
    median_y = np.median(y)
    mad = np.median(np.abs(y - median_y))
    modified_z_scores = 0.6745 * (y - median_y) / (mad + 1e-8)
    return np.abs(modified_z_scores) > threshold

outliers_mask = detect_outliers_mod_z(y_outliers)
y_cleaned = y_outliers.copy()
y_cleaned[outliers_mask] = np.nan

# Интерполяция пропусков и выбросов
valid_mask = ~np.isnan(y_cleaned)
interp_func = interp1d(t_missing[valid_mask], y_cleaned[valid_mask], 
                       kind='cubic', fill_value='extrapolate')
y_interpolated = interp_func(t_missing)

# Производные до и после очистки
dy_raw = np.gradient(y_outliers, t_missing)
dy_cleaned = np.gradient(y_interpolated, t_missing)

# Визуализация
fig, axes = plt.subplots(2, 1, figsize=(12, 8))

axes[0].plot(t_full, y_full, color='#CCCCCC', linewidth=1, alpha=0.5, label='Полный ряд')
axes[0].scatter(t_missing, y_outliers, color='black', s=30, label='Данные с пропусками и выбросами', zorder=3)
axes[0].scatter(t_missing[outliers_mask], y_outliers[outliers_mask], 
                color='red', s=80, marker='x', linewidths=2, label='Выбросы', zorder=4)
axes[0].plot(t_missing, y_interpolated, color='#666666', linewidth=1.5, 
             linestyle='--', label='После очистки и интерполяции')
axes[0].set_ylabel('Значение', fontsize=11)
axes[0].legend(fontsize=10)
axes[0].grid(alpha=0.3)

axes[1].plot(t_missing, dy_raw, color='#CCCCCC', linewidth=1.5, alpha=0.7, label='Производная (с выбросами)')
axes[1].plot(t_missing, dy_cleaned, color='black', linewidth=1.5, label='Производная (после очистки)')
axes[1].set_xlabel('Время', fontsize=11)
axes[1].set_ylabel('dy/dt', fontsize=11)
axes[1].legend(fontsize=10)
axes[1].grid(alpha=0.3)

plt.tight_layout()
plt.show()

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

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

Выбросы создают острые пики в производной, которые интерпретируются как резкие изменения скорости. После удаления выбросов и интерполяции производная становится гладкой и отражает реальную динамику ряда. Модифицированный Z-score надежнее классического, так как медиана устойчива к экстремальным значениям.

Скорость и ускорение рядов: применение в алгоритмическом трейдинге

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

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

Детекция смены тренда

Комбинация 1-й и 2-й производной позволяет идентифицировать точки разворота тренда. Первая производная показывает направление движения, вторая — изменение скорости. Локальный экстремум первой производной (dy/dt = 0) при смене знака второй производной (d²y/dt²) сигнализирует о развороте.

Условия для определения разворота:

  • Вершина (разворот вниз): dy/dt ≈ 0, d²y/dt² < 0
  • Дно (разворот вверх): dy/dt ≈ 0, d²y/dt² > 0

На практике dy/dt редко достигает нуля из-за шума. Используется пороговое значение: |dy/dt| < ε, где ε зависит от масштаба данных.

import pandas as pd
import yfinance as yf
from datetime import datetime, timedelta

# Загрузка котировок через yfinance
end_date = datetime.now()
start_date = end_date - timedelta(days=365)

ticker = yf.Ticker("BA")  # Boeing Co.
data = ticker.history(start=start_date, end=end_date)

# Проверка на MultiIndex
if isinstance(data.columns, pd.MultiIndex):
    data.columns = data.columns.droplevel(0)

price = data['Close'].values
t_trading = np.arange(len(price))

# Расчет первой и второй производной с сглаживанием
window = 21
poly = 3
dy = savgol_filter(price, window_length=window, polyorder=poly, deriv=1)
d2y = savgol_filter(price, window_length=window, polyorder=poly, deriv=2)

# Детекция разворотов
threshold_dy = np.std(dy) * 0.3  # Порог для "близко к нулю"
peaks = (np.abs(dy) < threshold_dy) & (d2y < -np.std(d2y) * 0.5)
troughs = (np.abs(dy) < threshold_dy) & (d2y > np.std(d2y) * 0.5)

# Визуализация
fig, axes = plt.subplots(3, 1, figsize=(14, 10))

# Цена
axes[0].plot(t_trading, price, color='black', linewidth=1.5, label='Цена закрытия Boeing')
axes[0].scatter(t_trading[peaks], price[peaks], color='red', s=100, marker='v', 
                label='Потенциальные вершины', zorder=5)
axes[0].scatter(t_trading[troughs], price[troughs], color='green', s=100, marker='^',
                label='Потенциальные впадины', zorder=5)
axes[0].set_ylabel('Цена (USD)', fontsize=11)
axes[0].legend(fontsize=10)
axes[0].grid(alpha=0.3)

# Первая производная
axes[1].plot(t_trading, dy, color='#666666', linewidth=1.5, label='dy/dt (скорость)')
axes[1].axhline(0, color='black', linestyle='--', linewidth=1)
axes[1].axhline(threshold_dy, color='red', linestyle=':', linewidth=1, alpha=0.5)
axes[1].axhline(-threshold_dy, color='red', linestyle=':', linewidth=1, alpha=0.5)
axes[1].set_ylabel('dy/dt', fontsize=11)
axes[1].legend(fontsize=10)
axes[1].grid(alpha=0.3)

# Вторая производная
axes[2].plot(t_trading, d2y, color='black', linewidth=1.5, label='d²y/dt² (ускорение)')
axes[2].axhline(0, color='#666666', linestyle='--', linewidth=1)
axes[2].scatter(t_trading[peaks], d2y[peaks], color='red', s=80, marker='v', zorder=5)
axes[2].scatter(t_trading[troughs], d2y[troughs], color='green', s=80, marker='^', zorder=5)
axes[2].set_xlabel('Дни с начала периода', fontsize=11)
axes[2].set_ylabel('d²y/dt²', fontsize=11)
axes[2].legend(fontsize=10)
axes[2].grid(alpha=0.3)

plt.tight_layout()
plt.show()

# Статистика по сигналам
print(f"Обнаружено потенциальных вершин: {np.sum(peaks)}")
print(f"Обнаружено потенциальных впадин: {np.sum(troughs)}")
Обнаружено потенциальных вершин: 18
Обнаружено потенциальных впадин: 14

Детекция разворотов тренда через производные цены акций Boeing. Верхняя панель — дневные котировки с отмеченными потенциальными вершинами (красные треугольники вниз) и впадинами (зеленые треугольники вверх). Средняя панель — первая производная с пороговой зоной (красные пунктирные линии), где скорость считается близкой к нулю. Нижняя панель — вторая производная с выделенными точками разворота. Отрицательное ускорение в точках вершин указывает на замедление роста, положительное на впадинах — на ослабление падения

Рис. 9: Детекция разворотов тренда через производные цены акций Boeing. Верхняя панель — дневные котировки с отмеченными потенциальными вершинами (красные треугольники вниз) и впадинами (зеленые треугольники вверх). Средняя панель — первая производная с пороговой зоной (красные пунктирные линии), где скорость считается близкой к нулю. Нижняя панель — вторая производная с выделенными точками разворота. Отрицательное ускорение в точках вершин указывает на замедление роста, положительное на впадинах — на ослабление падения

Алгоритм определяет точки, в которых скорость изменения цены стремится к нулю, а ускорение указывает на возможное начало нового движения. Однако не каждый такой сигнал означает существенный разворот — часть из них отражает лишь краткосрочные коррекции. Чтобы отфильтровать ложные сигналы, используется дополнительное условие: цена после предполагаемого разворота должна измениться как минимум на 2–3% от текущего уровня в течение ближайших 5–10 дней.

👉🏻  Библиотека ETNA в Python для прогнозирования временных рядов

Измерение волатильности режима

Вторая производная количественно описывает изменение скорости движения цены. Высокие абсолютные значения d²y/dt² указывают на резкие ускорения или торможения — характерный признак повышенной волатильности.

Метрика локальной волатильности рассчитывается через вторую производную:

σ_local = √(Σᵢ₌ₜ₋ₙᵗ (d²yᵢ/dt²)²) / n

где:

  • n — размер окна для расчета (например, 20 дней);
  • t — текущий момент времени;
  • σ_local — локальная волатильность режима.

Этот показатель отражает турбулентность рынка независимо от направления движения. Рост σ_local сигнализирует о входе в режим высокой неопределенности, что требует снижения размера позиций или расширения стоп-лоссов.

# Продолжение примера с Boeing

# Расчет локальной волатильности через d2y
window_vol = 20
local_vol = np.array([np.sqrt(np.mean(d2y[max(0, i-window_vol):i+1]**2)) 
                      for i in range(len(d2y))])

# Нормализация для сравнения с классической волатильностью
returns = np.diff(price) / price[:-1]
rolling_std = np.array([np.std(returns[max(0, i-window_vol):i+1]) 
                        for i in range(len(returns))])
rolling_std = np.append(rolling_std[0], rolling_std)  # Выравнивание длины

# Корреляция между метриками
corr = np.corrcoef(local_vol[window_vol:], rolling_std[window_vol:])[0, 1]

# Визуализация
fig, axes = plt.subplots(3, 1, figsize=(14, 10))

axes[0].plot(t_trading, price, color='black', linewidth=1.5)
axes[0].set_ylabel('Цена TSM (USD)', fontsize=11)
axes[0].grid(alpha=0.3)

axes[1].plot(t_trading, local_vol, color='#666666', linewidth=1.5, 
             label='Волатильность через d²y/dt²')
axes[1].set_ylabel('σ_local', fontsize=11)
axes[1].legend(fontsize=10)
axes[1].grid(alpha=0.3)

axes[2].plot(t_trading, rolling_std, color='black', linewidth=1.5,
             label=f'Классическая волатильность (корр={corr:.2f})')
axes[2].set_xlabel('Дни с начала периода', fontsize=11)
axes[2].set_ylabel('Rolling Std', fontsize=11)
axes[2].legend(fontsize=10)
axes[2].grid(alpha=0.3)

plt.tight_layout()
plt.show()

# Вывод периодов высокой волатильности
high_vol_threshold = np.percentile(local_vol, 75)
high_vol_periods = local_vol > high_vol_threshold
print(f"Процент времени в режиме высокой волатильности: {np.mean(high_vol_periods)*100:.1f}%")
Процент времени в режиме высокой волатильности: 24.9%

Сравнение волатильности через вторую производную и классическую метрику. Верхняя панель — котировки Boeing. Средняя панель — локальная волатильность σ_local, рассчитанная через d²y/dt². Нижняя панель — классическая волатильность (скользящее стандартное отклонение доходностей). Всплески σ_local часто опережают рост классической волатильности, что позволяет превентивно корректировать риски

Рис. 10: Сравнение волатильности через вторую производную и классическую метрику. Верхняя панель — котировки Boeing. Средняя панель — локальная волатильность σ_local, рассчитанная через d²y/dt². Нижняя панель — классическая волатильность (скользящее стандартное отклонение доходностей). Всплески σ_local часто опережают рост классической волатильности, что позволяет превентивно корректировать риски

В своих стратегиях я использую данный подход для динамической адаптации позиции обратно пропорционально σ_local. Если текущая волатильность превышает медианную в 1.5 раза, размер позиции сокращается вдвое. Если волатильность ниже медианной, позиция увеличивается на 20-30% от базовой.

Фильтрация ложных пробоев через ускорение

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

Логика фильтра:

  • Истинный пробой вверх: цена выше уровня, d²y/dt² > 0 (ускорение роста);
  • Истинный пробой вниз: цена ниже уровня, d²y/dt² < 0 (ускорение падения);
  • Ложный пробой: пробой уровня при отрицательном ускорении в направлении движения.

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

import yfinance as yf
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from scipy.signal import savgol_filter

# Загрузка внутридневных данных
data = yf.download(
    tickers="BTC-USD",
    period="7d",    
    interval="5m",  
    progress=False
)
# Берем только последние 288 баров (2 дня по 5-минуткам)
data = data.iloc[-288:]

# Проверка на MultiIndex и извлечение цены
if isinstance(data.columns, pd.MultiIndex):
    price = data.loc[:, ('Close', 'BTC-USD')].values
else:
    price = data['Close'].values
t_trading = np.arange(len(price))

# Расчет поддержки и сопротивления
lookback = 20
resistance = np.array([
    np.max(price[max(0, i-lookback):i]) if i > 0 else price[0] 
    for i in range(len(price))
])
support = np.array([
    np.min(price[max(0, i-lookback):i]) if i > 0 else price[0]
    for i in range(len(price))
])

# Первая и вторая производная
window = 7  
dy = savgol_filter(price, window_length=window, polyorder=3, deriv=1)
d2y = savgol_filter(price, window_length=window, polyorder=3, deriv=2)
# Порог ускорения для фильтрации мелких шумов
threshold_d2 = 0.01

# Детекция пробоев
breakout_up_raw = price > resistance * 1.0002
breakout_up_conf = breakout_up_raw & (d2y > threshold_d2)
breakout_down_raw = price < support * 0.9998
breakout_down_conf = breakout_down_raw & (d2y < -threshold_d2) print("Пробоев вверх (raw):", np.sum(breakout_up_raw)) print("Пробоев вверх (подтвержд):", np.sum(breakout_up_conf)) print("Пробоев вниз (raw):", np.sum(breakout_down_raw)) print("Пробоев вниз (подтвержд):", np.sum(breakout_down_conf)) # Оценка успешности пробоев def evaluate_breakout(price, breakout_idx, horizon=10, threshold=0.001): results = [] for idx in breakout_idx: if idx + horizon >= len(price):
            continue
        future_return = (price[idx + horizon] - price[idx]) / price[idx]
        results.append(future_return > threshold)
    return results

idx_up_raw = np.where(breakout_up_raw)[0]
idx_up_conf = np.where(breakout_up_conf)[0]
idx_down_raw = np.where(breakout_down_raw)[0]
idx_down_conf = np.where(breakout_down_conf)[0]

success_up_raw = evaluate_breakout(price, idx_up_raw)
success_up_conf = evaluate_breakout(price, idx_up_conf)
success_down_raw = evaluate_breakout(price, idx_down_raw)
success_down_conf = evaluate_breakout(price, idx_down_conf)

print_stats(success_up_raw, success_up_conf, "Пробои вверх")
print_stats(success_down_raw, success_down_conf, "Пробои вниз")

# Визуализация
fig, axes = plt.subplots(3, 1, figsize=(16, 10))

axes[0].plot(t_trading, price, color='black', label='Цена')
axes[0].plot(t_trading, resistance, '--', color='gray', label='Сопротивление')
axes[0].plot(t_trading, support, '--', color='gray', label='Поддержка')
axes[0].scatter(idx_up_raw, price[idx_up_raw], color='darkgray', label='Пробои вверх (raw)')
axes[0].scatter(idx_up_conf, price[idx_up_conf], color='green', label='Пробои вверх (подтвержд)')
axes[0].scatter(idx_down_raw, price[idx_down_raw], color='darkgray', label='Пробои вниз (raw)')
axes[0].scatter(idx_down_conf, price[idx_down_conf], color='red', label='Пробои вниз (подтвержд)')
axes[0].set_ylabel('Цена')
axes[0].legend()
axes[0].grid(alpha=0.3)

axes[1].plot(t_trading, dy, color='black', label='dy/dt')
axes[1].axhline(0, color='gray', linestyle='--')
axes[1].set_ylabel('dy/dt')
axes[1].legend()
axes[1].grid(alpha=0.3)

axes[2].plot(t_trading, d2y, color='black', label='d²y/dt²')
axes[2].axhline(0, color='gray', linestyle='--')
axes[2].set_ylabel('d²y/dt²')
axes[2].legend()
axes[2].grid(alpha=0.3)

plt.tight_layout()
plt.show()

Фильтрация пробоев уровней поддержки / сопротивления через 2-ю производную на примере 5М котировок Bitcoin. Верхняя панель - цена и динамический уровень сопротивления. Серые круги - все пробои, цветные - пробои с положительным ускорением. Средняя панель: 1-я производная показывает направление движения. Нижняя панель: 2-я производная в моменты пробоя. Видно, что не все пробои сопровождаются положительным ускорением: часть происходит при d²y/dt² < 0, что указывает на слабый импульс

Рис. 11: Фильтрация пробоев уровней поддержки / сопротивления через 2-ю производную на примере 5М котировок Bitcoin. Верхняя панель — цена и динамический уровень сопротивления. Серые круги — все пробои, цветные — пробои с положительным ускорением. Средняя панель: 1-я производная показывает направление движения. Нижняя панель: 2-я производная в моменты пробоя. Видно, что не все пробои сопровождаются положительным ускорением: часть происходит при d²y/dt² < 0, что указывает на слабый импульс

Фильтр через ускорение отсекает до 80% ложных пробоев в зависимости от рыночного режима и настроек window и threshold_d2. Неподтвержденные пробои часто происходят на фоне замедления роста (d²y/dt² < 0) — цена формально преодолевает уровень, но импульс уже иссякает. Подтвержденные пробои имеют win rate на 15-25 процентных пунктов выше благодаря требованию положительного ускорения.

👉🏻  Стационарность временных рядов. Как анализировать нестационарные данные?

Адаптивное управление стоп-лоссом

Фиксированный процентный стоп-лосс не учитывает текущую динамику цены. В периоды высокой волатильности узкий стоп срабатывает от случайного шума. В периоды низкой волатильности широкий стоп избыточен. Первая производная позволяет адаптировать расстояние стопа к скорости движения.

Базовая модель адаптивного стопа:

stop_distance = base_distance + k · |dy/dt|

где:

  • base_distance — минимальное расстояние стопа (например, 1-2%);
  • k — коэффициент чувствительности (подбирается эмпирически);
  • |dy/dt| — абсолютная скорость изменения цены.

Когда цена быстро движется (высокая |dy/dt|), стоп расширяется, давая позиции пространство для флуктуаций. Когда движение медленное, стоп затягивается, фиксируя прибыль при первых признаках разворота.

import yfinance as yf
import numpy as np
import matplotlib.pyplot as plt
from scipy.signal import savgol_filter

# Загрузка данных
ticker = "BTC-USD"
data = yf.download(
    tickers=ticker,
    period="7d",     # последние 7 дней
    interval="15m",  # 15-минутный интервал
    progress=False
)

# Извлечение цены закрытия
if isinstance(data.columns, pd.MultiIndex):
    price = data[('Close', ticker)].values
else:
    price = data['Close'].values

t_trading = np.arange(len(price))

# Расчет производной (скорости изменения)
window = 5
dy = savgol_filter(price, window_length=window, polyorder=3, deriv=1)
dy_normalized = np.abs(dy) / price  # нормализованная скорость

# Параметры стратегии
base_stop_pct = 0.02  # 2% фиксированный стоп
k_adaptive = 0.5      # коэффициент адаптации
entry_idx = 260       # точка входа на 260-й бар
entry_price = price[entry_idx]

# Фиксированный стоп
fixed_stop = entry_price * (1 - base_stop_pct)
fixed_stop_array = np.full(len(price), fixed_stop)

# Адаптивный стоп
adaptive_stop = np.zeros(len(price))
adaptive_stop[entry_idx] = entry_price * (1 - base_stop_pct)  # стартовое значение

for i in range(entry_idx + 1, len(price)):
    adaptive_distance = base_stop_pct + k_adaptive * dy_normalized[i]
    adaptive_distance = np.clip(adaptive_distance, 0.015, 0.10)  # ограничение 1.5%-10%
    new_stop = price[i] * (1 - adaptive_distance)
    adaptive_stop[i] = max(new_stop, adaptive_stop[i-1])

# Проверка срабатывания стопов
fixed_hit_idx = np.where(price[entry_idx:] < fixed_stop)[0]
adaptive_hit_idx = np.where(price[entry_idx:] < adaptive_stop[entry_idx:])[0] fixed_exit = entry_idx + fixed_hit_idx[0] if len(fixed_hit_idx) > 0 else len(price)-1
adaptive_exit = entry_idx + adaptive_hit_idx[0] if len(adaptive_hit_idx) > 0 else len(price)-1

fixed_pnl = (price[fixed_exit] - entry_price) / entry_price
adaptive_pnl = (price[adaptive_exit] - entry_price) / entry_price

# Визуализация
fig, axes = plt.subplots(2, 1, figsize=(14, 9))

# Цена и стопы
axes[0].plot(t_trading, price, color='black', linewidth=1.5, label='Цена')
axes[0].plot(t_trading, fixed_stop_array, '--', color='red', linewidth=1.5, label=f'Фиксированный стоп (-{base_stop_pct*100:.0f}%)')
axes[0].plot(t_trading, adaptive_stop, color='green', linewidth=1.5, label='Адаптивный стоп')
axes[0].scatter([entry_idx], [entry_price], color='blue', s=150, marker='o', zorder=5, label='Вход')
axes[0].scatter([fixed_exit], [price[fixed_exit]], color='red', s=150, marker='x', linewidths=3, zorder=5, label=f'Выход (fixed): {fixed_pnl*100:.1f}%')
axes[0].scatter([adaptive_exit], [price[adaptive_exit]], color='green', s=150, marker='x', linewidths=3, zorder=5, label=f'Выход (adaptive): {adaptive_pnl*100:.1f}%')
axes[0].set_ylabel('Цена', fontsize=11)
axes[0].legend(fontsize=9, loc='upper left')
axes[0].grid(alpha=0.3)
axes[0].set_ylim(105_000, 120_000)  

# Нормализованная скорость
axes[1].plot(t_trading, dy_normalized, color='#666666', linewidth=1.5, label='|dy/dt| / price')
axes[1].axvline(entry_idx, color='blue', linestyle=':', alpha=0.5)
axes[1].axvline(fixed_exit, color='red', linestyle=':', alpha=0.5)
axes[1].axvline(adaptive_exit, color='green', linestyle=':', alpha=0.5)
axes[1].set_xlabel('Бар', fontsize=11)
axes[1].set_ylabel('Нормализованная скорость', fontsize=11)
axes[1].legend(fontsize=10)
axes[1].grid(alpha=0.3)

plt.tight_layout()
plt.show()

# Результаты
print(f"\nТочка входа: день {entry_idx}, цена ${entry_price:.2f}")
print(f"Фиксированный стоп: выход на день {fixed_exit}, P&L = {fixed_pnl*100:.2f}%")
print(f"Адаптивный стоп: выход на день {adaptive_exit}, P&L = {adaptive_pnl*100:.2f}%")
print(f"Разница в удержании позиции: {adaptive_exit - fixed_exit} баров")

Сравнение фиксированного и адаптивного стоп-лосса на котировках Bitcoin. Верхняя панель — цена с уровнями стопов. Синий круг — точка входа, красный крестик — выход по фиксированному стопу, зеленый — по адаптивному. Фиксированный стоп срабатывает по достижению убытка 2%. Адаптивный стоп (зеленая линия) расширяется в периоды высокой скорости и останавливается при замедлении, позволяя дольше удерживать позицию и сохранять капитал при резком развороте. Нижняя панель — нормализованная первая производная показывает изменение скорости движения цены

Рис. 12: Сравнение фиксированного и адаптивного стоп-лосса на котировках Bitcoin. Верхняя панель — цена с уровнями стопов. Синий круг — точка входа, красный крестик — выход по фиксированному стопу, зеленый — по адаптивному. Фиксированный стоп срабатывает по достижению убытка 2%. Адаптивный стоп (зеленая линия) расширяется в периоды высокой скорости и останавливается при замедлении, позволяя дольше удерживать позицию и сохранять капитал при резком развороте. Нижняя панель — нормализованная первая производная показывает изменение скорости движения цены

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

Заключение

Производные временных рядов превращают сырые финансовые данные в метрики динамики: скорость показывает интенсивность изменения, а ускорение — моменты перелома тренда.

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

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

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