English
preview
Возможности Мастера MQL5, которые вам нужно знать (Часть 86):  Ускорение доступа к данным с помощью разреженной таблицы в пользовательском классе трейлинга

Возможности Мастера MQL5, которые вам нужно знать (Часть 86): Ускорение доступа к данным с помощью разреженной таблицы в пользовательском классе трейлинга

MetaTrader 5Торговые системы |
35 0
Stephen Njuki
Stephen Njuki

Содержание 

  1. Введение – парадигма приоритета данных
  2. Вычислительные ограничения – почему важна сложность O(1)
  3. Алгоритм разреженной таблицы
  4. Пайплайн от Polars к SQLite
  5. Измерение рыночной амплитуды
  6. Реализация на MQL5 
  7. Эмпирические результаты и анализ
  8. Заключение


Введение – парадигма приоритета данных

Многие советники с пользовательскими трейлинг-стопами сталкиваются с практическим ограничением, связанным с производительностью и качеством данных. На каждом баре, а иногда и на каждом тике они просматривают длинные исторические окна – обычно от 500 до 5 000 и более баров – в поисках максимумов и минимумов. Это линейно увеличивает время обработки и вызывает скрытый дрейф исполнения на VPS или в реальной торговле. В то же время данные, получаемые через встроенный механизм MetaTrader для доступа к истории, могут содержать пропуски и несогласованности, из-за которых поведение советника различается в тестере и при реальной работе.

В этой статье проблема рассматривается в более узком контексте:

  • (1) когда функция трейлинга многократно пересчитывает экстремумы диапазона на больших периодах анализа и с высокой частотой обновления, запросы O(N) становятся заметной причиной задержек и запаздывающего выхода из позиции; и
  • (2) советнику необходим воспроизводимый очищенный поток ценовых данных вместо несистемного чтения данных напрямую из терминала.

Для решения этих задач создадим структуру для запроса минимума/максимума на диапазоне со сложностью O(1) (разреженную таблицу) для поиска экстремумов за постоянное время. Затем объединим ее с пайплайном Python (Polars) → SQLite, который очищает и интерполирует данные тиков и баров, а также добавляет временные метки, чтобы советник получал детерминированные данные.


Вычислительные ограничения – почему важна сложность O(1)

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

С математической точки зрения это операция со сложностью O(N), то есть Big O от N. N показывает, как объем вычислений линейно зависит от размера входных данных. Так, при размере окна трейлинга 500 центральный процессор выполняет 500 × N сравнений несколько раз в день, где N обозначает число сравнений, выполняемых на каждом баре в другом диапазоне. При локальном запуске советника такой подход может быть приемлемым – это зависит от частоты запросов. На VPS, особенно с вычислительными кредитами, зависящими от времени работы, это может стать серьезным ограничением, которое необходимо учесть до развертывания.

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

Одним из решений может стать переход при извлечении исторических цен от линейной сложности O(N) к постоянной сложности O(1). При использовании разреженной таблицы основная часть поисковых операций выполняется один раз – при инициализации или через заданные интервалы. Сложность этой предварительной обработки, в ходе которой строится таблица, составляет O(N log N). После построения таблицы для поиска максимальной или минимальной цены в любом диапазоне – из 10 или 10 000 баров – потребуется одно обращение к таблице, поэтому сложность составит O(1). 

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

