Parallele Partikelschwarmoptimierung

Stanislav Korotky | 15 Dezember, 2020

Wie Sie wissen, ermöglicht MetaTrader 5 die Optimierung von Handelsstrategien mit Hilfe des integrierten Strategietesters, der auf zwei Algorithmen basiert: der direkten Auswertung der Eingabeparameter und einem genetischen Algorithmus - GA. Die genetische Optimierung ist eine Art von evolutionärem Algorithmus, der eine erhebliche Prozessbeschleunigung bietet. Allerdings können die GA-Ergebnisse erheblich von der Aufgabe und den Besonderheiten einer bestimmten GA-Implementierung abhängen. Deshalb versuchen viele Händler, die die Standardfunktionalität erweitern wollen, ihre eigenen Optimierer für MetaTrader zu erstellen. Dabei sind die möglichen schnellen Optimierungsmethoden nicht auf den genetischen Algorithmus beschränkt. Neben dem GA gibt es weitere populäre Methoden, wie das Simulated Annealing (simulierte Abkühlung) und Partikelschwarmoptimierung.

In diesem Artikel werden wir den Algorithmus der Partikelschwarmoptimierung (PSO) implementieren und versuchen, ihn in den MetaTrader-Tester zu integrieren, um ihn parallel auf verfügbaren lokalen Agenten laufen zu lassen. Die Zieloptimierungsfunktion wird die vom Nutzer ausgewählte Handelsvariable des EA sein.

Die Partikelschwarm-Methode

Aus algorithmischer Sicht ist die PSO-Methode relativ einfach. Die Hauptidee besteht darin, einen Satz virtueller "Partikel" im Raum der Eingabeparameter des Expert Advisors zu erzeugen. Die Partikel bewegen sich dann und ändern ihre Geschwindigkeit in Abhängigkeit von den Handelsmetriken des EA an den entsprechenden Punkten im Raum. Der Prozess wird so oft wiederholt, bis sich die Performance nicht mehr verbessert. Der Pseudocode des Algorithmus ist unten dargestellt:

Der Pseudo-Code der Partikelschwarmoptimierung

Der Pseudo-Code der Partikelschwarmoptimierung

Gemäß diesem Code hat jedes Partikel eine aktuelle Position, Geschwindigkeit und eine Erinnerung an seinen "besten" Punkt in der Vergangenheit. Dabei bedeutet der "beste" Punkt den Punkt (ein Satz von EA-Eingangsparametern), an dem der höchste Wert der Zielfunktion für dieses Partikel erreicht wurde. Lassen Sie uns dies in einer Klasse beschreiben.

  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;
      }
  };


Die Größe aller Arrays entspricht der Dimension des Optimierungsraums, ist also gleich der Anzahl der zu optimierenden Expert Advisor-Parameter (die an den Konstruktor übergeben werden). Standardmäßig gilt: Je größer der Wert der Zielfunktion, desto besser die Optimierung. Initialisieren Sie daher das Feld bestValue mit der kleinstmöglichen Zahl -DBL_MAX. Als Kriterium für die Bewertung eines EA wird in der Regel eine der Handelsmetriken verwendet, z. B. Profit, Rentabilität, Sharpe-Ratio usw. Wenn die Optimierung durch den Parameter erfolgt, dessen niedrigere Werte als besser angesehen werden, wie z. B. Drawdown, können entsprechende Transformationen vorgenommen werden, um die entgegengesetzten Werte zu maximieren.

Arrays und Variablen werden öffentlich gemacht, um den Zugriff und ihren Neuberechnungscode zu vereinfachen. Die strikte Einhaltung der OOP-Prinzipien würde erfordern, sie mit dem Modifikator 'private' zu verstecken und Methoden zum Lesen und Ändern zu beschreiben.

Zusätzlich zu den einzelnen Partikeln arbeitet der Algorithmus mit sogenannten "Topologien" oder Teilmengen von Partikeln. Diese können nach unterschiedlichen Prinzipien erstellt werden. In unserem Fall wird eine "soziale Gruppentopologie" verwendet. Eine solche Gruppe speichert Informationen über die beste Position unter allen ihren Partikeln.

  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;
      }
  };


Durch die Angabe eines Gruppennamens im Feld 'group' der Klasse Particle geben wir die Gruppe an, zu der der Partikel gehört (siehe oben).

Gehen wir nun zur Codierung des Partikelschwarm-Algorithmus selbst über. Er wird als eigene Klasse implementiert. Beginnen wir mit Arrays von Partikeln und Gruppen.

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


Für jeden Parameter muss ein Wertebereich angegeben werden, in dem die Optimierung durchgeführt werden soll, sowie eine Schrittweite (step).

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


Außerdem sollte der optimale Parametersatz irgendwo gespeichert werden.

      double solution[];


Da die Klasse mehrere verschiedene Konstruktoren haben wird, wollen wir die einheitliche Initialisierungsmethode beschreiben.

    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 dimensions
          {
            // 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;
      }
    }


Alle Arrays sind entsprechend der vorgegebenen Dimension verteilt und werden mit den übertragenen Daten gefüllt. Die Anfangsposition der Partikel, ihre Geschwindigkeit und Gruppenzugehörigkeit werden zufällig bestimmt. Im obigen Code ist etwas Wichtiges auskommentiert. Wir werden später darauf zurückkommen.

Beachten Sie, dass die klassische Version des Partikelschwarm-Algorithmus für die Optimierung von Funktionen gedacht ist, die auf kontinuierlichen Koordinaten definiert sind. Allerdings werden EA-Parameter normalerweise mit einer bestimmten Schrittweite getestet. Zum Beispiel kann ein gleitender Standard-Durchschnitt nicht eine Periode von 11,5 haben. Deshalb legen wir zusätzlich zu einem Bereich von akzeptablen Werten für alle Dimensionen den Schritt fest, mit dem die Partikelpositionen gerundet werden. Dies wird nicht nur in der Initialisierungsphase, sondern auch in den Berechnungen während der Optimierung gemacht.

Nun können wir mit init ein paar Konstruktoren implementieren.

  #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);
    }


Die erste verwendet eine bekannte Faustformel, um die Schwarmgröße und die Anzahl der Gruppen anhand der Anzahl der Parameter zu berechnen. Die Konstante AUTO_SIZE_FACTOR, die standardmäßig 5 ist, kann beliebig geändert werden. Der zweite Konstruktor erlaubt die explizite Angabe aller Werte.

Der Destruktor gibt den zugeordneten Speicher wieder frei.

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


Nun ist es an der Zeit, die Hauptmethode der Klasse zu schreiben, die direkt die Optimierung durchführt.

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


Der erste Parameter, Functor &f, ist von besonderem Interesse. Offensichtlich wird der Expert Advisor während des Optimierungsprozesses für verschiedene Eingabeparameter aufgerufen, woraufhin eine geschätzte Zahl (Gewinn, Rentabilität oder ein anderes Merkmal) zurückgegeben wird. Der Schwarm weiß nichts (und er sollte auch nichts wissen) über den Expert Advisor. Seine einzige Aufgabe ist es, den optimalen Wert einer unbekannten Zielfunktion mit einer beliebigen Menge numerischer Argumente zu finden. Aus diesem Grund verwenden wir eine abstrakte Schnittstelle, nämlich die Klasse Functor.

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


