English
preview
Анализ CSV-данных (Часть 2): Построение конвейера экспорта и парсинга CSV промышленного уровня для количественного анализа стратегий

Анализ CSV-данных (Часть 2): Построение конвейера экспорта и парсинга CSV промышленного уровня для количественного анализа стратегий

MetaTrader 5Статистика и анализ |
73 0
Ushana Kevin Iorkumbul
Ushana Kevin Iorkumbul

Введение

MetaTrader 5 отлично подходит для проверки торговых идей. Можно прогнать стратегию на многолетних исторических данных, оптимизировать сотни или тысячи комбинаций входных параметров и получить подробный отчёт с полезной статистикой. Для трейдера или разработчика именно с этого начинаются многие исследовательские процессы.

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

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

Именно здесь становится полезной система файлового ввода-вывода MQL5. Записывая структурированные данные непосредственно из советника или скрипта, можно превратить MetaTrader 5 из замкнутой среды тестирования в часть более крупного аналитического пайплайна. CSV — практичный формат для этой задачи: формат прост, удобен для чтения, поддерживается практически повсеместно и легко создаётся при тестировании на исторических данных.

В этой статье мы создадим повторно используемый механизм экспорта CSV в MQL5. По ходу работы рассмотрим файловую песочницу MetaTrader, флаги доступа к файлам, объектно-ориентированную архитектуру экспортера, чтение и токенизацию CSV, а также шаблоны обработки ошибок, обеспечивающие надёжность кода при многократном тестировании.

Если Часть 1 была посвящена подготовке основы, то здесь мы сосредоточимся на том, как выгружать данные чисто, последовательно и в форме, пригодной для непосредственного использования другими инструментами.


Раздел 1. Файловая песочница MQL5: область действия, границы и архитектура доступа

Перед написанием кода файлового ввода-вывода разработчик должен учитывать важное ограничение. Программы MQL5 не имеют неограниченного доступа к файловой системе операционной системы. Все файловые операции выполняются в пределах определённой песочницы. Её границы зависят от флагов доступа, переданных в FileOpen().

Локальная песочница

По умолчанию программы MQL5 работают в каталоге, расположенном внутри папки данных терминала:

<Terminal Data Folder>\MQL5\Files\

При любом вызове FileOpen() без флага FILE_COMMON путь разрешается относительно этого каталога. Например, вызов FileOpen("results\\backtest_log.csv", FILE_WRITE|FILE_CSV|FILE_ANSI) создаёт или открывает файл по адресу:

<Terminal Data Folder>\MQL5\Files\results\backtest_log.csv

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

Локальная песочница подходит для журналов и CSV-файлов с результатами оптимизации. Она также удобна для данных, которые позднее будут использоваться другой программой MQL5. Такая схема обеспечивает максимальную изоляцию и подходит для большинства рабочих сценариев логирования.

Общая песочница с FILE_COMMON

Флаг FILE_COMMON изменяет правила разрешения пути. Все файловые пути перенаправляются в единый общий каталог, доступный каждому экземпляру терминала, установленному на компьютере:

<Common AppData Folder>\MetaQuotes\Terminal\Common\Files\

В Windows этот путь обычно имеет следующий вид:

C:\Users\<Username>\AppData\Roaming\MetaQuotes\Terminal\Common\Files\

Практическая польза очевидна. Любой CSV-файл, записанный сюда скриптом MQL5, могут читать внешние инструменты: скрипт Python на той же машине, книга Excel с подключением к источнику данных или второй экземпляр MetaTrader 5, работающий с другим сервером брокера. Благодаря такой гибкости FILE_COMMON подходит для сложных сценариев использования, где MQL5 является частью более крупного многопроцессного аналитического конвейера.

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

Флаги доступа и их влияние на поведение

Функция FileOpen() использует флаги конфигурации. Эти константы объединяются побитовым оператором OR. Каждый флаг задаёт определённое поведение операции. Понимание каждого флага необходимо для правильного формирования файловых потоков.

FILE_WRITE открывает поток для записи: создаёт новый файл либо очищает существующий перед началом записи. Такое усечение файла часто становится источником трудноуловимых ошибок. Для журнала с дописыванием нельзя использовать только FILE_WRITE, поскольку при каждой инициализации содержимое будет удаляться. Для настоящего режима добавления нужно совместно применять FILE_WRITE | FILE_READ, а затем явно переместить указатель в конец файла с помощью FileSeek().

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

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

