English Русский 中文 Español Português
preview
Neuronale Netze im Trading: Skill-Hierarchie für das Verhalten adaptiver Agenten (letzter Teil)

Neuronale Netze im Trading: Skill-Hierarchie für das Verhalten adaptiver Agenten (letzter Teil)

MetaTrader 5Handelssysteme |
30 0
Dmitriy Gizlyk
Dmitriy Gizlyk

Einführung

Im vorherigen Artikel untersuchten wir die theoretischen Grundlagen des Frameworks HiSSD (Hierarchical and Separate Skill Discovery), einem modernen Ansatz für das Offline-Training von Multi-Agenten-Systemen, die in der Lage sind, in komplexen und hochdynamischen Umgebungen zu operieren. Dieses Framework ermöglicht es Agenten, effektive Interaktionsmuster zu erlernen und sich an verändernde Bedingungen anzupassen. Anfangs wurde HiSSD in simulierten Umgebungen getestet; seine Architektur und Designprinzipien machen es jedoch besonders für Finanzmärkte relevant, wo sich Bedingungen innerhalb von Sekunden dramatisch ändern können und intelligente Handelsagenten schnell und koordiniert reagieren müssen.

Einer der Hauptvorteile von HiSSD ist sein hoher Grad an Anpassungsfähigkeit. In Handelsszenarien, in denen Wirtschaftsindikatoren, das Verhalten von Marktteilnehmern oder Nachrichtenereignisse die Marktlandschaft abrupt verändern können, können Agenten, die mit HiSSD trainiert wurden, sich sofort ohne vollständiges Nachtraining anpassen. Dies wird durch eine zweistufige Skill-Architektur ermöglicht: allgemeine und aufgabenspezifische Skills. Allgemeine Skills repräsentieren Verhaltensmuster, die in einer Vielzahl von Situationen anwendbar sind – zum Beispiel Trenderkennung oder Risikobewertung. Aufgabenspezifische Skills steuern wiederum das Verhalten unter einzigartigen oder hoch spezialisierten Bedingungen. Diese zweischichtige Struktur ermöglicht es HiSSD-Agenten, unabhängig von sich ändernden Marktregimen stabil und effektiv zu bleiben.

Ein weiterer starker Aspekt von HiSSD ist seine Skalierbarkeit. Finanzmärkte stellen ein Multi-Agenten-System dar. Jeder Teilnehmer hier – ob menschlicher Händler, algorithmisches System oder großer Market Maker – beeinflusst die Gesamtdynamik. In solchen Umgebungen ist es wesentlich, das System zu skalieren, ohne seine interne Konsistenz zu beeinträchtigen. HiSSD schlägt eine hierarchische Architektur vor, bei der jeder Agent sein Verhalten durch gemeinsame Steuerungsmodule mit anderen koordinieren kann. Dies ist besonders nützlich bei der Entwicklung komplexer Strategien. In der Praxis ermöglicht dies den Aufbau robusterer und widerstandsfähigerer Handelssysteme.

Eine von den Autoren erstellte Visualisierung der Architektur des HiSSD-Frameworks ist nachfolgend dargestellt.

Im praktischen Teil des letzten Artikels haben wir begonnen, unsere Interpretation des von den Autoren vorgeschlagenen Frameworks mit MQL5 zu implementieren. Insbesondere haben wir eine Version eines universellen Skill-Encoders eingeführt, die in der Klasse CNeuronSkillsEncoder implementiert ist. In diesem Artikel setzen wir diese Arbeit fort und bringen sie zum Abschluss, einschließlich des Tests der Effektivität des implementierten Ansatzes anhand realer historischer Daten.

Bevor wir fortfahren, lassen Sie uns noch einmal die Struktur des HiSSD-Frameworks kurz rekapitulieren. Es besteht aus zwei Hauptkomponenten: dem Planner und dem Controller.

Der Planner folgt einem linearen Informationsfluss. Rohdaten durchlaufen einen gemeinsamen Skill-Encoder und werden dann in ein Vorhersagemodul eingespeist, das für die Prognose zukünftiger Zustände und des erwarteten Werts verantwortlich ist. Diese Architektur kann mithilfe vorhandener Tools als lineares Standardmodell implementiert werden.

Der Controller ist organisatorisch komplexer. Er enthält einen Aktionsdecoder für Agenten, der Eingaben aus drei Quellen empfängt: lokale Agentenbeobachtungen, allgemeine Skills und aufgabenspezifische Skills, die von verschiedenen Encodern generiert werden. Diese Struktur legt es nahe, den Controller als separates, dediziertes Objekt zu implementieren.



Controller-Objekt

Der nächste Schritt bei unserer Implementierung ist die Konstruktion des Controller-Objekts, das in der Klasse CNeuronHiSSDLowLevelControler gekapselt ist.

class CNeuronHiSSDLowLevelControler:  public CNeuronConvOCL
  {
protected:
   uint                       iTaskSkills;
   uint                       iCommonSkills;
   //---
   CNeuronSkillsEncoder       cTaskSpecificSkillsEncoder;
   CNeuronTransposeOCL        cTranspose;
   CNeuronBaseOCL             cObservAndSkillsConcat;
   CNeuronBatchNormOCL        cNormalizarion;
   CNeuronConvOCL             cActionDecoder[2];
   //---
   virtual bool               feedForward(CNeuronBaseOCL *NeuronOCL) override { return false; }
   virtual bool               feedForward(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput) override;
   virtual bool               updateInputWeights(CNeuronBaseOCL *NeuronOCL) override { return false; }
   virtual bool               updateInputWeights(CNeuronBaseOCL *NeuronOCL, CBufferFloat *second) override;
   virtual bool               calcInputGradients(CNeuronBaseOCL *prevLayer) override { return false; }
   virtual bool               calcInputGradients(CNeuronBaseOCL *NeuronOCL,
                                                 CBufferFloat *SecondInput,
                                                 CBufferFloat *SecondGradient,
                                                 ENUM_ACTIVATION SecondActivation = None
                                                ) override;

public:
                              CNeuronHiSSDLowLevelControler(void) {};
                             ~CNeuronHiSSDLowLevelControler(void) {};
   //---
   virtual bool               Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                                   uint time_step, uint variables,
                                   uint task_skills, uint common_skills, uint n_actions,
                                   uint window, uint step, uint window_key, uint heads,
                                   ENUM_OPTIMIZATION optimization_type, uint batch);
   //---
   virtual int                Type(void) override const   {  return defNeuronHiSSDLowLevelControler;   }
   //---
   virtual bool               Save(int const file_handle) override;
   virtual bool               Load(int const file_handle) override;
   //---
   virtual bool               WeightsUpdate(CNeuronBaseOCL *source, float tau) override;
   virtual void               SetOpenCL(COpenCLMy *obj) override;
  };

Es ist wichtig zu beachten, dass der Agenten-Aktionsdecoder innerhalb des HiSSD Controller von der parallelen Ausführung mehrerer unabhängiger Agenten ausgeht. Diese Funktionalität kann mithilfe sequenzieller Faltungsschichten implementiert werden. Da sich der Decoder in der Ausgangsstufe des Moduls befindet, kann die Funktionalität der finalen Decodierungsschicht an die Elternklasse delegiert werden. Aus diesem Grund haben wir ein Faltungsschicht-Objekt als Basisklasse für das Controller-Modul gewählt.