Die einzige Methode empfängt ein Array von Parametern und gibt eine Zahl zurück (alle Typen sind double). In Zukunft muss der EA irgendwie eine von Functor abgeleitete Klasse implementieren und die benötigte Variable innerhalb der Methode 'calculate' berechnen. Der erste Parameter der Methode 'optimize' wird also ein Objekt mit einer Callback-Funktion erhalten, die vom Handelsroboter bereitgestellt wird.

Der zweite Parameter der Methode 'optimize' ist die maximale Anzahl der Schleifen, in denen der Algorithmus ausgeführt wird. Die folgenden 3 Parameter legen die PSO-Koeffizienten fest: 'inertia' (Trägheit) - hält die Geschwindigkeit des Partikels (die Geschwindigkeit nimmt in der Regel mit den Werten kleiner als 1 ab), 'selfBoost' und 'groupBoost' bestimmen, wie reaktionsschnell der Partikel bei der Anpassung seiner Richtung an die besten bekannten Positionen in der Partikel-/Gruppenhistorie ist.

Nachdem wir nun alle Parameter berücksichtigt haben, können wir mit dem Algorithmus fortfahren. Die Optimierungsschleifen geben den Pseudocode fast vollständig wieder, in etwas vereinfachter Form.

    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;
    }


Die Methode gibt den gefundenen Maximalwert der Zielfunktion zurück. Eine weitere Methode ist für das Lesen von Koordinaten (Parametersatz) reserviert.

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


Dies ist fast der gesamte Algorithmus. Ich habe bereits erwähnt, dass es einige Vereinfachungen gibt. Betrachten wir zunächst die folgende Besonderheit.

Eine diskrete Welt ohne Wiederholungen

Der Funktor wird viele Male aufgerufen, um die Parametersätze dynamisch neu zu berechnen, aber es gibt keine Garantie, dass der Algorithmus nicht mehrmals auf denselben Punkt trifft, insbesondere wenn man die Diskretheit entlang der Achsen berücksichtigt. Um ein solches "Volltreffer" zu verhindern, ist es notwendig, bereits berechnete Punkte irgendwie zu identifizieren und zu überspringen.

Parameter sind nur Zahlen oder eine Folge von Bytes. Die bekannteste Technik zur Überprüfung der Eindeutigkeit von Daten ist die Verwendung eines Hash. Die bekannteste Methode, einen Hash zu erhalten, ist CRC. CRC ist eine Prüfzahl (in der Regel eine ganze Zahl mit mehreren Bits), die auf Basis der Daten so generiert wird, dass die Übereinstimmung zweier solcher Kennziffern aus Datensätzen mit hoher Wahrscheinlichkeit bedeutet, dass die Sätze identisch sind. Je mehr Bits in der CRC, desto höher ist die Wahrscheinlichkeit der Übereinstimmung (bis zu fast 100 %). Eine 64-Bit-CRC ist für unsere Aufgabe wahrscheinlich ausreichend. Bei Bedarf kann sie erweitert oder auf eine andere Hash-Funktion umgestellt werden. Die Implementierung der CRC-Berechnung kann leicht von C nach MQL portiert werden. Eine der möglichen Optionen ist in der unten angehängten Datei crc64.mqh verfügbar. Die Hauptarbeitsfunktion hat den folgenden Prototyp.

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


Sie akzeptiert die CRC des vorherigen Datenblocks (falls es mehrere sind, oder gibt 0 an, wenn es nur einen Block gibt), ein Array von Bytes und die Information, wie viele Elemente daraus verarbeitet werden sollen. Die Funktion gibt einen 64-Bit-CRC zurück.

Wir müssen einen Satz von Parametern in diese Funktion eingeben. Dies ist jedoch nicht direkt möglich, da jeder Parameter vom Typ double ist. Um sie in ein Byte-Array zu konvertieren, verwenden wir die Bibliothek TypeToBytes.mqh (die Datei ist dem Artikel beigefügt; es ist jedoch besser, in der Codebase nach der aktuellen Version zu suchen).

Nach Einbindung dieser Bibliothek kann eine Wrapper-Funktion erstellt werden, um CRC64 aus einem Array von Parametern zu berechnen:

  #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;
  }


Es stellen sich nun folgende Fragen: Wo speichert man Hashes und wie prüft man deren Eindeutigkeit. Die am besten geeignete Lösung ist ein Binärbaum. Es handelt sich dabei um eine Datenstruktur, die schnelle Operationen zum Hinzufügen neuer Werte und zur Überprüfung der Existenz der bereits hinzugefügten Werte bietet. Die hohe Geschwindigkeit wird durch eine spezielle Baumeigenschaft erreicht, die sich Balancing nennt. Mit anderen Worten: Der Baum muss balanciert sein (er muss ständig in einem balancierten Zustand gehalten werden), um die maximale Geschwindigkeit der Operationen zu gewährleisten. Die gute Tatsache ist, dass wir den Baum zum Speichern von Hashes verwenden. Hier ist die Definition von Hash.

Die Hash-Funktion (Hash-Erzeugungsalgorithmus) erzeugt für beliebige Eingabedaten einen gleichmäßig verteilten Ausgabewert. Das Hinzufügen eines Hashes zu einem Binärbaum sorgt somit statistisch gesehen für einen annähernd ausgeglichenen Zustand und führt somit zu einer hohen Effizienz.

Ein Binärbaum ist eine Sammlung von Knoten, von denen jeder einen bestimmten Wert und zwei optionale Verweise auf den sogenannten rechten und linken Knoten enthält. Der Wert im linken Knoten ist immer kleiner als der Wert im übergeordneten Knoten; der Wert im rechten Knoten ist immer größer als der im übergeordneten. Der Baum beginnt sich von der Wurzel aus zu füllen, indem ein neuer Wert mit den Knotenwerten verglichen wird. Wenn der neue Wert gleich dem Wert der Wurzel (oder eines anderen Knotens) ist, wird das Vorzeichen des im Baum vorhandenen Wertes zurückgegeben. Wenn der neue Wert kleiner als der Wert im Knoten ist, gehen Sie per Verweis zum linken Knoten und verarbeiten dessen Teilbaum auf ähnliche Weise. Wenn der neue Wert größer als der Wert im Knoten ist, gehe zum rechten Teilbaum. Wenn eine der Referenzen null ist (d. h. es gibt keine weiteren Verzweigungen), wird die Suche ohne Ergebnis beendet. Deshalb soll anstelle einer Null-Referenz ein neuer Knoten mit einem neuen Wert angelegt werden.

Um diese Logik zu implementieren, wurde ein Paar von Template-Klassen erstellt: TreeNode und BinaryTree. Ihr vollständiger Code ist in der angehängten Header-Datei enthalten.

  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();
  };


Die Methode 'add' gibt true zurück, wenn der Wert bereits im Baum existiert. Sie gibt false zurück, wenn er vorher nicht vorhanden war, sondern gerade hinzugefügt wurde. Das Löschen einer Wurzel im Destruktor des Baums führt automatisch zur Löschung aller Kindknoten.

Die implementierte Baumklasse ist eine der einfachsten Varianten. Es gibt noch andere, fortgeschrittenere Bäume, die Sie einbinden können, wenn Sie möchten.

Lassen Sie uns die Klasse Swarm um BinaryTree erweitern.

  class Swarm
  {
    private:
      BinaryTree<ulong> index;


Die Teile der Methode 'optimize', in denen wir Partikel an neue Positionen verschieben, sollten erweitert werden.

      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
        }