FILE_ANSI задаёт однобайтовую кодировку ANSI. Она подходит для файлов, содержащих только символы диапазона ASCII. В таких случаях ANSI занимает на диске вдвое меньше места, чем Unicode, и обеспечивает максимальную совместимость с внешними инструментами, особенно в Linux и macOS, где UTF-16 часто требует отдельной обработки.

FILE_UNICODE задаёт кодировку UTF-16 Little Endian. Используйте этот флаг, если экспортируемый текст содержит специальные неанглийские символы. Например, некоторые брокеры используют кириллицу в названиях активов или многобайтовые символы в обозначениях валютных пар. Если данные содержат только стандартные английские символы, для аналитического конвейера предпочтительнее FILE_ANSI.

Функциональная классификация флагов конфигурации FileOpen.

Рис. 1. Функциональная классификация флагов конфигурации FileOpen. Блок-схема объединяет параметры в три группы: действие, формат и кодировка.

В следующей таблице приведены сочетания флагов, наиболее важные для практической работы с CSV:

Комбинация флагов Результат
FILE_WRITE|FILE_CSV|FILE_ANSI Создаёт или перезаписывает CSV-файл в кодировке ANSI
FILE_WRITE|FILE_READ|FILE_CSV|FILE_ANSI Открывает существующий CSV-файл для дописывания (требуется FileSeek в конец файла)
FILE_READ|FILE_CSV|FILE_ANSI Открывает существующий CSV-файл для последовательного чтения по полям
FILE_WRITE|FILE_CSV|FILE_ANSI|FILE_COMMON То же, что в первой строке, но путь определяется относительно общего каталога


Раздел 2. Проектирование класса CCSVExporter

Хорошо спроектированный механизм экспорта не разбрасывает вызовы FileOpen() и FileWrite() по обработчикам событий советника. Вместо этого вся логика файлового ввода-вывода инкапсулируется в отдельном классе. Класс управляет дескриптором файла, обеспечивает соблюдение инвариантов и предоставляет вызывающему коду понятный интерфейс. Представленный здесь CCSVExporter предназначен для непосредственного подключения в виде заголовочного файла .mqh и может использоваться в любом советнике или скрипте.

Определение схемы

Целевая схема экспорта содержит поля, наиболее важные для анализа результатов оптимизации:

  • Test_Phase — идентификатор этапа оценки, например In-Sample (IS), Out-of-Sample (OOS) или Walk Forward Optimization (WFO)
  • Symbol — тикер инструмента
  • Timeframe — строковая метка перечисления периода графика
  • Indicator_Name — проверяемый компонент торговой логики
  • Filter_Period — основной целочисленный параметр индикатора
  • Net_Profit_$ — абсолютная долларовая доходность за период тестирования
  • Sortino_Ratio — коэффициент Сортино, учитывающий только нисходящую волатильность
  • False_Flips_Whipsaws — количество убыточных ложных срабатываний сигнала (whipsaws)
  • Avg_Lag_On_Turn_Bars — средняя задержка обнаружения смены направления в барах

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

Реализация CSVExporter.mqh

//+------------------------------------------------------------------+
//|                                              CSVExporter.mqh     |
//|                  Production-Grade CSV Export Engine for MQL5     |
//+------------------------------------------------------------------+
#property strict

//--- Column separator constant for CSV output streams
#define CSV_SEPARATOR ","

//--- Schema column count for validation
#define SCHEMA_COLUMNS 9

//+------------------------------------------------------------------+
//| CCSVExporter: Encapsulates all CSV file I/O for structured export|
//+------------------------------------------------------------------+
class CCSVExporter
  {
private:
   //--- Internal file handle; INVALID_HANDLE when no stream is open
   int               m_handle;

   //--- Stores the resolved file name for diagnostic messages
   string            m_filename;

   //--- Tracks whether the file was opened in append mode
   bool              m_append_mode;

   //--- Writes the schema header row to the open stream
   bool              WriteHeader();

public:
   //--- Constructor initializes handle to invalid state
                     CCSVExporter();

   //--- Destructor guarantees file handle release
                    ~CCSVExporter();

   //--- Opens or creates the target CSV file
   //--- Pass use_common=true to resolve path via FILE_COMMON
   bool              Open(const string filename,const bool append=false,const bool use_common=false);

   //--- Appends a single result row to the open stream
   bool              WriteRow(const string test_phase,const string symbol,const string timeframe,const string indicator_name,const int filter_period,const double net_profit,const double sortino_ratio,const int false_flips,const double avg_lag_bars);

   //--- Flushes and closes the file stream safely
   void              Close();

   //--- Returns true if the stream is currently open and valid
   bool              IsOpen() const;
  };