//+------------------------------------------------------------------+
//|                                                                  |
//+------------------------------------------------------------------+
bool CTrailingSparseTable::InitIndicators(CIndicators *indicators)
{  if(!CExpertTrailing::InitIndicators(indicators)) return false;
   // 1. Initialize ATR for the Geometric Gatekeeper
   if(!m_atr.Create(m_symbol.Name(), m_period, m_window_size)) return false;
   // 2. Pre-compute Log2 table for O(1) bitwise speed
   ArrayResize(m_log_table, 30001);
   m_log_table[1] = 0;
   for(int i = 2; i <= 30000; i++)
      m_log_table[i] = m_log_table[i / 2] + 1;
   // 3. Pre-load Historical Cleaned Data from SQLite
   MqlRates rates[];
   // Load 3x the window size to allow for multi-term query depth
   int initial_size = 3 * m_window_size;
   int count = m_db.GetCleanedRates(m_symbol.Name(), TimeCurrent() - (PeriodSeconds() * initial_size), initial_size, rates);
   if(count >= initial_size)
   {  // Build the initial table so the first tick is O(1)
      BuildTable(rates, true);
      printf("Sparse Table pre-initialized with %d bars from SQLite.", count);
   }
   return true;
}
Построение таблицы:
//+------------------------------------------------------------------+
//| Build Sparse Table from price array: O(N log N)                  |
//+------------------------------------------------------------------+
bool CTrailingSparseTable::BuildTable(MqlRates &R[], bool is_min)
{  int n = int(R.Size());
   if(n <= 0) return false;
   n = fmin(n,30000);
   int max_j = m_log_table[n] + 1;
   m_st.Resize(n, max_j);
   // Base case: intervals of length 1
   for(int i = 0; i < n; i++)
   {  if(is_min)
      {  m_st[i][0] = R[i].low;
      }
      else
      {  m_st[i][0] = R[i].high;
      }
   }
   // Compute intervals of length 2^j
   for(int j = 1; j < max_j; j++)
   {  for(int i = 0; i + (1 << j) <= n; i++)
      {  if(is_min)
         {  // Storing min values for Long positions;
            m_st[i][j] = MathMin(m_st[i][j - 1], m_st[i + (1 << (j - 1))][j - 1]);
         }
         else
         {  // Storing max values for Short positions;
            m_st[i][j] = MathMax(m_st[i][j - 1], m_st[i + (1 << (j - 1))][j - 1]);
         }
      }
   }
   return true;
}


Алгоритм разреженной таблицы

Чтобы обеспечить время запроса O(1), разреженная таблица использует дополнительную память и время на предварительную обработку для хранения заранее вычисленных максимумов и минимумов на выбранных интервалах. Эффективность метода основана на использовании степеней двойки. Вместо хранения минимума или максимума для каждого возможного диапазона сохраняются значения только для диапазонов, размер которых равен степени двойки, то есть 1, 2, 4, 8 и т.д.

Структура данных

При создании этой таблицы определяется матрица m_st, к элементам которой обращаются как к элементам двумерного массива m_st[i][j], где:

  • i – начальный индекс входного массива цен;
  • j – показатель степени двойки, используемый для задания различных длин интервалов (2^j);
  • таким образом, в m_st[i][j] хранится минимум или максимум диапазона, начинающегося с i и имеющего длину 2^j; этот диапазон соответствует интервалу [i, i + 2^(j - 1)].

Рекурсивное построение

Таблица строится с использованием метода динамического программирования. Базовый случай при j, равном нулю, прост. Длина диапазона 2^0 равна 1, поэтому учитывается только значение с индексом i.

f2

Где:

  • ST[i][0] – минимальное или максимальное, в зависимости от типа запроса, значение для интервала длиной 2^0 = 1, начинающегося с индекса i;
  • i – начальный индекс входного массива цен;
  • 0 – значение показателя j; при j, равном нулю, задается базовый уровень разреженной таблицы, содержащий ровно один элемент;
  • Price[i] – исходное значение цены с индексом i, которое служит основой для всех последующих запросов в диапазоне. Заполнение первого столбца разреженной таблицы отдельными значениями цен создает основу, на которой рекурсивно рассчитываются более крупные перекрывающиеся интервалы, длина которых равна степени двойки.

Во всех последующих столбцах при j > 0 значение вычисляется разделением интервала на две равные половины. Обе половины уже вычислены в предыдущем столбце (j - 1). Итак, для диапазона длиной 2^j первая половина начинается с i и имеет длину 2^(j - 1), а вторая начинается с i + 2^(j - 1) и имеет такую же длину. Рекурсивная формула поиска минимума в диапазоне:

f3

Где:

  • ST[i][j] – минимальное значение в интервале, начинающемся с индекса i и имеющем длину 2^j;
  • i – начальный индекс интервала в массиве цен;
  • j – показатель степени двойки, определяющий длину интервала (2^j);
  • min(...) – функция для сравнения двух половин интервала и определения общего минимума;
  • ST[i][j - 1] – минимум для первой половины интервала длиной 2^(j - 1), начинающегося с i;
  • ST[i + 2^(j - 1)][j - 1] – минимум для второй половины интервала длиной 2^(j - 1), начинающейся в средней точке i + 2^(j - 1).