Wir haben das Hilfsarray 'next' hinzugefügt, in dem die neu erstellten Koordinaten zuerst hinzugefügt werden. Für sie wird CRC berechnet und die Eindeutigkeit der Werte geprüft. Wenn die neue Position noch nicht angetroffen wurde, wird sie dem Baum hinzugefügt, auf das entsprechende Partikel kopiert und alle notwendigen Berechnungen für diese Position durchgeführt. Wenn die Position bereits im Baum vorhanden ist (d. h., der Funktor wurde bereits für sie berechnet), wird diese Iteration übersprungen.

Testen der Grundfunktionalität

Alles, was oben besprochen wurde, ist die minimal benötigte Grundlage, um die ersten Tests durchzuführen. Lassen Sie uns das Skript testpso.mq5 verwenden, um sicherzustellen, dass die Optimierung wirklich funktioniert. Die in diesem Skript verwendete Header-Datei ParticleSwarmParallel.mqh enthält nicht nur bereits bekannte Klassen, sondern auch weitere Verbesserungen, die wir im Folgenden besprechen werden.

Die Tests sind im OOP-Stil entworfen, was es Ihnen erlaubt, Ihre bevorzugten Zielfunktionen zu setzen. Die Basisklasse für die Tests ist 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]);
          }
        }
    };


Alle Objekte von abgeleiteten Klassen registrieren sich automatisch bei der Erstellung mit der Methode 'register' in der Klasse 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);
        }
      }
  };


Die Tests (Optimierung) werden von der Methode 'run' ausgeführt, die 'test' auf allen registrierten Objekten aufruft.

Es gibt viele populäre Benchmark-Funktionen, u. a. "rosenbrock", "griewank", "sphere", die im Skript implementiert sind. Zum Beispiel kann ein Suchbereich und eine Methode 'calculate' für eine 'sphere' wie folgt definiert werden.

      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
          }
      };


Beachten Sie, dass Standard-Benchmark-Funktionen eine Minimierung verwenden, während wir einen auf Maximierung basierenden Algorithmus implementiert haben (weil wir die maximale EA-Leistung anstreben). Aus diesem Grund wird das Berechnungsergebnis mit einem Minuszeichen verwendet. Außerdem verwenden wir hier keinen diskreten Schritt, so dass die Funktionen kontinuierlich sind.

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


Wenn Sie das Skript ausführen, können Sie sehen, dass es Koordinatenwerte nahe der exakten Lösung (Extremum) protokolliert. Da die Partikel zufällig initialisiert werden, ergeben sich bei jedem Durchlauf leicht unterschiedliche Werte. Die Genauigkeit der Lösung hängt von den Eingabeparametern des Algorithmus ab.

  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


Beachten Sie, dass die Schwarmgröße und die Anzahl der Gruppen (geschrieben ins Protokoll in Zeilen wie PSO[N] created: X/G, wobei N die Raumdimension, X die Anzahl der Partikel und G die Anzahl der Gruppen ist) automatisch nach den programmierten Faustregeln anhand der Eingabedaten ausgewählt werden.

Weiter zur Welt der Parallelberechnung

Der erste Test ist gut. Er hat jedoch eine Nuance - der Zyklus der Partikelzählung wird in einem einzigen Thread ausgeführt, während das Terminal die Nutzung aller Prozessorkerne ermöglicht. Unser Ziel ist es, eine PSO-Optimierungs-Engine zu schreiben, die in EAs für die Multithreading-Optimierung im MetaTrader-Tester eingebaut werden kann, und damit eine Alternative zum standardmäßigen genetischen Algorithmus zu bieten.

Die Berechnungen können nicht parallelisiert werden, indem der Algorithmus mechanisch in einen EA anstelle eines Skripts übertragen wird. Dies erfordert die Modifikation des Algorithmus.

Schaut man sich den vorhandenen Code an, so schlägt dieser vor, Gruppen von Partikeln für parallele Berechnungen auszuwählen. Jede Gruppe kann unabhängig von den anderen bearbeitet werden. Innerhalb jeder Gruppe wird ein vollständiger Zyklus für die angegebene Anzahl von Malen durchgeführt.

Um eine Modifikation des 'Swarm'-Klassenkerns zu vermeiden, wollen wir eine einfache Lösung verwenden: Statt mehrerer Gruppen innerhalb einer Klasse werden wir mehrere Klasseninstanzen erstellen, in denen sich die Anzahl der Gruppen jeweils verringert, d. h. gleich eins sein wird. Darüber hinaus müssen wir einen Code bereitstellen, der es den Instanzen ermöglicht, Informationen auszutauschen, da jede Instanz auf ihrem eigenen Testagenten ausgeführt wird.

Fügen wir zunächst eine neue Objektinitialisierungsart hinzu.

    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);
      }
      ...


Stellen Sie entsprechend dem Programmbetrieb im Optimierungsmodus die Anzahl der Gruppen gleich 1 ein. Die Standard-Schwarmgröße wird durch eine Faustregel bestimmt (es sei denn, der Parameter 'Größe' wird explizit auf einen anderen Wert als 0 gesetzt).

In der Ereignisbehandlung durch OnTester kann der Expert Advisor das Ergebnis eines Minischwarms (bestehend aus nur einer Gruppe) mit der Funktion getSolution abrufen und in einem Frame an das Terminal senden. Das Terminal kann die Durchläufe analysieren und den besten auswählen. Logischerweise sollte die Anzahl der parallelen Schwärme/Gruppen mindestens so groß sein wie die Anzahl der Kerne. Sie kann aber auch höher sein (allerdings sollte man versuchen, sie um ein Vielfaches der Anzahl der Kerne zu erhöhen). Je größer die Dimension des Raums ist, desto mehr Gruppen können erforderlich sein. Für einfache Tests sollte die Anzahl der CPU-Kerne aber ausreichen.

Der Datenaustausch zwischen den Instanzen ist erforderlich, um den Raum ohne doppelte Punkte zu berechnen. Wie Sie sich erinnern, wird die Liste der verarbeiteten Punkte in jedem Objekt im Binärbaum 'index' gespeichert. Sie könnte in einem Frame an das Terminal gesendet werden, ähnlich wie die Ergebnisse, aber das Problem ist, dass die hypothetische kombinierte Registrierung dieser Listen nicht an die Tester zurückgeschickt werden kann. Leider unterstützt die Testerarchitektur nur den kontrollierten Datentransfer von Agenten zum Terminal, aber nicht zurück. Aufgaben vom Terminal werden in einem geschlossenen Format an Agenten verteilt.

Daher habe ich mich entschieden, nur lokale Agenten zu verwenden und die Indizes jeder Gruppe in Dateien in einem gemeinsamen Ordner (FILE_COMMON) zu speichern. Jeder Agent schreibt seinen eigenen Index und kann jederzeit die Indizes aller anderen Durchläufe lesen und zu seinem eigenen Index hinzufügen. Dies kann bei der Initialisierung von Durchläufen erforderlich sein.

In MQL können Änderungen in der geschriebenen Datei erst dann von anderen Prozessen gelesen werden, wenn die Datei geschlossen wird. Die Flags FILE_SHARE_READ, FILE_SHARE_WRITE und die Funktion FileFlush helfen hier nicht weiter.

Die Unterstützung für das Schreiben von Indizes ist mit dem bekannten "Visitor"-Muster implementiert.

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