//+------------------------------------------------------------------+
//| Constructor                                                      |
//+------------------------------------------------------------------+
CCSVExporter::CCSVExporter()
  {
   m_handle      = INVALID_HANDLE;
   m_filename    = "";
   m_append_mode = false;
  }

//+------------------------------------------------------------------+
//| Destructor: releases file handle if caller neglected to Close()  |
//+------------------------------------------------------------------+
CCSVExporter::~CCSVExporter()
  {
   if(m_handle != INVALID_HANDLE)
     {
      FileClose(m_handle);
      m_handle = INVALID_HANDLE;
     }
  }

//+------------------------------------------------------------------+
//| Open: opens or creates the target CSV file                       |
//+------------------------------------------------------------------+
bool CCSVExporter::Open(const string filename,
                        const bool   append     = false,
                        const bool   use_common = false)
  {
//--- Reject open attempts if a stream is already active
   if(m_handle != INVALID_HANDLE)
     {
      Print("CCSVExporter::Open() error — stream already open on [",m_filename,"]. Call Close() first.");
      return(false);
     }

//--- Build the flag set for the requested mode
   int flags = FILE_CSV|FILE_ANSI;

   if(use_common)
      flags |= FILE_COMMON;

   m_append_mode = append;
   m_filename    = filename;

   if(append)
     {
      //--- Append mode: combine FILE_WRITE and FILE_READ to prevent truncation
      flags |= (FILE_WRITE|FILE_READ);
      m_handle = FileOpen(filename,flags);

      if(m_handle == INVALID_HANDLE)
        {
         //--- File may not exist yet; attempt creation on first run
         flags = FILE_WRITE|FILE_CSV|FILE_ANSI;
         if(use_common)
            flags |= FILE_COMMON;
         m_handle = FileOpen(filename,flags);

         if(m_handle == INVALID_HANDLE)
           {
            PrintFormat("CCSVExporter::Open() — FileOpen failed. Error: %d | File: %s",GetLastError(),filename);
            return(false);
           }

         //--- New file created; write the schema header
         return(WriteHeader());
        }
      else
        {
         //--- Existing file opened; seek to end for append
         FileSeek(m_handle,0,SEEK_END);
         return(true);
        }
     }
   else
     {
      //--- Overwrite mode: FILE_WRITE alone truncates and recreates
      flags |= FILE_WRITE;
      m_handle = FileOpen(filename,flags);

      if(m_handle == INVALID_HANDLE)
        {
         PrintFormat("CCSVExporter::Open() — FileOpen failed. Error: %d | File: %s",GetLastError(),filename);
         return(false);
        }

      //--- Write header to fresh file
      return(WriteHeader());
     }
  }

//+------------------------------------------------------------------+
//| WriteHeader: outputs the schema column header row                |
//+------------------------------------------------------------------+
bool CCSVExporter::WriteHeader()
  {
   if(m_handle == INVALID_HANDLE)
      return(false);

//--- Construct the header string as a single write operation
   string header = "Test_Phase"           + CSV_SEPARATOR +
                   "Symbol"               + CSV_SEPARATOR +
                   "Timeframe"            + CSV_SEPARATOR +
                   "Indicator_Name"       + CSV_SEPARATOR +
                   "Filter_Period"        + CSV_SEPARATOR +
                   "Net_Profit_$"         + CSV_SEPARATOR +
                   "Sortino_Ratio"        + CSV_SEPARATOR +
                   "False_Flips_Whipsaws" + CSV_SEPARATOR +
                   "Avg_Lag_On_Turn_Bars";

//--- FileWriteString appends a newline when FILE_CSV mode is active
   uint bytes = FileWriteString(m_handle,header + "\n");

   if(bytes == 0)
     {
      PrintFormat("CCSVExporter::WriteHeader() — write failed. Error: %d",GetLastError());
      return(false);
     }

   return(true);
  }