Эта предварительная обработка может занимать время O(N log N), где N – общее число обрабатываемых баров. На языке MQL5 это выполняется в функции BuildTable, как показано во втором фрагменте кода выше. Эта функция также вызывается при запросе новой порции данных.

Значение запросов со сложностью O(1)

Главное преимущество разреженной таблицы перед другими структурами данных, например деревьями отрезков, заключается в ее производительности на этапе выполнения запросов. Благодаря идемпотентности MathMin() и MathMax() интервалы можно перекрывать без изменения результата.

Чтобы найти минимум в диапазоне [L, R], сначала вычисляется наибольшая степень двойки, не превышающая длину этого диапазона. 

f4

Где:

  • k – наибольший показатель степени, для которого 2^k меньше или равно общей длине диапазона запроса;
  • log2 – функция логарифма по основанию 2, используемая для определения степени двойки;
  • R – правый индекс диапазона запроса;
  • L – левый индекс диапазона запроса;
  • R - L + 1 – общее число элементов, то есть длина диапазона запроса.

Если, как показано в формуле выше, это значение равно k, выбираются 2 перекрывающихся интервала длиной 2^k. Один из этих интервалов начинается в L, а другой заканчивается в R. Вместе они охватывают весь диапазон [L, R], а перекрытие в середине не влияет на искомый экстремум. Итоговая формула запроса выглядит следующим образом:

f5

Где:

  • RMQ(L, R) – итоговый результат запроса минимума в диапазоне [L, R];
  • min(...) – выбирает минимум из двух заранее вычисленных перекрывающихся интервалов длиной в степень двойки, охватывающих весь диапазон [L, R];
  • ST[L][k] – заранее вычисленный минимум для интервала длиной 2^k, начинающегося с левой границы L;
  • ST[R - 2^k + 1][k] – заранее вычисленный минимум для интервала длиной 2^k, заканчивающегося на правой границе R.

Реализуем это на языке MQL5 следующим образом:

//+------------------------------------------------------------------+
//| O(1) Range Query                                                 |
//+------------------------------------------------------------------+
double CTrailingSparseTable::QueryRMQ(int L, int R, bool is_min)
{  int length = R - L + 1;
   int k = m_log_table[length];
   if(is_min)
      return MathMin(m_st[L][k], m_st[R - (1 << k) + 1][k]);
   else
      return MathMax(m_st[L][k], m_st[R - (1 << k) + 1][k]);
}

Влияние на трейлинг-стопы

При непрерывном пересчете уровней трейлинг-стопа рассматривается период анализа, начинающийся с индекса текущего бара i. Индекс i важен, поскольку ценовые данные передаются через мост SQLite. Традиционно при обновлении ценовых баров советник вручную просматривал требуемый диапазон из X баров. При использовании разреженной таблицы сначала вычисляется упомянутое выше значение k – с помощью побитовых операций или заранее подготовленной таблицы логарифмов, – а затем выполняется одно обращение по индексу.


Пайплайн от Polars к SQLite

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

Предварительная обработка в Polars

Python быстро стал основным языком в области анализа данных. Однако даже при этом выбор библиотек влияет на эффективность и качество проверки данных на предмет пропусков и несогласованностей. Polars – библиотека, поддерживающая многопоточность и ленивые вычисления; она написана на Rust. Уже это само по себе позволяет библиотеке одновременно обрабатывать миллионы строк данных ценовых баров на всех доступных ядрах процессора.

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

# Copyright 2026, MetaQuotes Ltd.
# https://www.mql5.com

# ... (Previous get_mt5_data and apply_hampel_filter code) ...
import MetaTrader5 as mt5
import polars as pl
import numpy as np
import sqlite3
from datetime import datetime


mt5.initialize()

# 
# --- CONFIGURATION ---
SYMBOL = "EURUSD"
TIMEFRAME = mt5.TIMEFRAME_H1
COUNT = 2000
DB_PATH = "C:/Users/ADMIN P/AppData/Roaming/MetaQuotes/Terminal/Common/Files/market_data.sqlite"

