Параллельная оптимизация методом роя частиц (Particle Swarm Optimization)

Stanislav Korotky | 19 августа, 2020

Как известно, MetaTrader 5 позволяет оптимизировать торговые стратегии с помощью встроенного тестера на основе двух алгоритмов: прямого перебора входных параметров и генетики (генетический алгоритм - ГА). Генетическая оптимизация является одной из разновидностей эволюционных алгоритмов, которые предоставляют значительное ускорение процесса. Вместе с тем, результаты ГА могут существенно зависеть как от поставленной задачи, так и от нюансов конкретной реализации ГА, в частности, той, что предлагает тестер. Именно поэтому многие трейдеры, которым по тем или иным причинам не подходит стандартный функционал, пытаются создать собственные оптимизаторы для MetaTrader. При этом возможные способы быстрой оптимизации не ограничиваются генетикой. Помимо ГА широко известны такие методы как "имитация отжига" и "метод роя частиц".

В данной статье мы реализуем алгоритм роя частиц (Particle Swarm Optimization, PSO) и попытаемся встроить его в тестер MetaTrader для параллельного запуска на доступных локальных агентах. Целевой функцией оптимизации будет выбранный пользователем показатель торговли эксперта.

Метод роя частиц

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

Particle Swarm Optimization Pseudo-Code

Particle Swarm Optimization Pseudo-Code

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

  class Particle
  {
    public:
      double position[];    // current point
      double best[];        // best point known to the particle
      double velocity[];    // current speed
      
      double positionValue; // EA performance in current point
      double bestValue;     // EA performance in the best point
      int    group;
      
      Particle(const int params)
      {
        ArrayResize(position, params);
        ArrayResize(best, params);
        ArrayResize(velocity, params);
        bestValue = -DBL_MAX;
        group = -1;
      }
  };

Размер всех массивов равен размерности пространства оптимизации, то есть количеству оптимизируемых параметров эксперта (передается в конструктор). Поскольку оптимизация по умолчанию предполагает, что чем больше значение целевой функции, тем оно лучше, мы инициализируем поле bestValue минимальным возможным числом -DBL_MAX. В качестве критерия оценки эксперта обычно выступает один из показателей торговли: прибыль, прибыльность, коэффициент Шарпа и т.д. Если требуется оптимизация по величине,которая становится лучше при уменьшении, например, просадка, это легко обеспечить эквивалентными преобразованиями для максимизации обратных величин.

Массивы и переменные сделаны публичными для упрощения доступа и кода их пересчета. Строгое следование принципам ООП потребовало бы скрыть их с помощью модификатора private и описать методы для чтения и модификации.

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

  class Group
  {
    private:
      double result;    // best EA performance in the group
    
    public:
      double optimum[]; // best known position in the group
      
      Group(const int params)
      {
        ArrayResize(optimum, params);
        ArrayInitialize(optimum, 0);
        result = -DBL_MAX;
      }
      
      void assign(const double x)
      {
        result = x;
      }
      
      double getResult() const
      {
        return result;
      }
      
      bool isAssigned()
      {
        return result != -DBL_MAX;
      }
  };

Принадлежность частицы к группе задается номером группы в поле group в классе Particle (см. выше).