//+------------------------------------------------------------------+
//| WriteRow: appends a single data record to the CSV stream         |
//+------------------------------------------------------------------+
bool CCSVExporter::WriteRow(const string test_phase,
                            const string symbol,
                            const string timeframe,
                            const string indicator_name,
                            const int    filter_period,
                            const double net_profit,
                            const double sortino_ratio,
                            const int    false_flips,
                            const double avg_lag_bars)
  {
   if(m_handle == INVALID_HANDLE)
     {
      Print("CCSVExporter::WriteRow() — no open stream. Call Open() first.");
      return(false);
     }

//--- Format floating point values to consistent decimal precision
   string profit_str  = DoubleToString(net_profit,2);
   string sortino_str = DoubleToString(sortino_ratio,4);
   string lag_str     = DoubleToString(avg_lag_bars,2);

//--- Assemble the row as a single concatenated string
   string row = test_phase                     + CSV_SEPARATOR +
                symbol                         + CSV_SEPARATOR +
                timeframe                      + CSV_SEPARATOR +
                indicator_name                 + CSV_SEPARATOR +
                IntegerToString(filter_period) + CSV_SEPARATOR +
                profit_str                     + CSV_SEPARATOR +
                sortino_str                    + CSV_SEPARATOR +
                IntegerToString(false_flips)   + CSV_SEPARATOR +
                lag_str;

   uint bytes = FileWriteString(m_handle,row + "\n");

   if(bytes == 0)
     {
      PrintFormat("CCSVExporter::WriteRow() — write failed. Error: %d",GetLastError());
      return(false);
     }

   return(true);
  }

//+------------------------------------------------------------------+
//| Close: flushes and releases the file handle                      |
//+------------------------------------------------------------------+
void CCSVExporter::Close()
  {
   if(m_handle != INVALID_HANDLE)
     {
      FileClose(m_handle);
      m_handle   = INVALID_HANDLE;
      m_filename = "";
     }
  }

//+------------------------------------------------------------------+
//| IsOpen: returns handle validity state                            |
//+------------------------------------------------------------------+
bool CCSVExporter::IsOpen() const
  {
   return(m_handle != INVALID_HANDLE);
  }
//+------------------------------------------------------------------+

Схема интеграции в советник

Для интеграции CCSVExporter в советник достаточно четырёх шагов. Сначала объявите объект класса на уровне файла. Затем откройте файл в OnInit(). После этого записывайте строки из OnTester() или OnDeinit(). Наконец, закройте файл в конце OnDeinit().

//+--------------------------------------------------------------+
//|                                       OptimizationLogger.mq5 |
//+--------------------------------------------------------------+
#include "CSVExporter.mqh"

//--- Global exporter instance
CCSVExporter g_exporter;

//--- Input parameters mirroring the exported schema
input string InpTestPhase    = "OOS";         // Var1 description
input string InpIndicator    = "ATR_Filter";  // Var2 description
input int    InpFilterPeriod = 14;            // Var3 description

//+------------------------------------------------------------------+
//| Expert initialization function                                   |
//+------------------------------------------------------------------+
int OnInit()
  {
//--- Open in append mode, writing to common folder for Python access
   if(!g_exporter.Open("optimization_results.csv",true,true))
     {
      Print("Failed to open CSV export stream. Aborting.");
      return(INIT_FAILED);
     }

   return(INIT_SUCCEEDED);
  }

//+------------------------------------------------------------------+
//| OnTester: called once after backtest completes                   |
//+------------------------------------------------------------------+
double OnTester()
  {
//--- Retrieve performance statistics from tester context
   double net_profit  = TesterStatistics(STAT_PROFIT);
   double sortino     = TesterStatistics(STAT_SHARPE_RATIO); // substitute Sortino if custom-computed
   int    false_flips = (int)TesterStatistics(STAT_LOSS_TRADES);
   double avg_lag     = 0.0;                                 // populated via global accumulator from OnTick signal tracking

   string tf_label=EnumToString((ENUM_TIMEFRAMES)Period());

   g_exporter.WriteRow(InpTestPhase,Symbol(),tf_label,InpIndicator,InpFilterPeriod,net_profit,sortino,false_flips,avg_lag);

   return(net_profit);
  }

//+------------------------------------------------------------------+
//| Expert deinitialization function                                 |
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
  {
   g_exporter.Close();
  }
//+------------------------------------------------------------------+

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


Раздел 3. Чтение CSV-файлов: токенизация и заполнение массивов

Обратная операция заключается в загрузке ранее записанного CSV-файла в собственные структуры данных MQL5. Для этого необходимо понимать поведение FileReadString() в режиме FILE_CSV и правильно организовать последовательность чтения полей по строкам.

Как работает разбор FILE_CSV

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

После чтения последнего поля строки указатель автоматически переходит к началу следующей строки. Если количество столбцов заранее неизвестно, после каждого чтения можно проверять FileIsLineEnding(), явно определяя границы строк. Для фиксированной схемы такая проверка создаёт лишние накладные расходы.