def get_mt5_data(symbol, timeframe, count):
    """Initializes MT5 and retrieves raw rates."""
    if not mt5.initialize():
        print(f"MT5 Initialize failed, error code: {mt5.last_error()}")
        return None
    
    rates = mt5.copy_rates_from_pos(symbol, timeframe, 0, count)
    mt5.shutdown()
    
    if rates is None:
        return None
        
    # Convert to Polars DataFrame immediately for O(N) performance [cite: 4]
    return pl.from_numpy(rates)

def apply_hampel_filter(df, window_size=10, n_sigmas=3):
    """
    Identifies outliers using the Hampel Filter (Median Absolute Deviation).
    Formula: |x_i - median| > 3 * MAD
    """
    # Calculate rolling median and MAD using Polars expressions
    df = df.with_columns([
        pl.col("close").rolling_median(window_size=window_size).alias("median"),
    ])
    
    # Calculate MAD: Median(|x_i - median|)
    df = df.with_columns([
        (pl.col("close") - pl.col("median")).abs().rolling_median(window_size=window_size).alias("mad")
    ])
    
    # Define Outlier Mask [cite: 21]
    # Standard deviation constant for MAD is ~1.4826
    df = df.with_columns([
        pl.when((pl.col("close") - pl.col("median")).abs() > (n_sigmas * 1.4826 * pl.col("mad")))
        .then(pl.lit(None))
        .otherwise(pl.col("close"))
        .alias("cleaned_close")
    ])
    
    # Interpolate missing values (outliers and gaps)
    return df.with_columns(pl.col("cleaned_close").interpolate())


def validate_stationarity(df):
    """
    Implements Log-Return Validation to ensure data quality.
    Formula: r_t = ln(P_t / P_{t-1})
    """
    # 1. Calculate Log Returns
    df = df.with_columns([
        (pl.col("cleaned_close") / pl.col("cleaned_close").shift(1)).ln().alias("log_return")
    ])

    # 2. Validation Check: Ensure no infinite values or extreme outliers remain
    # In a production "Data-First" pipeline, we check for 'stationarity' 
    # by ensuring the mean of returns is near zero.
    mean_return = df["log_return"].mean()
    
    if mean_return is not None and abs(mean_return) > 0.01:
        print(f"Warning: Data may be non-stationary. Mean log-return: {mean_return}")
        
    return df

def upload_to_sqlite(df, db_path):
    """Bulk imports the refined and validated Polars output."""
    conn = sqlite3.connect(db_path)
    
    # We now include the log_return column for potential use in the RMQ Gatekeeper
    data_to_save = df.select([
        pl.col("time"),
        pl.col("open"),
        pl.col("high"),
        pl.col("low"),
        pl.col("cleaned_close").alias("close"),
        pl.col("log_return").fill_null(0) # Ensure no NULLs enter the SQLite bridge
    ]).to_dicts()

    cursor = conn.cursor()
    cursor.execute("DROP TABLE IF EXISTS MarketData")
    cursor.execute("""
        CREATE TABLE MarketData (
            time INTEGER PRIMARY KEY,
            open REAL,
            high REAL,
            low REAL,
            close REAL,
            log_return REAL
        )
    """)
    
    cursor.executemany("""
        INSERT INTO MarketData (time, open, high, low, close, log_return) 
        VALUES (:time, :open, :high, :low, :close, :log_return)
    """, data_to_save)
    
    conn.commit()
    conn.close()

if __name__ == "__main__":
    raw_df = get_mt5_data(SYMBOL, TIMEFRAME, COUNT)
    if raw_df is not None:
        # Step 1: Cleaning
        refined_df = apply_hampel_filter(raw_df)
        # Step 2: Log-Return Validation (The missing piece)
        validated_df = validate_stationarity(refined_df)
        # Step 3: Upload
        upload_to_sqlite(validated_df, DB_PATH)

mt5.shutdown()

Стационарность и логарифмические доходности

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

f6

Где:

  • r_t – рассчитанная логарифмическая доходность в момент времени t, обеспечивающая статистическую стационарность ценовых данных, хранящихся в SQLite;
  • ln – функция натурального логарифма, применяемая к отношению изменений цены;
  • P_t – цена в текущий момент времени t;
  • P_{t-1} – цена на предыдущем временном шаге t - 1.

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

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