In der Struktur des neuen Objekts definieren wir mehrere interne Komponenten, deren detaillierte Funktionalität während der Implementierung der Feed-Forward- und Backpropagation-Algorithmen besprochen wird. In diesem Stadium merken wir lediglich ihre statische Deklaration an, wodurch Konstruktor und Destruktor leer bleiben können. Die Initialisierung aller internen Objekte, einschließlich der geerbten, erfolgt in der Methode Init.

Wie üblich erhält die Initialisierungsmethode eine Reihe von Konstanten, die die Architektur des erforderlichen Objekts eindeutig definieren.

bool CNeuronHiSSDLowLevelControler::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                                         uint time_step, uint variables, uint task_skills,
                                         uint common_skills, uint n_actions, uint window,
                                         uint step, uint window_key, uint heads,
                                         ENUM_OPTIMIZATION optimization_type, uint batch)
  {
   if(!CNeuronConvOCL::Init(numOutputs, myIndex, open_cl, window_key, window_key,
                            n_actions, 1, variables, optimization_type, batch))
      return false;
   SetActivationFunction(SIGMOID);

Innerhalb des Methodenkörpers rufen wir zunächst die entsprechende Methode der Elternklasse auf, wie es unserer Standardpraxis entspricht. Hier müssen einige wichtige Aspekte hervorgehoben werden.

Erstens beabsichtigen wir, die Funktionalität der Elternklasse als letzte Schicht des Agenten-Aktionsdecoders zu verwenden. Daher besteht die Eingabe für die Methoden der Elternklasse aus den verarbeiteten Ausgaben der internen Decoder-Komponenten. Der Decoder soll parallele Informationsflüsse organisieren, die das „Bewusstsein“ einzelner Agenten bilden. Folglich werden die Größe des analysierten Datenfensters und die Schrittweite der Faltungsschicht gleich der Dimensionalität des internen Informationsvektors eines einzelnen Agenten gesetzt.

Auf der Ausgabeseite erwarten wir, einen Tensor von Agentenaktionen zu erhalten. Daher entspricht die Anzahl der Faltungskerne der Dimensionalität des Aktionsvektors eines einzelnen Agenten.

Hinzu kommt ein weiterer wichtiger Punkt. Um ein vollständig unabhängiges Lernen für jeden Agenten zu gewährleisten, müssen wir eindeutige Gewichtungsparameter zuweisen. Dies wird erreicht, indem die Dimension der Eingabesequenz auf eins gesetzt und die Anzahl der trainierbaren Agenten in den Parameter verschoben wird, der die Anzahl der Einheitsequenzen definiert. Diese einfache Technik ermöglicht es uns, den vollständig parallelen Betrieb einer beliebigen Anzahl unabhängiger Agenten zu implementieren.

Als Nächstes fahren wir mit der Initialisierung der in der Klassenstruktur deklarierten internen Objekte fort. Wie bereits erwähnt, wird die Initialisierung der geerbten Komponenten von der Methode der Elternklasse übernommen, die bereits aufgerufen wurde.

Die erste zu initialisierende Komponente ist der aufgabenspezifische Skill-Encoder, der unter Verwendung des im vorherigen Artikel entwickelten universellen Skill-Encoders implementiert wurde.

int index = 0;
if(!cTaskSpecificSkillsEncoder.Init(0, index, OpenCL, time_step, variables, task_skills,
                                    window, step, window_key, heads, optimization, iBatch))
   return false;
cTaskSpecificSkillsEncoder.SetActivationFunction(None);

Anschließend speichern wir die erforderlichen Architekturkonstanten.

iTaskSkills = task_skills;
iCommonSkills = MathMax(common_skills, 1);

Beachten Sie, dass die Dimensionalität spezifischer Skills unverändert bleibt, während allgemeinen Skills ein minimaler gültiger Wert zugewiesen wird. Dies ist unkompliziert. Wir haben die Dimension der aufgabenspezifischen Skills zuvor in den Parametern der Encoder-Initialisierungsmethode übergeben. Und die erfolgreiche Ausführung dieser Methode bestätigt die Gültigkeit des erhaltenen Wertes. Allgemeine Skill-Tensoren werden jedoch vom Planner bereitgestellt, der in dieser Implementierung ein separates Objekt und sogar ein separates Modell ist. Daher kann hier nur eine minimal akzeptable Einschränkung definiert werden.

Als Nächstes fahren wir mit der Konstruktion des Agenten-Aktionsdecoders fort. Die Autoren des HiSSD-Frameworks schlagen drei Eingangsquellen für den Decoder vor:

  • lokale Agentenbeobachtungen
  • allgemeine Skills
  • aufgabenspezifische Skills

Wie bereits erwähnt, werden allgemeine Skills von einem separaten Modell über einen zusätzlichen Informationsfluss erwartet. Spezifische Skills werden vom Encoder basierend auf lokalen Beobachtungen aus dem Haupt-Eingabestrom generiert. Somit sind alle erforderlichen Daten bereits verfügbar. Sie müssen lediglich in einer einheitlichen Struktur zusammengeführt werden.

Es ist wichtig zu betonen, dass jeder Agent seine eigene, eindeutige Datendarstellung erhalten muss. Daher muss eine korrekte Verkettung sichergestellt werden. Skill-Tensoren werden als abstrakte Matrix dargestellt, wobei jede Zeile dem Skill-Vektor eines einzelnen Agenten entspricht. Die zeilenweise Verkettung ergibt die gewünschte Matrix, die für die parallele Verarbeitung unabhängiger Informationsflüsse unter Verwendung der Funktionalität von Faltungsschichten geeignet ist.

Lokale Beobachtungen werden etwas anders gehandhabt. Wie bereits erörtert, wird angenommen, dass es sich bei der Eingabe um eine multimodale Zeitreihe handelt. Jeder Agent darin verarbeitet seine eigene univariate Sequenz. Vor der Verkettung mit Skill-Tensoren muss die Beobachtungsmatrix in ein Format transponiert werden, das für die Verarbeitung univariater Sequenzen geeignet ist.

index++;
if(!cTranspose.Init(0, index, OpenCL, time_step, variables, optimization, iBatch))
   return false;

Anschließend bestimmen wir die Dimensionalität des Eingabevektors eines einzelnen Agenten und initialisieren einen Puffer für den verketteten Tensor.

uint window_size = (time_step + iTaskSkills + iCommonSkills);
index++;
if(!cObservAndSkillsConcat.Init(0, index, OpenCL, window_size * iVariables,
                                optimization, iBatch))
   return false;
cObservAndSkillsConcat.SetActivationFunction(None);

Es ist erwähnenswert, dass die Daten aus drei Quellen stammen. Um die Kompatibilität sicherzustellen, wenden wir Batch-Normalisierung an, um deren Verteilungen zu harmonisieren.

index++;
if(!cNormalizarion.Init(0, index, OpenCL, cObservAndSkillsConcat.Neurons(),
                        iBatch, optimization))
   return false;
cNormalizarion.SetActivationFunction(None);

Nach Abschluss der Datenvorbereitung fahren wir mit der Konstruktion der neuronalen Schichten des Aktionsdecoders fort. Hier erstellen wir eine Schleife, in deren Körper wir die Faltungsschichten des Decoders initialisieren. Die Implementierungsprinzipien wurden während der Initialisierung der Elternklasse beschrieben.

   for(uint i = 0; i < cActionDecoder.Size(); i++)
     {
      index++;
      if(!cActionDecoder[i].Init(0, index, OpenCL, window_size, window_size, window_key,
                                 1, iVariables, optimization, iBatch))
         return false;
      cActionDecoder[i].SetActivationFunction(SoftPlus);
      window_size = window_key;
     }
//---
   return true;
  }