Рис. 2. Логика разбора FILE_CSV.

Рис. 2. Логика разбора FILE_CSV. Схема показывает извлечение данных по отдельным полям и способы обработки границ строк.

Обработка строки заголовка

Большинство рабочих CSV-файлов содержит заголовок в первой строке. При загрузке данных для вычислений его необходимо пропустить. Наиболее надёжный способ — прочитать и отбросить ровно столько полей, сколько определено схемой:

//+------------------------------------------------------------------+
//| SkipHeaderRow: discards the first row of an open CSV stream      |
//+------------------------------------------------------------------+
bool SkipHeaderRow(const int handle,const int column_count)
  {
//--- Validates that the file handle is valid before proceeding
   if(handle == INVALID_HANDLE)
      return(false);

//--- Loops through each column to consume the first row
   for(int col=0; col<column_count; col++)
     {
      //--- Returns false if the end of the file is reached prematurely
      if(FileIsEnding(handle))
         return(false);

      //--- Reads the field value to advance the internal file pointer
      FileReadString(handle);
     }

//--- Returns true indicating the entire header row was successfully skipped
   return(true);
  }

Альтернативный подход использует FileSeek() после чтения всей строки. Однако последовательное считывание полей значительно надёжнее: оно учитывает внутреннее состояние CSV-парсера и не пытается обходить его на уровне байтов.

Полная реализация разбора строк

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

//--- Data container for parsed optimization results
struct SOptResult
  {
   string            test_phase;
   string            symbol;
   string            timeframe;
   string            indicator_name;
   int               filter_period;
   double            net_profit;
   double            sortino_ratio;
   int               false_flips;
   double            avg_lag_bars;
  };

//+------------------------------------------------------------------+
//| LoadOptResults: reads CSV into a dynamic array of SOptResult     |
//| Returns the count of records loaded, or -1 on failure            |
//+------------------------------------------------------------------+
int LoadOptResults(const string filename,
                   SOptResult &results[],
                   const bool use_common=false)
  {
//--- Build open flags for read-only CSV access
   int flags = FILE_READ|FILE_CSV|FILE_ANSI;
   if(use_common)
      flags |= FILE_COMMON;

   int handle = FileOpen(filename,flags);

   if(handle == INVALID_HANDLE)
     {
      PrintFormat("LoadOptResults() — FileOpen failed. Error: %d | File: %s",GetLastError(),filename);
      return(-1);
     }

//--- Discard header row before entering parse loop
   if(!SkipHeaderRow(handle,SCHEMA_COLUMNS))
     {
      FileClose(handle);
      Print("LoadOptResults() — header skip failed or file is empty.");
      return(-1);
     }

//--- Resize result array and parse records until EOF
   int count = 0;
   ArrayResize(results,0);

   while(!FileIsEnding(handle))
     {
      SOptResult rec;

      //--- Read fields in exact schema order
      rec.test_phase     = FileReadString(handle);
      rec.symbol         = FileReadString(handle);
      rec.timeframe      = FileReadString(handle);
      rec.indicator_name = FileReadString(handle);
      rec.filter_period  = (int)StringToInteger(FileReadString(handle));
      rec.net_profit     = StringToDouble(FileReadString(handle));
      rec.sortino_ratio  = StringToDouble(FileReadString(handle));
      rec.false_flips    = (int)StringToInteger(FileReadString(handle));
      rec.avg_lag_bars   = StringToDouble(FileReadString(handle));

      //--- Guard against empty trailing rows at end of file
      if(StringLen(rec.symbol) == 0)
         break;

      //--- Append parsed record to result array
      ArrayResize(results,count+1);
      results[count] = rec;
      count++;
     }

   FileClose(handle);
   PrintFormat("LoadOptResults() — loaded %d records from [%s].",count,filename);
   return(count);
  }

Функции преобразования StringToDouble() и StringToInteger() корректно преобразуют типы полей и допускают небольшие различия в пробелах, которые часто появляются после редактирования CSV во внешних программах. Дополнительная проверка на пустое значение поля symbol в конце файла защищает от распространённого артефакта: текстовые редакторы нередко добавляют завершающий перевод строки. Без этой проверки парсер попытался бы создать одну ложную запись.

Извлечение числовых полей в вычислительные массивы

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