Мост SQLite 

Связь между данными, очищенными в Python, и MQL5 реализована через SQLite. Этот подход следует сохранить в качестве стандарта для будущих статей, где будут рассматриваться различные подходы не только к использованию трейлинг-стопов, но и к определению размера позиции. Помимо очистки потока данных брокера база данных может служить единым эталонным источником данных с более надежной доступностью. Это утверждение, разумеется, субъективно и зависит от брокера, однако при тщательной настройке очистки и хранения база данных вполне способна выполнять эту функцию. Этот мост в виде базы данных SQLite реализуется на языке MQL5 с помощью MarketDatabase.mqh. Его код приведен ниже:

//+------------------------------------------------------------------+
//|                                                           db.mqh |
//|                                  Copyright 2026, MetaQuotes Ltd. |
//|                                             https://www.mql5.com |
//+------------------------------------------------------------------+
#property copyright "Copyright 2026, MetaQuotes Ltd."
#property link      "https://www.mql5.com"
///+------------------------------------------------------------------+
//|                                                                  |
//+------------------------------------------------------------------+
class CMarketDatabase
{
protected:
   int               m_db_handle;
   string            m_filename;

public:
   CMarketDatabase(string filename = "MarketData.sqlite") :
      m_filename(filename), m_db_handle(INVALID_HANDLE) {}
   ~CMarketDatabase()
   {  Close();
   }

   // 1. Open the connection using the COMMON flag
   bool Open()
   {  // DATABASE_OPEN_COMMON is critical: it points to the same folder Python used
      m_db_handle = DatabaseOpen(m_filename, DATABASE_OPEN_READONLY | DATABASE_OPEN_COMMON);
      if(m_db_handle == INVALID_HANDLE)
      {  Print("DB: Failed to open ", m_filename, ". Error: ", GetLastError());
         return false;
      }
      return true;
   }

   void Close()
   {  if(m_db_handle != INVALID_HANDLE) DatabaseClose(m_db_handle);
   }

   // 2. Fetch data into the MqlRates structure for the Sparse Table
   int GetCleanedRates(string symbol, datetime start, int count, MqlRates &rates[])
   {  if(m_db_handle == INVALID_HANDLE && !Open()) return 0;
      // SQL Query: Selecting the cleaned OHLC data
      string query = StringFormat(
                        "SELECT Timestamp, open, high, low, close, spread FROM %s "
                        "WHERE Timestamp >= %lld ORDER BY Timestamp ASC LIMIT %d",
                        symbol, (long)start, count);
      int request = DatabasePrepare(m_db_handle, query);
      if(request == INVALID_HANDLE)
      {  Print("DB: Query Prep Failed: ", GetLastError());
         return 0;
      }
      int i = 0;
      ArrayFree(rates);
      while(DatabaseRead(request))
      {  ArrayResize(rates, i + 1);
         long ts;
         DatabaseColumnLong(request, 0, ts);
         rates[i].time = (datetime)ts;
         DatabaseColumnDouble(request, 1, rates[i].open);
         DatabaseColumnDouble(request, 2, rates[i].high);
         DatabaseColumnDouble(request, 3, rates[i].low);
         DatabaseColumnDouble(request, 4, rates[i].close);
         DatabaseColumnInteger(request, 5, rates[i].spread);
         i++;
      }
      DatabaseFinalize(request);
      return i;
   }
};
//+------------------------------------------------------------------+

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

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

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

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


Измерение рыночной амплитуды

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

Принцип рыночной амплитуды – различение тренда и шума

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

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

Нормализация "буфера безопасности"

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

Расширение подхода в сторону обучения с подкреплением

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

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

Входные данные MFI

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



Реализация на MQL5

Ниже приведена общая реализация основного интерфейса пользовательского класса трейлинга:

//+------------------------------------------------------------------+
//| Class CTrailingSparseTableEx.                                    |
//| Purpose: $O(1)$ Trailing stop using Range Minimum/Maximum Queries|
//+------------------------------------------------------------------+
class CTrailingSparseTableEx : public CExpertTrailing
{
protected:
   matrix            m_st;             // Sparse Table for storage
   int               m_window_size;    // Trailing lookback window
   int               m_log_table[];    // Pre-computed Log2 table
   CMarketDatabase   m_db;             // Database accessor
   string            m_db_name;
   //
   double            m_excursion;
   CiATR             m_atr;

public:
   CTrailingSparseTableEx(void);
   ~CTrailingSparseTableEx(void);

   //--- Setters for Wizard parameters
   void              WindowSize(int size)
   {  m_window_size = size;
   }
   void              DbName(string name)
   {  m_db_name = name;
   }
   void              Excursion(double excursion)
   {  m_excursion = excursion;
   }

   virtual bool      InitIndicators(CIndicators *indicators);
   virtual bool      CheckTrailingStopLong(CPositionInfo *position, double &sl, double &tp);
   virtual bool      CheckTrailingStopShort(CPositionInfo *position, double &sl, double &tp);

   bool              BuildTable(MqlRates &R[], bool is_min);
   double            QueryRMQ(int L, int R, bool is_min);
};

Логика запросов со сложностью O(1)

Класс управляет заранее вычисленной матрицей разреженной таблицы и хэндлом базы данных, содержащей очищенные данные. В отличие от обычных классов трейлинга, использующих индикаторы наподобие MA, этот класс требует значительных вычислений в OnInit. Помимо построения разреженной матрицы необходимо заранее сформировать таблицу log2. Ниже приведены две основные функции класса, наследующегося от CTrailingSparseTable.

//+------------------------------------------------------------------+
//| Check Long: Uses SQLite data to find Lowest price in window      |
//+------------------------------------------------------------------+
bool CTrailingSparseTableEx::CheckTrailingStopLong(CPositionInfo *position, double &sl, double &tp)
{  if(position == NULL) return false;
   static datetime last_bar_time = 0;
   datetime current_bar_time = iTime(m_symbol.Name(), m_period, 0);
   // Only rebuild the table if a new bar has formed
   if(current_bar_time != last_bar_time)
   {  MqlRates rates[];
      int size = 3 * m_window_size;
      int count = m_db.GetCleanedRates(m_symbol.CurrencyMargin() + m_symbol.CurrencyProfit(), TimeCurrent() - (PeriodSeconds() * size), size, rates);
      if(count >= size)
      {  BuildTable(rates, true); // O(N log N) happens once per bar
         last_bar_time = current_bar_time;
      }
   }
   // --- O(1) CONSTANT TIME QUERIES ---
   // This section executes every tick without iterative overhead
   int total_elements = int(m_st.Rows());
   if(total_elements > 1)
   {  double short_term = QueryRMQ(total_elements - m_window_size, total_elements - 1, true);
      double long_term  = QueryRMQ(0, total_elements - 1, true);
      double new_sl = (short_term + long_term) / 2.0;
      // Apply Geometric Excursion Validation
      sl = EMPTY_VALUE;
      tp = EMPTY_VALUE;
      double current_sl = position.StopLoss();
      double limit = m_symbol.Bid() - m_symbol.StopsLevel() * m_symbol.Point();
      if(( new_sl > current_sl || current_sl == 0.0) && new_sl < limit)
      {  m_atr.Refresh(-1);
         double excursion = fabs(m_symbol.Bid() - new_sl) / m_atr.Main(StartIndex());
         if(excursion >= m_excursion)
         {  sl = NormalizeDouble(new_sl, m_symbol.Digits());
            return true;
         }
      }
   }
   return false;
}
//+------------------------------------------------------------------+
//| Check Short: Uses SQLite data to find Highest price in window    |
//+------------------------------------------------------------------+
bool CTrailingSparseTableEx::CheckTrailingStopShort(CPositionInfo *position, double &sl, double &tp)
{  if(position == NULL) return false;
   static datetime last_bar_time = 0;
   datetime current_bar_time = iTime(m_symbol.Name(), m_period, 0);
   // Only rebuild the table if a new bar has formed
   if(current_bar_time != last_bar_time)
   {  MqlRates rates[];
      int size = 3 * m_window_size;
      int count = m_db.GetCleanedRates(m_symbol.CurrencyMargin() + m_symbol.CurrencyProfit(), TimeCurrent() - (PeriodSeconds() * size), size, rates);
      if(count >= size)
      {  BuildTable(rates, true); // O(N log N) happens once per bar
         last_bar_time = current_bar_time;
      }
   }
   // --- O(1) CONSTANT TIME QUERIES ---
   // This section executes every tick without iterative overhead
   int total_elements = int(m_st.Rows());
   if(total_elements > 1)
   {  double short_term = QueryRMQ(total_elements - m_window_size, total_elements - 1, true);
      double long_term  = QueryRMQ(0, total_elements - 1, true);
      double new_sl = (short_term + long_term) / 2.0;
      // Apply Geometric Excursion Validation
      sl = EMPTY_VALUE;
      tp = EMPTY_VALUE;
      double current_sl = position.StopLoss();
      double limit = m_symbol.Bid() - m_symbol.StopsLevel() * m_symbol.Point();
      if(( new_sl > current_sl || current_sl == 0.0) && new_sl > limit)
      {  m_atr.Refresh(-1);
         double excursion = fabs(m_symbol.Ask() - new_sl) / m_atr.Main(StartIndex());
         if(excursion >= m_excursion)
         {  sl = NormalizeDouble(new_sl, m_symbol.Digits());
            return true;
         }
      }
   }
   return false;
}
//+------------------------------------------------------------------+