Теперь приступим к кодированию самого алгоритма роя частиц, в виде отдельного класса. Начнем с массивов частиц и групп.

  class Swarm
  {
    private:
      Particle *particles[];
      Group *groups[];
      int _size;             // number of particles
      int _globals;          // number of groups
      int _params;           // number of parameters to optimize

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

      double highs[];
      double lows[];
      double steps[];

Кроме того, следует где-то хранить оптимальный набор параметров.

      double solution[];

Поскольку класс будет иметь несколько разных конструкторов, опишем унифицированный метод инициализации.

    void init(const int size, const int globals, const int params, const double &max[], const double &min[], const double &step[])
    {
      _size = size;
      _globals = globals;
      _params = params;
      
      ArrayCopy(highs, max);
      ArrayCopy(lows, min);
      ArrayCopy(steps, inc);
      
      ArrayResize(solution, _params);
      
      ArrayResize(particles, _size);
      for(int i = 0; i < _size; i++)          // loop through particles
      {
        particles[i] = new Particle(_params);
        
        ///do
        ///{
          for(int p = 0; p < _params; p++)    // loop through all dimesions
          {
            // random placement
            particles[i].position[p] = (MathRand() * 1.0 / 32767) * (highs[p] - lows[p]) + lows[p];
            // adjust it according to step granularity
            if(steps[p] != 0)
            {
              particles[i].position[p] = ((int)MathRound((particles[i].position[p] - lows[p]) / steps[p])) * steps[p] + lows[p];
            }
            // the only position is the best so far
            particles[i].best[p] = particles[i].position[p];
            // random speed
            particles[i].velocity[p] = (MathRand() * 1.0 / 32767) * 2 * (highs[p] - lows[p]) - (highs[p] - lows[p]);
          }
        ///}
        ///while(index.add(crc64(particles[i].position)) && !IsStopped());
      }
      
      ArrayResize(groups, _globals);
      for(int i = 0; i < _globals; i++)
      {
        groups[i] = new Group(_params);
      }
      
      for(int i = 0; i < _size; i++)
      {
        // random group membership
        particles[i].group = (_globals > 1) ? (int)MathMin(MathRand() * 1.0 / 32767 * _globals, _globals - 1) : 0;
      }
    }

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

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

Теперь мы можем реализовать пару конструкторов с помощью init.

  #define AUTO_SIZE_FACTOR 5
  
  public:
    Swarm(const int params, const double &max[], const double &min[], const double &step[])
    {
      init(params * AUTO_SIZE_FACTOR, (int)MathSqrt(params * AUTO_SIZE_FACTOR), params, max, min, step);
    }
    
    Swarm(const int size, const int globals, const int params, const double &max[], const double &min[], const double &step[])
    {
      init(size, globals, params, max, min, step);
    }

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

Деструктор освобождает распределенную память.

    ~Swarm()
    {
      for(int i = 0; i < _size; i++)
      {
        delete particles[i];
      }
      for(int i = 0; i < _globals; i++)
      {
        delete groups[i];
      }
    }

И вот настал момент для написания главного метода класса, выполняющего непосредственно оптимизацию.

    double optimize(Functor &f, const int cycles, const double inertia = 0.8, const double selfBoost = 0.4, const double groupBoost = 0.4)

Первый параметр Functor &f представляет особый интерес. Очевидно, что в процессе оптимизации мы будем вызывать эксперт для различных входных параметров и в ответ получать оценочное число (прибыль, прибыльность или другую характеристику). Рой ничего не знает и не должен знать об эксперте. Его единственная задача — найти оптимум неизвестной целевой функции с произвольным набором числовых аргументов. Поэтому в ход вступает абстрактный интерфейс, в нашем случае класс Functor.

  class Functor
  {
    public:
      virtual double calculate(const double &vector[]) = 0;
  };

Единственный метод принимает на входе массив параметров и возвращает число (все типы — double). В дальнейшем эксперт должен будет тем или иным образом реализовать класс, производный от Functor, и вычислять требуемый показатель внутри метода calculate. Таким образом, первый параметр метода optimize получит объект с функцией обратного вызова, предоставляемой торговым роботом.

Второй параметр метода optimize — это максимальное количество циклов для выполнения алгоритма. Следующие 3 параметра задают коэффициенты PSO: inertia — сохранение скорости частицы (благодаря значениям меньше 1, скорость обычно уменьшается), selfBoost и groupBoost определяют, насколько отзывчива частица на подстраивание направления своего движения к лучшим известным позициям, соответственно, в истории самой частицы и её группы.

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

    double optimize(Functor &f, const int cycles, const double inertia = 0.8, const double selfBoost = 0.4, const double groupBoost = 0.4)
    {
      double result = -DBL_MAX;
      ArrayInitialize(solution, 0);
      
      for(int c = 0; c < cycles && !IsStopped(); c++)   // predefined number of cycles
      {
        for(int i = 0; i < _size && !IsStopped(); i++)  // loop through all particles
        {
          for(int p = 0; p < _params; p++)              // update particle position and speed
          {
            double r1 = MathRand() * 1.0 / 32767;
            double rg = MathRand() * 1.0 / 32767;
            particles[i].velocity[p] = inertia * particles[i].velocity[p] + selfBoost * r1 * (particles[i].best[p] - particles[i].position[p]) + groupBoost * rg * (groups[particles[i].group].optimum[p] - particles[i].position[p]);
            particles[i].position[p] = particles[i].position[p] + particles[i].velocity[p];
            
            // make sure to keep the particle inside the boundaries of parameter space
            if(particles[i].position[p] < lows[p]) particles[i].position[p] = lows[p];
            else if(particles[i].position[p] > highs[p]) particles[i].position[p] = highs[p];
            
            // respect step size
            if(steps[p] != 0)
            {
              particles[i].position[p] = ((int)MathRound((particles[i].position[p] - lows[p]) / steps[p])) * steps[p] + lows[p];
            }
          }
          
          // get the function value for the particle i
          particles[i].positionValue = f.calculate(particles[i].position);
          
          // update the particle's best value and position (if improvement is found)          
          if(particles[i].positionValue > particles[i].bestValue)
          {
            particles[i].bestValue = particles[i].positionValue;
            ArrayCopy(particles[i].best, particles[i].position);
          }
          
          // update the group's best value and position (if improvement is found)          
          if(particles[i].positionValue > groups[particles[i].group].getResult())
          {
            groups[particles[i].group].assign(particles[i].positionValue);
            ArrayCopy(groups[particles[i].group].optimum, particles[i].position);
            
            // update the global maximum value and solution (if improvement is found)          
            if(particles[i].positionValue > result)
            {
              result = particles[i].positionValue;
              ArrayCopy(solution, particles[i].position);
            }
          }
        }
      }
      
      return result;
    }

Метод возвращает найденное максимальное значение целевой функции. Для чтения координат (набора параметров) зарезервирован другой метод.

    bool getSolution(double &result[])
    {
      ArrayCopy(result, solution);
      return !IsStopped();
    }

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

Дискретный мир без двойников

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

Параметры — это просто числа, последовательность байтов. Наиболее известным приемом для проверки уникальности данных является хэш. А наиболее популярным способом получения хэша можно считать CRC. CRC — это отдельное число (как правило, целое, многоразрядное), генерируемое на основе данных таким образом, что совпадение двух таких характеристических чисел от двух наборов данных с высокой вероятностью означает, что наборы идентичны. Чем больше разрядов (битов) в CRC, тем выше вероятность совпадения (вплоть до практически 100%). 64-битного CRC, пожалуй, достаточно для нашей задачи, но при необходимости его можно расширить и поменять на другую хэш-функцию. Реализацию расчета CRC легко портировать на MQL с языка C. Один из вариантов приложен к данной статье в файле crc64.mqh. Основная рабочая функция имеет следующий прототип.

  ulong crc64(ulong crc, const uchar &s[], int l);

Она принимает CRC предыдущего блока данных (если их несколько, а для одного блока следует указать 0), массив байтов и сколько элементов из него обработать. Функция возвращает 64-битный CRC.

Нам необходимо подать на вход этой функции набор параметров, но напрямую это сделать нельзя, так как каждый параметр — это число типа double. Для преобразования его в массив байтов будем использовать библиотеку TypeToBytes.mqh (файл приложен к статье, но наиболее актуальную версию лучше брать в codebase).

После включения данной библиотеки мы можем создать функцию "обертку" для расчета CRC64 от массива параметров:

  #include <TypeToBytes.mqh>
  #include <crc64.mqh>
  
  template<typename T>
  ulong crc64(const T &array[])
  {
    ulong crc = 0;
    int len = ArraySize(array);
    for(int i = 0; i < len; i++)
    {
      crc = crc64(crc, _R(array[i]).Bytes, sizeof(T));
    }
    return crc;
  }

Но возникают следующие вопросы: где хранить хэши и как проверять их на уникальность. Здесь лучше всего подойдет бинарное дерево. Это структура данных, которая обеспечивает быстрые операции добавления новых величин и проверку на существование уже добавленных. Скорость обеспечивается за счет особого свойства дерева, называемого сбалансированностью. Иными словами, дерево должно быть сбалансированным (постоянно поддерживаться в сбалансированном состоянии), чтобы обеспечить максимальную скорость операций над ним. К счастью, тот факт, что в дереве будут храниться хэши, играет нам на руку. Вспомним определение хэша.

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

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

Для реализации данной логики была создана пара классов-шаблонов: узел TreeNode и дерево BinaryTree. С их полными исходными кодами можно ознакомиться в прилагаемом заголовочном файле.

  template<typename T>
  class TreeNode
  {
    private:
      TreeNode *left;
      TreeNode *right;
      T value;
  
    public:
      TreeNode(T t): value(t) {}
      // adds new value into subtrees and returns false or
      // returns true if t exists as value of this node or in subtrees
      bool add(T t);
      ~TreeNode();
      TreeNode *getLeft(void) const;
      TreeNode *getRight(void) const;
      T getValue(void) const;
  };
    
  template<typename T>
  class BinaryTree
  {
    private:
      TreeNode<T> *root;
      
    public:
      bool add(T t);
      ~BinaryTree();
  };

Метод add возвращает true, если значение уже есть в дереве, и false — если его ранее не было, но оно только что добавлено. Удаление корня в деструкторе дерева автоматически разворачивается в удаление всех дочерних узлов.

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

Добавим BinaryTree в класс Swarm.

  class Swarm
  {
    private:
      BinaryTree<ulong> index;

В методе optimize следует доработать места, где мы перемещаем частицы на новые позиции.

      double optimize(Functor &f, const int cycles, const double inertia = 0.8, const double selfBoost = 0.4, const double groupBoost = 0.4)
      {
        // ...
        
        double next[];
        ArrayResize(next, _params);
  
        for(int c = 0; c < cycles && !IsStopped(); c++)
        {
          int skipped = 0;
          for(int i = 0; i < _size && !IsStopped(); i++)
          {
            // new placement of particles using temporary array next
            for(int p = 0; p < _params; p++)
            {
              double r1 = MathRand() * 1.0 / 32767;
              double rg = MathRand() * 1.0 / 32767;
              particles[i].velocity[p] = inertia * particles[i].velocity[p] + selfBoost * r1 * (particles[i].best[p] - particles[i].position[p]) + groupBoost * rg * (groups[particles[i].group].optimum[p] - particles[i].position[p]);
              next[p] = particles[i].position[p] + particles[i].velocity[p];
              if(next[p] < lows[p]) next[p] = lows[p];
              else if(next[p] > highs[p]) next[p] = highs[p];
              if(steps[p] != 0)
              {
                next[p] = ((int)MathRound(next[p] / steps[p])) * steps[p];
              }
            }
  
            // check if the tree contains this parameter set and add it if not
            if(index.Add(crc64(next)))
            {
              skipped++;
              continue;
            }
  
            // apply new position to the particle
            ArrayCopy(particles[i].position, next);
            
            particles[i].positionValue = f.calculate(particles[i].position);
            
            // ...
          }
          Print("Cycle ", c, " done, skipped ", skipped, " of ", _size, " / ", result);
          if(skipped == _size) break; // full coverage
        }

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

Тестирование базового функционала

Всё, рассмотренное вышe, составляет минимально необходимую основу для проведения первых тестов. Убедиться, что оптимизация действительно работает, можно с помощью скрипта testpso.mq5. Используемый им и прилагаемый к статье заголовочный файл ParticleSwarmParallel.mqh содержит, на самом деле, не только уже знакомые классы, но и много усовершенствований, которые будут описаны далее.

Тесты оформлены в ООП-стиле, что позволяет расширить их вашими любимыми целевыми функциями. Базу для тестов предоставляет класс BaseFunctor.

    class BaseFunctor: public Functor
    {
      protected:
        const int params;
        double max[], min[], steps[];
        
      public:
        BaseFunctor(const int p): params(p) // number of parameters
        {
          ArrayResize(max, params);
          ArrayResize(min, params);
          ArrayResize(steps, params);
          ArrayInitialize(steps, 0);
          
          PSOTests::register(&this);
        }
        
        virtual void test(const int loop)   // worker method
        {
          Swarm swarm(params, max, min, steps);
          swarm.optimize(this, loop);
          double result[];
          swarm.getSolution(result);
          for(int i = 0; i < params; i++)
          {
            Print(i, " ", result[i]);
          }
        }
    };

Все объекты производных классов будут автоматически регистрировать себя в момент создания с помощью метода register в классе PSOTests.

  class PSOTests
  {
      static BaseFunctor *testCases[];
    
    public:  
      static void register(BaseFunctor *f)
      {
        int n = ArraySize(testCases);
        ArrayResize(testCases, n + 1);
        testCases[n] = f;
      }
      
      static void run(const int loop = 100)
      {
        for(int i = 0; i < ArraySize(testCases); i++)
        {
          testCases[i].test(loop);
        }
      }
  };

Сами тесты (оптимизация) запускаются методом run — он вызывает test для всех зарегистрированных объектов.

Существует множество популярных тестовых целевых функций. Среди них, в частности, есть "rosenbrock", "griewank", "sphere", которые и запрограммированы в скрипте. Например, для "сферы" мы можем определить область поиска и метод calculate следующим образом.

      class Sphere: public BaseFunctor
      {
        public:
          Sphere(): BaseFunctor(3) // expected global minimum (0, 0, 0)
          {
            for(int i = 0; i < params; i++)
            {
              max[i] = 100;
              min[i] = -100;
            }
          }
          
          virtual void test(const int loop)
          {
            Print("Optimizing " + typename(this));
            BaseFunctor::test(loop);
          }
          
          virtual double calculate(const double &vec[])
          {
            int dim = ArraySize(vec);
            double sum = 0;
            for(int i = 0; i < dim; i++) sum += pow(vec[i], 2);
            return -sum; // negative for maximization
          }
      };

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

  void OnStart()
  {
    PSOTests::Sphere sphere;
    PSOTests::Griewank griewank;
    PSOTests::Rosenbrock rosenbrock;
    PSOTests::run();
  }

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

  Optimizing PSOTests::Sphere
  PSO[3] created: 15/3
  PSO Processing...
  Cycle 0 done, skipped 0 of 15 / -1279.167775306995
  Cycle 10 done, skipped 0 of 15 / -231.4807406906516
  Cycle 20 done, skipped 0 of 15 / -4.269510657558273
  Cycle 30 done, skipped 0 of 15 / -1.931949742316357
  Cycle 40 done, skipped 0 of 15 / -0.06018744740061506
  Cycle 50 done, skipped 0 of 15 / -0.009498109984732127
  Cycle 60 done, skipped 0 of 15 / -0.002058433538555499
  Cycle 70 done, skipped 0 of 15 / -0.0001494176502579518
  Cycle 80 done, skipped 0 of 15 / -4.141817579039349e-05
  Cycle 90 done, skipped 0 of 15 / -1.90930142126799e-05
  Cycle 99 done, skipped 0 of 15 / -8.161728746514931e-07
  PSO Finished 1500 of 1500 planned calculations: true
  0 -0.000594423827318461
  1 -0.000484001094843528
  2 0.000478096358862763
  Optimizing PSOTests::Griewank
  PSO[2] created: 10/3
  PSO Processing...
  Cycle 0 done, skipped 0 of 10 / -26.96927938978973
  Cycle 10 done, skipped 0 of 10 / -0.939220906325796
  Cycle 20 done, skipped 0 of 10 / -0.3074442362962919
  Cycle 30 done, skipped 0 of 10 / -0.121905607345751
  Cycle 40 done, skipped 0 of 10 / -0.03294107382891465
  Cycle 50 done, skipped 0 of 10 / -0.02138355984774098
  Cycle 60 done, skipped 0 of 10 / -0.01060479828529859
  Cycle 70 done, skipped 0 of 10 / -0.009728742850384609
  Cycle 80 done, skipped 0 of 10 / -0.008640623678293768
  Cycle 90 done, skipped 0 of 10 / -0.008578769833161193
  Cycle 99 done, skipped 0 of 10 / -0.008578769833161193
  PSO Finished 996 of 1000 planned calculations: true
  0 3.188612982502877
  1 -4.435728146291838
  Optimizing PSOTests::Rosenbrock
  PSO[2] created: 10/3
  PSO Processing...
  Cycle 0 done, skipped 0 of 10 / -19.05855349617553
  Cycle 10 done, skipped 1 of 10 / -0.4255148824156119
  Cycle 20 done, skipped 0 of 10 / -0.1935391314277153
  Cycle 30 done, skipped 0 of 10 / -0.006468452482022688
  Cycle 40 done, skipped 0 of 10 / -0.001031992354315317
  Cycle 50 done, skipped 0 of 10 / -0.00101322411502283
  Cycle 60 done, skipped 0 of 10 / -0.0008800704421316765
  Cycle 70 done, skipped 0 of 10 / -0.0005593151578155307
  Cycle 80 done, skipped 0 of 10 / -0.0005516786893301249
  Cycle 90 done, skipped 0 of 10 / -0.0005473814163781119
  Cycle 99 done, skipped 0 of 10 / -7.255520122486163e-06
  PSO Finished 982 of 1000 planned calculations: true
  0 1.001858172119364
  1 1.003524791491219

Обратите внимание, что размер роя и количество групп (пишутся в лог в строках вида PSO[N] created: X/G, где N — размерность пространства, X — количество частиц, G — количество групп) автоматически выбираются по запрограммированным эмпирическим правилам на основе входных данных.

Переходим в параллельный мир

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

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

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

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

Сперва дополним инициализацию объектов новым способом.

    Swarm(const int size, const int globals, const int params, const double &max[], const double &min[], const double &step[])
    {
      if(MQLInfoInteger(MQL_OPTIMIZATION))
      {
        init(size == 0 ? params * AUTO_SIZE_FACTOR : size, 1, params, max, min, step);
      }
      ...

Отталкиваясь от признака работы программы в режиме оптимизации, задаем количество групп равным 1. Размер роя по умолчанию определяется по эмпирическому правилу (если явно не задано отличное от 0 значение в параметре size).

В обработчике события OnTester эксперт сможет получить результат работы мини-роя (состоящего из единственной группы) с помощью функции getSolution и отправить его фреймом в терминал. Тот может анализировать проходы и выбирать лучший из них. Количество параллельных роев/групп имеет смысл делать равным как минимум количеству ядер, но может быть и больше (при этом желательно сохранять кратность количеству ядер). Разумеется, чем больше размерность пространства поиска оптимума, тем больше групп может потребоваться, но для простых тестов достаточно ограничиться количеством ядер.

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

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

В MQL изменения в записываемом файле могут быть прочитаны другими процессами только после закрытия файла. Флаги FILE_SHARE_READ, FILE_SHARE_WRITE, а также функция FileFlush здесь не помогают.

Поддержка записи index-ов реализована с использованием известного паттерна "визитор".

  template<typename T>
  class Visitor
  {
    public:
      virtual void visit(TreeNode<T> *node) = 0;
  };

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

  template<typename T>
  class Exporter: public Visitor<T>
  {
    private:
      int file;
      uint level;
      
    public:
      Exporter(const string name): level(0)
      {
        file = FileOpen(name, FILE_READ | FILE_WRITE | FILE_CSV | FILE_ANSI | FILE_SHARE_READ| FILE_SHARE_WRITE | FILE_COMMON, ',');
      }
      
      ~Exporter()
      {
        FileClose(file);
      }
      
      virtual void visit(TreeNode<T> *node) override
      {
        #ifdef PSO_DEBUG_BINTREE
        if(node.getLeft()) visit(node.getLeft());
        FileWrite(file, node.getValue());
        if(node.getRight()) visit(node.getRight());
        #else
        const T v = node.getValue();
        FileWrite(file, v);
        level++;
        if((level | (uint)v) % 2 == 0)
        {
          if(node.getLeft()) visit(node.getLeft());
          if(node.getRight()) visit(node.getRight());
        }
        else
        {
          if(node.getRight()) visit(node.getRight());
          if(node.getLeft()) visit(node.getLeft());
        }
        level--;
        #endif
      }
  };

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

С помощью класса Explorer легко добавить в класс BinaryTree специальный метод, выполняющий сохранение дерева в переданный "визитёр".

  template<typename T>
  class BinaryTree
  {
      ...
      void visit(Visitor<T> *visitor)
      {
        visitor.visit(root);
      }
  };

Чтобы запустить операцию, требуется также новый метод в классе Swarm.

    void exportIndex(const int id)
    {
      const string name = sharedName(id);
      Exporter<ulong> exporter(name);
      index.visit(&exporter);
    }

Параметр id — уникальный номер прохода (он же номер группы), именно по этому параметру мы будем настраивать оптимизацию в тестере. Вызывать метод exportIndex имеет смысл сразу после отработки двух методов роя: optimize и getSolution. Эта обязанность возложена на вызывающий код, потому что может требоваться не всегда — наш первый "параллельный" пример (см. далее) обойдется без неё. По определению, если количество групп равно числу ядер, они не успеют обменяться никакой информацией, поскольку будут запущены параллельно, а считывать файл внутри цикла — неэффективно.

Вспомогательная функция sharedName, упомянутая внутри exportIndex, позволяет создать уникальное имя на основе номера группы, названия эксперта и папки терминала.

  #define PPSO_FILE_PREFIX "PPSO-"
  
  string sharedName(const int id, const string prefix = PPSO_FILE_PREFIX, const string ext = ".csv")
  {
    ushort array[];
    StringToShortArray(TerminalInfoString(TERMINAL_PATH), array);
    const string program = MQLInfoString(MQL_PROGRAM_NAME) + "-";
    if(id != -1)
    {
      return prefix + program + StringFormat("%08I64X-%04d", crc64(array), id) + ext;
    }
    return prefix + program + StringFormat("%08I64X-*", crc64(array)) + ext;
  }

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

      bool restoreIndex()
      {
        string name;
        const string filter = sharedName(-1); // use wildcards to merge multiple indices for all cores
        long h = FileFindFirst(filter, name, FILE_COMMON);
        if(h != INVALID_HANDLE)
        {
          do
          {
            FileReader reader(name, FILE_COMMON);
            reader.read(this);
          }
          while(FileFindNext(h, name));
          FileFindClose(h);
        }
        return true;
      }

Каждый найденный файл передается на обработку новому классу FileReader. Он ответственен за открытие файла в режиме чтения и последовательной загрузке всех строк, с их немедленной передачей в интерфейс Feed.

  class Feed
  {
    public:
      virtual bool feed(const int dump) = 0;
  };
  
  class FileReader
  {
    protected:
      int dump;
      
    public:
      FileReader(const string name, const int flags = 0)
      {
        dump = FileOpen(name, FILE_READ | FILE_CSV | FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_ANSI | flags, ',');
      }
      
      virtual bool isReady() const
      {
        return dump != INVALID_HANDLE;
      }
      
      virtual bool read(Feed &pass)
      {
        if(!isReady()) return false;
        
        while(!FileIsEnding(dump))
        {
          if(!pass.feed(dump))
          {
            return false;
          }
        }
        return true;
      }
  };

Как не трудно догадаться, интерфейс Feed должен быть реализован в самом рое, ведь мы передали внутрь FileReader-а this.

  class Swarm: public Feed
  {
    private:
      ...
      int _read;
      int _unique;
      int _restored;
      BinaryTree<ulong> merge;
      
    public:
      ...
      virtual bool feed(const int dump) override
      {
        const ulong value = (ulong)FileReadString(dump);
        _read++;
        if(!index.add(value)) _restored++;    // just added into the tree
        else if(!merge.add(value)) _unique++; // was already indexed, hitting _unique in total
        return true;
      }
      ...

С помощью переменных _read, _unique и _restored метод подсчитывает, соответственно, общее число прочитанных элементов (из всех файлов), сколько было добавлено в index, и сколько среди тех, которые не были добавлены (то есть уже имеются в индексе), уникальных. Поскольку группы работают независимо, может оказаться, что в индексах разных групп есть дубликаты.

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

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

Диаграмма классов Particle Swarm Optimization

Диаграмма классов Particle Swarm Optimization

Тестирование параллельных вычислений

Для проверки работоспособности алгоритма в нескольких потоках преобразуем прежний скрипт в эксперт PPSO.mq5. Он будет запускаться в режиме математических вычислений, так как торговое окружение пока не нужно.

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

  enum TEST
  {
    Sphere,
    Griewank,
    Rosenbrock
  };
  
  sinput int Cycles = 100;
  sinput TEST TestCase = Sphere;
  sinput int SwarmSize = 0;
  input int GroupCount = 0;

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

  class BaseFunctor: public Functor
  {
    protected:
      const int params;
      double max[], min[], steps[];
      
      double optimum;
      double result[];
  
    public:
      ...
      virtual void test()
      {
        Swarm swarm(SwarmSize, GroupCount, params, max, min, steps);
        optimum = swarm.optimize(this, Cycles);
        swarm.getSolution(result);
      }
      
      double getSolution(double &output[]) const
      {
        ArrayCopy(output, result);
        return optimum;
      }
  };

Все вычисления запускаются из обработчика OnTester. Параметр GroupCount (по которому будут организованы итерации тестера) используется в качестве рандомизатора, чтобы экземпляры в разных потоках содержали разные частицы. В зависимости от параметра TestCase создается тот или иной тестовый функтор. Далее вызывается метод functor.test(), после чего результаты можно прочитать с помощью functor.getSolution() и отправить фреймом в терминал.

  double OnTester()
  {
    MathSrand(GroupCount); // reproducable randomization
    
    BaseFunctor *functor = NULL;
    
    switch(TestCase)
    {
      case Sphere:
        functor = new PSOTests::Sphere();
        break;
      case Griewank:
        functor = new PSOTests::Griewank();
        break;
      case Rosenbrock:
        functor = new PSOTests::Rosenbrock();
        break;
    }
    
    functor.test();
    
    double output[];
    double result = functor.getSolution(output);
    if(MQLInfoInteger(MQL_OPTIMIZATION))
    {
      FrameAdd("PSO", 0xC0DE, result, output);
    }
    else
    {
      Print("Solution: ", result);
      for(int i = 0; i < ArraySize(output); i++)
      {
        Print(i, " ", output[i]);
      }
    }
    
    delete functor;
    return result;
  }

В самом терминале работает связка функций OnTesterInit, OnTesterPass, OnTesterDeinit, которая собирает фреймы, попутно определяя наилучшее решение из присланных.

  int passcount = 0;
  double best = -DBL_MAX;
  double location[];
  
  void OnTesterPass()
  {
    ulong pass;
    string name;
    long id;
    double r;
    double data[];
    
    while(FrameNext(pass, name, id, r, data))
    {
      // compare r with all other passes results
      if(r > best)
      {
        best = r;
        ArrayCopy(location, data);
      }
    
      Print(passcount, " ", id);
      
      const int n = ArraySize(data);
      ArrayResize(data, n + 1);
      data[n] = r;
      ArrayPrint(data, 12);
    
      passcount++;
    }
  }
  
  void OnTesterDeinit()
  {
    Print("Solution: ", best);
    ArrayPrint(location);
  }

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

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

  int OnInit()
  {
    if(!MQLInfoInteger(MQL_TESTER))
    {
      EventSetTimer(1);
    }
    return INIT_SUCCEEDED;
  }
  
  void OnTimer()
  {
    EventKillTimer();
    OnTester();
  }

Посмотрим, как это работает. Выберем конкретные значения входных параметров:

При размещении эксперта на чарте получим примерно следующий лог.

  Successive PSO of Griewank
  PSO[2] created: 100/10
  PSO Processing...
  Cycle 0 done, skipped 0 of 100 / -1.000317162069485
  Cycle 10 done, skipped 0 of 100 / -0.2784790501384311
  Cycle 20 done, skipped 0 of 100 / -0.1879188508394087
  Cycle 30 done, skipped 0 of 100 / -0.06938172138150922
  Cycle 40 done, skipped 0 of 100 / -0.04958694402304631
  Cycle 50 done, skipped 0 of 100 / -0.0045818974357138
  Cycle 60 done, skipped 0 of 100 / -0.0045818974357138
  Cycle 70 done, skipped 0 of 100 / -0.002161613760466419
  Cycle 80 done, skipped 0 of 100 / -0.0008991629607246754
  Cycle 90 done, skipped 0 of 100 / -1.620636881582982e-05
  Cycle 99 done, skipped 0 of 100 / -1.342285474092986e-05
  PSO Finished 9948 of 10000 planned calculations: true
  Solution: -1.342285474092986e-05
  0 0.004966759354110293
  1 0.002079707592422949

Поскольку тестовые примеры считаются довольно быстро (в пределах секунды-двух), производить замеры времени не имеет смысла. Для реальных торговых задач мы это сделаем позднее.

Теперь выберем эксперт в тестере, в списке "Моделирование" установим "Математические вычисления", параметры эксперта оставим такими же как выше за исключением GroupCount. По этому параметру будет проводиться оптимизация, поэтому начальное и конечное значения поставим, например, соответственно 0 и 3, с шагом 1, чтобы было обработано 4 группы (по числу ядер). Причем в этом случае все группы будут иметь размер 100 (SwarmSize, целиком весь рой). При достаточном количестве ядер процессора (если все группы работают параллельно на агентах), это не должно сказаться на быстродействии, но увеличит точность решения за счет дополнительных проверок пространства оптимизации. Получим примерно такой лог.

  Parallel PSO of Griewank
  -12.550070232909  -0.002332638407  -0.039510275469
  -3.139749741924  4.438437934965 -0.007396077598
   3.139620588383  4.438298282495 -0.007396126543
   0.000374731767 -0.000072178955 -0.000000071551
  Solution: -7.1550806279852e-08 (after 4 passes)
   0.00037 -0.00007

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

Виртуализация и оптимизация эксперта (MQL4 API в MetaTrader 5)

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

Здесь возникает дилемма, с которой сталкиваются многие прикладные разработчики при написании собственного оптимизатора поверх и/или вместо штатного. А именно — каким образом обеспечить торговое окружение, в первую очередь, котировки, но также и состояние счета, включая архив сделок. Если использовать режим математических вычислений, то требуется каким-то образом подготавливать и затем передавать в эксперт (на агенты) соответствующие данные. При этом приходится разрабатывать промежуточный слой API, "прозрачно" эмулирующий многие торговые функции, чтобы позволить эксперту работать по аналогии с обычным режимом онлайн.

Чтобы этого не делать, было принято решение воспользоваться уже существующим решением для виртуальной торговли, созданном полностью на MQL и утилизирующем стандартные структуры исторических данных, в частности тики и бары. Речь идет о библиотеке Virtual (автор fxsaber). С помощью неё можно рассчитать виртуальный проход эксперта на доступной истории как онлайн (например, для периодической самооптимизации на чарте), так и в тестере. В последнем случае, разумеется, используется любой привычный тиковый режим ("Все тики", "Каждый тик на основе реальных тиков") или даже "OHLC на M1" — для быстрой, но более грубой оценки системы (количество тиков ограничено 4-мя в минуту).

После включения заголовочного файла Virtual.mqh (скачивается вместе с необходимыми зависимостями) в код эксперта, легко организовать виртуальный тест с помощью следующих строк:

      MqlTick _ticks[];                                     // global array
      ...                                                   // copy/collect ticks[]
      VIRTUAL::Tester(_ticks, OnTick /*, TesterStatistics(STAT_INITIAL_DEPOSIT)*/ );
      Print(VIRTUAL::ToString(INT_MAX));                    // output virtual trades in the log
      const double result = TesterStatistics(STAT_PROFIT);  // get required performance meter
      VIRTUAL::ResetTickets();                              // optional
      VIRTUAL::Delete();                                    // optional

Всю работу выполняет статический метод VIRTUAL::Tester. В него потребуется передать предварительно заполненный массив тиков желаемого исторического периода и степени детализации, указатель на функцию OnTick (подойдет и стандартный обработчик, если в него зашита логика переключения с торговли онлайн на виртуальную), а также начальный депозит (это опционально; если он не указан, будет взят текущий баланс счета). Если разместить приведенный выше фрагмент в обработчике OnTester (как это будет у нас), можно передать начальный депозит тестера. Узнать результат виртуальной торговли можно, вызвав привычную функцию TesterStatistics, которая после подключения библиотеки оказывается, на самом деле, "перекрыта", как и многие другие функции MQL API (желающие могут заглянуть в исходный код). Данное "перекрытие" — достаточно умное, чтобы делегировать вызовы исходной функции ядра, там где идет реальная торговля. Обратите внимание, что при виртуальной торговле в библиотеке рассчитаются не все стандартные показатели из TesterStatistics.

Особенностью библиотеки является то, что она основывается на торговом API MetaTrader 4. Иными словами, она применима только к экспертам, которые используют в коде "старые" функции, хотя и написаны на MQL5. Они работают в среде MetaTrader 5 благодаря другой известной библиотеке того же автора — MT4Orders.

В качестве "подопытного кролика" выступит модификация эксперта ExprBot.mq5, который был изначально представлен в статье Вычисление математических выражений (Часть 2). Он как раз реализован с использованием MT4Orders. Новая версия под именем ExprBotPSO.mq5 прилагается к данной статье.

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

  input string SignalBuy = "EMA_OPEN_{Fast}(0)/EMA_OPEN_{Slow}(0) > 1 + Threshold";
  input string SignalSell = "EMA_OPEN_{Fast}(0)/EMA_OPEN_{Slow}(0) < 1 - Threshold";
  input string Variables = "Threshold=0.001";
  input int Fast = 10;

  input int Slow = 21;

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

Обратите внимание, движок парсеров был обновлен до версии v1.1 и также прилагается. Его старая версия, если она была скачана ранее, не подойдёт.

Помимо входных параметров для сигналов, о которых нам еще придется обсудить один нюанс, в эксперт внесены параметры для управления виртуальным тестированием и алгоритмом PSO.

В режиме VirtualTester эксперт только собирает тики в OnTick в массив. Затем в OnTester библиотека Virtual ведет торговлю по этому массиву, вызывая тот же обработчик OnTick, но уже со специальным взведенным признаком, допускающим исполнение кода с виртуальными операциям.

Итак, для каждого инкрементируемого значения PSO_GroupCount на отдельном агенте выполняется цикл из PSO_Cycles пересчетов роя размером PSO_SwarmSize частиц. Итого, получается проверка для PSO_GroupCount * PSO_Cycles * PSO_SwarmSize = N точек в пространстве оптимизации. Каждая точка — виртуальный прогон торговой системы.

Для достижения лучших результатов потребуется методом проб и ошибок найти подходящие PSO-параметры. Задавшись общим числом тестов N, можно варьировать компоненты. За счет случайных попаданий в одни и те же точки (напомним, они хранятся в бинарном дереве в рое) окончательное количество тестов будет меньше N.

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

В эксперт ExprBotPSO, разумеется, были добавлены классы функторов, в целом похожие на те, что мы видели в предыдущих примерах. В частности, метод test ожидаемо создает экземпляр роя, выполняет в нем оптимизацию и сохраняет результаты в переменных-членах (optimum, result[]).

  class BaseFunctor: public Functor
  {
    ...
    public:
      virtual bool test(void)
      {
        Swarm swarm(PSO_SwarmSize, PSO_GroupCount, params, max, min, steps);
        if(MQLInfoInteger(MQL_OPTIMIZATION))
        {
          if(!swarm.restoreIndex()) return false;
        }
        optimum = swarm.optimize(this, PSO_Cycles);
        swarm.getSolution(result);
        if(MQLInfoInteger(MQL_OPTIMIZATION))
        {
          swarm.exportIndex(PSO_GroupCount);
        }
        return true;
      }
  };

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

Виртуальное тестирование эксперта осуществляется в методе calculate по заявленной схеме. А вот в инициализации пространства оптимизации участвует один новый класс Settings.

  class WorkerFunctor: public BaseFunctor
  {
    string names[];
  
    public:
      WorkerFunctor(const Settings &s): BaseFunctor(s.size())
      {
        s.getNames(names);
        for(int i = 0; i < params; i++)
        {
          max[i] = s.get<double>(i, SET_COLUMN_STOP);
          min[i] = s.get<double>(i, SET_COLUMN_START);
          steps[i] = s.get<double>(i, SET_COLUMN_STEP);
        }
      }
  
      virtual double calculate(const double &vec[])
      {
        VIRTUAL::Tester(_ticks, OnTick, TesterStatistics(STAT_INITIAL_DEPOSIT));
        VIRTUAL::ResetTickets();
        const double r = TesterStatistics(Estimator);
        VIRTUAL::Delete();
        return r;
      }
  };

Дело в том, что для запуска оптимизации пользователь будет настраивать входные параметры эксперта привычным образом. Однако алгоритм роя использует тестер только как средство распараллеливания задач (за счет инкремента номера группы). Поэтому наш эксперт должен уметь прочитать настройки рабочих параметров для оптимизации, сохранить их во вспомогательный файл, передаваемый на каждый агент, сбросить эти настройки в тестере и назначить оптимизацию по номеру группы. Класс Settings как раз и предназначен для чтения настроек из вспомогательного файла. У нас это будет "имя_эксперта.mq5.csv", который должен быть подключен с помощью директивы.

  #define PPSO_SHARED_SETTINGS __FILE__ + ".csv"
  #property tester_file PPSO_SHARED_SETTINGS

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

  #define MAX_COLUMN 4
  #define SET_COLUMN_NAME  0
  #define SET_COLUMN_START 1
  #define SET_COLUMN_STEP  2
  #define SET_COLUMN_STOP  3

Все они запоминаются во внутренних массивах и доступны через get-методы по имени или номеру. Метод isVoid() возвращает признак отсутствия настроек (файл не удалось прочитать, он пустой или неверного формата).

Запись настроек в файл производится в обработчике OnTesterInit (см. ниже).

Рекомендуется сразу вручную создать пустой файл "имя_эксперта.mq5.csv" в папке MQL5/Files. Если этого не сделать, возникнут проблемы с тем, чтобы запустить оптимизацию с первого раза.

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

  string header[];
  
  void OnTesterInit()
  {
    int h = FileOpen(PPSO_SHARED_SETTINGS, FILE_ANSI|FILE_WRITE|FILE_CSV, ',');
    if(h == INVALID_HANDLE)
    {
      Print("FileSave error: ", GetLastError());
    }
    
    MqlParam parameters[];
    string names[];
    
    EXPERT::Parameters(0, parameters, names);
    for(int i = 0; i < ArraySize(names); i++)
    {
      if(ResetOptimizableParam<double>(names[i], h))
      {
        const int n = ArraySize(header);
        ArrayResize(header, n + 1);
        header[n] = names[i];
      }
    }
    FileClose(h); // 5008
    
    bool enabled;
    long value, start, step, stop;
    if(ParameterGetRange("PSO_GroupCount", enabled, value, start, step, stop))
    {
      if(!enabled)
      {
        const int cores = TerminalInfoInteger(TERMINAL_CPU_CORES);
        Print("PSO_GroupCount is set to default (number of cores): ", cores);
        ParameterSetRange("PSO_GroupCount", true, 0, 1, 1, cores);
      }
    }
    
    // remove CRC indices from previous optimization runs
    Swarm::removeIndex();
  }

Поиском параметров, для которых включен флаг оптимизации, и сбросом этих флагов занимается дополнительная функция ResetOptimizableParam. Также в OnTesterInit мы запоминаем имена этих параметров с помощью библиотеки Expert (автор fxsaber), чтобы более наглядно впоследствии выводить результаты. Но библиотека потребовалась прежде всего потому, что для вызова стандартных функций ParameterGetRange/ParameterSetRange нужно знать имена заранее, а MQL API не позволяет получить список параметров. Вместе с тем хотелось сделать код наиболее универсальным: пригодным для вставки в произвольный эксперт без особых правок.

  template<typename T>
  bool ResetOptimizableParam(const string name, const int h)
  {
    bool enabled;
    T value, start, step, stop;
    if(ParameterGetRange(name, enabled, value, start, step, stop))
    {
      // disable all native optimization except for PSO-related params
      // preserve original settings in the file h
      if((StringFind(name, "PSO_") != 0) && enabled)
      {
        ParameterSetRange(name, false, value, start, step, stop);
        FileWrite(h, name, start, step, stop); // 5007
        return true;
      }
    }
    return false;
  }

В обработчике OnInit, выполняемом уже на агенте, настройки считываются в глобальный объект Settings следующим образом.

  Settings settings;
  
  int OnInit()
  {
      ...
      FileReader f(PPSO_SHARED_SETTINGS);
      if(f.isReady() && f.read(settings))
      {
        const int n = settings.size();
        Print("Got settings: ", n);
      }
      else
      {
        if(MQLInfoInteger(MQL_OPTIMIZATION))
        {
          Print("FileLoad error: ", GetLastError());
          return INIT_PARAMETERS_INCORRECT;
        }
        else
        {
          Print("WARNING! Virtual optimization inside single pass - slowest mode, debugging only");
        }
      }
      ...
  }

Как мы увидим далее, этот объект передается в создаваемый объект WorkerFunctor в обработчике OnTester, внутри которого фактически и производятся все вычисления и оптимизация. Но для того чтобы начать вычисления, мы должны сперва собрать тики. Это происходит в обработчике OnTick.

  bool OnTesterCalled = false;
  
  void OnTick()
  {
    if(VirtualTester && !OnTesterCalled)
    {
      MqlTick _tick;
      SymbolInfoTick(_Symbol, _tick);
      const int n = ArraySize(_ticks);
      ArrayResize(_ticks, n + 1, n / 2);
      _ticks[n] = _tick;
      
      return; // skip all time scope and collect ticks
    }
    ...
    // trading goes on here
  }

Почему делается именно так, а не вызовом функции CopyTicksRange непосредственно в OnTester? Во-первых, данная функция работает только в потиковых режимах, а желательно иметь поддержку быстрого режима OHLC M1 (по 4 тика в минуту). Во-вторых, в режиме генерации тиков размер возвращаемого массива по каким-то причинам ограничен 131072 (при работе по реальным тикам такого ограничения нет).

Переменная OnTesterCalled изначально равна false и потому собирается история тиков. OnTesterCalled взводится в true позднее, как и следует из её названия — в OnTester, перед тем как запустить PSO. Тогда объект Swarm начнет в цикле вычислять функтор, в котором, как мы видели выше, вызывается VIRTUAL::Tester со ссылкой на тот же OnTick. Только теперь OnTesterCalled будет равен true и управление передастся не на блок сборки тиков, а на блок торговой логики. Про неё поговорим чуть ниже. В будущем, по мере развития библиотеки PSO возможно появление механизмов упрощения интеграции в существующие эксперты путем подмены обработчика OnTick в самом заголовочном файле библиотеки.

А сейчас — собственно OnTester (в упрощенном виде).

  double OnTester()
  {
    if(VirtualTester)
    {
      OnTesterCalled = true;
  
      // MQL API implies some limitations for CopyTicksRange function, so ticks are collected in OnTick
      const int size = ArraySize(_ticks);
      PrintFormat("Ticks size=%d error=%d", size, GetLastError());
      if(size <= 0) return 0;
      
      if(settings.isVoid() || !InternalOptimization) // fallback to a single virtual test without PSO
      {
        VIRTUAL::Tester(_ticks, OnTick, TesterStatistics(STAT_INITIAL_DEPOSIT));
        Print(VIRTUAL::ToString(INT_MAX));
        Print("Trades: ", VIRTUAL::VirtualOrdersHistoryTotal());
        return TesterStatistics(Estimator);
      }
      
      settings.print();
      const int n = settings.size();
  
      if(PSO_Enable)
      {
        MathSrand(PSO_GroupCount + PSO_RandomSeed); // reproducable randomization
        WorkerFunctor worker(settings);
        Swarm::Stats stats;
        if(worker.test(&stats))
        {
          double output[];
          double result = worker.getSolution(output);
          if(MQLInfoInteger(MQL_OPTIMIZATION))
          {
            FrameAdd(StringFormat("PSO%d/%d", stats.done, stats.planned), PSO_GroupCount, result, output);
          }
          ArrayResize(output, n + 1);
          output[n] = result;
          ArrayPrint(output);
          return result;
        }
      }
      ...
      return 0;
    }
    return TesterStatistics(Estimator);
  }

Здесь мы как раз видим создание функтора WorkerFunctor по набору параметров из объекта settings и запуск роя с помощью его метода test. Полученные результаты отправляются фреймом в терминал, где попадают в OnTesterPass.

Обработчик OnTesterPass аналогичен тому, что был в тестовом эксперте PPSO, только в нем получаемые во фреймах данные выводятся не в лог, а в CSV-файл с именем вида PPSO-название_эксперта-дата_время.

Диаграмма последовательности Parallel Particle Swarm Optimization

Диаграмма последовательности Parallel Particle Swarm Optimization

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

  input string SignalBuy = "EMA_OPEN_{Fast}(0)/EMA_OPEN_{Slow}(0) > 1 + Threshold";
  input string SignalSell = "EMA_OPEN_{Fast}(0)/EMA_OPEN_{Slow}(0) < 1 - Threshold";

Теперь же их следует считывать с исторических баров (ведь вычисления производятся в самом конце прохода, из OnTester). Узнать номер "текущего" бара в прошлом очень просто: библиотека Virtual перекрывает системную функцию TimeCurrent, и потому мы можем в OnTick написать:

    const int bar = iBarShift(_Symbol, PERIOD_CURRENT, TimeCurrent());

Актуальный номер бара следует добавить под подходящим именем в таблицу переменных для выражений, например, "Bar", и тогда сигнальные формулы можно переписать так:

  input string SignalBuy = "EMA_OPEN_{Fast}(Bar)/EMA_OPEN_{Slow}(Bar) > 1 + Threshold";
  input string SignalSell = "EMA_OPEN_{Fast}(Bar)/EMA_OPEN_{Slow}(Bar) < 1 - Threshold";

В обновленной версии парсеров изменение переменной (номера бара) и расчет с ней формулы делается с промежуточным вызовом нового метода with (также в OnTick):

    const int bar = iBarShift(_Symbol, PERIOD_CURRENT, TimeCurrent()); // NEW
    bool buy = p1.with("Bar", bar).resolve();    // WAS: bool buy = p1.resolve();
    bool sell = p2.with("Bar", bar).resolve();   // WAS: bool sell = p2.resolve();

Далее весь торговый код OnTick идет без изменений.

Но на этом необходимые исправления не заканчиваются.

Текущие приведенные формулы используют фиксированные периоды EMA, заданные в настройках и преобразуемые в переменные внутри выражений. Однако в процессе оптимизации эти периоды требуется менять, что означает разные экземпляры индикаторов. Но ведь виртуальная оптимизация с подстройкой параметров роем проходит _внутри_ прогона тестера, в самом его конце, в функции OnTester. Там уже поздно создавать хэндлы индикаторов.

Данная проблема носит глобальный характер для любой виртуальной оптимизации и имеет 3 наиболее очевидных решения:

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

В нашем эксперте торговля ведется по барам, так что это не проблема. В некоторых примерах экспертов, поставляемых вместе с MetaTrader 5, это также имеет место, но следует обращать внимание, как именно детектируется событие открытия нового бара. Способ с контролем единичного тикового объема не подходит для виртуальной истории, потому что все бары уже заполнены тиками. Поэтому рекомендуется определять новый бар путем сравнения его времени с предыдущим известным.

Для решения описанной проблемы третьим способом был расширен движок выражений. В дополнение к функциям одиночных индикаторов MA (MAIndicatorFunc) созданы функции вееров MA (MultiMAIndicatorFunc, см. Indicators.mqh). Их имя должно начинаться с префикса "M_" и содержать минимальный период, шаг периода и максимальный период, например:

  input string SignalBuy = "M_EMA_OPEN_9_6_27(Fast,Bar)/M_EMA_OPEN_9_6_27(Slow,Bar) > 1 + T";
  input string SignalSell = "M_EMA_OPEN_9_6_27(Fast,Bar)/M_EMA_OPEN_9_6_27(Slow,Bar) < 1 - T";

Способ расчета и тип цены указываются в имени как и прежде. Здесь в сигналах предписано создать веер EMA по цене OPEN с периодами от 9 до 27 (включительно), с шагом 6.

Еще одним новшеством в библиотеке выражений является набор переменных, обеспечивающих доступ к торговой статистике из TesterStatistics (см. TesterStats.mqh). На его основе появилась возможность добавить в эксперт входную переменную Formula для задания целевого показателя в виде произвольного выражения. Когда эта переменная заполнена, Estimator игнорируется. В частности, вместо STAT_PROFIT_FACTOR (который неопределен для нулевых убытков) в Estimator можно задать более "гладкий" показатель с похожей по смыслу формулой: "(GROSSPROFIT-(1/(TRADES+1)))/-(GROSSLOSS-1/(TRADES+1))".

Итак, все готово для запуска оптимизации виртуальной торговли методом PSO.

Проверка на практике

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

Оптимизацию проводим по максимуму пользовательского критерия.

Сперва оптимизируем эксперт штатным способом, с отключенной библиотекой Virtual (файл с настройками ExprBotPSO-standard-optimization.set). Количество комбинаций параметров для оптимизации выбрано небольшим в целях демонстрации. Параметры Fast и Slow меняются от 9 до 45 с шагом 6, параметр T — от 0 до 0.01 с шагом 0.0025.

EURUSD, H1, диапазон — с начала 2020 года, по реальным тикам. Получим примерно следующие результаты:

Таблица результатов штатной оптимизации

Таблица результатов штатной оптимизации

В логе посмотрим, сколько ушло времени на двух агентах — почти 21 минута.

  Experts	optimization frame expert ExprBotPSO (EURUSD,H1) processing started
  Tester	Experts\ExprBotPSO.ex5 on EURUSD,H1 from 2020.01.01 00:00 to 2020.08.01 00:00
  Tester	complete optimization started
  ...
  Core 2	connected
  Core 1	connected
  Core 2	authorized (agent build 2572)
  Core 1	authorized (agent build 2572)
  ...
  Tester	optimization finished, total passes 245
  Statistics	optimization done in 20 minutes 55 seconds
  Statistics	shortest pass 0:00:05.691, longest pass 0:00:23.114, average pass 0:00:10.206

Теперь оптимизируем эксперт с включенной виртуальной торговлей и PSO (ExprBotPSO-virtual-pso-optimization.set). Количество групп равное 4 определяется итерацией параметра PSO_GroupCount от 0 до 3. Прочие рабочие параметры, для которых включена оптимизация, будут принудительно отключены в штатной оптимизации, но переданы на агенты в CSV-файлах для внутренней виртуальной оптимизации алгоритмом PSO.

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

В логе тестера получим примерно следующее:

  Tester	input parameter 'Fast' set to: enable=false, value=9, start=9, step=6, stop=45
  Tester	input parameter 'Slow' set to: enable=false, value=21, start=9, step=6, stop=45
  Tester	input parameter 'T' set to: enable=false, value=0, start=0, step=0.0025, stop=0.01
  Experts	optimization frame expert ExprBotPSO (EURUSD,H1) processing started
  Tester	Experts\ExprBotPSO.ex5 on EURUSD,H1 from 2020.01.01 00:00 to 2020.08.01 00:00
  Tester	complete optimization started
  ...
  Core 1	connected
  Core 2	connected
  Core 2	authorized (agent build 2572)
  Core 1	authorized (agent build 2572)
  ...
  Tester	optimization finished, total passes 4
  Statistics	optimization done in 4 minutes 00 seconds
  Statistics	shortest pass 0:01:27.723, longest pass 0:02:24.841, average pass 0:01:56.597
  Statistics	4 frames (784 bytes total, 196 bytes per frame) received

Каждый pass — это теперь пакет виртуальных оптимизаций, поэтому он стал длиннее, но их в целом меньше, и общее время существенно сократилось — до 4-х минут.

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

  22:22:52.261	ExprBotPSO (EURUSD,H1)	2 tmp-files deleted
  22:25:07.981	ExprBotPSO (EURUSD,H1)	0 PSO75/1500 0 1974.400000000025
  22:25:23.348	ExprBotPSO (EURUSD,H1)	2 PSO84/1500 2 402.6000000000062
  22:26:51.165	ExprBotPSO (EURUSD,H1)	3 PSO70/1500 3 455.000000000003
  22:26:52.451	ExprBotPSO (EURUSD,H1)	1 PSO79/1500 1 458.3000000000047
  22:26:52.466	ExprBotPSO (EURUSD,H1)	Solution: 1974.400000000025
  22:26:52.466	ExprBotPSO (EURUSD,H1)	39.00000 15.00000  0.00500

Следует отметить, что результаты не будут точно совпадать (даже если бы у нас была потиковая безиндикаторная стратегия), потому что существуют нюансы работы тестера, которые невозможно повторить в MQL библиотеке. Вот лишь некоторые из них:

Более подробную информацию по библиотеке Virtual можно найти в её документации и обсуждениях.

В качестве отладки и для понимания процесса работы роя тестовый эксперт поддерживает режим виртуальной оптимизации на одном ядре внутри обычного прогона тестера. Пример настроек прилагается в файле ExprBotPSO-virtual-internal-optimization-single-pass.set. Не забудьте отключить оптимизацию в тестере.

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

  Ticks size=15060113 error=0
           [,0]     [,1]     [,2]     [,3]
  [0,] "Fast"   "9"      "6"      "45"    
  [1,] "Slow"   "9"      "6"      "45"    
  [2,] "T"      "0"      "0.0025" "0.01"  
  PSO[3] created: 15/3
  PSO Processing...
  Fast:9.0, Slow:33.0, T:0.0025, 1.31285
  Fast:21.0, Slow:21.0, T:0.0025, -1.0
  Fast:15.0, Slow:33.0, T:0.0075, -1.0
  Fast:27.0, Slow:39.0, T:0.0025, 0.07673
  Fast:9.0, Slow:9.0, T:0.005, -1.0
  Fast:33.0, Slow:21.0, T:0.01, -1.0
  Fast:39.0, Slow:45.0, T:0.0025, -1.0
  Fast:15.0, Slow:15.0, T:0.0025, -1.0
  Fast:33.0, Slow:21.0, T:0.0, 0.32895
  Fast:33.0, Slow:39.0, T:0.0075, -1.0
  Fast:33.0, Slow:15.0, T:0.005, 384.5
  Fast:15.0, Slow:27.0, T:0.0, 2.44486
  Fast:39.0, Slow:27.0, T:0.0025, 11.41199
  Fast:9.0, Slow:15.0, T:0.0, 1.08838
  Fast:33.0, Slow:27.0, T:0.0075, -1.0
  Cycle 0 done, skipped 0 of 15 / 384.5000000000009
  ...
  Fast:45.0, Slow:9.0, T:0.0025, 0.86209
  Fast:21.0, Slow:15.0, T:0.005, -1.0
  Cycle 15 done, skipped 13 of 15 / 402.6000000000062
  Fast:21.0, Slow:15.0, T:0.0025, 101.4
  Cycle 16 done, skipped 14 of 15 / 402.6000000000062
  Fast:27.0, Slow:15.0, T:0.0025, 8.18754
  Fast:39.0, Slow:15.0, T:0.005, 1974.40002
  Cycle 17 done, skipped 13 of 15 / 1974.400000000025
  Fast:45.0, Slow:9.0, T:0.005, 1.00344
  Cycle 18 done, skipped 14 of 15 / 1974.400000000025
  Cycle 19 done, skipped 15 of 15 / 1974.400000000025
  PSO Finished 89 of 1500 planned calculations: true
    39.00000   15.00000    0.00500 1974.40000
  final balance 10000.00 USD
  OnTester result 1974.400000000025

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

Не забудьте, что при PSO один проход тестера для каждой из PSO_GroupCount выполняет внутри вплоть до PSO_Cycles*PSO_SwarmSize виртуальных одиночных проходов, поэтому индикация прогресса будет существенно медленнее, чем обычно.

Многие трейдеры пытаются добиться от встроенной генетической оптимизации лучших результатов, запуская её много раз подряд — это собирает различные тесты в силу случайной инициализации, и после нескольких запусков есть шанс обнаружить прогресс. В случае PSO аналогом многократного запуска генетики выступает PSO_GroupCount. Количество одиночных прогонов, которое в генетике может достигать 10000, следует распределить в PSO между двумя составляющими произведения PSO_Cycles*PSO_SwarmSize, например, 100*100. PSO_Cycles выступает аналогом поколений в генетике, а PSO_SwarmSize — размером популяции.

Виртуализация экспертов MQL5 API

До сих пор мы изучали пример эксперта, написанного с использованием торгового API MQL4. Это вызвано особенностями реализации библиотеки Virtual. Вместе с тем хотелось бы применить PSO и для экспертов с "новыми" функциями MQL5 API. Для этой цели был разработан экспериментальный промежуточный слой переадресации вызовов MQL5 API в MQL4 API. Он оформлен в виде файла MT5Bridge.mqh, который требует для своей работы библиотеку Virtual и/или MT4Orders.

  #include <fxsaber/Virtual/Virtual.mqh> 
  #include <MT5Bridge.mqh>
  
  #include <Expert\Expert.mqh>
  #include <Expert\Signal\SignalMA.mqh>
  #include <Expert\Trailing\TrailingParabolicSAR.mqh>
  #include <Expert\Money\MoneySizeOptimized.mqh>
  ...

После добавления Virtual и MT5Bridge в начале кода, перед другими #include, обращение к функциям MQL5 API идет через переопределенные функции "моста", из которых вызываются "виртуальные" функции MQL4 API. В результате появляется возможность виртуально тестировать и оптимизировать эксперт. В частности, можно организовать оптимизацию PSO аналогично тому, как это было сделано в ExprBotPSO. Потребуется написать (частично скопировать) функтор и обработчики для тестера. Но самым трудоемким может оказаться адаптация генерирования сигналов от индикаторов для переменных параметров.

Экспериментальный статус MT5Bridge.mqh означает, что его работоспособность широко не проверялась. Это исследование из разряда Proof Of the Concept. Используйте исходный код для отладки и исправления ошибок.

Заключение

Мы рассмотрели алгоритм оптимизации методом роя частиц и реализовали его на MQL с поддержкой многопоточной работы на агентах тестера. Наличие открытых настроек PSO позволяет более гибко регулировать процесс, чем при использовании встроенной генетической оптимизации. Помимо настроек, вынесенных сейчас во входные параметры, имеет смысл "поиграть" и другими адаптируемыми коэффициентами, которые пока использовались как аргументы метода optimize со значениями по умолчанию: inertia(0.8), selfBoost(0.4), groupBoost(0.4). Это несомненно придаст гибкости алгоритму, но и затруднит подбор настроек для конкретной задачи. Прилагаемую библиотеку PSO можно использовать как в режиме математических вычислений (если у Вас проработан собственный механизм виртуальных котировок, индикаторов и сделок), так и в тиково-баровых режимах с привлечением сторонних готовых классов эмулирования торговли, таких как Virtual.