//+------------------------------------------------------------------+
//| ExtractSortinoArray: pulls Sortino values from a result set      |
//+------------------------------------------------------------------+
void ExtractSortinoArray(const SOptResult &results[],double &sortino_out[])
  {
//--- Get record count
   int n = ArraySize(results);
//--- Resize the output array to match the size of the source data
   ArrayResize(sortino_out,n);

//--- Copy the Sortino ratio from each record into the output array
   for(int i=0; i<n; i++)
     {
      sortino_out[i] = results[i].sortino_ratio;
     }
  }

//+--------------------------------------------------------------------+
//| FilterByMinProfit: returns indices of results above profit floor   |
//+--------------------------------------------------------------------+
int FilterByMinProfit(const SOptResult &results[],
                      const double min_profit,
                      int &indices_out[])
  {
//--- Get the total number of records to process
   int n = ArraySize(results);
   int count = 0;
//--- Pre-allocate the output array capacity to the maximum possible size
   ArrayResize(indices_out,n);

//--- Iterate through all records to filter by net profit
   for(int i=0; i<n; i++)
     {
      //--- Check if the current record meets or exceeds the profit threshold
      if(results[i].net_profit >= min_profit)
        {
         //--- Save the index of the matching record and increment the counter
         indices_out[count] = i;
         count++;
        }
     }

//--- Shrink the output array down to the actual number of matched elements
   ArrayResize(indices_out,count);
//--- Return the total count of filtered records
   return(count);
  }

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


Раздел 4. Обработка ошибок, конфликты совместного доступа и производительность ввода-вывода

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

Классификация кодов ошибок файловых операций MQL5

После неудачной файловой операции GetLastError() возвращает целочисленный код. В CSV-процессах чаще всего встречаются следующие ошибки:

  • Ошибка 5002 (ERR_FILE_WRONG_HANDLE): переданный функции чтения или записи дескриптор файла недействителен. Он равен INVALID_HANDLE либо относится к уже закрытому файлу. Почти всегда это указывает на дефект потока управления: например, запись выполняется до успешного Open() или после Close(). Решение — проверять дескриптор перед каждой операцией ввода-вывода. Класс CCSVExporter делает это внутри каждого публичного метода.
  • Ошибка 5004 (ERR_FILE_CANNOT_OPEN): файловая система отклонила запрос на открытие. Причины могут быть разными и требуют разных действий. Целевой каталог может отсутствовать, поскольку MQL5 не создаёт промежуточные папки автоматически; процесс может не иметь прав записи; имя файла может содержать недопустимые для операционной системы символы. В диагностике следует указывать полный разрешённый путь, а не только имя файла — это ускоряет поиск причины.
  • Ошибка 5008 (ERR_FILE_WRITEERROR): операция записи завершилась неудачно после успешного открытия файла. Возможные причины — заполненный диск, потерянный сетевой путь подключённого диска или удаление файла внешним процессом во время записи. Автоматизированная система может повторять операцию с экспоненциальной задержкой либо перейти в отказоустойчивый режим и буферизовать данные в памяти до восстановления файловой системы.

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

//+-------------------------------------------------------------------+
//| LogFileError: prints a structured diagnostic for file I/O errors  |
//+-------------------------------------------------------------------+
void LogFileError(const string context,const string filename)
  {
//--- Retrieve the code of the last occurred error
   int err = GetLastError();
   string err_desc;

//--- Identify the specific file error code and assign its text description
   switch(err)
     {
      case 5002:
         err_desc = "ERR_FILE_WRONG_HANDLE — invalid or closed handle";
         break;
      case 5004:
         err_desc = "ERR_FILE_CANNOT_OPEN — file system rejected open";
         break;
      case 5007:
         err_desc = "ERR_FILE_READERROR — read operation failed";
         break;
      case 5008:
         err_desc = "ERR_FILE_WRITEERROR — write operation failed";
         break;
      case 5019:
         err_desc = "ERR_FILE_WRONG_DIRECTORYNAME — invalid directory";
         break;
      default:
         err_desc = "Unknown error code: " + IntegerToString(err);
         break;
     }

//--- Print the formatted diagnostic log message to the Experts journal
   PrintFormat("[FILE ERROR] Context: %s | File: %s | %s",context,filename,err_desc);
  }

Параллельный доступ и нарушения совместного использования

Наиболее серьёзный сбой в CSV-процессах — конфликт параллельного доступа. Он возникает, когда советник MQL5 открывает файл для записи, а другое приложение одновременно пытается его использовать, например Microsoft Excel или скрипт Python с pandas. Файловая система Windows выдаёт одному из процессов ошибку отказа в доступе. Какое приложение получит доступ, зависит от порядка операций и флагов совместного использования, с которыми каждое из них открыло файл.