Они входят в состав класса CTrailingSparseTableEx, наследующегося от CExpertTrailing, что обеспечивает его полную совместимость с модульной архитектурой Мастера MQL5. Как уже отмечалось, особенность описанной настройки трейлинга состоит в сочетании запросов разреженной таблицы со сложностью O(1) с постоянно доступным источником данных на базе SQLite.

Управление памятью

Для достижения требуемой производительности таблица m_log_table заполняется в конструкторе, чтобы в реальной торговле не вызывать MathLog. Теоретически это означает, что даже при высокочастотных данных объемом более миллиона тиков время выполнения останется сравнительно небольшим.


Эмпирические результаты и анализ

Переход от O(N) к O(1) наиболее заметен при большом периоде анализа. Для сравнения проводятся тесты с периодом анализа в 1 000 баров на таймфрейме H1. Это чуть более восьми недель, или около двух месяцев. Если требуется отслеживать просадки на еще меньшем таймфрейме, например M30 или M15, необходимое число баров легко может вырасти примерно до 4 000. Поэтому обработку больших окон истории при нескольких тестовых периодах не следует недооценивать. Ниже приведена таблица сравнения разреженной таблицы и прямого перебора:

метрика период анализа время обработки

Итеративный подход O(N)

1000

0:18:49.145

Разреженная таблица O(1)

1000

0:11:23.441


В приведенном выше стресс-тесте разреженная таблица при периоде анализа в 1000 баров сокращает время выполнения по сравнению со стандартными итеративными методами. Помимо скорости выполнения, для трейдеров важнее результативность сделок при использовании этого метода трейлинг-стопа. Используемый сигнал входа прост: он основан на двух встроенных индикаторах – Envelopes и RSI. При сравнительном тестировании двух пользовательских классов трейлинга получаются следующие отчеты. Первый использует усредненный стоп-лосс, рассчитанный по пяти периодам, а второй дополняет этот подход проверкой тренда по ATR, как показано выше. Тестирование EURUSD проводилось на таймфрейме H1 с 01.01.2023 по 30.03.2026.

r1_f

r2_f

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

i_t

Из этих двух отчетов видно, что дополнительная осторожность при корректировке стоп-лосса не принесла существенных результатов. Однако на итог могли повлиять и другие факторы.


Заключение

На этом рассмотрение производительности и воспроизводимости данных завершено. На практике статья предлагает воспроизводимый пайплайн и готовые фрагменты кода. Они позволяют заменить линейный перебор на каждом тике запросами RMQ со сложностью O(1) и применять ту же логику трейлинга к очищенному потоку данных SQLite с временными метками.

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

Пайплайн данных: Очистка в Polars (фильтр Хампеля, интерполяция и проверка логарифмических доходностей) → экспорт в SQLite с временными метками. Это позволяет MQL5 получать детерминированные MqlRates независимо от особенностей терминала.