Seine minimalistische Schnittstelle deklariert, dass wir eine beliebige Operation mit dem übergebenen Baumknoten durchführen werden. Für die Arbeit mit Dateien wurde eine spezielle Nachfolger-Implementierung geschaffen: Exporter. Der interne Wert jedes Knotens wird in einer separaten Zeile in der Datei gespeichert, und zwar in der Reihenfolge, in der der gesamte Baum per Referenz durchlaufen wird.

  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
      }
  };


Das geordnete Durchgehen durch den Baum, das am logischsten erscheint, kann nur für Debugging-Zwecke verwendet werden, wenn Sie sortierte Zeilen innerhalb von Dateien für den kontextuellen Vergleich erhalten müssen. Diese Methode wird von der bedingten Kompilieranweisung PSO_DEBUG_BINTREE umgeben und ist standardmäßig deaktiviert. In der Praxis wird der statistische Ausgleich des Baums durch das Hinzufügen von zufälligen, gleichmäßig verteilten Werten, die im Baum gespeichert sind (Hashes), sichergestellt. Wenn die Baumelemente in einer sortierten Form gespeichert werden, kommt es beim Laden aus der Datei zur suboptimalsten und langsamsten Konfiguration (ein langer Zweig oder eine Liste). Um dies zu vermeiden, wird beim Speichern des Baumes eine Unsicherheit eingeführt, in welcher Reihenfolge die Knoten verarbeitet werden.

Die spezielle Methode zum Speichern des Baums an den übergebenen Besucher kann der Klasse BinaryTree mit Hilfe der Klasse Explorer leicht hinzugefügt werden.

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


Außerdem wird eine neue Methode in der Swarm-Klasse benötigt, um den Vorgang auszuführen.

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


Der Parameter 'id' bedeutet eine eindeutige Durchlaufnummer (gleich der Gruppennummer). Dieser Parameter wird zur Konfiguration der Optimierung im Prüfgerät verwendet. Die Methode exportIndex sollte unmittelbar nach der Ausführung zweier Schwarm-Methoden aufgerufen werden: optimize und getSolution. Dies wird von einem aufrufenden Code durchgeführt, da es nicht immer erforderlich ist: unser erstes "paralleles" Beispiel (siehe weiter) benötigt es nicht. Wenn die Anzahl der Gruppen gleich der Anzahl der Kerne ist, können sie keine Informationen austauschen, da sie parallel gestartet werden, und das Lesen einer Datei innerhalb der Schleife ist nicht effizient.

Die Hilfsfunktion sharedName, die innerhalb von exportIndex erwähnt wird, ermöglicht die Erstellung eines eindeutigen Namens auf Basis der Gruppennummer, des EA-Namens und des Terminalordners.

  #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;
  }


Wenn ein Identifikator gleich -1 an die Funktion übergeben wird, erstellt die Funktion eine Maske, um alle Dateien dieser Terminalinstanz zu finden. Diese Funktion wird beim Löschen alter temporärer Dateien (aus der vorherigen Optimierung dieses Expert Advisors) sowie beim Lesen von Indizes paralleler Streams verwendet. Im Folgenden wird allgemein beschrieben, wie es gemacht wird.

      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;
      }


Jede gefundene Datei wird zur Verarbeitung an eine neue FileReader-Klasse übergeben. Die Klasse ist für das Öffnen der Datei im Lesemodus verantwortlich. Außerdem lädt sie sequentiell alle Zeilen und übergibt sie sofort an die Feed-Schnittstelle.

  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;
      }
  };


Wie Sie sich denken können, muss die Feed-Schnittstelle direkt im Schwarm implementiert werden, da wir diese innerhalb des FileReaders übergeben haben.

  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;
      }
      ...


Mit Hilfe der Variablen _read, _unique und _restored berechnet die Methode die Gesamtzahl der gelesenen Elemente (aus allen Dateien), die Anzahl der zum Index hinzugefügten Elemente und die Anzahl der nicht hinzugefügten Elemente (die sich bereits im Index befinden). Da die Gruppen unabhängig voneinander arbeiten, können Indizes verschiedener Gruppen Duplikate aufweisen.

Diese Statistiken sind wichtig, um den Zeitpunkt zu bestimmen, an dem der Suchraum vollständig erforscht ist oder kurz davor steht, vollständig erforscht zu werden. In diesem Fall nähert sich die Anzahl von _unique der Anzahl der möglichen Parameterkombinationen.

Mit zunehmender Anzahl der abgeschlossenen Durchläufe werden immer mehr eindeutige Punkte aus der gemeinsamen Historie in die lokalen Indizes geladen. Nach der nächsten Ausführung von 'calculate' wird der Index neue geprüfte Punkte erhalten, und die Größe der gespeicherten Dateien wird ständig wachsen. Nach und nach werden sich überlappende Elemente in den Dateien durchsetzen. Dies wird einige zusätzliche Kosten verursachen, die jedoch geringer sind als die Neuberechnung der Handelsaktivität des EAs. Dies führt zu einer Beschleunigung der PSO-Zyklen bei der Verarbeitung jeder der nachfolgenden Gruppen (Tester-Tasks), da sich die Verarbeitung der vollständigen Abdeckung des Optimierungsraums nähert.

Klassendiagramm der Partikelschwarmoptimierung

Klassendiagramm der Partikelschwarmoptimierung

Testen der Parallelberechnung

Um die Leistung des Algorithmus in mehreren Threads zu testen, wandeln wir das alte Skript in den Expert Advisor PPSO.mq5 um. Es wird im mathematischen Berechnungsmodus ausgeführt, da die Handelsumgebung noch nicht benötigt wird.

Der Satz von Test-Zielfunktionen ist derselbe, und auch die Klassen, die sie implementieren, sind praktisch unverändert. Ein bestimmter Test wird in den Eingabevariablen ausgewählt.

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


Hier können wir auch die Anzahl der Zyklen, die Schwarmgröße und die Anzahl der Gruppen angeben. All dies wird in der Funktor-Implementierung verwendet, insbesondere im Swarm-Konstruktor. Voreingestellte Nullwerte bedeuten eine automatische Auswahl basierend auf der Dimension der Aufgabe.

  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;
      }
  };


Alle Berechnungen werden von OnTester gestartet. Der Parameter GroupCount (nach dem die Iterationen des Testers organisiert werden) wird als Zufallsgenerator verwendet, um sicherzustellen, dass Instanzen in verschiedenen Threads unterschiedliche Partikel enthalten. In Abhängigkeit vom Parameter TestCase wird ein Test-Functor erstellt. Anschließend wird die Methode functor.test() aufgerufen, woraufhin die Ergebnisse mit functor.getSolution() ausgelesen und in einem Frame an das Terminal gesendet werden können.

  double OnTester()
  {
    MathSrand(GroupCount); // reproducible 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;
  }


Ein Bündel von Funktionen OnTesterInit, OnTesterPass, OnTesterDeinit arbeitet im Terminal. Sie sammeln Frames und ermitteln die beste Lösung aus den gesendeten.

  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);
  }


Folgende Daten werden ins Protokoll geschrieben: der Durchlaufzähler, seine Sequenznummer (kann bei komplexen Tasks abweichen, wenn ein Thread einen anderen aufgrund von Datenunterschieden überholt), der Wert der Zielfunktion und die entsprechenden Parameter. Die endgültige Entscheidung wird in OnTesterDeinit getroffen.