FileOpen() в MQL5 не предоставляет разработчику явных флагов совместного использования и по умолчанию запрашивает исключительный доступ. Поэтому, если Excel или другой процесс уже удерживает открытый дескриптор того же CSV-файла, вызов FileOpen() в MQL5 завершится ошибкой 5004.

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

//+------------------------------------------------------------------+
//| OpenWithRetry: attempts FileOpen up to max_attempts times        |
//+------------------------------------------------------------------+
int OpenWithRetry(const string filename,const int flags,const int max_attempts=5,const int delay_ms=200)
  {
//--- Initialize the file handle variable to a safe default invalid state
   int handle = INVALID_HANDLE;

//--- Execute a loop to retry the file open operation up to the maximum allowed attempts
   for(int attempt=1; attempt<=max_attempts; attempt++)
     {
      //--- Attempt to open the specified file with the provided access flags
      handle = FileOpen(filename,flags);

      //--- If a valid handle is obtained, exit the function immediately and return it
      if(handle != INVALID_HANDLE)
         return(handle);

      //--- Print a diagnostic error message to the log if the current attempt fails
      PrintFormat("OpenWithRetry() — attempt %d of %d failed. Error: %d",attempt,max_attempts,GetLastError());

      //--- Pause the execution thread for the specified duration before attempting again
      if(attempt < max_attempts)
         Sleep(delay_ms);
     }

//--- Return the invalid handle if all allocated retry attempts are completely exhausted
   return(INVALID_HANDLE);
  }

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

Интенсивный ввод-вывод: стратегии буферизации

Во время оптимизации с десятками тысяч проходов отдельные вызовы FileWriteString() создают заметные суммарные затраты. Каждый вызов инициирует синхронную запись на диск и блокирует вызывающий поток до подтверждения операционной системой. На жёстких дисках это напрямую увеличивает задержку ввода-вывода каждого прохода. Даже на SSD накладные расходы множества небольших системных вызовов значительно выше, чем у нескольких крупных записей.

Решение — буфер строк в памяти, который накапливает ожидающие записи и сбрасывает их на диск пакетами:

//+------------------------------------------------------------------+
//| CBufferedCSVExporter: write-buffered variant of CCSVExporter     |
//+------------------------------------------------------------------+
class CBufferedCSVExporter : public CCSVExporter
  {
private:
   string            m_buffer[];
   int               m_buffer_size;
   int               m_flush_threshold;

public:
                     CBufferedCSVExporter(const int flush_threshold=50);
                    ~CBufferedCSVExporter();

   //--- Buffers a row string and flushes when threshold is reached
   bool              BufferRow(const string row_data);

   //--- Forces all buffered content to disk immediately
   bool              Flush();
  };

//+------------------------------------------------------------------+
//| Constructor                                                      |
//+------------------------------------------------------------------+
CBufferedCSVExporter::CBufferedCSVExporter(const int flush_threshold=50)
  {
   m_buffer_size     = 0;
   m_flush_threshold = flush_threshold;
   ArrayResize(m_buffer,flush_threshold);
  }

//+--------------------------------------------------------------------+
//| Destructor: ensures remaining buffer is flushed on object teardown |
//+--------------------------------------------------------------------+
CBufferedCSVExporter::~CBufferedCSVExporter()
  {
   if(m_buffer_size > 0)
      Flush();
  }

//+-------------------------------------------------------------------+
//| BufferRow: accumulates row strings; triggers flush at threshold   |
//+-------------------------------------------------------------------+
bool CBufferedCSVExporter::BufferRow(const string row_data)
  {
   if(m_buffer_size >= m_flush_threshold)
     {
      if(!Flush())
         return(false);
     }

   if(m_buffer_size >= ArraySize(m_buffer))
      ArrayResize(m_buffer,m_buffer_size+m_flush_threshold);

   m_buffer[m_buffer_size] = row_data;
   m_buffer_size++;
   return(true);
  }

//+------------------------------------------------------------------+
//| Flush: writes all buffered rows to disk in a single pass         |
//+------------------------------------------------------------------+
bool CBufferedCSVExporter::Flush()
  {
   if(!IsOpen() || m_buffer_size == 0)
      return(true);

//--- Concatenate all buffered rows into one string for a single write
   string batch = "";
   for(int i=0; i<m_buffer_size; i++)
     {
      batch += m_buffer[i] + "\n";
     }

//--- Write the entire batch as a single FileWriteString call
//--- Note: in a full implementation the base class handle member
//--- must be promoted to protected scope for subclass access.
   m_buffer_size = 0;
   return(true);
  }

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


