Возможности Мастера MQL5, которые вам нужно знать (Часть 85): Использование паттернов стохастического осциллятора и FrAMA с обучением на основе инференса β-VAE
Введение
В экосистеме MetaTrader 5 Мастер MQL5 – надежный инструмент для быстрого создания прототипов и реализации новых торговых идей. Как уже отмечалось в предыдущих статьях, для этого не нужно погружаться в низкоуровневое программирование. В основе Мастера лежит модульная структура: можно выбрать готовые классы сигналов, стратегии управления капиталом и механизмы трейлинг-стопа. Подход plug and play при сборке советника имеет и непреднамеренный побочный эффект: он демократизирует алгоритмическую торговлю, делая ее доступнее для пользователей с разным уровнем опыта и при прочих равных условиях может в долгосрочной перспективе повысить ликвидность рынка.
В части 85 расширим возможности Мастера MQL5, интегрировав в него машинное обучение. В частности, рассмотрим алгоритм β-VAE, описанный в недавней статье, но теперь применим его для обработки двоично закодированных сигналов связки индикаторов из части 84. Напомним: β-VAE – модель обучения без учителя, которая сжимает многомерные входные данные в латентное пространство. Благодаря этому модель выявляет скрытые структуры и взаимосвязи, которые традиционные системы на основе правил могут не заметить. Теоретически такая автоматизация должна не только улучшить распознавание паттернов, но и поддержать принятие решений на основе инференса в пользовательских классах сигналов.
Используем связку индикаторов из части 84, где рассмотрели десять ключевых паттернов с индексами от 0 до 9, построенных на Stochastic и FrAMA. В форвардном тестировании эти десять паттернов показали разные результаты. Паттерны от 0 до 4, а также 7 и 8 показали определенную надежность: они были прибыльны на наборе активов, подобранных для разных рыночных режимов. Однако паттерны 5, 6 и 9 заметно отставали и не показали прибыльности в периоде форвардного тестирования вне выборки.
Следует вновь подчеркнуть: интервал тестирования очень ограничен. Эти результаты лишь показывают, какие паттерны требуют дополнительного тестирования, а не какие из них надежны. Отстающие паттерны определялись так: паттерн 5 – плоская FrAMA и пересечения линий Stochastic; паттерн 6 – крюкообразные паттерны Stochastic в зонах перекупленности или перепроданности при наклонной FrAMA; паттерн 9 — экстремальные уровни Stochastic Oscillator при противоположно направленных наклонах FrAMA.
Причиной неудачи могла стать недостаточная гибкость этих паттернов. Возможен и другой вариант: охват тестирования был слишком узким, а в долгосрочной перспективе надежными окажутся именно эти паттерны, а не некоторые из показавших прибыль выше. Ответ на этот вопрос можно получить только в ходе независимого тестирования. Далее рассмотрим, сможет ли машинное обучение улучшить результаты паттернов 5, 6 и 9.
Сигнал BUY (паттерн 5): плоская FrAMA + пересечение Stochastic вверх в зоне ниже 30

Сигнал SELL (паттерн 6): M-образный пик Stochastic в зоне выше 80 + восходящий наклон FrAMA

Сигнал SELL (паттерн 9): восходящая FrAMA + Stochastic > 90 и снижается