Lassen wir den Expert Advisor nicht nur im Tester, sondern auch auf einem regulären Chart laufen. In diesem Fall wird der PSO-Algorithmus im regulären Single-Thread-Modus ausgeführt.

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


Schauen wir uns an, wie es arbeitet. Die folgenden Werte der Eingabeparameter werden verwendet:

Beim Starten eines Expert Advisors auf dem Chart wird das folgende Protokoll geschrieben:

  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


Da Testfälle schnell berechnet werden (innerhalb von ein oder zwei Sekunden), macht es keinen Sinn, die Zeit zu messen. Dies wird später für echte Handelsaufgaben hinzugefügt.

Wählen Sie nun den EA im Tester aus, stellen Sie in der Liste "Modellierung" "Mathematische Berechnungen" ein, verwenden Sie die oben genannten Parameter für den EA, mit Ausnahme von GroupCount. Dieser Parameter wird für die Optimierung verwendet. Stellen Sie also für ihn Anfangs- und Endwerte ein, sagen wir 0 und 3, mit einem Schritt von 1, um 4 Gruppen zu erzeugen (gleich der Anzahl der Kerne). Die Größe aller Gruppen wird 100 sein (SwarmSize, der gesamte Schwarm). Bei ausreichender Anzahl von Prozessorkernen (wenn alle Gruppen parallel auf Agenten arbeiten) sollte dies keinen Einfluss auf die Performance haben, sondern erhöht die Lösungsgenauigkeit durch zusätzliche Überprüfungen des Optimierungsraums. Das folgende Protokoll kann empfangen werden:

  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


So haben wir dafür gesorgt, dass die parallele Modifikation des PSO-Algorithmus im Tester im Optimierungsmodus verfügbar wurde. Aber bisher war es nur ein Test mit mathematischen Berechnungen. Als Nächstes wollen wir PSO anpassen, um Expert Advisors in einer Handelsumgebung zu optimieren.

Die Virtualisierung und -Optimierung des Expert Advisors (MQL4 API in MetaTrader 5)

Um Expert Advisors mit der PSO-Engine zu optimieren, ist es notwendig, Funktoren zu implementieren, die den Handel in der Historie basierend auf einem Satz von Eingabeparametern simulieren und Statistiken berechnen können.

Dies wirft ein Dilemma auf, mit dem viele Anwendungsentwickler konfrontiert sind, wenn sie ihren eigenen Optimierer über und/oder anstelle des Standardoptimierers schreiben. Wie kann man eine Handelsumgebung zur Verfügung stellen, die in erster Linie Kurse, sowie den Zustand des Kontos und das Archiv der Positionen enthält? Wenn der mathematische Berechnungsmodus verwendet wird, müssen wir die benötigten Daten irgendwie vorbereiten und dann an den Expert Advisor (an Agenten) weitergeben. Dies erfordert die Entwicklung einer API-Mittelschicht, die viele Handelsfunktionen "transparent" emuliert - dies würde es dem Expert Advisor ermöglichen, ähnlich wie im üblichen Online-Modus zu arbeiten.

Um dies zu vermeiden, entschied ich mich, eine bestehende virtuelle Handelslösung zu verwenden, die vollständig in MQL erstellt wurde und die Standardstrukturen für historische Daten, insbesondere Ticks und Balken, verwendet. Dies ist die Bibliothek Virtual.mqh von fxsaber. Sie ermöglicht die Berechnung eines virtuellen Durchlaufs eines Expert Advisors auf der verfügbaren Historie sowohl online (z.B. zur periodischen Selbstoptimierung auf einem Chart) als auch im Tester. Im letzteren Fall können wir jeden üblichen Tick-Modus verwenden ("Every tick", "Every tick based on real ticks") oder sogar "OHLC on M1" - für eine schnelle, aber eher grobe Einschätzung des Systems (es hat nur 4 Ticks pro Minute).

Nach dem Einbinden der Header-Datei Virtual.mqh (sie wird mit den notwendigen Abhängigkeiten heruntergeladen) in den EA-Code kann ein virtueller Test einfach mit den folgenden Zeilen organisiert werden:

      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


Alle Operationen werden von der statischen Methode VIRTUAL::Tester ausgeführt. Die folgenden Daten sollten an diese Methode übergeben werden: ein vorausgefülltes Array mit Ticks der gewünschten historischen Periode und Details, ein Zeiger auf die OnTick-Funktion (Sie können einen Standard-Handler verwenden, wenn er die Logik des Umschaltens vom Online-Handel zum virtuellen Handel enthält) und eine Ersteinlage (sie ist optional - wenn keine Ersteinlage angegeben wird, wird der aktuelle Kontostand verwendet. Wenn das obige Fragment in OnTester platziert wird (wir werden es dort platzieren), kann die Ersteinlage des Testers übergeben werden. Um das Ergebnis des virtuellen Handels herauszufinden, rufen Sie die bekannte Funktion TesterStatistics auf, die sich nach dem Einbinden der Bibliothek als tatsächlich "überlappend" herausstellt, wie viele andere MQL-API-Funktionen (Sie können den Quellcode überprüfen, wenn Sie möchten). Diese "Überlappung" ist klug genug, um Aufrufe an die ursprüngliche Kernel-Funktion zu delegieren, wo der Handel tatsächlich durchgeführt wird. Bitte beachten Sie, dass nicht alle Standardindikatoren von TesterStatistics in der Bibliothek während des virtuellen Handels berechnet werden.

Achten Sie darauf, dass die Bibliothek auf dem Handels-API für den MetaTrader 4 basiert. Mit anderen Worten, sie ist nur für Expert Advisors geeignet, die "alte" Funktionen in ihrem Code verwenden, obwohl sie in MQL5 geschrieben sind. Sie können in der Umgebung des MetaTrader 5 dank einer anderen bekannten Bibliothek desselben Autors laufen - MT4Orders.

Die Tests werden mit der EA-Modifikation ExprBot.mq5 durchgeführt, die ursprünglich im Artikel Berechnung mathematischer Ausdrücke (Teil 2) vorgestellt wurde. Der EA ist mit MT4Orders implementiert. Eine neue Version mit dem Namen ExprBotPSO.mq5 ist im Anhang verfügbar.

Der Expert Advisor nutzt eine Parser-Engine, um Handelssignale auf Basis von Ausdrücken zu berechnen. Die Vorteile davon werden später erklärt. Die Handelsstrategie ist die gleiche: Schnittpunkt zweier gleitender Durchschnitte, unter Berücksichtigung der angegebenen Divergenzschwelle. Hier sind die EA-Einstellungen zusammen mit Ausdrücken für Signale:

  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;


Wenn Sie Fragen dazu haben, wie Eingabevariablen in Ausdrücke ersetzt werden und wie die eingebauten EMA-Funktionen mit dem entsprechenden Indikator integriert werden, empfehle ich Ihnen, den erwähnten Artikel zu lesen. Der neue Roboter verwendet die gleichen Prinzipien. Er wird leicht verbessert sein.

Bitte beachten Sie, dass die Parser-Engine auf die Version v1.1 aktualisiert wurde und ebenfalls enthalten ist. Die alte Version wird nicht funktionieren.

Zusätzlich zu den Eingabeparametern für Signale, die wir später besprechen werden, verfügt der EA über Parameter zur Verwaltung von VirtualTester und des PSO-Algorithmus.

Im VirtualTester-Modus sammelt der EA die Ticks in OnTick in einem Array. Dann handelt die Bibliothek Virtual in OnTester mit diesem Array und ruft dasselbe OnTick mit einem speziellen gesetzten Flag auf, das die Codeausführung mit virtuellen Operationen erlaubt.

Daher wird für jeden inkrementierten PSO_GroupCount-Wert ein Zyklus von PSO_Cycles von Neuberechnungen des Schwarms mit einer Größe von PSO_SwarmSize-Partikeln ausgeführt. Wir testen also PSO_GroupCount * PSO_Cycles * PSO_SwarmSize = N Punkte im Optimierungsraum. Jeder Punkt ist ein virtueller Durchlauf des Handelssystems.

Um beste Ergebnisse zu erzielen, finden Sie die geeigneten PSO-Parameter durch Ausprobieren. Die Anzahl der Komponenten kann für die N Anzahl von Tests variiert werden. Die endgültige Anzahl der Tests wird kleiner als N sein, weil die gleichen Punkte getroffen werden können (denken Sie daran, dass die Punkte in einem binären Baum im Schwarm gespeichert sind).

Agenten tauschen nur dann Daten aus, wenn die nächste Aufgabe gesendet wird. Die Aufgaben, die parallel ausgeführt werden, sehen die Ergebnisse der anderen noch nicht und könnten mit einer gewissen Wahrscheinlichkeit auch mehrere identische Koordinaten berechnen.

Natürlich enthält der Expert Advisor ExprBotPSO auch Funktorklassen, die im Allgemeinen denen ähnlich sind, die wir in den vorherigen Beispielen besprochen haben. Dazu gehört die Methode 'test', die eine Schwarminstanz erzeugt, darin eine Optimierung durchführt und die Ergebnisse in Mitgliedsvariablen (optimum, result[]) speichert.

  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;
      }
  };