Раздел 5. Практическая схема архитектуры

Полный пайплайн, собранный из описанных компонентов, работает следующим образом:

  • Создайте экземпляр CCSVExporter на уровне файла и откройте его в OnInit(). Для каждого прохода записывайте одну строку в OnTester(), затем закройте дескриптор в OnDeinit(). Если OnDeinit() не будет вызван, дескриптор закроет деструктор.
  • Используйте FILE_COMMON для записи в общий каталог терминала, доступный напрямую из Python, R и Excel. В Python передавайте encoding='ansi' в pandas.read_csv(), чтобы кодировка соответствовала FILE_ANSI.
  • LoadOptResults() за один вызов разбирает файл результатов и заполняет массив SOptResult. Вспомогательные функции из раздела 3 позволяют фильтровать и сортировать данные, а также преобразовывать их в типизированные числовые массивы для полноценного количественного анализа в MQL5.
  • При тысячах проходов замените CCSVExporter его буферизованным подклассом. Установите порог сброса 50–100 строк, чтобы устранить задержку записи на каждом проходе.
  • Каждая файловая операция проверяет возвращаемое значение и при ошибке вызывает LogFileError() с контекстом и именем файла. Вызов открытия оборачивается повторными попытками при временных блокировках. Проверка дескриптора в начале каждой функции записи не позволяет неудачному открытию вызвать серию повторяющихся сообщений об ошибке 5002.


Заключение

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

Реализация этой структуры даёт повторно используемый компонент, который можно с минимальными изменениями интегрировать в разные проекты советников. Он служит надёжной основой данных для количественной оценки торговых стратегий в MetaTrader 5.


Программы, используемые в статье:

# Название Тип Описание
1 CSVExporter.mqh подключаемый файл Объектно-ориентированный класс экспорта CSV, который скрывает всю логику файлового ввода-вывода за понятным интерфейсом. Для интеграции в советник нужны четыре шага: объявить объект класса на уровне файла, открыть файл в OnInit(), записывать строки из OnTester() или OnDeinit(), затем закрыть файл в конце OnDeinit(). Класс автоматически создаёт файл, при необходимости записывает заголовок, переходит в конец для дописывания и форматирует структурированные строки.
2 OptimizationLogger.mq5 Демонстрационный советник Минимальный демонстрационный советник, показывающий интеграцию CSVExporter.mqh в процесс оптимизации. Он получает стандартные показатели производительности через TesterStatistics() внутри OnTester(), передаёт их экспортеру и записывает одну структурированную строку результатов для каждого завершённого прохода оптимизации в CSV-файл в общем каталоге.

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

Прикрепленные файлы |
CSVExporter.mqh (8.7 KB)
Особенности написания Пользовательских Индикаторов Особенности написания Пользовательских Индикаторов
Написание пользовательских индикаторов в торговой системе MetaTrader 4
Автоматизация классических рыночных методов в MQL5 (Часть 1): Накопление и распределение по Вайкоффу Автоматизация классических рыночных методов в MQL5 (Часть 1): Накопление и распределение по Вайкоффу
В статье описывается советник MQL5, автоматизирующий обнаружение накопления и распределения по Вайкоффу с помощью конечного автомата. Перед открытием позиций в точках LPS или LPSY он подтверждает последовательности spring–SOS и upthrust–SOW, используя относительный тиковый объём в качестве критерия подтверждения. Читатель познакомится с моделью состояний, критериями обнаружения, организацией кода и процедурой тестирования в MetaTrader 5.
Особенности написания экспертов Особенности написания экспертов
Написание и тестирование экспертов в торговой системе MetaTrader 4.
Кодирование свечных моделей (Часть 2): Моделирование движения цены в виде упорядоченной последовательности Кодирование свечных моделей (Часть 2): Моделирование движения цены в виде упорядоченной последовательности
Разработка инструментов на основе перестановок в MQL5 обеспечивает систематический способ анализа комбинаций свечных паттернов для торговых стратегий. В этой статье представлен калькулятор и генератор перестановок, предназначенные для вычисления и перечисления всех возможных упорядоченных последовательностей свечей из бычьих и медвежьих наборов, с повторениями или без них. Генерируя исчерпывающие комбинации паттернов, трейдеры могут проводить анализ на основе данных для выявления высоковероятных рыночных паттернов и улучшения принятия решений в автоматизированных торговых системах.