Die Methode schließt mit der Rückgabe des logischen Ergebnisses der Operation an das aufrufende Programm ab.

Als Nächstes wird der Algorithmus für den Vorwärtsdurchlauf in der Methode feedForward konstruiert. Wie bereits erwähnt, arbeiten wir mit zwei Datenquellen. Über den Hauptstrom empfangen wir eine multimodale Zeitreihe, die den Umgebungszustand beschreibt, und über einen Hilfsstrom empfangen wir einen Tensor allgemeiner Skills.

bool CNeuronHiSSDLowLevelControler::feedForward(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput)
  {
   if(!SecondInput)
      return false;

Zuerst überprüfen wir die Gültigkeit des Zeigers auf den Tensor der allgemeinen Skills. Gleichzeitig validieren wir den Haupteingabezeiger nicht explizit. Stattdessen wird er direkt an die entsprechende Methode des aufgabenspezifischen Skill-Encoders übergeben, die ihre eigene Prüfung enthält.

if(!cTaskSpecificSkillsEncoder.FeedForward(NeuronOCL))
   return false;

Nach der Generierung des aufgabenspezifischen Skill-Tensors transponieren wir den Tensor der Umgebungsbeobachtung und verketten ihn zeilenweise mit beiden Skill-Matrizen (allgemeine und aufgabenspezifische).

if(!cTranspose.FeedForward(NeuronOCL))
   return false;
if(!Concat(cTranspose.getOutput(), cTaskSpecificSkillsEncoder.getOutput(), SecondInput,
           cObservAndSkillsConcat.getOutput(), cTranspose.GetCount(),
           iTaskSkills, iCommonSkills, iVariables))
   return false;

Die resultierenden Daten werden normalisiert und durch den dreischichtigen Agenten-Aktionsdecoder geleitet, wodurch ein verketteter Tensor von Aktionen für alle Agenten erzeugt wird.

   if(!cNormalizarion.FeedForward(cObservAndSkillsConcat.AsObject()))
      return false;
   CNeuronBaseOCL *neuron = cNormalizarion.AsObject();
   for(uint i = 0; i < cActionDecoder.Size(); i++)
     {
      if(!cActionDecoder[i].FeedForward(neuron))
         return false;
      neuron = cActionDecoder[i].AsObject();
     }
//---
   return CNeuronConvOCL::feedforward(neuron);
  }

Die Methode schließt mit der Rückgabe des logischen Ergebnisses der Operation an das aufrufende Programm ab.

Wir gehen nun zum Backpropagation-Verfahren über. Es wird in zwei Phasen unterteilt:

  • Verteilung der Fehlergradienten auf alle beteiligten Komponenten entsprechend ihrem Beitrag zum endgültigen Modellausgang
  • Optimierung der Modellparameter zur Reduzierung des Fehlers

Die erste Phase ist in der Methode calcInputGradients implementiert. Diese Methode empfängt Zeiger auf die Eingabedatenströme und die entsprechenden Fehlergradienten. Wir validieren diese Zeiger umgehend.

bool CNeuronHiSSDLowLevelControler::calcInputGradients(CNeuronBaseOCL *NeuronOCL,
                                                       CBufferFloat *SecondInput,
                                                       CBufferFloat *SecondGradient,
                                                       ENUM_ACTIVATION SecondActivation = -1)
  {
   if(!NeuronOCL || !SecondGradient)
      return false;

Die Gradientenfortpflanzung folgt derselben Struktur wie der Feedforward-Pass, jedoch in umgekehrter Reihenfolge. Der Feedforward-Pass endet mit dem Aktionsdecoder. Daher beginnt die Backpropagation dort, indem die Faltungsschichten in umgekehrter Reihenfolge durchlaufen werden.

   uint total = cActionDecoder.Size();
   if(total <= 0)
      return false;
   CObject *neuron = cActionDecoder[total - 1].AsObject();
//---
   if(!CNeuronConvOCL::calcInputGradients(neuron))
      return false;
   for(int i = int(total - 2); i >= 0; i--)
     {
      if(!cActionDecoder[i].calcHiddenGradients(neuron))
         return false;
      neuron = cActionDecoder[i].AsObject();
     }

Die resultierenden Gradienten werden durch eine Normalisierungsschicht bis auf die Ebene des verketteten Drei-Quellen-Tensors geleitet.

if(!cNormalizarion.calcHiddenGradients(neuron))
   return false;
if(!cObservAndSkillsConcat.calcHiddenGradients(cNormalizarion.AsObject()))
   return false;

Wir verteilen dann die Fehlergradienten durch Auftrennen der Verkettung der Daten zurück auf die drei ursprünglichen Ströme.

if(!DeConcat(cTranspose.getGradient(), cTaskSpecificSkillsEncoder.getGradient(), SecondGradient,
             cObservAndSkillsConcat.getGradient(), cTranspose.GetCount(),
             iTaskSkills, iCommonSkills, iVariables))
   return false;

Es ist wichtig zu beachten, dass jeder Datenstrom seine eigene Aktivierungsfunktion haben kann. Daher prüfen wir alle Informationsströme auf Aktivierungsschichten und passen die Gradienten bei Bedarf mithilfe der entsprechenden Ableitungen an.

if(SecondActivation != None)
  {
   if(!DeActivation(SecondInput, SecondGradient, SecondGradient, SecondActivation))
      return false;
  }
if(NeuronOCL.Activation() != None)
  {
   if(!DeActivation(cTranspose.getOutput(), cTranspose.getGradient(),
                    cTranspose.getGradient(), NeuronOCL.Activation()))
      return false;
  }
if(cTaskSpecificSkillsEncoder.Activation() != None)
  {
   if(!DeActivation(cTaskSpecificSkillsEncoder.getOutput(), cTaskSpecificSkillsEncoder.getGradient(),
                    cTaskSpecificSkillsEncoder.getGradient(), cTaskSpecificSkillsEncoder.Activation()))
      return false;
  }

In diesem Stadium wurde der Gradient vollständig in den Hilfsdatenstrom zurückpropagiert, der nun als vollständig betrachtet werden kann. Die verbleibende Aufgabe besteht darin, die Gradienten, die den Haupteingabestrom beeinflussen, sowohl aus den Hilfs- als auch aus den Primärpfaden zu aggregieren. Wir leiten die Daten zuerst durch den aufgabenspezifischen Skills-Encoder.

if(!NeuronOCL.calcHiddenGradients(cTaskSpecificSkillsEncoder.AsObject()))
   return false;

Anschließend ersetzen wir den Gradienten-Pufferzeiger des Haupteingabestroms und propagieren die Gradienten durch den zweiten Strom, der vom Transpositionsmodul stammt.

   CBufferFloat *temp = NeuronOCL.getGradient();
   if(!NeuronOCL.SetGradient(cTranspose.getPrevOutput(), false) ||
      !NeuronOCL.calcHiddenGradients(cTranspose.AsObject()) ||
      !SumAndNormilize(temp, NeuronOCL.getGradient(), temp, iVariables, false, 0, 0, 0, 1) ||
      !NeuronOCL.SetGradient(temp, false))
      return false;
//---
   return true;
  }