Интеграции: совместимый с Мастером MQL5 класс трейлинга, строящий разреженную таблицу при инициализации и формировании нового бара; мост SQLite для получения очищенных котировок по временным меткам; валидатор рыночной амплитуды на основе ATR, предотвращающий изменение SL из-за шума.

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

Приложения:

имя описание
Trailing Iteration.mqh Пользовательский класс трейлинга без разреженной таблицы, добавленный в качестве контрольного варианта
TrailingSparseTable.mqh Пользовательский класс трейлинга, использующий базовую разреженную таблицу
TrailingSparseTableEx.mqh Пользовательский класс трейлинга, использующий разреженную таблицу и валидатор порога рыночной амплитуды
MarketDatabase.mqh Пользовательский класс для доступа к данным через мост SQLite
polars.py Код на Python для очистки и экспорта данных в SQLite
wz_86.mq5 Советник, собранный Мастером MQL5 для базовой разреженной таблицы
wz_86_b.mq5 Советник, собранный Мастером MQL5 для разреженной таблицы с валидатором
wz_86_c.mq5 Советник, собранный Мастером MQL5 для контрольного варианта без разреженной таблицы

Прилагаемые файлы предназначены для использования в Мастере MQL5 при сборке советников, пригодных для тестирования. Новые читатели могут ознакомиться с этой статьей, чтобы узнать, как это выполняется.

Перевод с английского произведен MetaQuotes Ltd.
Оригинальная статья: https://www.mql5.com/en/articles/22136

Прикрепленные файлы |
wz_86.mq5 (8.71 KB)
wz_86_b.mq5 (8.97 KB)
wz_86_c.mq5 (8.7 KB)
polars.py (4.12 KB)
Изучение Стандартной библиотеки MQL5 (Часть 6): Оптимизация сгенерированного советника Изучение Стандартной библиотеки MQL5 (Часть 6): Оптимизация сгенерированного советника
В данном обсуждении мы продолжаем работу над ранее разработанным мультисигнальным советником с целью изучения и применения доступных методов оптимизации. Цель состоит в том, чтобы определить, можно ли существенно улучшить торговые показатели советника путем систематической оптимизации на основе исторических данных.
Создание торговых систем с искусственным интеллектом на MQL5 (Часть 7): Дальнейшая модульная реорганизация и автоматизация торговли Создание торговых систем с искусственным интеллектом на MQL5 (Часть 7): Дальнейшая модульная реорганизация и автоматизация торговли
В этой статье мы повышаем модульность торговой системы с ИИ, выделяя компоненты интерфейса в отдельный подключаемый файл. Система автоматизирует исполнение сделок по сигналам, сгенерированным ИИ. Она разбирает JSON-ответы с директивами BUY/SELL/NONE и уровнями входа, SL и TP, отображает на графике паттерны, такие как поглощение или дивергенции, с помощью стрелок, линий и меток, а также при необходимости автоматически проверяет сигналы на новых барах.
Создание торговых систем с искусственным интеллектом на MQL5 (Часть 8): Доработка интерфейса с анимацией, метриками времени и инструментами управления ответами Создание торговых систем с искусственным интеллектом на MQL5 (Часть 8): Доработка интерфейса с анимацией, метриками времени и инструментами управления ответами
В этой статье мы дорабатываем торговую систему с ИИ на языке MQL5: добавляем в интерфейс анимацию загрузки для этапов подготовки запроса и ожидания ответа (или "размышления"), а также выводим в ответах метрики времени для более наглядной обратной связи. Добавляются инструменты управления ответами: кнопки повторной генерации для повторного обращения к ИИ и возможность экспортировать последний ответ в файл, что делает взаимодействие с системой проще и удобнее.
Изучение Стандартной библиотеки MQL5 (Часть 5): Мультисигнальный советник Изучение Стандартной библиотеки MQL5 (Часть 5): Мультисигнальный советник
В этом занятии мы создадим продвинутый мультисигнальный советник с использованием стандартной библиотеки MQL5. Такой подход позволяет нам органично сочетать встроенные сигналы с нашей пользовательской логикой, демонстрируя, как построить мощный и гибкий торговый алгоритм. Читайте далее.