Hier sehen wir zum ersten Mal die Verwendung der Methoden restoreIndex und exportIndex, die in den vorherigen Abschnitten beschrieben wurden. Die Optimierung eines Expert Advisor erfordert in der Regel viele Berechnungen (Parameter und Gruppen, jede Gruppe ist ein Testerdurchlauf), so dass die Agenten Informationen austauschen müssen.

Virtuelle EA-Tests werden in der Methode 'calculate' entsprechend der deklarierten Reihenfolge durchgeführt. Es gibt eine neue Klasse, die bei der Initialisierung des Optimierungsraums verwendet wird - Einstellungen.

  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;
      }
  };


Der Punkt ist, dass der Nutzer, um eine Optimierung zu starten, die Eingabeparameter von EA auf die übliche Weise konfiguriert. Der Schwarmalgorithmus verwendet den Tester jedoch nur zur Parallelisierung von Aufgaben (durch Inkrementierung der Gruppennummer). Daher sollte der EA in der Lage sein, die Einstellungen der Optimierungsparameter zu lesen, sie in einer Hilfsdatei zu speichern, die an jeden Agenten übertragen wird, diese Einstellungen im Tester zurückzusetzen und die Optimierung nach Gruppennummer zuzuordnen. Die Klasse Settings liest die Parameter aus einer Hilfsdatei. Die Datei ist "EA_name.mq5.csv", die mit einer Direktive verbunden werden soll.

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


Sie können die Klasse Settings im Anhang ansehen. Sie liest eine CSV-Datei Zeile für Zeile ein. Die Datei sollte die folgenden Spalten enthalten:

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


Sie werden alle in internen Arrays gespeichert und sind über die Methode 'get' nach Name oder Index abrufbar. Die Methode isVoid() gibt einen Hinweis darauf zurück, dass keine Einstellungen vorhanden sind (die Datei konnte nicht gelesen werden, sie ist leer oder hat ein falsches Format).

Die Einstellungen werden in OnTesterInit in eine Datei geschrieben (siehe unten).

Ich empfehle, im Vorfeld manuell eine leere Datei "EA_name.mq5.csv" im Ordner MQL5/Files anzulegen. Andernfalls kann es zu Problemen beim ersten Optimierungsdurchlauf kommen.

Leider wird diese Datei beim ersten Start zwar automatisch erstellt, aber nicht an die Agenten gesendet, weshalb die EA-Initialisierung auf ihnen mit dem Fehler INIT_PARAMETERS_INCORRECT endet. Auch bei einem wiederholten Start der Optimierung wird sie nicht gesendet, da der Tester Informationen über die angeschlossenen Ressourcen zwischenspeichert und die neu hinzugefügte Datei erst berücksichtigt, wenn der Nutzer den EA in der Dropdown-Liste der Tester-Einstellungen erneut auswählt. Erst danach kann die Datei aktualisiert und an Agenten gesendet werden. Daher ist es einfacher, die Datei im Voraus zu erstellen.

  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();
  }


Eine zusätzliche Funktion ResetOptimizableParam wird verwendet, um nach Parametern zu suchen, für die das Optimierungsflag aktiviert ist, und um solche Flags zurückzusetzen. Außerdem merken wir uns in OnTesterInit die Namen dieser Parameter mit Hilfe der Bibliothek Expert von fxsaber, was eine visuell übersichtlichere Darstellung der Ergebnisse ermöglicht. Die Bibliothek wurde aber vor allem deshalb benötigt, weil die Namen im Voraus bekannt sein sollten, um die Standardfunktionen ParameterGetRange/ParameterSetRange aufzurufen, die MQL-API es aber nicht erlaubt, die Liste der Parameter zu erhalten. Dadurch wird der Code auch universeller, und somit können Sie diesen Code ohne besondere Änderungen in jeden EA einbinden.

  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;
  }


In OnInit, der auf dem Agenten ausgeführt wird, werden die Einstellungen wie folgt in das globale Objekt Settings eingelesen:

  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");
        }
      }
      ...
  }


Wie Sie später sehen werden, wird dieses Objekt im OnTester-Handler an das erstellte Objekt WorkerFunctor übergeben, in dem alle Berechnungen und Optimierungen durchgeführt werden. Bevor wir mit den Berechnungen beginnen, müssen wir die Ticks sammeln. Dies wird in OnTick durchgeführt.

  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
  }


Warum verwenden wir die obige Methode anstelle des Funktionsaufrufs CopyTicksRange direkt in OnTester? Erstens funktioniert diese Funktion nur im Tick-für-Tick-Modus, während wir Unterstützung für den schnellen Modus OHLC M1 (4 Ticks pro Minute) benötigen. Zweitens ist die Größe des zurückgegebenen Arrays im Tick-Generierungsmodus aus irgendeinem Grund auf 131072 begrenzt (es gibt keine solche Beschränkung, wenn man mit echten Ticks arbeitet).

Die Variable OnTesterCalled ist anfangs gleich false und daher wird die Tick-Historie gesammelt. OnTesterCalled wird später, in OnTester, vor dem Start von PSO auf true gesetzt. Dann beginnt das Swarm-Objekt mit der Berechnung des Funktors in einer Schleife, in der VIRTUAL::Tester mit einer Referenz auf denselben OnTick aufgerufen wird. Dieses Mal wird OnTesterCalled gleich true sein und die Kontrolle wird nicht in den Tick-Sammelmodus, sondern in einen Handelslogikmodus übertragen. Dies wird ein wenig später besprochen. In der Zukunft, wenn sich die PSO-Bibliothek weiterentwickelt, könnten Mechanismen erscheinen, die die Integration in bestehende Expert Advisors vereinfachen, indem der OnTick-Handler in der Header-Datei der Bibliothek ersetzt wird.