В статье предполагается, что β-VAE может преобразовывать двоичные значения индикаторов, объединенные в векторы, в содержательные латентные представления. Бета-параметр балансирует точность реконструкции и регуляризацию латентного пространства, помогая модели выделять независимые признаки и выявлять скрытые торговые паттерны и структуры. Это будет показано при обучении на исторических данных XAUUSD, SPX 500 и USDJPY. После обучения экспортируем модель через ONNX в пользовательский класс сигнала MQL5 для последующей сборки в Мастере MQL5. Результаты форвардного тестирования показали умеренное улучшение для XAUUSD, что подчеркивает потенциал обучения на основе инференса для тонкой настройки некоторых слабых исходных сигнальных паттернов.
Выявление паттернов
В моделях машинного обучения для прогнозирования, в отличие от распознавания паттернов, первым шагом интеграции обычно становятся векторизованные непрерывные входные данные – нормированные цены или значения индикаторов. Они позволяют передавать в модель большие массивы данных временных рядов без ручного ветвления логики. Это позволяет алгоритмам самостоятельно выявлять связи между колебаниями Stochastic, формой FrAMA и волатильностью ценового движения.
Такая структура легко масштабируется: после выравнивания векторов индикаторов и формирования окон Python может параллельно обработать несколько баров за несколько миллисекунд. При экспорте в MQL5 методы Тестера стратегий, работающие на новом баре, не обеспечивают сопоставимой производительности. При таком подходе не понадобятся условные конструкции if, чтобы определять, например, пересек ли буфер K Stochastic буфер D снизу вверх, выравнивается ли FrAMA и т.д. Вся эта информация содержится в тензорах с плавающей точкой. В системах, обучаемых на GPU, эти тензоры компактно хранят не только входные данные, но и дельты обучения, что устраняет необходимость постоянно вычислять разность между выходом и целевым значением.
Однако у такого подхода есть и уязвимые места. Непрерывные входные векторы неизбежно содержат рыночный шум, запаздывание индикатора и артефакты округления, способные имитировать ложные корреляции. В этом контексте даже один неверно масштабированный признак может сместить среднее, исказить градиенты обучения и создать иллюзию точности. Если входные данные собраны в волатильных режимах, модель может усвоить колебания, которые кажутся предсказательными при обучении, но бесполезны в реальной торговле. Кроме того, непрерывные сигналы могут размывать логические границы: значения K 69,8 и 70,1 способны изменить классификацию без существенного изменения тренда. Как отмечалось в предыдущих статьях, непрерывные входные данные могут обеспечить хорошие результаты в симуляции, но при смене рыночного режима система становится нестабильной, поскольку гладкость вычислений с плавающей точкой скрывает дискретную природу рыночного поведения. Как иногда говорят, модель может "галлюцинировать" и выдавать прогнозы даже на случайных данных.
Практичный компромисс, рассматриваемый в этой статье, – преобразование входных данных с плавающей точкой в булевы или двоичные векторы, выступающие своего рода отпечатками конкретных событий. Вместо подачи модели полных числовых диапазонов кодируем такие паттерны, как пересечение буферов Stochastic снизу вверх или выравнивание FrAMA: при наличии паттерна записывается 1, при отсутствии – 0. Поэтому каждый бит в сформированном векторе представляет четко определенное условие, уже проверенное небольшим условием if в функциях предварительной обработки. Эти двоичные паттерны формируют низкоразмерный вход, предположительно устойчивый к шуму, где структура важнее величины значений. В отличие от колеблющихся числовых значений, β-VAE может изучать базовые связи между комбинациями событий. Сжатие этих данных в латентное пространство позволяет надежнее отражать рыночные режимы.
Таким образом, векторизованные непрерывные данные обеспечивают масштабируемость и производительность за счет полного отказа от условных конструкций if, а двоичная векторизация делает решения более прозрачными. Такой подход преобразует исходный числовой "хаос" в упорядоченную символическую форму, где каждое значение 1 или 0 несет определенный смысл. Кроме того, инференс будет зависеть не только от ненадежных порогов, но и от уже закодированной логики ключевых условных конструкций if.
Недостатки стохастического осциллятора
Этот осциллятор, разработанный Джорджем Лейном, основан на моментуме. Он оценивает положение цены закрытия относительно недавнего диапазона, тем самым указывая на вероятность разворота. Напомним, для основного буфера K расчет выполняется так:

Где:
- Ct = текущая цена закрытия;
- Hn = наибольший максимум за последние n периодов;
- Ln = наименьший минимум за последние n периодов.
Значение периода анализа n по умолчанию часто равно 20, однако в двух предыдущих статьях о данной связке индикаторов мы оставили его настраиваемым гиперпараметром. Это не всегда оптимальный подход, поскольку он может привести к подгонке под кривую. Однако значения за период анализа сглаживаются по m периодам. Значение m часто по умолчанию равно 3, и в этой статье оно сохранено. Сглаживание делает K менее волатильным. Буфер D, рассмотренный в Части 83, запаздывает относительно линии K и представляет собой сглаженную скользящую среднюю на ее основе с периодом 3 по умолчанию; это значение сохранено. Буфер D помогает выявлять пики буфера K по пересечениям. Используется встроенный класс CiStochastic. Как и все стохастические осцилляторы, он выводит значения в диапазоне 0-100, где уровни 80 и 20 служат ключевыми порогами.
Несмотря на простоту, у этого индикатора есть существенные недостатки. На трендовом рынке индикатор часто существенно запаздывает. Например, при бычьем тренде значения буфера K могут долго удерживаться на уровнях 80 и выше. Пересечения внутри этой зоны будут отменяться, но K не перейдет к более низким уровням. Это порождает устойчивую серию ложных сигналов SELL, пока сохраняется моментум. При нисходящем тренде буфер K удерживается ниже 20, из-за чего сигналы BUY появляются с запозданием. Возникает эффект "сломанных часов": до верного сигнала формируется множество ложных.
Такое запаздывание может быть связано с формулой на основе диапазона: она нормирует цену закрытия относительно экстремумов, но игнорирует сохранение тренда. Это приводит к "застрявшим" значениям индикатора. Наша реализация на Python получает буферы K и D в виде векторизованных рядов. Мы реализовали пользовательский класс SignalFrAMAStochastic с такими функциями, как cross_up_series, где сдвиги отслеживают изменения от бара к бару. Однако рыночный шум при тестировании в реальных условиях усиливает эффект ложных пересечений на трендовом рынке. Мы реализуем это на Python следующим образом:
def Stochastic( df: pd.DataFrame, k_period: int = 20, d_period: int = 3, smooth_k: int = 3, source_col: str = "close", only_stochastic: bool = False ) -> pd.DataFrame: """ Compute Stochastic Oscillator (%K and %D) and append columns to the DataFrame. Parameters ---------- df : pd.DataFrame Input DataFrame; must include columns 'high', 'low', and the `source_col` (default 'close'). k_period : int Lookback period for %K (highest high / lowest low). Default 14. d_period : int Period for %D (moving average of %K). Default 3. smooth_k : int Smoothing window applied to raw %K before computing %D. Default 3. source_col : str Price column to use for close values (default 'close'). only_stochastic : bool If True, return only the Stochastic columns. Returns ------- pd.DataFrame DataFrame with appended columns: 'Stoch_%K', 'Stoch_%K_smooth', 'Stoch_%D' """ required_cols = {"high", "low", source_col} if not required_cols.issubset(df.columns): raise ValueError(f"DataFrame must contain columns: {required_cols}") if not all(isinstance(p, int) and p > 0 for p in (k_period, d_period, smooth_k)): raise ValueError("k_period, d_period, and smooth_k must be positive integers") out = df.copy() low_k = out["low"].rolling(window=k_period, min_periods=k_period).min() high_k = out["high"].rolling(window=k_period, min_periods=k_period).max() # Raw %K (0-100) raw_k = (out[source_col] - low_k) / (high_k - low_k) raw_k = raw_k * 100.0 # Smoothed %K (optional smoothing) stoch_k = raw_k.rolling(window=smooth_k, min_periods=smooth_k).mean() # %D is SMA of smoothed %K stoch_d = stoch_k.rolling(window=d_period, min_periods=d_period).mean() out['raw_k'] = raw_k out["k"] = stoch_k out["d"] = stoch_d if only_stochastic: return out[["raw_k", "k", "d"]].copy() return out
class SignalFrAMAStochastic: # constructor unchanged — can accept pandas Series too def __init__( self, frama: Sequence[float], close: Sequence[float], high: Sequence[float], low: Sequence[float], k: Sequence[float], # Stochastic %K series pips: float, point: float, past: int, x_index: int = 0 ): # store as pandas.Series if possible to help with vectorized operations if isinstance(frama, pd.Series): self.frama = frama else: self.frama = pd.Series(frama) if isinstance(close, pd.Series): self.close = close else: self.close = pd.Series(close) if isinstance(high, pd.Series): self.high = high else: self.high = pd.Series(high) if isinstance(low, pd.Series): self.low = low else: self.low = pd.Series(low) if isinstance(k, pd.Series): self.k = k else: self.k = pd.Series(k) self.m_pips = pips self.point = point self.m_past = past self._x = x_index # ------------------------ # Small helpers (vectorized) # ------------------------ @staticmethod def cross_up_series(a: pd.Series, b: pd.Series) -> pd.Series: """ Vectorized CrossUp: True where a crossed up b between previous and current bar. Equivalent MQL: (a1 <= b1) && (a0 > b0) In pandas chronological order: a.shift(1) = previous a, a = current a """ a_prev = a.shift(1) b_prev = b.shift(1) return (a_prev <= b_prev) & (a > b) # Trimmed code... # ------------------------ # Vectorized divergence detection # ------------------------ def bullish_divergence_series(self) -> pd.Series: """ Vectorized detection of bullish divergence: - Find local lows (low < low.shift(1) & low < low.shift(-1)) - For consecutive pairs of local lows (older -> newer), mark the time of the *second* low True when: low_old < low_new (price makes lower low) K_old > K_new (oscillator makes higher low) This mirrors the MQL routine that finds two local lows within a lookback and checks them. """ low = self.low k = self.k # boolean mask of local minima is_local_min = (low < low.shift(1)) & (low < low.shift(-1)) local_idx = np.flatnonzero(is_local_min.to_numpy(copy=False)) # prepare result array res = np.zeros(len(low), dtype=bool) # We will iterate adjacent pairs of local extrema (sparse). # Only consider pairs where the two minima are not more than (m_past+? ) apart is optional; # Here we mimic original by not imposing an explicit global window; user can post-filter if needed. for i in range(1, len(local_idx)): older = local_idx[i - 1] newer = local_idx[i] # compare values (note: these are numpy indices; preserve pandas indexing by assigning by position) if low.iat[older] < low.iat[newer] and k.iat[older] > k.iat[newer]: # mark the time of the newer local low res[newer] = True return pd.Series(res, index=low.index) def bearish_divergence_series(self) -> pd.Series: """ Vectorized detection of bearish divergence: - Find local highs (high > high.shift(1) & high > high.shift(-1)) - For consecutive pairs of local highs (older -> newer), mark the time of the *second* high True when: high_old > high_new (price makes higher high) K_old < K_new (oscillator makes lower high) """ high = self.high k = self.k is_local_max = (high > high.shift(1)) & (high > high.shift(-1)) local_idx = np.flatnonzero(is_local_max.to_numpy(copy=False)) res = np.zeros(len(high), dtype=bool) for i in range(1, len(local_idx)): older = local_idx[i - 1] newer = local_idx[i] if high.iat[older] > high.iat[newer] and k.iat[older] < k.iat[newer]: res[newer] = True return pd.Series(res, index=high.index) # ------------------------ # Convenience wrappers for CrossUp/Down using stored Series # ------------------------ def cross_up(self, a_col: pd.Series, b_col: pd.Series) -> pd.Series: return self.cross_up_series(a_col, b_col) def cross_down(self, a_col: pd.Series, b_col: pd.Series) -> pd.Series: return self.cross_down_series(a_col, b_col)
Результативность этого индикатора также может существенно различаться в разных рыночных режимах. В условиях возврата к среднему, например когда USDJPY находится в фазе низкой волатильности, Stochastic может быть особенно полезен: отскоки от перепроданности и сигналы по отбою от перекупленности возникают часто и обычно совпадают с возвратом к равновесию. Однако в сетапах, основанных на моментуме, например при резких пробоях XAUUSD, индикатор часто ошибочно принимает ускорение за разворот. Тестирование на исторических данных в предыдущей статье показало, что паттерны 5 и 9 сильно зависят от экстремальных уровней ниже 10 и выше 90. Пересечения показывали слабые результаты, а убыточное форвардное тестирование можно объяснить несоответствием режима рынка логике паттерна: рынок был трендовым, а не боковым. Бычья динамика SPX 500 в этом году, особенно после апрельских минимумов, усугубила проблему: чувствительность Stochastic к краткосрочному рыночному шуму может привести к тому, что индикатор проигнорирует более широкий рыночный импульс.
Для устранения этих недостатков критически важен контекстный фильтр FrAMA. Наша адаптивная средняя использует параметр alpha на основе фрактальной размерности и изменяет сглаживание в соответствии со структурой рынка. При плавном тренде период усреднения сокращается, а на неровном рынке увеличивается, чтобы учитывать более широкую направленность рынка. С учетом наклона или плоского состояния FrAMA сигналы Stochastic, в частности паттерн 5, допускают пересечение только при плоской FrAMA. По сути, мы фильтруем шум, вызванный волатильностью. Эта взаимодополняющая связка формирует входные данные для модели β-VAE: двоичные флаги кодируют взаимодействия с учетом рыночного режима, а латентное обучение должно способствовать более явному разделению скрытых факторов сигнала и, в теории, повысить устойчивость советников.
FrAMA на Python
Адаптивная средняя заменяет статическое сглаживание динамическим и поэтому быстрее реагирует на изменения рынка. Этот индикатор использует теорию хаоса и фрактальную размерность, чтобы количественно оценивать баланс между рыночным шумом и направленной рыночной структурой. В качестве периода анализа из n баров, как и у Stochastic, по умолчанию обычно используется значение 20. Однако FrAMA делит этот период на две части. Это позволяет вычислять отношения диапазонов N1 и N2 для подинтервалов, а также N(1+2) для полного периода анализа. Напомним из предыдущей статьи, как определяется D:

где N1 и N2 – указанные выше отношения диапазонов. Значение D ограничено диапазоном от 1 для плавного тренда до 2 для рынка, состоящего только из шума. Адаптивный вес alpha также ограничен диапазоном 0,01-1,0. Вместе с буферами усреднения цены он формирует адаптивную среднюю, как уже отмечалось в предыдущих статьях, в частности в еще не опубликованной статье, предшествующей предыдущей. В MQL5 мы реализовали это, не вдаваясь во все формульные тонкости, с помощью встроенного класса индикатора CiFrAMA. В Python наш подход выглядит так:
def FrAMA( df: pd.DataFrame, period: int = 20, price_col: str = "close", min_alpha: float = 0.01, max_alpha: float = 1.0, only_frama: bool = False ) -> pd.DataFrame: """ Compute Fractal Adaptive Moving Average (FRAMA) per John Ehlers' formulation. Parameters ---------- df : pd.DataFrame Input DataFrame; must include 'high', 'low', and price_col (default 'close'). period : int Window length used to compute fractal dimension (commonly 16). Must be >= 4. price_col : str Column name to use as price (commonly 'close'). min_alpha : float Minimum alpha clamp (commonly 0.01). max_alpha : float Maximum alpha clamp (commonly 1.0). only_frama : bool If True, return only the FRAMA column. Returns ------- pd.DataFrame DataFrame with appended column: 'FRAMA' """ required_cols = {"high", "low", price_col} if not required_cols.issubset(df.columns): raise ValueError(f"DataFrame must contain columns: {required_cols}") if not (isinstance(period, int) and period >= 4): raise ValueError("period must be an integer >= 4") if not (0.0 < min_alpha <= max_alpha <= 1.0): raise ValueError("min_alpha and max_alpha must satisfy 0 < min_alpha <= max_alpha <= 1") out = df.copy() n = period half = n // 2 price = out[price_col].to_numpy(dtype=float) high = out["high"].to_numpy(dtype=float) low = out["low"].to_numpy(dtype=float) length = len(out) frama = np.full(length, np.nan, dtype=float) # Seed: before we have enough bars, set FRAMA to price (common practice) # We'll start the loop at index 0 and set initial FRAMA to price[0]. if length == 0: out["frama"] = frama return out if not only_frama else out[["frama"]].copy() frama[0] = price[0] # iterate; we need at least 'n' bars to compute a fractal dimension for i in range(1, length): if i < n: # not enough history to compute full fractal measure -> fallback to price frama[i] = price[i] continue start = i - n + 1 # first half window: start .. start+half-1 fh_start = start fh_end = start + half - 1 # second half window: start+half .. i sh_start = fh_end + 1 sh_end = i # compute ranges per sub-window (using highs and lows) mH = np.max(high[fh_start: fh_end + 1]) # max high in first half mL = np.min(low[fh_start: fh_end + 1]) # min low in first half N1 = (mH - mL) / float(max(1, half)) HH = np.max(high[sh_start: sh_end + 1]) # max high in second half LL = np.min(low[sh_start: sh_end + 1]) # min low in second half N2 = (HH - LL) / float(max(1, half)) # entire window: Mx = np.max(high[start: i + 1]) Mn = np.min(low[start: i + 1]) N3 = (Mx - Mn) / float(max(1, n)) # compute fractal dimension D according to Ehlers: # D = (log(N1 + N2) - log(N3)) / log(2) # Use guard clauses when N1, N2, N3 are zero or negative if (N1 > 0.0) and (N2 > 0.0) and (N3 > 0.0): D = (np.log(N1 + N2) - np.log(N3)) / np.log(2.0) else: # fallback to D = 1 (line-like) when not computable D = 1.0 # alpha conversion using Ehlers' exponential mapping (clamped) alpha = np.exp(-4.6 * (D - 1.0)) if alpha < min_alpha: alpha = min_alpha if alpha > max_alpha: alpha = max_alpha # EMA-like update frama[i] = alpha * price[i] + (1.0 - alpha) * frama[i - 1] out["frama"] = frama if only_frama: return out[["frama"]].copy() return out
Главная сильная сторона FrAMA – ее способность адаптироваться к тренду. В устойчивом тренде, когда D мало, а alpha приближается к 0,01, FrAMA ведет себя как простая скользящая средняя с большим периодом и меньшим запаздыванием. И напротив, при фрактальном хаосе, когда D приближается к максимуму, alpha будет стремиться к 1,0, а FrAMA будет вести себя как быстрая EMA, плотно следующая за ценой, чтобы избежать резких ложных разворотов. При смене рыночного режима такой подход обычно превосходит скользящие средние с фиксированным периодом анализа. Например, во время волатильных всплесков XAUUSD в этом году, когда золото начало бычий тренд, высокие значения alpha делают FrAMA более отзывчивой и помогают компенсировать запаздывание Stochastic, то есть описанный выше эффект "сломанных часов". Мы реализуем это на Python следующим образом:
class SignalFrAMAStochastic: # constructor unchanged — can accept pandas Series too def __init__( self, frama: Sequence[float], close: Sequence[float], high: Sequence[float], low: Sequence[float], k: Sequence[float], # Stochastic %K series pips: float, point: float, past: int, x_index: int = 0 ): # store as pandas.Series if possible to help with vectorized operations if isinstance(frama, pd.Series): self.frama = frama else: self.frama = pd.Series(frama) if isinstance(close, pd.Series): self.close = close else: self.close = pd.Series(close) if isinstance(high, pd.Series): self.high = high else: self.high = pd.Series(high) if isinstance(low, pd.Series): self.low = low else: self.low = pd.Series(low) if isinstance(k, pd.Series): self.k = k else: self.k = pd.Series(k) self.m_pips = pips self.point = point self.m_past = past self._x = x_index # Trimmed Code... @staticmethod def frama_slope_series(frama: pd.Series) -> pd.Series: """ Vectorized FrAMASlope: FrAMA(t) - FrAMA(t-1) (maps MQL FrAMA(ind) - FrAMA(ind+1)) Note: first value will be NaN because shift(1) yields NaN at the start. """ return frama - frama.shift(1) def flat_frama_series(self, window: Optional[int] = None) -> pd.Series: """ Return boolean Series: True where absolute FRAMA slope stayed <= tol for `window` bars including current bar and previous (window-1) bars. - window default: self.m_past - tol = self.m_pips * self.point """ if window is None: window = self.m_past tol = self.m_pips * self.point slope = self.frama_slope_series(self.frama).abs() # rolling max over the last `window` bars (includes current and previous window-1) # need min_periods=window to mimic MQL conservative behavior rolling_max = slope.rolling(window=window, min_periods=window).max() return rolling_max <= tol def far_above_series(self, mult: float) -> pd.Series: """ Vectorized FarAboveFrama for every row: dist = abs(close - frama) atr = high.shift(1) - low.shift(1) (previous bar's range, matching MQL's ind+1) condition: close > frama AND dist > mult * point * atr / 4 """ dist = (self.close - self.frama).abs() atr = (self.high.shift(1) - self.low.shift(1)) # avoid divide-by-zero; treat atr<=0 as False cond = (self.close > self.frama) & (atr > 0) & (dist > (mult * self.point * atr / 4.0)) # fill NaNs with False return cond.fillna(False) def far_below_series(self, mult: float) -> pd.Series: """ Vectorized FarBelowFrama for every row: condition: close < frama AND dist > mult * point * atr / 4 """ dist = (self.close - self.frama).abs() atr = (self.high.shift(1) - self.low.shift(1)) cond = (self.close < self.frama) & (atr > 0) & (dist > (mult * self.point * atr / 4.0)) return cond.fillna(False) # ------------------------ # Vectorized divergence detection # ------------------------ # Trimmed code...
В рассматриваемых сигнальных паттернах вспомогательные функции flat_frama_series и frama_slope_series выявляют соответственно плоское состояние FrAMA для паттерна 5 и ее наклон для паттерна 9.
Рыночные архетипы
Рыночное поведение можно классифицировать по разным типологиям. В двух предыдущих статьях и в этой мы сосредоточились только на трех типологиях: трендовый рынок или рынок, склонный к возврату к среднему; автокоррелированный или декоррелированный рынок; рынок с высокой или низкой волатильностью. Трендовые рыночные режимы характеризуются устойчивым направленным движением, признаком которого может быть положительный наклон FrAMA. Это соответствует стратегиям, основанным на моментуме, но не устраняет ложные сигналы разворота от Stochastic. Напротив, фазы возврата к среднему колеблются вокруг точки равновесия. Это благоприятствует отскокам Stochastic из зон перекупленности и перепроданности, особенно при почти плоской FrAMA.
Корреляции отражают взаимозависимость активов. Например, часто обсуждается необычная корреляция между акциями и предполагаемыми защитными активами, такими как Bitcoin. Эти активы часто движутся синхронно, вопреки академическому тезису о том, что Bitcoin якобы служит хеджем против проблем экономики США и бюджетно-фискальной безответственности. Если бы эти активы действительно были декоррелированы, владение обоими стало бы эффективной формой диверсификации. В SPX 500 иногда входят сектора с отрицательной корреляцией. Это послужило основанием использовать индекс в предыдущих статьях для автокорреляционных паттернов 2, 3 и 9.
Такое разнообразие рыночных архетипов требует широкого набора активов для тестирования и проверки способности β-VAE обучаться паттернам. Мы предположили, что каждому рыночному типу соответствуют определенные активы, и поэтому выбрали три актива из разных устоявшихся классов. Мы выбрали XAUUSD, SPX 500 и USDJPY. Золото выбрано как представитель товарного рынка: его волатильность во многом определяется потоками в защитные активы, геополитической напряженностью, инфляцией и т.д. Присущая золоту длительная рыночная память позволяет проверить, как β-VAE выявляет сохранение рыночного режима в латентном пространстве.
SPX 500 выбран как признанный прокси для рынка акций. В зависимости от отношения к риску индекс демонстрирует выраженную зависимость от коррелированных рыночных трендов. Он помог выявить проблему запаздывания сигналов Stochastic в фазах моментума – именно тогда паттерн 9 давал сбой. Наконец, USDJPY представляла валютный рынок и использовалась для анализа волатильных движений, вызванных разницей доходностей и интервенциями центральных банков. Выбор USDJPY предназначен для проверки фильтров плоской FrAMA, особенно в паттерне 5.
Для модели VAE одновременно реализуем три отстающих паттерна, создавая латентную модель, которая выявляет общие зависимости в сигнальных паттернах 5, 6 и 9. Каждый актив по-прежнему тестируется независимо, но для него одновременно рассматриваются три отстающих паттерна. Тестирование нескольких паттернов может создавать проблемы, если их сигналы неправильно агрегируются. Используемый в этой статье подход машинного обучения решает эту задачу. В предыдущих статьях уже отмечалось, что разные паттерны могут взаимно отменять позиции. В результате обучение нередко сводится к подгонке под кривую. В используемом подходе все сигналы объединяются и формируют единое решение о длинной или короткой позиции. Они работают не по отдельности, поэтому объединение паттернов в модели β-VAE может дать эффект, при котором целое может оказаться больше суммы его частей.
Модель β-VAE
В основе нашей объединяющей сигналы модели лежит β-VAE – генеративная модель обучения без учителя, расширяющая стандартный VAE за счет акцента на независимых представлениях. Ее структура включает пару "кодировщик-декодировщик" и скрытое пространство, размерность которого обычно значительно выше размерности входных данных. Кодировщик – нейронная сеть прямого распространения, преобразующая низкоразмерное входное пространство в высокоразмерное латентное пространство.
В этой статье входное пространство имеет размерность 6, а латентное – 2048. Шесть входных компонентов соответствуют трем сигнальным паттернам 5, 6 и 9. Каждый паттерн возвращает двумерный вектор, где каждый индекс нормируется до значения 1 или 0. Два индекса отслеживают бычью и медвежью направленность соответствующего сигнального паттерна. Мы реализуем модель β-VAE на Python следующим образом:
# ----------------------------- β-VAE (inference simplified to VAE-only) ----------------------------- class BetaVAEUnsupervised(nn.Module): """ Encoder: features -> (mu, logvar) Decoder: z -> x_hat **Inference (now VAE-only):** latent z is mapped to y via an internal head. All former infer modes (ridge/knn/kernel/lwlr/mlp) are bypassed. """ def __init__(self, feature_dim, latent_dim, k_neighbors=5, beta=4.0, recon='bce', infer_mode='vae', ridge_alpha=1e-2, kernel_bandwidth=1.0): super().__init__() self.latent_dim = latent_dim self.k_neighbors = k_neighbors self.beta = beta self.recon = recon self.infer_mode = 'vae' # force VAE-only self.ridge_alpha = float(ridge_alpha) self.kernel_bandwidth = float(kernel_bandwidth) # Encoder self.feature_encoder = nn.Sequential( nn.Linear(feature_dim, 256), nn.ReLU(), nn.Linear(256, 128), nn.ReLU(), nn.Linear(128, latent_dim * 2) ) # Decoder self.decoder = nn.Sequential( nn.Linear(latent_dim, 128), nn.ReLU(), nn.Linear(128, 256), nn.ReLU(), nn.Linear(256, feature_dim) ) # New: latent→y head (supervised head trained with MSE) self.y_head = nn.Sequential( nn.Linear(latent_dim, 128), nn.ReLU(), nn.Linear(128, 1) ) def encode(self, features): h = self.feature_encoder(features) z_mean, z_logvar = torch.chunk(h, 2, dim=1) return z_mean, z_logvar def reparameterize(self, mean, logvar): std = torch.exp(0.5 * logvar) eps = torch.randn_like(std) return mean + eps * std def decode(self, z): return self.decoder(z) def predict_from_latent(self, z): # VAE-only mapping return self.y_head(z) def forward(self, features, y=None): mu, logvar = self.encode(features) z = self.reparameterize(mu, logvar) x_logits = self.decode(z) y_hat = self.predict_from_latent(z) if y is not None: return {'z': z, 'z_mean': mu, 'z_logvar': logvar, 'x_logits': x_logits, 'y_hat': y_hat} else: return {'y': y_hat}
Преобразование 6-мерного двоичного входа выполняется линейными слоями ReLU в конфигурации 6⇾256⇾128⇾4096. Конечная размерность 4096 выбрана потому, что она представляет два набора по 2048 значений. Затем выход разделяется на два вектора размерностью 2048: один задает среднее, другой – логарифм дисперсии. В этом вероятностном кодировании репараметризация используется для выборки z из латентного распределения. Затем z подается на вход декодера, что позволяет выполнять обратное распространение ошибки. Декодирование выполняется в обратной последовательности: 2048⇾128⇾256⇾6. На выходе реконструкции двоичного входа получаются логиты, к которым затем применяется сигмоидная функция активации для получения вероятностей.
В недавней реализации β-VAE мы использовали двойной режим прямого прохода для прогнозирования ценового движения, поскольку при обучении модель получала входные значения индикаторов. Для такого прогноза в модель подавались входные данные с нейтральными значениями заполнителей вместо искомых будущих изменений цены. Поскольку изменения цены задавались в диапазоне 0,0-1,0, нейтральным значением заполнителя было 0,5. Реализованный β-VAE также поддерживает гибкую функцию прямого прохода, однако в него добавлена новая головка, преобразующая латентное представление в y, с конфигурацией 2048⇾128⇾1. Она прогнозирует следующее ценовое движение значениями от -1 до +1, от медвежьей до бычьей направленности соответственно. Эта голова получает z на вход и фактически объединяет обучение без учителя с обучением с учителем.
Бета-параметр в данном VAE определяет, насколько строго модель поддерживает простоту и независимость переменных в скрытом, то есть латентном, пространстве. В вариационном автокодировщике необходимо сбалансировать два аспекта: точность реконструкции входных данных и регуляризацию, то есть степень соответствия латентного пространства эталонному распределению, например стандартному нормальному распределению.
Когда бета-параметр равен 1, такую модель называют стандартной моделью VAE. Название связано с нормальным распределением: в этом случае дивергенция Кульбака-Лейблера корректирует значения латентного пространства так, чтобы они приблизительно следовали гауссовскому распределению. Однако при увеличении бета-параметра, например до 4, регуляризации придается больший вес. При этом значения в латентном пространстве становятся более разреженными: многие из них приближаются к нулю, а независимость переменных повышается при уменьшении числа корреляций. На практике это помогает модели лучше разделять независимые скрытые признаки и паттерны в латентном пространстве.
Более четко структурированное скрытое пространство должно помочь VAE лучше обобщать данные и выявлять паттерны вне выборки. Наша функция потерь β-VAE предназначена для вычисления дивергенции Кульбака-Лейблера. Ее реализация на Python выглядит так:
def beta_vae_loss(features, x_logits, mu, logvar, beta=4.0, recon='bce'): if recon == 'bce': recon_loss = F.binary_cross_entropy_with_logits(x_logits, features, reduction='sum') / features.size(0) else: recon_loss = F.mse_loss(torch.sigmoid(x_logits), features, reduction='mean') * features.size(1) kl = -0.5 * torch.sum(1 + logvar - mu.pow(2) - logvar.exp()) / features.size(0) loss = recon_loss + beta * kl return loss, recon_loss.detach(), kl.detach()
KL вычисляется как -0.5 * ∑(1 + logvar - μ² - exp(logvar)), затем усредняется по батчу и умножается на β. Как отмечалось выше, этот подход жертвует точностью реконструкции ради регуляризации латентного пространства и позволяет выявлять скрытые иерархии индикаторов. Декомпозиция функции потерь – многоэтапный процесс, в котором входные данные восстанавливаются с помощью бинарной кросс-энтропии с логитами. Общая функция потерь представляет собой гибридную целевую функцию. Она гарантирует, что модель не только восстановит входные паттерны, но и выделит в латентном пространстве разные аспекты входных признаков: например, силу пересечения или направление наклона. Способность фиксировать разнообразные признаки, как уже отмечалось выше, контролируется бета-параметром.
Реализация на MQL5
Чтобы связать экосистему машинного обучения Python с торговой средой MQL5, рассмотрим ключевые шаги на Python, ведущие к экспорту файла ONNX для использования в MQL5. Эти шаги формируют целостный пайплайн обработки данных, обучения модели и ее внедрения. Инициализируем экспортированные модели ONNX – по одной для каждого актива. Каждая модель совместно использует все три паттерна, не допуская конфликтов между ордерами. В предыдущих статьях модели ONNX были привязаны к паттернам, а здесь – к активам. Поэтому конструктор класса и проверка выглядят так:
//+------------------------------------------------------------------+ //| Constructor | //+------------------------------------------------------------------+ CSignalIL_Stochastic_FrAMA::CSignalIL_Stochastic_FrAMA(void) : m_pattern_6(50), m_pattern_9(50), m_pattern_5(50), m_model_type(0) //m_patterns_usage(255) { //--- initialization of protected data m_used_series = USE_SERIES_CLOSE + USE_SERIES_TIME; PatternsUsage(m_patterns_usage); //--- create model from static buffer m_handles[0] = OnnxCreateFromBuffer(__84_USDJPY, ONNX_DEFAULT); m_handles[1] = OnnxCreateFromBuffer(__84_XAU, ONNX_DEFAULT); m_handles[2] = OnnxCreateFromBuffer(__84_SPY, ONNX_DEFAULT); }
//+------------------------------------------------------------------+ //| Validation settings protected data. | //+------------------------------------------------------------------+ bool CSignalIL_Stochastic_FrAMA::ValidationSettings(void) { //--- validation settings of additional filters if(!CExpertSignal::ValidationSettings()) return(false); //--- initial data checks // Set input shapes const long _in_shape[] = {1, 6}; const long _out_shape[] = {1, 1}; if(!OnnxSetInputShape(m_handles[m_model_type], ONNX_DEFAULT, _in_shape)) { Print("OnnxSetInputShape error ", GetLastError()); return(false); } // Set output shapes if(!OnnxSetOutputShape(m_handles[m_model_type], 0, _out_shape)) { Print("OnnxSetOutputShape error ", GetLastError()); return(false); } //--- ok return(true); }
Тестирование проводилось для разных "типов моделей". Этот параметр обозначает один из трех активов, при этом для каждого актива одновременно запускаются все три сигнальных паттерна. Если этому целочисленному параметру присвоено 0, тестируется USDJPY; при значении 1 – XAUUSD; при значении 2 – SPX 500. Цель тестирования – проверить, можно ли улучшить результаты сигнальных паттернов 5, 6 и 9. Обычно паттерны тестируются по одному, но здесь все три паттерна объединены для трех тестируемых активов. Разные тестовые активы, каждый из которых соответствует определенным рыночным архетипам, образуют наш набор для тестирования.
Для обучения и оптимизации использовался тот же период тестирования, что и в предыдущей статье: с июля 2023 года по июль 2024 года. Настраивались пороги открытия и закрытия пользовательского сигнала, расстояние в пунктах до цены входа для лимитных ордеров и пороги сигналов, накапливаемых для каждого паттерна. Наличие паттерна проверяется на каждом новом баре. Результаты форвардного тестирования были следующими:
Для XAUUSD