Schließlich werden die Gradienten beider Ströme summiert und alle Pufferzeiger in ihren ursprünglichen Zustand zurückversetzt.

Die Methode schließt mit der Rückgabe des Ausführungsergebnisses an den Aufrufer ab.

Dies vervollständigt die Beschreibung der Controller-Implementierung in unserer Interpretation des HiSSD-Frameworks. Der vollständige Quellcode des neuen Objekts und seiner Methoden steht im Anhang zur Verfügung.



Modellarchitektur

Nach Abschluss der Konstruktion der einzelnen Komponenten des HiSSD-Frameworks beschreiben wir nun die Architektur der trainierbaren Modelle. Es ist wichtig anzumerken, dass wir insgesamt vier Modelle trainieren wollen.

Das erste Modell ist der Environmental State Encoder, der in diesem Kontext die Rolle des Planner im HiSSD-Framework übernimmt. Wir planen, ihn mittels überwachtem Lernen zu trainieren. Aus dem beobachteten Zustand der Umgebung generiert er die gemeinsamen Skills der Agenten. Basierend auf diesen Skills wird es dann verwendet, um zukünftige Umgebungszustände über einen festgelegten Planungshorizont zu beschreiben.

Hier kann man eine Abweichung von der ursprünglichen Formulierung des HiSSD beobachten, die nur eine einstufige Vorhersage des nächsten Zustands berücksichtigt. Unser Ziel ist es jedoch, eine Strategie zu entwickeln, die in der Lage ist, Positionen zu eröffnen und über die Zeit zu halten, was eine tiefere Analyse und eine Planung mit längerem Horizont erfordert.

Das zweite Modell ist der Controller, der den aktuellen Zustand der Umgebung analysiert und einen Tensor von Aktionen für mehrere Agenten erzeugt.

Das dritte Modell ist der Manager (Actor). In unserer Implementierung analysiert dieses Modell den Kontozustand, bewertet die von den Controller-Agenten vorgeschlagenen Handelsaktionen und entscheidet letztendlich, ob ein Handel ausgeführt werden soll.

Das vierte Modell ist ein prädiktives Netzwerk, das die Wahrscheinlichkeit der Richtung der bevorstehenden Preisbewegung schätzt.

Die Architektur aller Modelle wird in der Methode CreateDescriptions definiert.