Bis dahin wird OnTester (in einer vereinfachten Form) verwendet.

  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);
  }


Der obige Code zeigt die Erstellung des WorkerFunctor durch einen Satz von Parametern aus dem Objekt 'settings' und den Start eines Schwarms mit seiner Methode 'test'. Die erhaltenen Ergebnisse werden in einem Frame an das Terminal gesendet, wo sie an OnTesterPass empfangen werden.

OnTesterPass ähnelt dem im PPSO-Test-EA, außer dass die in Frames empfangenen Daten nicht in das Protokoll, sondern in eine CSV-Datei mit dem Namen PPSO-EA-name-date_time ausgegeben werden.

Ablaufdiagramm der parallelen Partikelschwarmoptimierung

Ablaufdiagramm der parallelen Partikelschwarmoptimierung

Kommen wir nun endlich zurück zur Handelsstrategie. Sie ist fast die gleiche wie die im Artikel Berechnen mathematischer Ausdrücke (Teil 2) verwendete. Es sind jedoch einige Anpassungen notwendig, um den virtuellen Handel zu ermöglichen. Frühere Signalformeln berechnen EMA-Indikatoren basierend auf offenen Preisen auf einem Null-Bar:

  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";


Jetzt sollten sie aus historischen Balken gelesen werden (weil die Berechnungen ganz am Ende des Durchlaufs, von OnTester, durchgeführt werden). Die Nummer des "aktuellen" Balkens in der Vergangenheit lässt sich leicht ermitteln: Die Bibliothek Virtual überschreibt die Systemfunktion TimeCurrent, und deshalb kann in OnTick folgendes geschrieben werden:

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


Die aktuelle Balkennummer sollte unter einem geeigneten Namen, z. B. "Balken", in die Variablentabelle der Ausdrücke aufgenommen werden, und dann können die Signalformeln wie folgt umgeschrieben werden:

  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";


Die aktualisierte Version des Parsers hat einen Zwischenaufruf der neuen Methode 'with' (auch in OnTick) beim Ändern der Variablen (Balken-Nummer) und Berechnen der Formel mit diesem Wert:

    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();


Ansonsten gibt es im Code von OnTick keine Änderungen.

Es sind jedoch weitere Änderungen erforderlich.

Die aktuellen Formeln verwenden die festen EMA-Perioden, die in den Einstellungen angegeben und in Variablen innerhalb der Ausdrücke umgewandelt wurden. Die Perioden sollten jedoch während der Optimierung geändert werden, was bedeutet, dass verschiedene Instanzen von Indikatoren verwendet werden. Das Problem ist, dass die virtuelle Optimierung mit Parameter-Tuning durch den Schwarm _innerhalb_ des Tester-Durchlaufs durchgeführt wird, ganz am Ende, in der OnTester-Funktion. Es ist zu spät, hier Indikator-Handles zu erstellen.

Dies ist ein globales Problem für jede virtuelle Optimierung. Es gibt drei offensichtliche Lösungen:

Die letzte Methode ist fragwürdig für Systeme, die Signale auf einer Tick-für-Tick-Basis berechnen. Eigentlich sind alle Balken in der virtuellen Historie bereits geschlossen und die Indikatoren wurden bereits berechnet. Mit anderen Worten: Es sind nur Balken-Signale verfügbar. Wenn wir ein System ohne Kontrolle der Balkeneröffnung auf einer solchen Historie laufen lassen, wird dies viel weniger Positionen mit einer geringeren Qualität produzieren, wenn man es mit nicht-virtuellen Ticks vergleicht.

Unser Expert Advisor handelt nach Balken, so dass dies kein Problem darstellt. Diese Situation kann typisch für einige Standard-Expert Advisors in MetaTrader 5 sein - es ist notwendig zu verstehen, wie ein neues Balken-Eröffnungsereignis erkannt wird. Die Methode mit einer einzelnen Tick-Volumensteuerung ist für die virtuelle Historie nicht geeignet, da alle Balken bereits mit Ticks gefüllt sind. Daher wird empfohlen, einen neuen Balken zu definieren, indem man seine Zeit mit dem vorherigen vergleicht.

Um das beschriebene Problem zu lösen, wurde die Expression-Engine um die dritte Option erweitert. Zusätzlich zu den Funktionen für einzelne MA-Indikatoren (MAIndicatorFunc) habe ich MA-Fächer-Funktionen (MultiMAIndicatorFunc, siehe Indicators.mqh) erstellt. Ihr Name muss mit dem Präfix "M_" beginnen und z. B. die minimale Periode, den Periodenschritt und die maximale Periode enthalten:

  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";


Berechnungsmethode und Preistyp sind wie zuvor im Namen angegeben. Hier wird ein EMA-Fächer auf Basis von OPEN-Preisen mit Perioden von 9 bis 27 (einschließlich) erstellt, mit einem Schritt von 6.

Eine weitere Neuerung in der Ausdrucksbibliothek ist ein Satz von Variablen, die den Zugriff auf Handelsstatistiken aus TesterStatistics ermöglichen (siehe TesterStats.mqh). Basierend auf diesem Satz ist es möglich, dem EA den Formula-Eingang hinzuzufügen, der es erlaubt, den Zielwert als beliebigen Ausdruck zu setzen. Wenn diese Variable gefüllt ist, wird der Estimator ignoriert. Insbesondere kann anstelle von STAT_PROFIT_FACTOR (der für Nullverluste undefiniert ist) ein "glatterer" Indikator mit einer ähnlichen Formel in "Estimator" gesetzt werden: "(GROSSPROFIT-(1/(TRADES+1)))/-(GROSSLOSS-1/(TRADES +1))".

Jetzt ist alles bereit, um die virtuelle Handelsoptimierung mit der PSO-Methode durchzuführen.

Praktisches Testen

Lassen Sie uns einen Test vorbereiten. Er sollte eine langsame Optimierung verwenden, d.h. eine vollständige Iteration aller Parameter. In unserem Fall wird sie nicht langsam sein, da in jedem Durchlauf nur die Gruppennummer geändert wird, während die selektive Iteration der Parameter des EA von einem Schwarm innerhalb seines Zyklus durchgeführt wird. Die Genetik kann aus drei Gründen nicht verwendet werden. Erstens garantiert sie nicht, dass alle Kombinationen von Parametern (in unserem Fall eine bestimmte Anzahl von Gruppen) berechnet werden. Zweitens wird sie aufgrund ihrer spezifischen Natur allmählich zu den Parametern "wandern", die ein attraktiveres Ergebnis hervorbringen, ohne zu berücksichtigen, dass es keine Abhängigkeit zwischen der Gruppenzahl und dem Erfolg gibt, da die Gruppenzahl nur ein Zufallsgenerator der PSO-Datenstruktur ist. Drittens ist die Anzahl der Gruppen normalerweise nicht groß genug, um den genetischen Ansatz zu verwenden.

Die Optimierung erfolgt nach dem maximierenden Nutzerkriterium.

Zunächst wird der Expert Advisor auf reguläre Weise optimiert, wobei die virtuelle Bibliothek deaktiviert ist (Datei ExprBotPSO-standard-optimization.set). Die Anzahl der Parameterkombinationen für die Optimierung ist zu Demonstrationszwecken gering. Die Parameter Fast und Slow variieren von 9 bis 45 mit einem Schritt von 6, der Parameter T - von 0 bis 0,01 mit einem Schritt von 0,0025 Schritten.