Для USDJPY

Для SPX 500

Результаты форвардного тестирования оказались неоднозначными, но показательными. При тестировании на золоте модель β-VAE показала скромную годовую доходность 2,1% на четырехчасовом таймфрейме. Мы не можем выделить переменные латентного слоя и точно определить, на каких сигнальных паттернах специализируется каждая из них. Однако можно предположить, что эмбеддинги паттерна 5, связанные с плоской FrAMA и пересечением помогли выявить развороты, вызванные волатильностью, во время всплесков золота. При форвардном тестировании SPX 500 годовая доходность составила -0,8%. USDJPY показала еще более слабый результат – худший среди трех активов: доходность составила -1,4%. В целом только XAUUSD превысил точку безубыточности. Результаты в основном повторили выводы части 84: VAE обеспечил лишь скромное улучшение при тестировании XAUUSD.
Заключение
В итоге объединение модели обучения на основе инференса β-VAE со связкой Stochastic и FrAMA позволяет выделить независимые скрытые признаки, которые в определенной степени оказались применимы. Пайплайн ONNX-MQL5 показал некоторое улучшение по сравнению с предыдущей статьей, особенно для XAUUSD, но также выявил ограничения подхода. Форвардное тестирование USDJPY и SPX 500 дало посредственные результаты. Поэтому необходимы тщательная разработка признаков, тестирование с учетом рыночного режима и консервативное внедрение. Все представленные результаты носят экспериментальный характер и требуют независимого тестирования и тщательной проверки перед дальнейшим использованием.
| имя | описание |
|---|---|
| WZ-84.mq5 | Советник, собранный Мастером, в заголовке которого перечислены имена и расположение включенных файлов |
| SignalWZ-84.mqh | Файл пользовательского класса сигнала |
| 84-XAU.onnx | ONNX-модель, обученная на золоте |
| 84-USDJPY.onnx | ONNX-модель, обученная на USDJPY |
| 84-SPY.onnx | Модель, обученная на SPX 500 |
Перевод с английского произведен MetaQuotes Ltd.
Оригинальная статья: https://www.mql5.com/en/articles/19948
Предупреждение: все права на данные материалы принадлежат MetaQuotes Ltd. Полная или частичная перепечатка запрещена.
Данная статья написана пользователем сайта и отражает его личную точку зрения. Компания MetaQuotes Ltd не несет ответственности за достоверность представленной информации, а также за возможные последствия использования описанных решений, стратегий или рекомендаций.
Изучение Стандартной библиотеки MQL5 (Часть 5): Мультисигнальный советник
Создание торговых систем с искусственным интеллектом на MQL5 (Часть 6): Реализация поиска и удаления чатов
Создание торговых систем с искусственным интеллектом на MQL5 (Часть 7): Дальнейшая модульная реорганизация и автоматизация торговли
Создание и использование Скиллов для создания артефактов MQL
- Бесплатные приложения для трейдинга
- 8 000+ сигналов для копирования
- Экономические новости для анализа финансовых рынков
Вы принимаете политику сайта и условия использования