bool CreateDescriptions(CArrayObj *&encoder, 
                        CArrayObj *&task, 
                        CArrayObj *&actor, 
                        CArrayObj *&probability)
  {
//---
   CLayerDescription *descr;
//---
   if(!encoder)
     {
      encoder = new CArrayObj();
      if(!encoder)
         return false;
     }
   if(!task)
     {
      task = new CArrayObj();
      if(!task)
         return false;
     }
   if(!actor)
     {
      actor = new CArrayObj();
      if(!actor)
         return false;
     }
   if(!probability)
     {
      probability = new CArrayObj();
      if(!probability)
         return false;
     }

Die Methode empfängt Zeiger auf vier dynamische Arrays, die zum Speichern der architektonischen Beschreibungen der jeweiligen Modelle verwendet werden. Alle Zeiger werden validiert und bei Bedarf werden neue Instanzen der entsprechenden Objekte erstellt.

Wir beginnen mit der Beschreibung der Architektur des Environmental State Encoders. Wie üblich beginnt das Modell mit einer vollständig verbundenen Schicht ausreichender Größe, um die Rohdaten einzubetten.

//--- Encoder
   encoder.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   int prev_count = descr.count = (HistoryBars * BarDescr);
   descr.activation = None;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

Das Modell empfängt unverarbeitete Umgebungsbeobachtungen, die zunächst mithilfe einer Batch-Normalisierungsschicht standardisiert werden.

//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBatchNormOCL;
   descr.count = prev_count;
   descr.batch = 1e4;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

Darauf folgt ein universeller Skill-Encoder, der in dieser Konfiguration für die Generierung des Tensors der gemeinsamen Skills verantwortlich ist.

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronSkillsEncoder;
   descr.count = HistoryBars;
     {
      int temp[] = {BarDescr, NSkills, 4};   // Variables, Common Skills, Heads
      if(ArrayCopy(descr.windows, temp) < (int)temp.Size())
         return false;
     }
   descr.window = 8;
   descr.step = 1;
   descr.window_out = 32;
   prev_count = descr.windows[0];
   int prev_out = descr.windows[1];
   descr.batch = 1e4;
   descr.optimization = ADAM;
   descr.activation = None;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

Anschließend werden zwei Faltungsschichten angewendet, um die zukünftige Entwicklung von Sequenzen einzelner Einheiten innerhalb der multimodalen Zeitreihe vorherzusagen, konditioniert auf den Tensor der gemeinsamen Skills.

//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   descr.count = 1;
   descr.window = prev_out;
   descr.step = prev_out;
   prev_out=descr.window_out = 4*NForecast;
   descr.layers = prev_count;
   descr.activation = SoftPlus;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   descr.count = 1;
   descr.window = prev_out;
   descr.step = prev_out;
   prev_out=descr.window_out = NForecast;
   descr.layers = prev_count;
   descr.activation = TANH;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

Es ist wichtig zu betonen, dass die Vorhersage für jede einzelne Einheitensequenz aus dem gemeinsamen Skill-Vektor eines einzelnen Agenten über einen definierten Planungshorizont konstruiert wird. Infolgedessen unterscheidet sich die Ausgabe dieses Planungsblocks von einer herkömmlichen multimodalen Zeitreihendarstellung. Um ein konsistentes Format wiederherzustellen, müssen die Daten transponiert werden.

//--- layer 5
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronTransposeOCL;
   descr.count = prev_count;
   descr.window = prev_out;
   descr.activation = None;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

Anschließend wird die transformierte Darstellung durch inverse Normalisierung in den ursprünglichen Verteilungsraum zurückgeführt.

//--- layer 6
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronRevInDenormOCL;
   descr.count = prev_count*prev_out;
   descr.layers = 1;
   descr.activation = None;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

Damit ist die Architektur des Environmental State Encoders abgeschlossen. Bevor wir fortfahren, speichern wir das latente Repräsentationsobjekt, das den gemeinsamen Skill-Tensor der Ausgabe enthält.

//--- Latent
   CLayerDescription *latent = encoder.At(LatentLayer);
   if(!latent)
      return false;

Das zweite Modell ist, wie oben erwähnt, der Controller. Es generiert aufgabenspezifische Skills der Agenten basierend auf derselben Repräsentation des Umgebungszustands. Daher verwenden wir die ersten beiden Schichten des vorherigen Modells wieder.

//--- Task
   task.Clear();
//--- Input layer
   if(!task.Add(encoder.At(0)))
     {
      return false;
     }
//--- layer 1
   if(!task.Add(encoder.At(1)))
     {
      return false;
     }

Das Modell wird dann durch das zuvor konstruierte Controller-Modul vervollständigt.

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronHiSSDLowLevelControler;
   descr.count = HistoryBars;
     {
      int temp[] = {latent.windows[0], // Variables
                    NSkills,           // Task Skills
                    latent.windows[1], // Common Skills
                    NActions,          // Action Space
                    4};                // Heads
      if(ArrayCopy(descr.windows, temp) < (int)temp.Size())
         return false;
     }
   descr.window = 8;
   descr.step = 1;
   descr.window_out = 32;
   prev_count = descr.windows[0];
   prev_out = descr.windows[3];
   descr.batch = 1e4;
   descr.optimization = ADAM;
   descr.activation = SIGMOID;
   if(!task.Add(descr))
     {
      delete descr;
      return false;
     }

Das dritte Modell, der übergeordnete Manager, empfängt Kontozustandsvektoren als Eingabe. Eine vollständig verbundene Schicht wird verwendet, um diese Informationen einzubetten.

//--- Actor
   actor.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = AccountDescr;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

Die resultierende Repräsentation wird dann normalisiert.

//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBatchNormOCL;
   descr.count = AccountDescr;
   descr.batch = 1e4;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

Als Nächstes wird ein Cross-Attention-Mechanismus angewendet, um den aktuellen Kontozustand mit den vorgeschlagenen Handelsaktionen abzugleichen.

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronCrossDMHAttention;
     {
      int temp[] = {AccountDescr,    // Inputs window
                    prev_out         // Cross window
                   };
      if(ArrayCopy(descr.windows, temp) < (int)temp.Size())
         return false;
     }
     {
      int temp[] = {1,              // Inputs units
                    prev_count      // Cross units
                   };
      if(ArrayCopy(descr.units, temp) < (int)temp.Size())
         return false;
     }
   descr.step = 4;                  // Heads
   descr.window_out = 32;
   descr.batch = 1e4;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

Darauf folgen drei vollständig verbundene Schichten, die einen Entscheidungskopf bilden. Diese Struktur transformiert extrahierte Merkmale in den finalen Aktionsvektor des Actors.

//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = LatentCount;
   descr.batch = 1e4;
   descr.activation = TANH;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = LatentCount;
   descr.activation = SoftPlus;
   descr.batch = 1e4;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 5
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = NActions;
   descr.activation = SIGMOID;
   descr.batch = 1e4;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

Das Modell, das für die Vorhersage der Richtung zukünftiger Preisbewegungen verantwortlich ist, arbeitet mit den gemeinsamen Skills, die aus der latenten Repräsentation des Planner extrahiert wurden. Daher wird der entsprechende latente Tensor vom Environmental State Encoder als Eingabe verwendet.

//--- Probability
   probability.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = latent.windows[0] * latent.windows[1];
   descr.activation = latent.activation;
   descr.optimization = ADAM;
   if(!probability.Add(descr))
     {
      delete descr;
      return false;
     }

Das Netzwerk selbst ist als mehrschichtiges Perzeptron mit zwei verborgenen, vollständig verbundenen Schichten implementiert. Zwischen den Schichten werden nichtlineare Aktivierungsfunktionen angewendet, um Repräsentationskapazität einzuführen. Die letzte Schicht verwendet eine Sigmoid-Aktivierung, die Wahrscheinlichkeiten für die Richtung der Preisbewegung erzeugt.

//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 2 * LatentCount;
   descr.activation = SoftPlus;
   descr.batch = 1e4;
   descr.optimization = ADAM;
   if(!probability.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = LatentCount;
   descr.activation = TANH;
   descr.batch = 1e4;
   descr.optimization = ADAM;
   if(!probability.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = NActions / 3;
   descr.activation = SIGMOID;
   descr.batch = 1e4;
   descr.optimization = ADAM;
   if(!probability.Add(descr))
     {
      delete descr;
      return false;
     }
//---
   return true;
  }

Nach Abschluss der Architekturdefinition aller trainierbaren Modelle gibt die Methode einen booleschen Status zurück, der eine erfolgreiche Ausführung anzeigt.



Modelltraining

In diesem Stadium haben wir einen wesentlichen Teil der Implementierung unseres auf HiSSD basierenden Systems abgeschlossen und fahren mit dem Training von vier Modellen fort. Wie von den Autoren des Frameworks vorgeschlagen, werden alle Modelle gleichzeitig in einem Offline-Modus trainiert. Hier verwenden wir die Trainingsstichprobe, die in früheren Arbeiten gesammelt wurde.

Erinnern Sie sich daran, dass der Datensatz unter Verwendung realer historischer EURUSD M1-Daten für das gesamte Jahr 2024 erstellt wurde. Die Indikatoren wurden mit Standardparametern verwendet.

Wir werden jedoch später auf die Datensatzkonstruktion zurückkommen. Vorerst konzentrieren wir uns auf das Trainingsverfahren selbst. Das gleichzeitige Training von vier interagierenden Modellen erforderte eine grundlegende Neugestaltung des Expert Advisors. In diesem Artikel werden wir nicht den vollständigen Programmcode untersuchen, sondern uns nur auf die Methode Train konzentrieren.

void Train(void)
  {
//---
   vector<float> probability = vector<float>::Full(Buffer.Size(), 1.0f / Buffer.Size());
//---
   vector<float> result, target, state;
   matrix<float> fstate = matrix<float>::Zeros(1, NForecast * BarDescr);
   bool Stop = false;
//---
   uint ticks = GetTickCount();

Zuerst wird ein vorbereitender Schritt durchgeführt, bei dem ein Wahrscheinlichkeitsvektor über die Experience-Replay-Trajektorien konstruiert wird. Anfangs wird eine Gleichverteilung angenommen, was bedeutet, dass jede Trajektorie die gleiche Chance hat, ausgewählt zu werden.

Nach jedem Trainingsbatch wird die Verteilung jedoch angepasst: Die Wahrscheinlichkeit, die zuletzt verwendete Trajektorie auszuwählen, wird verringert, wodurch die Wahrscheinlichkeit erhöht wird, zuvor ungenutzte Trajektorien häufiger auszuwählen. Dies fördert eine gleichmäßigere Abdeckung des Datensatzes und verbessert die Generalisierung.

Mehrere lokale Variablen werden ebenfalls zur temporären Datenspeicherung deklariert.

Als Nächstes konstruieren wir den Trainingsprozess. Zu diesem Zweck erstellen wir ein System von verschachtelten Schleifen.

for(int iter = 0; (iter < Iterations && !IsStopped() && !Stop); iter += Batch)
  {
   int tr = SampleTrajectory(probability);
   int start = (int)((MathRand() * MathRand() / MathPow(32767, 2)) * (Buffer[tr].Total - 2 - NForecast - Batch));
   if(start <= 0)
     {
      iter -= Batch;
      continue;
     }
   if(
      !Encoder.Clear()
      || !Task.Clear()
      || !Actor.Clear()
   )
     {
      PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
      Stop = true;
      break;
     }
   result = vector<float>::Zeros(NActions);

Die äußere Schleife iteriert über Trainings-Batches. Für jeden Batch wird eine Trajektorie aus dem Replay-Buffer ausgewählt und ein zufälliger Startpunkt innerhalb dieser Trajektorie ausgewählt. Die innere Schleife iteriert dann sequenziell über die Umgebungszustände innerhalb des ausgewählten Segments.

for(int i = start; i < MathMin(Buffer[tr].Total, start + Batch); i++)
  {
   if(!state.Assign(Buffer[tr].States[i].state) ||
      MathAbs(state).Sum() == 0 ||
      !bState.AssignArray(state))
     {
      iter -= Batch + start - i;
      break;
     }

Innerhalb dieser Schleife wird das eigentliche Modelltraining durchgeführt. Zuerst wird der ausgewählte Zustand aus dem Datensatz in einen Eingabepuffer für die Modellverarbeitung kopiert.

Anschließend wird ein Zeitstempelvektor für den aktuellen Zustand konstruiert.

bTime.Clear();
double time = (double)Buffer[tr].States[i].account[7];
double x = time / (double)(D'2024.01.01' - D'2023.01.01');
bTime.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
x = time / (double)PeriodSeconds(PERIOD_MN1);
bTime.Add((float)MathCos(x != 0 ? 2.0 * M_PI * x : 0));
x = time / (double)PeriodSeconds(PERIOD_W1);
bTime.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
x = time / (double)PeriodSeconds(PERIOD_D1);
bTime.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
if(bTime.GetIndex() >= 0)
   bTime.BufferWrite();

Danach werden die Kontozustands- und Daten zu offenen Positionen vorbereitet.

//--- Account
float PrevBalance = Buffer[tr].States[MathMax(i - 1, 0)].account[0];
float PrevEquity = Buffer[tr].States[MathMax(i - 1, 0)].account[1];
float profit = float(bState[0] / _Point * (result[0] - result[3]));
bAccount.Clear();
bAccount.Add(1);
bAccount.Add((PrevEquity + profit) / PrevEquity);
bAccount.Add(profit / PrevEquity);
bAccount.Add(MathMax(result[0] - result[3], 0));
bAccount.Add(MathMax(result[3] - result[0], 0));
bAccount.Add((bAccount[3] > 0 ? profit / PrevEquity : 0));
bAccount.Add((bAccount[4] > 0 ? profit / PrevEquity : 0));
bAccount.Add(0);
bAccount.AddArray(GetPointer(bTime));
if(bAccount.GetIndex() >= 0)
   bAccount.BufferWrite();

An diesem Punkt ist die Eingabevorbereitung abgeschlossen, und wir fahren mit dem Feedforward-Pass aller Modelle fort. Zuerst rufen wir den Feedforward-Pass des Umgebungszustands-Encoders auf und übergeben ihm den entsprechenden Puffer mit den zuvor vorbereiteten Daten.

//--- Feed Forward
if(!Encoder.feedForward((CBufferFloat*)GetPointer(bState), 1, false, (CBufferFloat*)NULL))
  {
   PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
   Stop = true;
   break;
  }

Als Nächstes folgt der Controller, der zusätzlich zur Beschreibung des Umgebungszustands die gemeinsamen Skills aus dem latenten Raum des Encoders analysiert.

if(!Task.feedForward((CBufferFloat*)GetPointer(bState), 1, false, GetPointer(Encoder), LatentLayer))
  {
   PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
   Stop = true;
   break;
  }

Der Manager empfängt sowohl den Kontostatusvektor als auch die Ausgabe des Controllers, die mehrere potenzielle Handelsaktionen darstellt.

if(!Actor.feedForward((CBufferFloat*)GetPointer(bAccount), 1, false, GetPointer(Task), -1))
  {
   PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
   Stop = true;
   break;
  }

Das Modell zur Vorhersage der Preisrichtung arbeitet ausschließlich mit den gemeinsamen Skills, die aus dem latenten Zustand des Encoders extrahiert wurden.

if(!Probability.feedForward(GetPointer(Encoder), LatentLayer, (CBufferFloat*)NULL))
  {
   PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
   Stop = true;
   break;
  }

In diesem Stadium haben alle Modelle Eingabedaten analysiert und ihre jeweiligen Ausgaben erzeugt. Der nächste Schritt besteht darin, sie mit Zielwerten zu vergleichen – aber woher kommen diese Ziele?

Für den Encoder ist die erwartete Ausgabe eine Vorhersage zukünftiger Umgebungszustände. Daher werden Ziel-Tensoren konstruiert, indem tatsächliche zukünftige Zustände aus dem Datensatz extrahiert und in der entsprechenden Reihenfolge angeordnet werden.

//--- Look for target
target = vector<float>::Zeros(NActions);
bActions.AssignArray(target);
if(!state.Assign(Buffer[tr].States[i + NForecast].state) ||
   !state.Resize(NForecast * BarDescr) ||
   MathAbs(state).Sum() == 0)
  {
   iter -= Batch + start - i;
   break;
  }
if(!fstate.Resize(1, NForecast * BarDescr) ||
   !fstate.Row(state, 0) ||
   !fstate.Reshape(NForecast, BarDescr))
  {
   iter -= Batch + start - i;
   break;
  }
for(int j = 0; j < NForecast / 2; j++)
  {
   if(!fstate.SwapRows(j, NForecast - j - 1))
     {
      PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
      Stop = true;
      break;
     }
  }

Diese Werte können als Ziele an den Encoder für den Umgebungszustand übergeben werden, wonach die Modellparameter angepasst werden können, um den Vorhersagefehler zu minimieren.

//--- State Encoder
Result.AssignArray(fstate);
if(!Encoder.backProp(Result, (CBufferFloat*)NULL, NULL))
  {
   PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
   Stop = true;
   break;
  }

Dieselben zugrunde liegenden Daten zu zukünftigen Zuständen werden für die anderen Modelle wiederverwendet, jedoch auf eine ausgefeiltere Weise. Um eine optimale Handelsaktion zu generieren, berücksichtigen wir nicht nur die zukünftige Preisbewegung (die bereits aus dem Datensatz verfügbar ist), sondern auch den Status offener Positionen. Wenn Positionen existieren, sind wir an Ausstiegssignalen interessiert, die sich für Long- und Short-Positionen unterscheiden.

target = fstate.Col(0).CumSum();
if(result[0] > result[3])
  {
   float tp = 0;
   float sl = 0;
   float cur_sl = float(-(result[2] > 0 ? result[2] : 1) * MaxSL * Point());
   int pos = 0;
   for(int j = 0; j < NForecast; j++)
     {
      tp = MathMax(tp, target[j] + fstate[j, 1] - fstate[j, 0]);
      pos = j;
      if(cur_sl >= target[j] + fstate[j, 2] - fstate[j, 0])
         break;
      sl = MathMin(sl, target[j] + fstate[j, 2] - fstate[j, 0]);
     }
   if(tp > 0)
     {
      sl = (float)MathMax(MathMin(MathAbs(sl) / (MaxSL * Point()), 1), 0.01);
      tp = float(MathMin(tp / (MaxTP * Point()), 1));
      result[0] = MathMax(result[0] - result[3], 0.011f);
      result[5] = result[1] = tp;
      result[4] = result[2] = sl;
      result[3] = 0;
      bActions.AssignArray(result);
     }
  }
else
  {
   if(result[0] < result[3])
     {
      float tp = 0;
      float sl = 0;
      float cur_sl = float((result[5] > 0 ? result[5] : 1) * MaxSL * Point());
      int pos = 0;
      for(int j = 0; j < NForecast; j++)
        {
         tp = MathMin(tp, target[j] + fstate[j, 2] - fstate[j, 0]);
         pos = j;
         if(cur_sl <= target[j] + fstate[j, 1] - fstate[j, 0])
            break;
         sl = MathMax(sl, target[j] + fstate[j, 1] - fstate[j, 0]);
        }
      if(tp < 0)
        {
         sl = (float)MathMax(MathMin(MathAbs(sl) / (MaxSL * Point()), 1), 0.01);
         tp = float(MathMin(-tp / (MaxTP * Point()), 1));
         result[3] = MathMax(result[3] - result[0], 0.011f);
         result[2] = result[4] = tp;
         result[1] = result[5] = sl;
         result[0] = 0;
         bActions.AssignArray(result);
        }
     }

Wenn keine Positionen offen sind, konzentrieren wir uns stattdessen auf Einstiegspunkte. Wir bestimmen zunächst die Richtung und Stärke der erwarteten Preisbewegung.

else
  {
   ulong argmin = target.ArgMin();
   ulong argmax = target.ArgMax();
   float max_sl = float(MaxSL * Point());
   while(argmax > 0 && argmin > 0)
     {
      if(argmax < argmin && target[argmax] / 2 > MathAbs(target[argmin]) &&
                                           MathAbs(target[argmin]) < max_sl)
         break;
      if(argmax > argmin && target[argmax] < MathAbs(target[argmin] / 2) && 
                                                   target[argmax] < max_sl)
         break;
      target.Resize(MathMin(argmax, argmin));
      argmin = target.ArgMin();
      argmax = target.ArgMax();
     }

Danach definieren wir die Handelsparameter.

if(argmin == 0 || (argmax < argmin && argmax > 0))
  {
   float tp = 0;
   float sl = 0;
   float cur_sl = - float(MaxSL * Point());
   ulong pos = 0;
   for(ulong j = 0; j < argmax; j++)
     {
      tp = MathMax(tp, target[j] + fstate[j, 1] - fstate[j, 0]);
      pos = j;
      if(cur_sl >= target[j] + fstate[j, 2] - fstate[j, 0])
         break;
      sl = MathMin(sl, target[j] + fstate[j, 2] - fstate[j, 0]);
     }
   if(tp > 0)
     {
      sl = (float)MathMax(MathMin(MathAbs(sl) / (MaxSL * Point()), 1), 0.01);
      tp = (float)MathMin(tp / (MaxTP * Point()), 1);
      result[0] = float(MathMax(Buffer[tr].States[i].account[0] / 100 * 0.01, 0.011));
      result[5] = result[1] = tp;
      result[4] = result[2] = sl;
      result[3] = 0;
      bActions.AssignArray(result);
     }
  }
    else
      {
       if(argmax == 0 || argmax > argmin)
         {
          float tp = 0;
          float sl = 0;
          float cur_sl = float(MaxSL * Point());
          ulong pos = 0;
          for(ulong j = 0; j < argmin; j++)
            {
             tp = MathMin(tp, target[j] + fstate[j, 2] - fstate[j, 0]);
             pos = j;
             if(cur_sl <= target[j] + fstate[j, 1] - fstate[j, 0])
                break;
             sl = MathMax(sl, target[j] + fstate[j, 1] - fstate[j, 0]);
            }
          if(tp < 0)
            {
             sl = (float)MathMax(MathMin(MathAbs(sl) / (MaxSL * Point()), 1), 0.01);
             tp = (float)MathMin(-tp / (MaxTP * Point()), 1);
             result[3] = float(MathMax(Buffer[tr].States[i].account[0] / 100 * 0.01, 0.011));
             result[2] = result[4] = tp;
             result[1] = result[5] = sl;
             result[0] = 0;
             bActions.AssignArray(result);
            }
         }
      }
   }
}

Die resultierende „optimale Handelsaktion“ wird ausschließlich für das Training des Managers verwendet.

//--- Actor Policy
if(!Actor.backProp(GetPointer(bActions), (CNet*)GetPointer(Task), -1))
  {
   PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
   Stop = true;
   break;
  }

Als Nächstes konstruieren wir den Ziel-Tensor für den Controller. Es wäre natürlich, hier die Parameter derselben optimalen Handelsaktion zu verwenden. Diese Aktion beinhaltet jedoch die Positionsgröße, die nicht allein aus der Analyse des dem Controller zur Verfügung stehenden Umgebungszustands bestimmt werden kann. Wir benötigen auch Informationen zum Kontostatus, die nur dem Manager zur Verfügung stehen. Daher ersetzen wir das absolute Handelsvolumen durch die Gewinnwahrscheinlichkeit. Für einen optimalen Trade wird diese Wahrscheinlichkeit auf 1 gesetzt.

//--- Agents
target=result;
if(target[0] > 0)
   target[0] = 1;
if(target[3] > 0)
   target[3] = 1;

Die angepasste optimale Trade-Aktion wird auf alle Agenten repliziert und als Trainingsziel für den Controller verwendet.

Result.Clear();
for(int i = 0; i < BarDescr; i++)
  {
   if(!Result.AddArray(target))
     {
      PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
      Stop = true;
      break;
     }
  }
if(!Task.backProp(Result, (CNet*)GetPointer(Encoder), LatentLayer)
  )
  {
   PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
   Stop = true;
   break;
  }

Es ist wichtig zu beachten, dass alle Agenten identische Ziele erhalten. Wir erwarten jedoch kein synchronisiertes Verhalten, da jeder Agent nur lokale Beobachtungen aus seiner eigenen univariaten Zeitreihe verarbeitet. Daher wird eine unidirektionale Interpretation des analysierten Umgebungszustands durch mehrere Agenten potenziell dem Manager eine stärkere Entscheidungsgrundlage liefern.

Schließlich definieren wir Ziele für das Modell zur Vorhersage der Preisrichtung. Hier kehren wir wieder zu zukünftigen Umgebungszuständen zurück. Wir berechnen die kumulative Preisbewegung und identifizieren die maximale Bewegung über den Planungshorizont. Die Richtung dieser maximalen Abweichung wird zum Prioritätstrend, der für das Training des Vorhersagemodells verwendet wird.

//--- Probability
target = vector<float>::Zeros(NActions / 3);
vector<float> trend=fstate.Col(0).CumSum();
ulong argmax=MathAbs(trend).ArgMax();
if(trend[argmax] > 0)
   target[0] = 1;
else
   if(trend[argmax] < 0)
      target[1] = 1;
if(!Result.AssignArray(target)
   || !Probability.backProp(Result, (CNet*)GetPointer(Encoder),LatentLayer)
   || !Encoder.backPropGradient((CBufferFloat*)NULL, (CBufferFloat*)NULL, LatentLayer)
  )
  {
   PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
   Stop = true;
   break;
  }

Wichtig ist, dass Gradienten aus diesem Modell auch in die gemeinsame Skill-Repräsentation zurückgeführt und die Encoder-Parameter angepasst werden. Unser Ziel ist es, sicherzustellen, dass gemeinsame Skills Informationen über den Prioritätstrend enthalten.

Jetzt müssen wir den Nutzer nur noch über den Fortschritt des Trainingsprozesses informieren und mit der nächsten Iteration des Schleifensystems fortfahren.

    if(GetTickCount() - ticks > 500)
      {
       double percent = double(iter + i - start) * 100.0 / (Iterations);
       string str = StringFormat("%-12s %6.2f%% -> Error %15.8f\n", "Encoder",
                                     percent, Encoder.getRecentAverageError());
       str += StringFormat("%-14s %6.2f%% -> Error %15.8f\n", "Task", percent,
                                                 Task.getRecentAverageError());
       str += StringFormat("%-14s %6.2f%% -> Error %15.8f\n", "Actor", percent,
                                                Actor.getRecentAverageError());
       str += StringFormat("%-13s %6.2f%% -> Error %15.8f\n", "Probability", 
                                 percent, Probability.getRecentAverageError());
       Comment(str);
       ticks = GetTickCount();
      }
   }
}

Nach Abschluss aller Iterationen gibt das System Trainingsergebnisse für alle Modelle aus und leitet die Abschaltvorgänge für den Expert Advisor ein.

   Comment("");
//---
   PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Encoder", 
                                                 Encoder.getRecentAverageError());
   PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Task", 
                                                    Task.getRecentAverageError());
   PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Actor",
                                                   Actor.getRecentAverageError());
   PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Probability",
                                             Probability.getRecentAverageError());
   ExpertRemove();