EURUSD, H1, Bereich von Anfang 2020, unter Verwendung von echten Ticks. Die folgenden Ergebnisse können erhalten werden:

Tabelle der Standard-Optimierungsergebnisse

Tabelle der Standard-Optimierungsergebnisse

Den Logs zufolge dauerte die Optimierung bei zwei Agenten fast 21 Minuten.

  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


Optimieren wir nun den Expert Advisor mit virtuellem Handel und aktiviertem PSO (ExprBotPSO-virtual-pso-optimization.set). Die Anzahl der Gruppen gleich 4 wird durch Iteration des Parameters PSO_GroupCount von 0 bis 3 bestimmt. Andere Arbeitsparameter, für die die Optimierung aktiviert ist, werden in der Standardoptimierung zwangsweise deaktiviert, aber sie werden den Agenten in CSV-Dateien für die interne virtuelle Optimierung mit dem PSO-Algorithmus übergeben.

Verwenden wir auch hier die Simulation durch reale Ticks, obwohl es auch möglich ist, generierte Ticks oder OHLC M1 für schnelle Berechnungen zu verwenden. Mathematische Berechnungen können hier nicht verwendet werden, da die Ticks im Tester für den virtuellen Handel gesammelt werden.

In den Logs des Testers kann man folgendes lesen:

  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


Jeder "Durchlauf" ist jetzt ein Paket von virtuellen Optimierungen, also ist er länger geworden. Aber ihre Gesamtzahl ist geringer und die Gesamtdauer ist deutlich reduziert - nur 4 Minuten.

Meldungen von Frames werden in Logs empfangen (sie zeigen die besten Messwerte jeder Gruppe). Die realen und virtuellen Handelsergebnisse sind jedoch etwas unterschiedlich.

  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


Die Ergebnisse werden nicht genau übereinstimmen (selbst wenn wir eine Tick-für-Tick-indikatorfreie Strategie hätten), weil der Tester spezifische Betriebsmerkmale hat, die in der MQL-Bibliothek nicht wiederholt werden können. Hier sind nur ein paar von ihnen:

Für weitere Informationen über die Bibliothek Virtual lesen Sie bitte die entsprechende Dokumentation und Diskussionen.

Zu Debugging-Zwecken und zum Verständnis des Schwarmbetriebs unterstützt der Test-EA den virtuellen Optimierungsmodus auf einem Kern innerhalb eines normalen Testerlaufs. Ein Beispiel für die Einstellungen finden Sie in der unten angehängten Datei ExprBotPSO-virtual-internal-optimization-single-pass.set. Vergessen Sie nicht, die Optimierung im Tester zu deaktivieren.

Die Zwischenergebnisse werden detailliert in das Testerprotokoll geschrieben. In jedem Zyklus werden die Position und der Wert der Zielfunktion eines jeden Partikels aus den angegebenen PSO_Cycles ausgegeben. Wenn das Partikel auf bereits geprüfte Koordinaten trifft, wird die Berechnung übersprungen.

  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


Da der Optimierungsraum klein ist, wurde er mit 19 Zyklen vollständig abgedeckt. Natürlich wird die Situation bei realen Problemen mit Millionen von Kombinationen anders sein. Bei solchen Problemen ist es extrem wichtig, die richtigen Kombinationen von PSO_Cycles, PSO_SwarmSize und PSO_GroupCount zu finden.

Vergessen Sie nicht, dass bei PSO ein Testdurchlauf für jeden von PSO_GroupCount intern bis zu PSO_Cycles*PSO_SwarmSize virtuelle Einzeldurchläufe durchführt, deshalb wird die Fortschrittsanzeige deutlich langsamer sein als üblich.

Viele Händler versuchen, die besten Ergebnisse aus der eingebauten genetischen Optimierung herauszuholen, indem sie diese viele Male hintereinander ausführen. Dadurch werden aufgrund der zufälligen Initialisierung verschiedene Tests gesammelt, und der Fortschritt kann nach mehreren Durchläufen gefunden werden. Im Falle von PSO fungiert PSO_GroupCount als ein Analogon des mehrfachen Starts der Genetik. Die Anzahl der Einzelläufe, die bei Genetics bis zu 10000 betragen kann, sollte bei PSO auf die beiden Komponenten des Produkts aus PSO_Cycles*PSO_SwarmSize verteilt werden, z. B. 100*100. PSO_Cycles ist analog zu den Generationen in der Genetik, und PSO_SwarmSize ist die Größe der Population.

Virtualisierung von MQL5 API Expert Advisors

Bis jetzt haben wir ein Beispiel eines Expert Advisors untersucht, der mit der MQL4-Handels-API geschrieben wurde. Dies war mit den Besonderheiten der Implementierung der virtuellen Bibliothek verbunden. Ich wollte jedoch die Möglichkeit implementieren, PSO für EAs mit "neuen" MQL5-API-Funktionen zu verwenden. Zu diesem Zweck habe ich eine experimentelle Zwischenschicht zur Umleitung von MQL5-API-Aufrufen auf MQL4-API entwickelt. Sie ist als Datei MT5Bridge.mqh verfügbar, die zum Betrieb die Virtual Library und/oder MT4Orders benötigt.

  #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>
  ...


Nach dem Hinzufügen von Virtual und MT5Bridge am Anfang des Codes, vor anderen #include, werden die MQL5-API-Funktionen durch die neu definierten Funktionen "bridge" aufgerufen, von denen "virtuelle" MQL4-API-Funktionen aufgerufen werden. Als Ergebnis ist es möglich, den Expert Advisor virtuell zu testen und zu optimieren. Insbesondere ist es nun möglich, die PSO-Optimierung ähnlich wie im obigen ExprBotPSO-Beispiel durchzuführen. Dies erfordert das Schreiben (teilweise Kopieren) eines Funktors und Handlers für den Tester. Der ressourcen- und zeitintensivste Prozess betrifft jedoch die Anpassung der Indikatorsignale für variable Parameter.

MT5Bridge.mqh hat einen experimentellen Status, da seine Funktionalität noch nicht ausgiebig getestet wurde. Die ist der Versuch eines Konzeptnachweises. Sie können den Quellcode zum Debuggen und zur Fehlerbehebung verwenden.

Schlussfolgerung

Wir haben den Algorithmus der Partikel-Schwarm-Optimierung besprochen und in MQL implementiert, mit Unterstützung für Multithreading durch Tester-Agenten. Die Verfügbarkeit von offenen PSO-Einstellungen erlaubt eine größere Flexibilität bei der Regulierung des Prozesses, verglichen mit der Verwendung der eingebauten genetischen Optimierung. Zusätzlich zu den Einstellungen, die in den Eingabeparametern vorgesehen sind, ist es sinnvoll, andere anpassbare Koeffizienten auszuprobieren, die wir als Argumente für die Methode 'optimize' mit Standardwerten verwendet haben: inertia(0.8), selfBoost(0.4) und groupBoost(0.4). Dies macht den Algorithmus flexibler, erschwert aber die Auswahl der Einstellungen für eine bestimmte Aufgabe. Die unten angehängte PSO-Bibliothek kann sowohl im mathematischen Berechnungsmodus (wenn Sie Ihren eigenen Mechanismus von virtuellen Kursen, Indikatoren und Trades haben), als auch im Tick-Balken-Modus verwendet werden, indem Sie fertige Handelsemulationsklassen von Drittanbietern, wie z. B. Virtual.