//---
  }

Der vollständige Code für dieses Programm befindet sich im Anhang. Der Anhang enthält auch Programme zum Sammeln von Trainingsbeispielen und zum Testen trainierter Modelle. Was spezifische Änderungen in diesen Programmen betrifft, so ermutige ich Sie, diese eigenständig zu erkunden.



Tests

Wir kommen nun zu einer der wichtigsten Phasen: der Bewertung der Wirksamkeit des vorgeschlagenen Ansatzes anhand realer historischer Daten. Wie bereits erwähnt, wurde das Training mit Marktdaten für das gesamte Jahr 2024 durchgeführt.

Um die Qualität der generierten Strategien objektiv zu bewerten, wurden die trainierten Modelle im Strategietester des MetaTrader 5 unter Verwendung von Out-of-Sample-Daten von Januar bis März 2025 getestet. Alle anderen Parameter, einschließlich Marktbedingungen, Zeitrahmen und Simulationseinstellungen, blieben unverändert, um Konsistenz und Vergleichbarkeit zu gewährleisten.

Die Testergebnisse sind unten dargestellt.

Über den dreimonatigen Testzeitraum führte das Modell 860 Trades aus, von denen 340 profitabel waren, was zu 39,53 % profitablen Trades führte. Der durchschnittliche Gewinn pro gewinnbringendem Trade überstieg jedoch den durchschnittlichen Verlust um etwa 70 %, wodurch die Strategie insgesamt profitabel blieb.

Es ist zudem erwähnenswert, dass jeder der drei Monate im Testzeitraum mit einem Gewinn abschloss.



Schlussfolgerung

In dieser Arbeit untersuchten wir das Framework HiSSD, das für algorithmische Handelsaufgaben angepasst wurde. Die zentrale Idee – die Zerlegung von Skills in gemeinsame und aufgabenspezifische Komponenten – erwies sich unter hochdynamischen Marktbedingungen als effektiv. Diese Struktur ermöglichte es den Agenten, sich schnell an veränderte Umgebungen anzupassen, ohne dass eine erneute Schulung erforderlich war.

Die Implementierung berücksichtigte die Merkmale von Finanzdaten: Das Training wurde mit realen historischen EURUSD-Kursen aus dem Jahr 2024 durchgeführt, während die Tests zuvor ungesehenen Daten aus Anfang 2025 durchgeführt wurden. Dies ermöglichte eine realistischere Bewertung der Modellleistung unter marktnahen Bedingungen.

Es sollte jedoch noch einmal betont werden, dass ein solches System vor dem Einsatz im Live-Handel mit einem repräsentativeren Datensatz trainiert und umfassenden Tests unter verschiedenen Marktphasen unterzogen werden muss.


Referenzen


In diesem Artikel verwendete Programme

# Name Typ Beschreibung
1 Research.mq5 Expert Advisor Expert Advisor für die Datenerfassung
2 ResearchRealORL.mq5
Expert Advisor
Expert Advisor für die Datenerfassung mit der Methode Real-ORL
3 Study.mq5 Expert Advisor Expert Advisor für das Modelltraining
4 Test.mq5 Expert Advisor Expert Advisor für die Modelltests
5 Trajectory.mqh Klassenbibliothek Struktur der Beschreibung des Systemzustands und der Modellarchitektur
6 NeuroNet.mqh Klassenbibliothek Eine Bibliothek von Klassen zur Erstellung eines neuronalen Netzes
7 NeuroNet.cl Codebibliothek OpenCL-Programmcode

Übersetzt aus dem Russischen von MetaQuotes Ltd.
Originalartikel: https://www.mql5.com/ru/articles/17739

Beigefügte Dateien |
MQL5.zip (2622.44 KB)
Extremaloptimierung (EO) Extremaloptimierung (EO)
Der Artikel diskutiert den Algorithmus der Extremaloptimierung (EO), eine Optimierungsmethode, die vom Bak-Sneppen-Modell der selbstorganisierten Kritikalität inspiriert ist, bei dem Evolution durch die Eliminierung der schlechtesten Komponenten des Systems erfolgt. Die modifizierte Populationsversion des Algorithmus zeigt eine Abkehr von theoretischen Prinzipien zugunsten praktischer Effizienz, was zur Schaffung leistungsfähiger Optimierungswerkzeuge führt.
Benutzerdefinierte Symbole in MQL5: Erstellung eines 3D-Bar-Symbols Benutzerdefinierte Symbole in MQL5: Erstellung eines 3D-Bar-Symbols
Der Artikel bietet eine detaillierte Anleitung zur Erstellung des innovativen Indikators 3DBarCustomSymbol.mq5, der benutzerdefinierte Symbole in MetaTrader 5 generiert, die Kurs, Zeit, Volumen und Volatilität in einer einzigen dreidimensionalen Darstellung kombinieren. Die mathematischen Grundlagen, die Systemarchitektur, praktische Aspekte der Implementierung und die Anwendung in Handelsstrategien werden behandelt.
Eine alternative Log-datei mit der Verwendung der HTML und CSS Eine alternative Log-datei mit der Verwendung der HTML und CSS
In diesem Artikel werden wir eine sehr einfache, aber leistungsfähige Bibliothek zur Erstellung der HTML-Dateien schreiben, dabei lernen wir auch, wie man eine ihre Darstellung einstellen kann (nach seinem Geschmack) und sehen wir, wie man es leicht in seinem Expert Advisor oder Skript hinzufügen oder verwenden kann.
Exchange-Market-Algorithmus (EMA) Exchange-Market-Algorithmus (EMA)
Der Artikel präsentiert eine detaillierte Analyse des Exchange Market Algorithm (EMA), der vom Verhalten von Börsenhändlern inspiriert ist. Der Algorithmus simuliert den Aktienhandel, bei dem Marktteilnehmer mit unterschiedlichem Erfolg verschiedene Strategien anwenden, um ihre Gewinne zu maximieren.