Optimierungsmanagement (Teil II): Erstellen der Schlüsselobjekte und der Add-on-Logik

4 Oktober 2019, 11:18
Andrey Azatskiy
0
198

Inhaltsverzeichnis

Einführung

Dieser Artikel enthält eine weitere Beschreibung eines komfortablen GUI-Erstellungsprozesses, der für die Verwaltung von Optimierungen in mehreren Terminals gleichzeitig vorgesehen ist. Der vorherige Artikel betrachtete eine Methode zum Starten eines Terminals von der Konsole aus und enthielt eine Beschreibung der Konfigurationsdatei. In diesem Zusammenhang gehen wir zur Erstellung eines C#-Wrappers für das Terminal über, der ein Optimierungsmanagement als Drittprozess ermöglicht. Das früher betrachtete GUI hatte keine Logik und konnte keine Aktionen ausführen. Es konnte nur auf Tastenanschläge reagieren, die den Text der gedrückten Taste an die Konsole ausgeben (von der aus sie gestartet wird). In diesem Teil wird eine Logik hinzugefügt, die GUI-Ereignisse verarbeitet und die eingebettete Logik implementiert. Es werden eine Reihe von Objekten erstellt, die mit Dateien arbeiten, was die Implementierung des logischen Teils der Programmoperation durch dieses Objekt ermöglicht, anstatt mit Dateien zu arbeiten - dies vereinfacht die Bedienung und macht den Code informativer. Innerhalb dieses Artikels wird die Anwendung schließlich die im Video gezeigte Form annehmen.



Externer Terminalmanager (ITerminalManager und Config)

Zuvor haben wir die Erstellung einer grafischen Ebene für unser Add-on untersucht. Die Methode zur Erstellung logischer Komponenten wird in diesem Teil berücksichtigt. Unter Nutzung der Vorteile von OOP wurde der logische Teil in eine Reihe von Klassen aufgeteilt, die jeweils für ihren spezifischen Bereich verantwortlich sind. Beginnen wir mit Klassen, die bestimmte Aktionen im Zusammenhang mit Dateien und dem Terminal ausführen. Danach fahren wir mit der resultierenden Klasse ExtentionGUI_M fort, in der die endgültige Logik beschrieben wird. Beginnen wir mit den Klassen, die in der Logikimplementierung verwendet werden. 

In diesem Kapitel wird der Betrieb mit Terminals beschrieben. Die folgenden Komponenten werden berücksichtigt:

  1. Arbeiten mit Konfigurationsdateien
  2. Arbeiten Sie mit dem Terminal wie mit dem Drittanbieterprozess.

Beginnen wir mit den Konfigurationsdateien. Die Details sind in der Referenz des Terminals angegeben. Zuerst müssen wir alle erforderlichen Variablen erstellen, die wir in der Konfigurationsdatei anwenden werden: Zahlenwerte dieser Variablen können im Terminal eingesehen werden, während ihre Implementierung in der Datei Config.cs verfügbar ist. Lassen Sie uns einen Blick auf die Implementierung einer geeigneten Methode zur Übergabe der Serveradresse werfen. Beachten Sie, dass es in einem bestimmten Format mit einer zusätzlich angegebenen Portnummer übergeben werden sollte. Dieses Problem wird durch die Erstellung einer Klasse gelöst, die die über einen Konstruktor empfangene Serveradresse speichert und vor der Installation auf ihre Richtigkeit überprüft.

/// <summary>
/// IPv4 server address and port
/// </summary>
class ServerAddressKeeper
{
    public ServerAddressKeeper(IPv4Adress ip, uint port)
    {
        IP = ip;
        Port = port;
    }
    public ServerAddressKeeper(string adress)
    {
        if (string.IsNullOrEmpty(adress) || string.IsNullOrWhiteSpace(adress))
            throw new ArgumentException("adress is incorrect");

        string[] data = adress.Split(':');

        if (data.Length != 2)
            throw new ArgumentException("adress is incorrect");

        IP = new IPv4Adress(data[0]);
        Port = Convert.ToUInt32(data[1]);
    }

    public IPv4Adress IP { get; }
    public uint Port { get; }

    public string Address => $"{IP.ToString()}:{Port}";
}

/// <summary>
/// IPv4 server address
/// </summary>
struct IPv4Adress
{
    public IPv4Adress(string adress)
    {
        string[] ip = adress.Split('.');
        if (ip.Length != 4)
            throw new ArgumentException("ip is incorrect");

        part_1 = (char)Convert.ToInt32(ip[0]);
        part_2 = (char)Convert.ToInt32(ip[1]);
        part_3 = (char)Convert.ToInt32(ip[2]);
        part_4 = (char)Convert.ToInt32(ip[3]);
    }

    public char part_1;
    public char part_2;
    public char part_3;
    public char part_4;

    public new string ToString()
    {
        return $"{(int)part_1}.{(int)part_2}.{(int)part_3}.{(int)part_4}";
    }
}

Die Klasse enthält die Struktur IPv4Adress, die die IP-Adresse des Servers speichert. Bei der Vorbereitung der Daten für diesen Artikel habe ich keine einzige Serveradresse getroffen, die sich vom IPv4-Format unterscheidet, daher ist genau dieses Format implementiert. In ihrem Konstruktor akzeptiert die Struktur eine Zeichenkette mit der Adresse, analysiert sie dann und speichert sie in den entsprechenden Feldern. Wenn die Anzahl der Ziffern in der Adresse kleiner als 4 ist, wird ein Fehler zurückgegeben. Die Hauptklasse hat einen Konstruktor mit zwei Überladungen, von denen einer eine Zeichenkette mit der Serveradresse akzeptiert, der andere eine gebildete IP-Adresse und Portnummer. Zusätzlich hat die Struktur IPv4Adress die überladene Methode ToString, die von der Basis-Objektklasse abgeleitet wurde, von der implizit alle C#-Objekte vererbt werden. Die Klasse ServerAddressKeeper hat die Eigenschaft Address, die die gleichen Objekte implementiert. Als Ergebnis haben wir die Wrapperklasse, die die Serveradresse in einer komfortablen Form speichert und zu einer für Konfigurationsdateien benötigten Form zusammenstellen kann.  

Nun müssen wir Methoden für die Arbeit mit Konfigurationsdateien im *.ini-Format in Betracht ziehen. Wie bereits erwähnt, gilt dieses Dateiformat als veraltet und wird heute nur noch selten verwendet. C# hat keine integrierten Schnittstellen für die Arbeit mit diesen Dateien, ähnlich denen für die Arbeit mit dem XML-Markup, das wir im vorherigen Artikel berücksichtigt haben. WinApi unterstützt jedoch weiterhin die Funktionen WritePrivateProfileString und GetPrivateProfileString für die Arbeit mit diesem Dateiformat. Hier ist ein Hinweis von Microsoft:

Diese Funktion ist nur aus Gründen der Kompatibilität mit 16-Bit-Windows-basierten Anwendungen verfügbar. Anwendungen sollten Initialisierungsinformationen in der Registry speichern.

Wir können es nutzen und vermeiden, dass wir eine eigene Lösung entwickeln müssen. Dazu müssen wir die Daten von C-Funktionen in unseren C#-Code importieren. In C# kann dies ähnlich wie bei MQL5 erfolgen:

[DllImport("kernel32.dll", SetLastError = true)]
private extern static int GetPrivateProfileString(string AppName, string KeyName, string Default, StringBuilder ReturnedString, int Size, string FileName);

[DllImport("kernel32.dll", SetLastError = true)]
private extern static int WritePrivateProfileString(string AppName, string KeyName, string Str, string FileName);

Im Gegensatz zu #import müssen wir hier das Attribut DLLImport angeben und ihm den Namen der dll-Datei übergeben, aus der die Funktion importiert werden soll, sowie weitere optionale Parameter. Insbesondere habe ich beim Import den Parameter SetLastErro = true angegeben, der die Möglichkeit bietet, Fehler aus C++-Code mit GetLastError() in unserem C#-Code zu empfangen und damit die korrekte Ausführung dieser Methoden zu kontrollieren. Da C# und C unterschiedliche Methoden für Operationen mit Zeichenketten aufweisen, verwenden wir die Wrapper-Methoden, die ein komfortables Arbeiten mit exportierten Funktionen ermöglichen und mögliche Fehler verarbeiten können. Ich habe sie wie folgt implementiert:   

/// <summary>
/// Convenient wrapper for WinAPI function GetPrivateProfileString
/// </summary>
/// <param name="section">section name</param>
/// <param name="key">key</param>
/// <returns>the requested parameter or null if the key was not found</returns>
protected virtual string GetParam(string section, string key)
{
    //To get the value
    StringBuilder buffer = new StringBuilder(SIZE);
 
   //Get value to buffer
    if (GetPrivateProfileString(section, key, null, buffer, SIZE, Path) == 0)
        ThrowCErrorMeneger("GetPrivateProfileStrin", Marshal.GetLastWin32Error());

    //Return the received value
    return buffer.Length == 0 ? null : buffer.ToString();
}

/// <summary>
/// Convenient wrapper for WinAPI WritePrivateProfileString
/// </summary>
/// <param name="section">Section</param>
/// <param name="key">Key</param>
/// <param name="value">Value</param>
protected virtual void WriteParam(string section, string key, string value)
{
    //Write value to the INI-file
    if (WritePrivateProfileString(section, key, value, Path) == 0)
        ThrowCErrorMeneger("WritePrivateProfileString", Marshal.GetLastWin32Error());
}

/// <summary>
/// Return error
/// </summary>
/// <param name="methodName">Method name</param>
/// <param name="er">Error code</param>
private void ThrowCErrorMeneger(string methodName, int er)
{
    if (er > 0)
    {
        if (er == 2)
        {
            if (!File.Exists(Path))
                throw new Exception($"{Path} - File doesn1t exist");
        }
        else
        {
            throw new Exception($"{methodName} error {er} " +
                $"See System Error Codes (https://docs.microsoft.com/ru-ru/windows/desktop/Debug/system-error-codes) for detales");
        }
    }
}

Bei der Arbeit mit diesen Methoden stieß ich auf eine interessante Funktion. Nach der Suche nach zusätzlichen Informationen habe ich sichergestellt, dass dies nicht nur mein Problem ist. Das Merkmal ist, dass die Methode GetPrivateProfileString ERROR_FILE_NOT_FOUND (Fehlercode = 2) zurückgibt, nicht nur wenn die Datei nicht gefunden wird, sondern auch in den folgenden Fällen:

  1. Der Abschnitt ist in der gelesenen Datei nicht vorhanden.
  2. Der angeforderte Schlüssel ist nicht vorhanden.

Aufgrund dieser Besonderheit führen wir im Fehlerfall eine Überprüfung in der Methode ThrowCErrorMeneger durch, ob die Datei existiert. Um den letzten Fehler (Methode GetLastError) zu erhalten, hat C# eine statische Methode der Marshal-Klasse ( Marshal.GetLastWin32Error()), die wir verwenden werden, um den Fehler nach jedem Aufruf einer Datei zu erhalten. Aus Gründen der Übersichtlichkeit wurden Methoden zum Lesen und Schreiben nur von Zeichenketten importiert, da jeder Datentyp in eine Zeichenkette umgewandelt werden kann. 

Ein weiterer interessanter Aspekt der Funktionsweise ist das Löschen von Daten aus der Datei. Um beispielsweise den gesamten Abschnitt zu löschen, ist es notwendig, an die Methode WriteParam den Schlüsselnamen gleich null zu übergeben. Mit dieser Möglichkeit habe ich eine geeignete Methode erstellt. Alle Abschnittsnamen wurden zuvor zu ENUM_SectionType hinzugefügt:

/// <summary>
/// Section deletion
/// </summary>
/// <param name="section">section selected for deletion</param>
public void DeleteSection(ENUM_SectionType section)
{
    WriteParam(section.ToString(), null, null);
}

Es gibt auch eine Möglichkeit, einen bestimmten Schlüssel zu löschen, bei dem der Schlüsselname angegeben werden muss, der Schlüsselwert jedoch Null sein muss. In dieser Methodenimplementierung wird der Name des übergebenen Schlüssels als Zeichenkettenfeld belassen, da jeder Abschnitt meist eindeutige Schlüssel hat.

/// <summary>
/// Key deletion
/// </summary>
/// <param name="section">section from which key should be deleted</param>
/// <param name="key">Key to delete</param>
public void DeleteKey(ENUM_SectionType section, string key)
{
    if (string.IsNullOrEmpty(key) || string.IsNullOrWhiteSpace(key))
        throw new ArgumentException("Key is not vailed");

    WriteParam(section.ToString(), key, null);
}

Für einen bequemen Zugriff auf den Abschnitt habe ich mich entschieden, sie über Eigenschaften zu implementieren, so dass die Instanz der Config-Klasse den Zugriff auf jeden Abschnitt über den Operator Punkt (.) und dann auf jeden Schlüssel dieser Abschnitte zulässt, wie in der folgenden Abbildung dargestellt:

Config myConfig = new Config("Path");

myConfig.Tester.Expert = MyExpert;
string MyExpert = myConfig.Tester.Expert; 

Um diese Idee umzusetzen, müssen wir natürlich für jeden der Abschnitte eine Klasse erstellen. In der Klasse jeden Abschnitts müssen wir die Eigenschaften angeben, die diese spezifische Zeichenkette in der Datei schreiben und lesen. Da Abschnitte eigentlich die Komponenten dieser spezifischen Initialisierungsdatei sind, während die Config eine objektorientierte Darstellung dieser Datei ist, ist es eine vernünftige Lösung, die Klassen zu erstellen, die diese Abschnitte als verschachtelte Klassen der Klasse Config beschreiben, und dann schreibgeschützte Eigenschaften in der Klasse Config zu setzen, die von diesen spezifischen Klassen typisiert werden sollten. Im folgenden Beispiel werden unnötige, gemischte Teile weggelassen, so dass es sich nur um die obige Beschreibung handelt:   

class Config
{
    public Config(string path)
    {
        Path = path;
        CreateFileIfNotExists();

        Common = new CommonSection(this);
        Charts = new ChartsSection(this);
        Experts = new ExpertsSection(this);
        Objects = new ObjectsSection(this);
        Email = new EmailSection(this);
        StartUp = new StartUpSection(this);
        Tester = new TesterSection(this);
    }

    protected virtual void CreateFileIfNotExists()
    {
        if (!File.Exists(Path))
        {
            File.Create(Path).Close();
        }
    }

    public readonly string Path; // path to file

    public virtual Config DublicateFile(string path)
    {
        File.Copy(Path, path, true);
        return new Config(path);
    }

    #region Section managers
    internal class CommonSection
    {
    }
    internal class ChartsSection
    {
    }
    internal class ExpertsSection
    {
    }
    internal class ObjectsSection
    {
    }
    internal class EmailSection
    {
    }
    internal class StartUpSection
    {
    }
    internal class TesterSection
    {
    }
    #endregion

    public CommonSection Common { get; }
    public ChartsSection Charts { get; }
    public ExpertsSection Experts { get; }
    public ObjectsSection Objects { get; }
    public EmailSection Email { get; }
    public StartUpSection StartUp { get; }
    public TesterSection Tester { get; }
}

Die Implementierung jeder der verschachtelten Klassen, die einen bestimmten Abschnitt beschreiben, ist ähnlich; betrachten Sie es am Beispiel der Klasse Config.ChartsSection.

internal class ChartsSection
{
    private readonly Converter converter;
    public ChartsSection(Config parent)
    {
        converter = new Converter(parent, "Charts");
    }

    public string ProfileLast
    {
        get => converter.String("ProfileLast");
        set => converter.String("ProfileLast", value);
    }
    public int? MaxBars
    {
        get => converter.Int("MaxBars");
        set => converter.Int("MaxBars", value);
    }
    public bool? PrintColor
    {
         get => converter.Bool("PrintColor");
         set => converter.Bool("PrintColor", value);
    }
    public bool? SaveDeleted
    {
         get => converter.Bool("SaveDeleted");
         set => converter.Bool("SaveDeleted", value);
    }
 }

Die Klasse, die den Abschnitt beschreibt, enthält einen Nullable-Abschnitt, der eine andere Zwischenklasse zum Lesen und Schreiben in eine Datei verwendet. Die Implementierung der Klasse wird später berücksichtigt. Nun möchte ich Ihre Aufmerksamkeit auf die Rückgabedaten lenken: Wenn die Klasse nicht in die Datei geschrieben wird, gibt die Wrapperklasse anstelle des Schlüsselwertes null zurück. Wenn wir null an eine Schlüsseleigenschaft übergeben, wird dieser Wert einfach ignoriert. Um ein Feld zu löschen, verwenden Sie eine spezielle Methode DeleteKey, die oben erwähnt wurde.

Betrachten wir nun die Klasse Converter, die Daten aus der Datei schreibt und liest: Sie ist auch eine verschachtelte Klasse, und so kann sie die Methoden WriteParam und GetParam der Hauptklasse verwenden, obwohl sie mit dem Zugriffsmodifikator 'protected' gekennzeichnet sind. Die Klasse hat Überladungen von Lese- und Schreibmethoden für die folgenden Typen

  • Bool
  • Int
  • Double
  • String
  • DateTime

Alle anderen Typen werden auf einen der am besten geeigneten Typen gegossen. Die Implementierung der Klasse ist unten dargestellt:

private class Converter
{
    private readonly Config parent;
    private readonly string section;
    public Converter(Config parent, string section)
    {
        this.parent = parent;
        this.section = section;
    }

    public bool? Bool(string key)
    {
        string s = parent.GetParam(section, key);
        if (s == null)
            return null;

        int n = Convert.ToInt32(s);
        if (n < 0 || n > 1)
            throw new ArgumentException("string mast be 0 or 1");
        return n == 1;
    }
    public void Bool(string key, bool? val)
    {
        if (val.HasValue)
            parent.WriteParam(section, key, val.Value ? "1" : "0");
    }

    public int? Int(string key)
    {
        string s = parent.GetParam(section, key);
        return s == null ? null : (int?)Convert.ToInt32(s);
    }
    public void Int(string key, int? val)
    {
        if (val.HasValue)
            parent.WriteParam(section, key, val.Value.ToString());
    }

    public double? Double(string key)
    {
        string s = parent.GetParam(section, key);
        return s == null ? null : (double?)Convert.ToDouble(s);
    }
    public void Double(string key, double? val)
    {
        if (val.HasValue)
            parent.WriteParam(section, key, val.Value.ToString());
    }

    public string String(string key) => parent.GetParam(section, key);
    public void String(string key, string value)
    {
        if (value != null)
            parent.WriteParam(section, key, value);
    }

    public DateTime? DT(string key)
    {
        string s = parent.GetParam(section, key);
        return s == null ? null : (DateTime?)DateTime.ParseExact(s, "yyyy.MM.dd", null);
    }
    public void DT(string key, DateTime? val)
    {
        if (val.HasValue)
            parent.WriteParam(section, key, val.Value.ToString("yyyy.MM.dd"));
    }
}

Die Klasse konvertiert die übergebenen Daten in ein in der Datei erwartetes Formular und schreibt die Daten in diese Datei. Beim Lesen aus der Datei konvertiert sie Zeichenketten in eine Rückgabedatei und übergibt das Ergebnis an eine Klasse, die den jeweiligen Abschnitt beschreibt und den Wert dann in das erwartete Format konvertiert. Bitte beachten Sie, dass diese Klasse beim Zugriff auf eine der Eigenschaften Daten direkt in/aus der Datei schreibt oder liest. Dies stellt sicher, dass bei der Arbeit mit der Datei aktuelle Daten vorliegen, kann aber im Vergleich zum Speicherzugriff mehr Zeit in Anspruch nehmen. Da das Lesen und Schreiben nur Mikrosekunden dauert, ist diese Verzögerung im Programmbetrieb nicht spürbar.   

Als Nächstes betrachten wir den Manager der Terminaltoperationen. Der Zweck dieser Klasse: Terminalstart und -stopp, Möglichkeit, Daten darüber zu erhalten, ob das Terminal läuft oder nicht, Setzen der Konfigurationsdatei und Startflags. Mit anderen Worten, die Klasse muss alle im Terminalhandbuch beschriebenen Terminalstartmethoden verstehen und die Verwaltung des Terminalbetriebsprozesses ermöglichen. Basierend auf diesen Anforderungen wurde die folgende Schnittstelle geschrieben, die die Signaturen der benötigten Eigenschaften und Methoden beschreibt. Der weitere Betrieb mit dem Terminal wird über die untenstehende Schnittstelle realisiert.

interface ITerminalManager
{
    uint? Login { get; set; }
    string Profile { get; set; }
    Config Config { get; set; }
    bool Portable { get; set; }
    System.Diagnostics.ProcessWindowStyle WindowStyle { get; set; }
    DirectoryInfo TerminalInstallationDirectory { get; }
    DirectoryInfo TerminalChangeableDirectory { get; }
    DirectoryInfo MQL5Directory { get; }
    List<string> Experts { get; }
    List<string> Indicators { get; }
    List<string> Scripts { get; }
    string TerminalID { get; }
    bool IsActive { get; }

    bool Run();
    void Close();
    void WaitForStop();
    bool WaitForStop(int miliseconds);

    event Action<ITerminalManager> TerminalClosed;
}

Wie aus der Schnittstelle ersichtlich ist, akzeptieren die ersten 4 Eigenschaften die Werte der Flags, die in der Anleitung angegeben und im Teil über die GUI-Erstellung besprochen wurde. Das fünfte Flag setzt die Fenstergröße des Terminals beim Start - es kann das Terminal minimieren, im Vollmodus oder im Small Window Modus starten. Wenn jedoch der Wert Hidden ausgewählt ist (um das Fenster auszublenden), wird das erwartete Verhalten nicht ausgeführt. Um das Terminal auszublenden, müssen wir eine weitere Initialisierungsdatei bearbeiten. Da das Verhalten jedoch entscheidend ist, habe ich mich entschieden, den Code nicht zu komplizieren.

Die Klasse, die dieses Interface erbt, hat zwei Konstruktorüberladungen.

public TerminalManager(DirectoryInfo TerminalChangeableDirectory) :
    this(TerminalChangeableDirectory, new DirectoryInfo(File.ReadAllText(TerminalChangeableDirectory.GetFiles().First(x => x.Name == "origin.txt").FullName)), false)
{
}

public TerminalManager(DirectoryInfo TerminalChangeableDirectory, DirectoryInfo TerminalInstallationDirectory, bool isPortable)
{
    this.TerminalInstallationDirectory = TerminalInstallationDirectory;
    this.TerminalChangeableDirectory = TerminalChangeableDirectory;

    TerminalID = TerminalChangeableDirectory.Name;

    CheckDirectories();

    Process.Exited += Process_Exited;

    Portable = isPortable;
}

Nach dem Artikel von Vladimir Karputov hat das variable Terminalverzeichnis die Datei origin.txt, in der der Pfad zum Installationsverzeichnis gespeichert ist. Diese Tatsache wird bei der ersten Überladung des Konstruktors verwendet. Diese Überladung sucht nach der Datei origin.txt, liest die gesamte Datei und erstellt die Klasse DirectoryInfo, die dieses Verzeichnis beschreibt, indem sie aus der Datei gelesene Daten an sie weitergibt. Beachten Sie auch, dass alle Aktionen im Zusammenhang mit der Vorbereitung der Klasse für den Betrieb vom zweiten Konstruktor ausgeführt werden, der drei Parameter akzeptiert:

  • Der Pfad zum Variablenverzeichnis (das in AppData).
  • Der Pfad zum Installationsverzeichnis.
  • Das Flag des Starts des Terminals im Portable-Modus.  

Der letzte Parameter in diesem Konstruktor wurde zur Vereinfachung der Konfiguration hinzugefügt. Sie sollte absichtlich am Ende des Konstruktors zugewiesen werden. Wenn das Terminal im Portable-Modus gestartet wird, wird sein MQL5-Verzeichnis, in dem sich alle Expert Advisors und Indikatoren befinden, im Terminal-Installationsverzeichnis angelegt (falls es noch nicht erstellt wurde). Wenn das Terminal zunächst noch nie im Portable-Modus betrieben wurde, existiert dieses Verzeichnis nicht, daher ist es notwendig, beim Setzen dieses Kennzeichens zu prüfen, ob dieses Verzeichnis existiert. Die Eigenschaft, die dieses Flag setzt und liest, wird wie folgt beschrieben.

/// <summary>
/// Flag of terminal launch in /portable mode
/// </summary>
private bool _portable;
public bool Portable
{
    get => _portable;
    set
    {
        _portable = value;
        if (value && !TerminalInstallationDirectory.GetDirectories().Any(x => x.Name == "MQL5"))
        {
            WindowStyle = System.Diagnostics.ProcessWindowStyle.Minimized;
            if (Run())
            {
                System.Threading.Thread.Sleep(100);
                Close();
            }
	    WaitForStop();
            WindowStyle = System.Diagnostics.ProcessWindowStyle.Normal;
        }
    }
} 

Bei der Zuweisung eines übergebenen Wertes wird die Existenz des MQL5-Verzeichnisses überprüft. Wenn es kein solches Verzeichnis gibt, starten Sie das Terminal und halten Sie den Thread, bis das Terminal gestartet wird. Gemäß dem zuvor gesetzten Startflag für das Terminal wird das Terminal im Portable-Modus gestartet und beim ersten Start wird das gewünschte Verzeichnis erstellt. Sobald das Terminal gestartet ist, schließen wir es mit dem Befehl Close aus unserem Wrapper für die Arbeit mit dem Terminal und warten, bis das Terminal geschlossen ist. Danach wird das gewünschte MQL5-Verzeichnis erstellt, sofern es keine Zugriffsprobleme gibt. Unsere Eigenschaft, die den Pfad zum MQL5-Ordner des Terminals zurückgibt, funktioniert über ein bedingtes Konstrukt: Es gibt den Pfad zum gewünschten Verzeichnis entweder aus dem Installationsverzeichnis oder aus dem Verzeichnis mit veränderbaren Dateien zurück, abhängig vom oben genannten Flag.

/// <summary>
/// Path to the MQL5 folder
/// </summary>
public DirectoryInfo MQL5Directory => (Portable ? TerminalInstallationDirectory : TerminalChangeableDirectory).GetDirectory("MQL5");

Ein weiterer Hinweis sollte bezüglich der Überlastung des Konstruktors gemacht werden. Wenn Sie anstelle des Variablenverzeichnisses plötzlich den Pfad zum Installationsverzeichnis übergeben, dann sollte diese Klasse korrekt funktionieren, vorausgesetzt, es gab mindestens einen Start im Portable-Modus (oder wenn das isPortable = true Flag gesetzt ist). Es erkennt jedoch nur das Installationsverzeichnis des Terminals, und in diesem Fall ist die TerminalID nicht eine Menge von Zahlen und lateinischen Zeichen, die im Variablenverzeichnis des Terminals angegeben ist, sondern gleich dem Namen des Ordners, in dem das Terminal installiert ist, d.h. dem Namen des Installationsverzeichnisses.  
Achten Sie auch auf die Eigenschaften, die Informationen über Handelsroboter, Indikatoren und Skripte im Terminal liefern. Die Eigenschaften werden über die 'private' Methode GetEX5FilesR implementiert.

#region .ex5 files relative paths
/// <summary>
/// List of full EA names
/// </summary>
public List<string> Experts => GetEX5FilesR(MQL5Directory.GetDirectory("Experts"));
/// <summary>
/// List of full indicator names
/// </summary>
public List<string> Indicators => GetEX5FilesR(MQL5Directory.GetDirectory("Indicators"));
/// <summary>
/// List of full script names
/// </summary>
public List<string> Scripts => GetEX5FilesR(MQL5Directory.GetDirectory("Scripts"));
#endregion

private List<string> GetEX5FilesR(DirectoryInfo path, string RelativeDirectory = null)
{
    if (RelativeDirectory == null)
        RelativeDirectory = path.Name;
    string GetRelevantPath(string pathToFile)
    {
        string[] path_parts = pathToFile.Split('\\');
        int i = path_parts.ToList().IndexOf(RelativeDirectory) + 1;
        string ans = path_parts[i];
        for (i++; i < path_parts.Length; i++)
        {
            ans = Path.Combine(ans, path_parts[i]);
        }

        return ans;
    }

    List<string> files = new List<string>();
    IEnumerable<DirectoryInfo> directories = path.GetDirectories();

    files.AddRange(path.GetFiles("*.ex5").Select(x => GetRelevantPath(x.FullName)));

    foreach (var item in directories)
        files.AddRange(GetEX5FilesR(item, RelativeDirectory));

    return files;
}

In diesen Eigenschaften versuchen wir nicht, mögliche Pfade zu EA-Dateien zu erhalten. Stattdessen erhalten wir Pfade zu EAs relativ zum Ordner Experts (oder relativ zum Ordner Indicators für Indikatoren und zum Ordner Scripts für Skripte). Bei der Auswahl prüft die Klasse die Dateiendung (sucht nur nach EX5-Dateien).

Die Methode, die die Liste der gefundenen EX5-Dateien zurückgibt, verwendet Rekursionen. Lassen Sie uns diese Methode im Detail betrachten. Erstens überprüft es die Werte seines zweiten Parameters, was optional ist: Wenn der Parameter nicht gesetzt ist, wird ihm der Name des aktuell übergebenen Verzeichnisses zugewiesen. So können wir verstehen, in Bezug darauf, welche Verzeichnisdateipfade erzeugt werden sollen. Eine weitere verwendete C#-Sprachkonstruktion ist verschachtelte Funktionen. Diese Funktionen existieren nur innerhalb der aktuellen Methode. Wir haben diese Konstruktion verwendet, da die Funktion nicht weiter verwendet wird. Ihr Körper ist nicht zu groß und so kann sie in diese Methode integriert werden. Diese Funktion akzeptiert den Pfad zur EX5-Datei als Eingabe und teilt ihn mit dem Symbol "\\" als Trennzeichen. Als Ergebnis erhalten wir ein Array mit Verzeichnisnamen, und der Name der EX5-Datei ist am Ende dieses Arrays verfügbar. Weisen Sie im nächsten Schritt der Variablen i den Index des Verzeichnisses zu, in Bezug darauf, welcher Pfad zur Datei gesucht wird. Erhöhen Sie ihn um 1 und verschieben Sie den Zeiger auf das nächste Verzeichnis oder die nächste Datei. Die Variable 'ans' speichert die gefundene Adresse: Weisen Sie ihr dazu den Wert des angegebenen Verzeichnisses zu und fügen Sie dann ein neues Verzeichnis oder eine neue Datei in einer Schleife hinzu, bis wir die Schleife verlassen (d.h. bis der Name der gewünschten Datei hinzugefügt wird). Die Methode GetEX5FilesR funktioniert wie folgt:

  1. Empfangen der Pfade zu allen verschachtelten Verzeichnissen.
  2. Suchen nach EX5-Dateien im aktuellen Verzeichnis und speichern deren relative Pfade.
  3. Starten einer Schleife die Rekursion für jedes verschachtelte Verzeichnis und dabei den Namen des Verzeichnisses übergeben, in Bezug auf das wir den Pfad empfangen müssen. Hinzufügen zur Liste der relativen Pfade von EX5-Dateien. 
  4. Rückgabe der gefundenen Dateipfade.

Daher führt diese Methode eine vollständige Dateisuche durch und gibt alle gefundenen Expert Advisors und andere MQL5 ausführbare Dateien zurück.

Betrachten wir nun, wie Anwendung von Drittanbietern in der Sprache C# gestartet werden. Es bietet sehr komfortable Funktionen zum Starten und Arbeiten mit anderen Anwendungen: die Prozessklasse, die ein Wrapper für jeden externen Prozess ist, der gestartet wird. Um beispielsweise Notepad von C# aus zu starten, müssen Sie nur 3 Zeilen Code schreiben: 

System.Diagnostics.Process Process = new System.Diagnostics.Process();
Process.StartInfo.FileName = "Notepad.exe";
Process.Start();

Wir werden diese Klasse verwenden, um den Prozess der Verwaltung von Terminals von Drittanbietern aus unserem Add-on heraus zu implementieren. Hier ist das Verfahren zum Starten des Terminals:

public bool Run()
{
    if (IsActive)
        return false;
    // Set path to terminal
    Process.StartInfo.FileName = Path.Combine(TerminalInstallationDirectory.FullName, "terminal64.exe");
    Process.StartInfo.WindowStyle = WindowStyle;
    // Set data for terminal launch (if any were installed)
    if (Config != null && File.Exists(Config.Path))
        Process.StartInfo.Arguments = $"/config:{Config.Path} ";
    if (Login.HasValue)
        Process.StartInfo.Arguments += $"/login:{Login.Value} ";
    if (Profile != null)
        Process.StartInfo.Arguments += $"/profile:{Profile} ";
    if (Portable)
        Process.StartInfo.Arguments += "/portable";

    // Notify the process of the need to call an Exit event after closing the terminal
    Process.EnableRaisingEvents = true;

    // Run the process and save the launch status to the IsActive variable
    return (IsActive = Process.Start());
}

Bei der Konfiguration des Terminals vor dem Start ist Folgendes zu beachten:

  1. Geben Sie den Pfad zur ausführbaren Datei an, die gestartet werden soll.
  2. Setzen Sie den Fenstertyp des Prozesses.
  3. Setzen Sie die Schlüssel (in der Konsolenanwendung waren dies alle Werte, die nach dem Namen der zu startenden Datei angegeben wurden).
  4. Setzen Sie das Flag Process.EnableRaisingEvents = true. Wenn dieses Flag nicht gesetzt ist, wird das Prozessabbruchereignis nicht ausgelöst.
  5. Starten Sie den Prozess und speichern Sie den Startstatus in der Variablen IsActive.

Die Eigenschaft IsActive wird wieder auf false gesetzt bei einem Rückruf, der nach dem Schließen des Terminals ausgelöst wird. Das Ereignis TerminalClosed wird durch diesen Rückruf ebenfalls aufgerufen.

/// <summary>
/// Terminal closing event
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void Process_Exited(object sender, EventArgs e)
{
    IsActive = false;
    TerminalClosed?.Invoke(this);
}

Andere Methoden des Terminalmanagements (Warten auf Terminalstopp und -abschluss) sind ein Wrapper über Standardmethoden der Prozessklasse.

public void WaitForStop()
{
    if (IsActive)
        Process.WaitForExit();
}
/// <summary>
/// Stop the process
/// </summary>
public void Close()
{
    if (IsActive && !Process.HasExited)
        Process.Kill();
}
/// <summary>
/// Wait for terminal completion for specified time
/// </summary>
public bool WaitForStop(int miliseconds)
{
    if (IsActive)
        return Process.WaitForExit(miliseconds);
   return true;
}

Daher haben wir mit der Standard-Prozessklasse einen praktischen Wrapper erstellt, der mit dem MetaTrader 5 Terminal funktioniert. Dies ermöglicht eine komfortablere Bedienung mit dem Terminal als die direkte Nutzung der Prozessklasse.

Objekte der Verzeichnisstruktur

Im ersten Teil des Artikels haben wir bereits überlegt, mit Verzeichnissen zu arbeiten. Lassen Sie uns nun die Methoden für den Zugriff auf das Dateisystem besprechen. Beginnen wir mit der Methode zur Erstellung von Datei- und Verzeichnispfaden. Dazu bietet C# eine komfortable Klasse Pfad. Es ermöglicht die sichere Erstellung von Pfaden zu Dateien und Verzeichnissen und damit auch die Beseitigung möglicher Fehler. Das Verzeichnis wird mit der Klasse DirectoryInfo dargestellt, wodurch wir schnell Daten über verschachtelte Verzeichnisse, übergeordnetes Verzeichnis, Verzeichnisname und vollständigen Pfad zu diesem Verzeichnis sowie Zugriff auf viele andere nützliche Eigenschaften erhalten können. Mit dieser Klasse können Sie beispielsweise alle Dateien in diesem Verzeichnis abrufen, indem Sie nur eine Methode aufrufen. Die Klasse FileInfo wird für die objektorientierte Darstellung einer beliebigen Datei verwendet. Diese Klasse ist ein Analogon zu DirectoryInfo in Bezug auf die Funktionsweise. Dadurch wird die gesamte Operation mit Dateien und Verzeichnissen als Operation mit präsentierten Klassen implementiert, was es ermöglicht, sich auf das Hauptproblem zu konzentrieren, ohne dass Zwischenfunktionen und -methoden erstellt werden müssen.

In der zuvor beschriebenen Klasse TerminalManager wurde häufig die Methode GetDirectory auf der Instanz der Klasse DirectoryInfo verwendet. Diese Methode ist nicht im Standard-Layout der Klasse DirectoryInfo enthalten und wurde aus Gründen der Übersichtlichkeit hinzugefügt. C# bietet eine Methode zur Erweiterung der Funktionalität von Standard- und benutzerdefinierten Klassen durch Hinzufügen von Erweiterungsmethoden. Wir haben diese Funktionalität der C#-Sprache genutzt, um die Erweiterungsmethode GetDirectory hinzuzufügen.

static class DirectoryInfoExtention
{
    public static DirectoryInfo GetDirectory(this DirectoryInfo directory, string Name, bool createIfNotExists = false)
    {
        DirectoryInfo ans = new DirectoryInfo(Path.Combine(directory.FullName, Name));
        if (!ans.Exists)
        {
            if (!createIfNotExists)
                return null;
            ans.Create();
        }
        return ans;
    }
}

Um eine erweiterte Methode zu erstellen, ist es notwendig, eine statische Klasse zu erstellen, in der wir eine 'public static' Methode erstellen. Ihr erster Parameter muss genau mit dem Typ typisiert werden, für den die Erweiterung erstellt wird. Davor muss das Schlüsselwort "this" stehen. Dieser Parameter wird beim Aufruf der automatischen Erweiterungsmethode angegeben, er muss nicht explizit an die Funktion übergeben werden, da dies genau die Klasseninstanz ist, für die die Erweiterung erstellt wurde. Es ist nicht erforderlich, ein Beispiel für eine Klasse zu erstellen, die Erweiterungsmethoden speichert, während alle Erweiterungsmethoden automatisch dem Methoden-Set der Klasse hinzugefügt werden, für die sie erstellt wurden. Das spezifische Verfahren arbeitet nach dem folgenden Algorithmus:

  1. Wenn der Parameter createIfNotExists=false (oder nicht angegeben), gibt er einen Unterordner mit dem übergebenen Namen an den DirectoryInfo-Typ (falls vorhanden) oder null zurück.
  2. Wenn der Parameter createIfNotExists=true gesetzt ist, wird der Ordner, wenn er nicht erstellt wird, erstellt und als Antwort kehrt dieser Ordner zum Typ DirectoryInfo zurück. 

Für komfortable Operationen mit Ordnern von variablen Terminalverzeichnissen haben wir eine Klasse erstellt, die eine objektorientierte Darstellung des Verzeichnisses ist. 

~\AppData\Roaming\MetaQuotes\Terminal

Diese Klasse ist wie folgt implementiert.

class TerminalDirectory
{
    public TerminalDirectory() :
        this(Path.Combine(System.Environment.GetFolderPath(System.Environment.SpecialFolder.ApplicationData), "MetaQuotes", "Terminal"))
    {
    }

    public TerminalDirectory(string path)
    {
        pathToTerminal = path;
    }

    private readonly string pathToTerminal;

    public List<DirectoryInfo> Terminals
    {
        get
        {
            List<DirectoryInfo> ans = new List<DirectoryInfo>();
            string[] dir_array = Directory.GetDirectories(pathToTerminal);
            foreach (var item in dir_array)
            {
                string pathToOrigin = Path.Combine(pathToTerminal, item, "origin.txt");
                if (!File.Exists(pathToOrigin))
                    continue;
                if (!File.Exists(Path.Combine(File.ReadAllText(pathToOrigin), "terminal64.exe")))
                    continue;
                ans.Add(new DirectoryInfo(Path.Combine(pathToTerminal, item)));
            }

            return ans;
        }
    }
    public DirectoryInfo Common => new DirectoryInfo(Path.Combine(pathToTerminal, "Common"));
    public DirectoryInfo Community => new DirectoryInfo(Path.Combine(pathToTerminal, "Community"));
}

Es gibt drei Felder:

  1. Terminals
  2. Common
  3. Community

Diese Felder entsprechen den Namen der Unterverzeichnisse in diesem Verzeichnis. Die Eigenschaft Terminals gibt die Liste der Verzeichnisse zurück, die zum Terminal-Dateisystem gehören. Oftmals bleibt nach dem Löschen eines Terminals der Terminalordner noch in diesem Verzeichnis, deshalb habe ich mich entschieden, die Überprüfung der Verzeichnisrelevanz hinzuzufügen. Die Prüfung erfolgt nach den folgenden Kriterien:

  1. Das Vorhandensein der Datei "origin.txt" im Stammverzeichnis des analysierten Verzeichnisses: Diese Datei ermöglicht den Zugriff auf den Pfad des Terminalverzeichnisses.
  2. Das Vorhandensein einer ausführbaren Terminaldatei im entsprechenden Verzeichnis. 

Achten Sie darauf, dass die Erweiterung für die 64-Bit-Terminalversion ausgelegt ist. Um mit der 32-Bit-Version zu arbeiten, sollte "terminal64.exe" in "terminal.exe" überall im Programm (insbesondere dem TerminalManager und der aktuell diskutierten Klasse) umbenannt werden. Dabei ignoriert sie die Verzeichnisse, für die keine ausführbaren Terminaldateien gefunden werden können.

Gehen Sie zur Betrachtung des ersten Konstruktors über. Dieser Konstruktor ermöglicht die automatische Generierung des Pfades zum Verzeichnis der Terminaldateien:

System.Environment.GetFolderPath(System.Environment.SpecialFolder.ApplicationData)

Die Klasse Environment erlaubt es, automatisch den Pfad zu den "AppData" zu erhalten, so dass wir den Nutzernamen nicht angeben müssen. Dank dieser Leitung kann die Erweiterung die Liste aller Terminals finden, die mit einer Standardinstallationsmethode auf Ihrem PC installiert wurden. 

Zusätzlich zur Klasse, die den Ordner mit Terminaldatenverzeichnissen beschreibt, hat die Erweiterung ein eigenes Verzeichnis, in dem temporäre Dateien und Optimierungsberichte gespeichert werden. Hier ist die Klasse, die dieses Verzeichnis beschreibt.

class OptimisationExtentionWorkingDirectory
{
    public OptimisationExtentionWorkingDirectory(string DirectoryName)
    {
        DirectoryRoot = CreateIfNotExists(DirectoryName);
        Configs = CreateIfNotExists(Path.Combine(DirectoryName, "Configs"));
        Reports = CreateIfNotExists(Path.Combine(DirectoryName, "Reports"));
    }

    public DirectoryInfo DirectoryRoot { get; }

    public DirectoryInfo Configs { get; }

    public DirectoryInfo Reports { get; }

    protected DirectoryInfo CreateIfNotExists(string path)
    {
        DirectoryInfo ans = new DirectoryInfo(path);
        if (!ans.Exists)
            ans.Create();
        return ans;
    }
}

Wie aus dem Klassenkonstruktor ersichtlich, überprüfen wir bei seiner Erstellung die Existenz der Stamm- und verschachtelten Verzeichnisse. Wenn diese Verzeichnisse nicht verfügbar sind, werden sie erstellt.

  • "DirectoryRoot" ist das Hauptverzeichnis, in dem das Add-on seine Dateien und Verzeichnisse speichert. 
  • "Configs" ist das Verzeichnis, in das wir Konfigurationsdateien kopieren, ändern und dann beim Terminalstart als Eingabeparameter festlegen.
  • Das Verzeichnis "Reports" speichert die Struktur von Dateien und Ordnern mit Berichten und Optimierungseinstellungen, die nach jedem Test geladen werden.

Die interne Struktur des Verzeichnisses Reports wird in der Klasse "OptimisationManager" erstellt und für jede Optimierung nach deren Abschluss gebildet. Sie besteht aus den folgenden Punkten:

  1. Das Verzeichnis mit dem Namen ist gleich der Terminal-ID. 
  2. Das Verzeichnis mit dem Namen gleich dem Roboternamen. Enthält folgendes:
    • Settings.xml — Datei mit Optimierungseinstellungen (wird innerhalb des Programms gebildet)
    • History.xml — kopierte historische Optimierungsdatei (gebildet durch das Terminal)
    • Forward.xml — kopierte Vorwärts-Optimierungsdatei (gebildet durch das Terminal)

So haben wir zwei Klassen geschaffen, die der Ausgangspunkt für die Arbeit mit dem Dateisystem sind. Die weitere Arbeit mit dem Dateisystem im Code erfolgt mit Standard-C#-Klassen - dies vermeidet Fehler in den Dateipfaden und beschleunigt die Programmierung erheblich.

Objekte, die mit Report- und EA-Einstellungsdateien arbeiten (OptimisatorSettingsManager, ReportReader, SetFileManager).

In diesem Kapitel wird der Umgang mit Dateien behandelt. Das Add-on muss mit den folgenden Dateien arbeiten können:

  • Die Registerkarte der Einstellungen des EAs
  • Datei mit dem Handelsbericht
  • Die Einstellungsdatei der Optimierung, die mit Berichten im Verzeichnis "Reports" des Add-ons gespeichert wird.

Beginnen wir mit der Datei mit EA-Parametern zur Optimierung. EA-Einstellungsdateien haben die Erweiterung (*.set). Es gibt jedoch mehrere Setup-Dateien, die Einstellungen beim Start im Chart und Einstellungen für die Ausführung in einem Tester enthalten. Wir interessieren uns für das zweite Dateiformat: Diese Dateien werden im Variablenverzeichnis des Terminals abgelegt unter 

~\MQL5\Profiles\Tester

Beachten Sie, dass dieses Verzeichnis bei der reinen Installation manchmal nicht existiert, daher sollten Sie es überprüfen und gegebenenfalls anlegen. Wenn dieses Verzeichnis nicht vorhanden ist, kann das Terminal die Optimierungseinstellungen nicht speichern. Dies ist oft ein Grund für folgendes Problem: Bei einer solchen reinen Terminalinstallation enthält die Registerkarte Optimierungseinstellungen nach jedem neuen Test oder Optimierungslauf immer noch die Standardeinstellungen. Die beschriebene Dateistruktur ähnelt in etwa der von INI-Dateien und sieht wie folgt aus:

Variable_name=Value||Start||Step||Stop||(Y/N)

Mit anderen Worten, der Schlüssel in diesen Dateien ist der Name des EA-Parameters, und der Schlüsselwert kann eine Liste seiner Werte aufnehmen, deren Namen im gegebenen Beispiel identisch mit den entsprechenden Spalten im Strategietester sind. Die letzte Variable kann einen der beiden Werte (J/N) annehmen und aktiviert bzw. deaktiviert die Optimierung für diesen spezifischen EA-Parameter. Eine Ausnahme von dieser Regel ist das Schreiben von Zeichenkettenparametern, die das Format einer INI-Datei haben:

Variable_name=Value

Ähnlich wie bei Initialisierungsdateien haben SET-Dateien auch Kommentare. Eine Kommentarzeile beginnt immer mit ";" (Semikolon). Hier sind einfache Beispiele:

; saved automatically on 2019.05.19 09:04:18

; this file contains last used input parameters for testing/optimizing 2MA_Martin expert advisor

;

Fast=12||12||1||120||N

Slow=50||50||1||500||N

maxLot=1||1||0.100000||10.000000||N

pathToDB=C:\Users\Administrator\Desktop\test_2MA_8

Um mit diesen Dateien zu arbeiten, müssen wir eine Wrapperklasse erstellen, die das Lesen dieser Dateien ermöglicht, sowie eine Klasse, die Werte jeder gelesenen Zeichenkette speichert. Diese Klasse wurde im Beschreibungsteil "View" in diesem Artikel erwähnt, daher werden wir sie hier nicht berücksichtigen. Betrachten wir die Hauptklasse, die Parameter-Set über die grafische Oberfläche liest und schreibt - SetFileManager. Hier ist die Implementierung dieser Klasse:

class SetFileManager
{
    public SetFileManager(string filePath, bool createIfNotExists)
    {
        if ((FileInfo = new FileInfo(filePath)).Extension.CompareTo(".set") != 0)
            throw new ArgumentException("File mast have '.set' extention!");
        if (createIfNotExists)
            File.Create(filePath).Close();
        if (!File.Exists(filePath))
            throw new ArgumentException("File doesn`t exists");

    }

    public FileInfo FileInfo { get; }

    #region File data
        
    private List<ParamsItem> _params = new List<ParamsItem>();
    public List<ParamsItem> Params
    {
        get
        {
            if (_params.Count == 0)
                UpdateParams();
            return _params;
        }
        set
        {
            if (value != null && value.Count != 0)
                _params = value;
        }
    }
    #endregion

    public virtual void SaveParams()
    {
        if (_params.Count == 0)
            return;

        using (var file = new StreamWriter(FileInfo.FullName, false))
        {
            file.WriteLine(@"; saved by OptimisationManagerExtention program");
            file.WriteLine(";");
            foreach (var item in _params)
            {
                file.WriteLine($"{item.Variable}={item.Value}||{item.Start}||{item.Step}||{item.Stop}||{(item.IsOptimize ? "Y" : "N")}");
            }
        }
    }

    public virtual SetFileManager DublicateFile(string pathToFile)
    {
        if (new FileInfo(pathToFile).Extension.CompareTo(".set") != 0)
            throw new ArgumentException("File mast have '.set' extention!");

        File.Copy(FileInfo.FullName, pathToFile, true);
        return new SetFileManager(pathToFile, false);
    }
        
    public virtual void UpdateParams()
    {
        _params.Clear();

        using (var file = FileInfo.OpenText())
        {
            string line;
            while ((line = file.ReadLine()) != null)
            {
                if (line[0].CompareTo(';') != 0 && line[0].CompareTo('#') != 0)
                {
                    string[] key_value = line.Replace(" ", "").Split('=');
                    string[] value_data = key_value[1].Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries);

                    ParamsItem item = new ParamsItem(key_value[0])
                    {
                        Value = (value_data.Length > 0 ? value_data[0] : null),
                        Start = (value_data.Length > 1 ? value_data[1] : null),
                        Step = (value_data.Length > 2 ? value_data[2] : null),
                        Stop = (value_data.Length > 3 ? value_data[3] : null),
                        IsOptimize = (value_data.Length > 4 ? value_data[4].CompareTo("Y") == 0 : false)
                    };

                    _params.Add(item);
                }
            }
        }
    }
}

Das erste, worauf Sie achten sollten, ist die Dateiformatprüfung, die im Konstruktor der Klasse bereitgestellt wird. Wenn das Dateiformat vom SET-Dateiformat abweicht, gibt diese Klasse einen Fehler zurück, da wir versuchen würden, mit einer Datei zu arbeiten, die dem Terminal unbekannt ist. Die Datei wird in der 'public', schreibgeschützten Eigenschaft FileInfo gespeichert. Das Lesen der Datei erfolgt in der Methode UpdateParams, die in der Methode 'using construction' die Datei von der ersten bis zur letzten Zeile liest, wobei Kommentarzeilen ignoriert werden. Achten Sie auch auf die Einstellung der Parameter der gelesenen Datei. Die gelesene Zeile wird zunächst in zwei Teile geteilt und das Gleichheitszeichen ("=") als Trennzeichen verwendet. Dadurch wird der Variablenname von seinen Werten getrennt. Im nächsten Schritt werden die Variablenwerte in ein Array von 4 Elementen unterteilt [Value, Start, Step, Stop, IsOptimise]. Im Falle von Zeichenketten wird das Array nicht in diese Zeichenketten unterteilt, da zwei Zeilen ("|||") zwischen den Symbolen nicht gefunden werden. Um Fehler bei Zeichenketten zu vermeiden, empfehlen wir, dieses Zeichen nicht in ihnen zu verwenden. Wenn für jedes neue Element keine Daten im Array vorhanden sind, wird ihm der Nullwert zugewiesen, ansonsten wird ein Wert aus dem Array verwendet.

Das Speichern der Werte erfolgt in der Methode SaveParams. Bitte beachten Sie das Schreibformat der Daten in der Datei. Dies geschieht in der folgenden Codezeile:

file.WriteLine($"{item.Variable}={item.Value}||{item.Start}||{item.Step}||{item.Stop}||{(item.IsOptimize ? "Y" : "N")}");

Unabhängig davon, um welche Art von Daten es sich handelt, werden die Daten als NON-String-Typ geschrieben. Das Terminal kann erkennen, ob es sich um eine Zeichenkette handelt oder nicht, daher wurde der einzige Datenschreibtyp gewählt. Einer der Nachteile dieser Klasse ist, dass es nicht möglich ist, den Datentyp herauszufinden. Wir können sein Format nicht kennen, da die Dateistruktur solche Informationen nicht liefert. Das Terminal erhält diese Informationen direkt vom Expert Advisor.  

Der Zugriff auf die Lesedatei und die Einstellung ihrer Parameter erfolgt über die Eigenschaft Params. Da Operationen mit Dateidaten über die beschriebene Eigenschaft implementiert werden, prüfen Sie aus Gründen der Übersichtlichkeit, ob die Datei bereits gelesen wurde oder nicht. Wenn die Datei nicht gelesen wurde, wird die Methode UpdateParams aufgerufen. Im Allgemeinen ist die Vorgehensweise für das Arbeiten mit dieser Klasse wie folgt:

  1. Instanziieren, wodurch die OOP-Darstellung der Datei erhalten wird.
  2. Lesen durch Aufruf der Methode 'Params' (oder UpdateParams bei Bedarf, z.B. wenn die Datei von außen geändert wurde).
  3. Setzen der benutzerdefinierte Werte mit 'Setter' oder ändern der Daten mit dem empfangenen Array über 'Getter'.
  4. Speichern der Änderungen mit der Methode SaveParams

Der größte Nachteil gegenüber INI-Dateien ist, dass zwischen Lesen und Schreiben Daten im Programmspeicher gespeichert werden. Wird die Datei jedoch versehentlich oder absichtlich ausgeschlossen, kann die Datei von außen verändert werden. Die Klasse hat auch die Methode DublicateFile, die darauf abzielt, die Datei im übergebenen Pfad zu kopieren (durch Ersetzen, wenn es eine Datei mit dem gleichen Namen in diesem Pfad gab).

Die weitere Klasse RepirtReader liest vom Terminal generierte Optimierungsberichte und parst diese Berichte auf vorbereitete Daten für die Tabelle. Die Optimierungsverlaufsdatei ist in dem für MS Excel erstellten XML-Format verfügbar. Sein Wurzelknoten (das allererste Tag) — <Workbook/> — beschreibt das Buch. Der nächste Knoten <DocumentProperties/> beschreibt Parameter, mit denen die Optimierung durchgeführt wurde. Die folgenden nützlichen Informationen sind in diesem Knoten enthalten:

  1. Kopf, der sich aus dem Roboternamen, dem Asset-Namen, dem Zeitrahmen und dem Optimierungszeitraum zusammensetzt.
  2. Erstellungsdatum
  3. Der Name des Servers, auf dem die Optimierung durchgeführt wurde.
  4. Einlagen und Einlagenwährung
  5. Leverage (Hebel)

Der Knoten <Styles/> ist für uns nutzlos - er wird hauptsächlich für Excel erstellt. Der nächste Knoten <Worksheet/> beschreibt das Arbeitsblatt mit Optimierungsdurchläufen. Dieser Knoten enthält den Knoten <Table/>, der die gesuchten Daten speichert: die Liste der Optimierungsergebnisse, unterteilt in Spalten, wie nach dem Testen aller Parameter im Strategietester. Beachten Sie, dass die erste Zeile der Tabelle Spaltenüberschriften enthält, während weitere Zeilen Werte enthalten. Jeder <Row/> Knoten enthält die Liste der Tabellenwerte innerhalb des <Cell/> Tags. Außerdem enthält jedes Tag <Cell/> das Attribut Type, das den Werttyp in dieser Zelle angibt. Da diese Datei zu groß ist, werde ich sie hier nicht angeben. Sie können die gesamte Datei einsehen, indem Sie jeden Expert Advisor optimieren und seine Optimierungsergebnisse aus dem Ordner Reports unseres Add-ons öffnen. Kommen wir nun zur Betrachtung der beschriebenen Klasse: Beginnen wir mit der Überprüfung der Eigenschaften, die die Optimierungsdatei beschreiben. 

#region DocumentProperties and column names
/// <summary>
/// Document column names
/// </summary>
protected List<string> columns = new List<string>();
/// <summary>
/// Access to the collection of document columns from outside, collection copy is returned 
/// to protect the initial collection from modification 
/// </summary>
public List<string> ColumnNames => new List<string>(columns);
/// <summary>
/// Document header
/// </summary>
public string Title { get; protected set; }
/// <summary>
/// Document author
/// </summary>
public string Author { get; protected set; }
/// <summary>
/// Document creation date
/// </summary>
public DateTime Created { get; protected set; }
/// <summary>
/// Server on which optimization was performed
/// </summary>
public string Server { get; protected set; }
/// <summary>
/// Initial deposit 
/// </summary>
public Deposit InitialDeposit { get; protected set; }
/// <summary>
/// Leverage
/// </summary>
public int Leverage { get; protected set; }
#endregion

Diese Eigenschaften sind verfügbar unter <DokumentProperties/>. Die Eigenschaften werden von der folgenden Methode zugewiesen:

protected virtual void GetDocumentProperties(string path)
{
    document.Load(path);

    Title = document["Workbook"]["DocumentProperties"]["Title"].InnerText;
    Author = document["Workbook"]["DocumentProperties"]["Author"].InnerText;
    string DT = document["Workbook"]["DocumentProperties"]["Created"].InnerText;
    Created = Convert.ToDateTime(DT.Replace("Z", ""));
    Server = document["Workbook"]["DocumentProperties"]["Server"].InnerText;
    string[] deposit = document["Workbook"]["DocumentProperties"]["Deposit"].InnerText.Split(' ');
    Deposit = new Deposit(Convert.ToDouble(deposit[0]), deposit[1]);
    Leverage = Convert.ToInt32(document["Workbook"]["DocumentProperties"]["Leverage"].InnerText);

    enumerator = document["Workbook"]["Worksheet"]["Table"].ChildNodes.GetEnumerator();
    enumerator.MoveNext();

    foreach (XmlElement item in (XmlElement)enumerator.Current)
    {
        columns.Add(item["Data"].InnerText);
    }
}

So ist die Arbeit mit (*.xml) Dateien mit C#-Tools fast so einfach wie die Arbeit mit Arrays. Das Objekt "document" ist eine Instanz der Klasse XmlDocument, die die gelesene Datei speichert und ein komfortables Arbeiten mit ihr ermöglicht. Das Feld enumerator, dem der Wert zugeordnet ist, wird in der Read-Methode verwendet, die das Dokument zeilenweise liest. Bei der Klassendeklaration wird die Schnittstelle IDisposable verwendet: 

class ReportReader : IDisposable

Dieses Interface enthält nur eine Dispose()-Methode, die die Verwendung dieser Klasse im Konstrukt "using" ermöglicht. Das Konstrukt "using" gewährleistet einen korrekten Betrieb, d.h. bei jedem Lesen aus der Datei muss die Datei nicht geschlossen werden. Stattdessen wird die Datei in der Methode Dispose() geschlossen, die automatisch aufgerufen wird, nachdem der geschweifte Klammerblock verlassen wurde, in dem Operationen mit der Datei ausgeführt werden. In diesem speziellen Fall werden wir das Dokumentfeld in der Methode 'Dispose' löschen, um nicht zu viele unnötige Informationen in einer gelesenen Datei zu speichern. Die Implementierung dieser Methode sieht wie folgt aus:

public void Dispose()
{
    document.RemoveAll();
}

Betrachten wir nun die Schnittstelle IEnumerator, die eine Standard-Schnittstelle von C# ist. Sie sieht wie folgt aus:

//
// Summary:
//     Supports a simple iteration over a non-generic collection.
[ComVisible(true)]
[Guid("496B0ABF-CDEE-11d3-88E8-00902754C43A")]
public interface IEnumerator
{
    object Current { get; }

    bool MoveNext();
    
    void Reset();
}

Sie besteht aus zwei Methoden und einer Eigenschaft. Diese Schnittstelle dient als eine Art Wrapper für die Kollektion, über deren Werte nacheinander iteriert wird. Die Methode MoveNext bewegt den Cursor um einen Wert vorwärts und so weiter, bis zum Ende der Kollektion. Wenn wir versuchen, diese Methode aufzurufen, nachdem wir die gesamte Kollektion durchlaufen haben, gibt sie false zurück, was das Ende der Iteration bedeutet. Die Reset-Methode ermöglicht einen Neustart der Iteration, d.h. sie bewegt den Cursor auf den Nullindex der Sammlung. Die Eigenschaft 'Current' enthält das aktuell ausgewählte Kollektionselement des Index, der nach einer Verschiebung in MoveNext empfangen wurde. Diese Schnittstelle ist in C# weit verbreitet. Somit basieren die "foreach"-Schleifen darauf. Allerdings benötigen wir dies für die Implementierung der Read-Methode. 

/// <summary>
/// Command to read a row from the optimizations table
/// </summary>
/// <param name="row">
/// Read row key - column header; value - cell value</param>
/// <returns>
/// true - if the row has been read
/// false - if the row has not been read
/// </returns>
public virtual bool Read(out List<KeyValuePair<string, object>> row)
{
    row = new List<KeyValuePair<string, object>>();

    if (enumerator == null)
        return false;

    bool ans = enumerator.MoveNext();
    if (ans)
    {
        XmlNodeList nodes = ((XmlElement)enumerator.Current).ChildNodes;

        for (int i = 0; i < columns.Count; i++)
        {
            string value = nodes[i]["Data"].InnerText;
            string type = nodes[i]["Data"].Attributes["ss:Type"].Value;
            KeyValuePair<string, object> item = new KeyValuePair<string, object>(columns[i], ConvertToType(value, type));
            row.Add(item);
        }
    }
    return ans;
}

Der Zweck der Methode 'Read' ist ähnlich wie bei MoveNext(). Darüber hinaus gibt es das Operationsergebnis über einen an ihn übergebenen Parameter zurück. Da es nur Zeilen mit Werten zurückgeben soll, rufen wir beim Setzen eines Wertes auf die Enumerator-Variable die Methode MoveNext einmal auf und bewegen den Cursor von der Nullposition (Tabellenspaltenüberschriften) auf den Index 1 (die erste Zeile mit Werten). Beim Lesen von Daten verwenden wir auch den ConvertToType, der die gelesenen Werte vom Zeichenkettenformat in das durch das Attribut 'Type' gesetzte Format konvertiert. Deshalb wird in der Rückgabe list der Typ 'object' angegeben — so können wir jeden Typ in den Rückgabetyp konvertieren. Die Implementierung der Methode ConvertToType ist nachfolgend dargestellt.

private object ConvertToType(string value, string type)
{
    object ans;
    switch (type)
    {
        case "Number":
            {
                System.Globalization.NumberFormatInfo provider = new System.Globalization.NumberFormatInfo()
                {
                    NumberDecimalSeparator = ","
                };

                ans = Convert.ToDouble(value.Replace('.', ','), provider);
            }
            break;
        case "DateTime": ans = Convert.ToDateTime(value); break;
        case "Boolean":
            {
                try
                {
                    ans = Convert.ToBoolean(value.ToLower());
                }
                catch (Exception)
                {
                    ans = Convert.ToInt32(value) == 1;
                }
            }
            break;
        default: ans = value; break; // String
    }

    return ans;
}

Innerhalb dieser Methode wird string in ein numerisches Format umgewandelt. Da es in verschiedenen Ländern unterschiedliche Daten- und Zeitdarstellungsformate gibt, ist es notwendig, explizit das Dezimaltrennzeichen anzugeben.

Der Neustart von "Reader" wird über die Methode 'ResetReader' aktiviert, die ein Wrapper für die Methode IEnumerator.Reset ist. Sie ist wie folgt implementiert:

public void ResetReader()
{
    if (enumerator != null)
    {
        enumerator.Reset(); // Reset
        enumerator.MoveNext(); // Skip the headers
    }
}

Mit dem praktischen Wrapper zum Parsen von XML-Dateien in C# können wir also ganz einfach eine Wrapper-Klasse schreiben, um Berichtsdateien zu analysieren, zu lesen und zusätzliche Daten zu erhalten.

Die nächste Klasse arbeitet mit den Optimierer-Einstellungsdateien, die vom Add-on selbst und nicht direkt vom Terminal erzeugt werden. Eines der Hauptmerkmale ist die Möglichkeit, einen EA im Tester durch einen Doppelklick mit einen Optimierungsparameter zu starten. Aber wohin mit den Einstellungen für den Tester (Datumsbereich, Symbolname und andere Parameter)? Der Optimierungsreport speichert nur einen Teil dieser Daten, aber nicht alle. Um dieses Problem zu lösen, müssen Sie diese Einstellungen natürlich in einer Datei speichern. XML-Markup wurde für das komfortable Datenspeicherformat gewählt. Das Beispiel zum Lesen von XML-Dateien wurde in der obigen Klasse gezeigt. Zusätzlich zum Lesen werden wir sie auch in eine Datei schreiben. Zuerst müssen wir die Informationen bestimmen, die in der Einstellungsdatei gespeichert werden sollen.

Das erste zu speichernde Objekt ist die Struktur, in der die Daten der Optimierer-Einstellungen (verfügbar auf der Registerkarte Einstellungen im unteren Bereich der Registerkarte Haupteinstellungen) gespeichert sind. Diese Struktur ist wie folgt implementiert.

struct OptimisationInputData
{
    public void Copy(OptimisationInputData data)
    {
        Login = data.Login;
        ForvardDate = data.ForvardDate;
        IsVisual = data.IsVisual;
        Deposit = data.Deposit;
        Laverage = data.Laverage;
        Currency = data.Currency;
        DepositIndex = data.DepositIndex;
        ExecutionDelayIndex = data.ExecutionDelayIndex;
        ModelIndex = data.ModelIndex;
        CurrencyIndex = data.CurrencyIndex;
        LaverageIndex = data.LaverageIndex;
        OptimisationCriteriaIndex = data.OptimisationCriteriaIndex;
    }
        
    public uint? Login;
    public DateTime ForvardDate;
    public bool IsVisual;
    public int Deposit;
    public string Laverage;
    public string Currency;
    public int DepositIndex, ExecutionDelayIndex, ModelIndex,
               CurrencyIndex, LaverageIndex, OptimisationCriteriaIndex;
    public ENUM_Model Model => GetEnum<ENUM_Model>(ModelIndex);
    public ENUM_OptimisationCriteria GetOptimisationCriteria => GetEnum<ENUM_OptimisationCriteria>(OptimisationCriteriaIndex);
    public ENUM_ExecutionDelay ExecutionDelay => GetEnum<ENUM_ExecutionDelay>(ExecutionDelayIndex);
    private T GetEnum<T>(int ind)
    {
        Type type = typeof(T);
        string[] arr = Enum.GetNames(type);
        return (T)Enum.Parse(type, arr[ind]);
    }
}

Die Struktur wurde ursprünglich als Container für die Datenübergabe von View an Model angelegt, daher enthält sie neben den Daten auch Indizes für die ComboBox. Für einen effizienten Ablauf mit der Struktur im Modell und anderen Klassen habe ich Methoden zur Konvertierung von Werten von Enumerationen (Enum) erstellt, die in der Struktur über die Indexnummer in die gewünschten Aufzählungstypen gespeichert werden. Die Aufzählung funktioniert wie folgt: Um die Werte dieser Listen in der ComboBox auszugeben, werden sie in einem komfortablen Zeichenkettenformat gespeichert. Die Methode GetEnum<T> wird für die Rückkonvertierung verwendet. Es handelt sich um eine generische Methode, die analog zu C++-Templates ist. Um die gewünschte Enum in dieser Methode zu finden, ermitteln Sie den spezifischen Wert des übergebenen Typs, für den die Typklasse mit dem Typwert verwendet wird. Dann zerlegen Sie diesen Aufzählungstyp in eine Liste von Zeilen und verwenden Sie dann die Rückkonvertierung von string nach enum — um den Wert der spezifischen Aufzählung nicht in der Zeichenkettenansicht, sondern als gewünschte Aufzählung zu erhalten.

Das nächste Objekt, das die gespeicherten Daten enthält, ist ConfigCreator_inputData. Diese Struktur enthält Daten aus der Tabelle mit dem ausgewählten Terminal und wird in der Klasse OptimisationManager zum Erstellen einer Konfigurationsdatei verwendet. Die Strukturen sehen wie folgt aus:

struct ConfigCreator_inputData
{
    public ENUM_Timeframes TF;
    public uint? Login;
    public string TerminalID, pathToBot, setFileName,
           Pass, CertPass, Server, Symbol, ReportName;
    public DateTime From, Till;
    public ENUM_OptimisationMode OptimisationMode;
}

Der dritte und letzte aller gespeicherten Daten ist die Liste der EA-Parametertypen durch ein Listenelement ParamItem (List<ParamsItem>). Betrachten wir nun die Datei, die während der Klassenoperation erstellt wird:


<Settings>
        <OptimisationInputData>
                <Item Name="Login" />
                <Item Name="ForvardDate">2019.04.01</Item>
                <Item Name="IsVisual">False</Item>
                <Item Name="Deposit">10000</Item>
                <Item Name="Laverage">1:1</Item>
                <Item Name="Currency">USD</Item>
                <Item Name="DepositIndex">2</Item>
                <Item Name="ExecutionDelayIndex">0</Item>
                <Item Name="ModelIndex">1</Item>
                <Item Name="CurrencyIndex">1</Item>
                <Item Name="LaverageIndex">0</Item>
                <Item Name="OptimisationCriteriaIndex">0</Item>
        </OptimisationInputData>
        <ConfigCreator_inputData>
                <Item Name="TF">16386</Item>
                <Item Name="Login">18420888</Item>
                <Item Name="TerminalID">0CFEFA8410765D70FC53545BFEFB44F4</Item>
                <Item Name="pathToBot">Examples\MACD\MACD Sample.ex5</Item>
                <Item Name="setFileName">MACD Sample.set</Item>
                <Item Name="Pass" />
                <Item Name="CertPass" />
                <Item Name="Server" />
                <Item Name="Symbol">EURUSD</Item>
                <Item Name="ReportName">MACD Sample</Item>
                <Item Name="From">2019.01.01</Item>
                <Item Name="Till">2019.06.18</Item>
                <Item Name="OptimisationMode">2</Item>
        </ConfigCreator_inputData>
        <SetFileParams>
                <Variable Name="InpLots">
                        <Value>0.1</Value>
                        <Start>0.1</Start>
                        <Step>0.010000</Step>
                        <Stop>1.000000</Stop>
                        <IsOptimize>False</IsOptimize>
                </Variable>
                <Variable Name="InpTakeProfit">
                        <Value>50</Value>
                        <Start>50</Start>
                        <Step>1</Step>
                        <Stop>500</Stop>
                        <IsOptimize>False</IsOptimize>
                </Variable>
                <Variable Name="InpTrailingStop">
                        <Value>30</Value>
                        <Start>30</Start>
                        <Step>1</Step>
                        <Stop>300</Stop>
                        <IsOptimize>False</IsOptimize>
                </Variable>
                <Variable Name="InpMACDOpenLevel">
                        <Value>3</Value>
                        <Start>3</Start>
                        <Step>1</Step>
                        <Stop>30</Stop>
                        <IsOptimize>True</IsOptimize>
                </Variable>
                <Variable Name="InpMACDCloseLevel">
                        <Value>2</Value>
                        <Start>2</Start>
                        <Step>1</Step>
                        <Stop>20</Stop>
                        <IsOptimize>True</IsOptimize>
                </Variable>
                <Variable Name="InpMATrendPeriod">
                        <Value>26</Value>
                        <Start>26</Start>
                        <Step>1</Step>
                        <Stop>260</Stop>
                        <IsOptimize>False</IsOptimize>
                </Variable>
        </SetFileParams>
</Settings>

Die Datei wurde während der im Video gezeigten EA-Operation erstellt. Wie aus seiner Struktur ersichtlich, ist der Wurzeldateiknoten <Settings/>, mit drei weiteren Knoten darin:   <OptimisationInputData/><ConfigCreator_inputData/><SetFileParams/>. Die Datentypen in diesen Knoten entsprechen ihren Namen. Das letzte Element im Knoten, das die Testereinstellungsdaten speichert, ist das Tag 'Item', das das Attribut 'Name' enthält, über das wir den Namen des gespeicherten Parameters festlegen. Das Tag <Variable/> wird für die EA-Parameterliste verwendet. Das Attribut 'Name' speichert den Namen des Parameters und der entsprechende Wert der Optimierungsparameter wird in verschachtelten Tags gespeichert. Um diese Datei zu erstellen, wird die Klasse OptimisatorSettingsManager von der Schnittstelle IDisposable geerbt und die angegebenen Werte werden in der Methode Dispose in der Datei gespeichert. Getters der entsprechenden Eigenschaften werden verwendet, um Daten aus der Datei zu lesen.

#region OptimisationInputData
/// <summary>
/// The OptimisationInputData structure for saving data
/// </summary>
private OptimisationInputData? _optimisationInputData = null;
/// <summary>
/// Get and save the OptimisationInputData structure
/// </summary>
public virtual OptimisationInputData OptimisationInputData
{
    get
    {
        return new OptimisationInputData
        {
            Login = StrToUintNullable(GetItem(NodeType.OptimisationInputData, "Login")),
            ForvardDate = DateTime.ParseExact(GetItem(NodeType.OptimisationInputData, "ForvardDate"), DTFormat, null),
            IsVisual = Convert.ToBoolean(GetItem(NodeType.OptimisationInputData, "IsVisual")),
            Deposit = Convert.ToInt32(GetItem(NodeType.OptimisationInputData, "Deposit")),
            Laverage = GetItem(NodeType.OptimisationInputData, "Laverage"),
            Currency = GetItem(NodeType.OptimisationInputData, "Currency"),
            DepositIndex = Convert.ToInt32(GetItem(NodeType.OptimisationInputData, "DepositIndex")),
            ExecutionDelayIndex = Convert.ToInt32(GetItem(NodeType.OptimisationInputData, "ExecutionDelayIndex")),
            ModelIndex = Convert.ToInt32(GetItem(NodeType.OptimisationInputData, "ModelIndex")),
            CurrencyIndex = Convert.ToInt32(GetItem(NodeType.OptimisationInputData, "CurrencyIndex")),
            LaverageIndex = Convert.ToInt32(GetItem(NodeType.OptimisationInputData, "LaverageIndex")),
            OptimisationCriteriaIndex = Convert.ToInt32(GetItem(NodeType.OptimisationInputData, "OptimisationCriteriaIndex"))
        };
    }
    set => _optimisationInputData = value;
}
#endregion

In diesem spezifischen Beispiel wird die Struktur OptimisationInputData im Getter erhalten. Die Werte für die Struktur werden aus der obigen Datei übernommen. Die GetItem-Methode im Getter wird verwendet, um Daten aus der Datei zu übernehmen. Die Methode hat 2 Parameter:

  1. Der Typ des Knotens, von dem aus die Daten verwendet werden. 
  2. Der Name der Parameter, die im Attribut 'Name' angegeben wird. 

Hier ist die Implementation der Methode:

/// <summary>
/// Get element from a settings file
/// </summary>
/// <param name="NodeName">Structure type</param>
/// <param name="Name">Field name</param>
/// <returns>
/// Field value
/// </returns>
public string GetItem(NodeType NodeName, string Name)
{
    if (!document.HasChildNodes)
        document.Load(Path.Combine(PathToReportDataDirectory, SettingsFileName));

    return document.SelectSingleNode($"/Settings/{NodeName.ToString()}/Item[@Name='{Name}']").InnerText;
}

Diese Datenerfassungsmethode verwendet die Sprache Xpath, die SQL ähnlich ist, aber auf das XML-Format angewendet wird. Um Daten vom gewünschten Knoten mit dem angegebenen Attributwert zu erhalten, geben Sie den vollständigen Pfad zu diesem Knoten an, dann müssen wir im letzten Elementknoten die folgende Bedingung angeben: das Attribut Name muss gleich dem übergebenen Namen sein. Somit werden alle Strukturen aus der Datei gelesen. Für die Liste der Parameter wird eine andere Methode verwendet, da diese Knotenstruktur komplexer ist.

#region SetFileParams
/// <summary>
/// List of parameters to save
/// </summary>
private List<ParamsItem> _setFileParams = new List<ParamsItem>();
/// <summary>
/// Get and set (.set) file parameters to save
/// </summary>
public List<ParamsItem> SetFileParams
{
    get
    {
        if (!document.HasChildNodes)
            document.Load(Path.Combine(PathToReportDataDirectory, SettingsFileName));
        var data = document["Settings"]["SetFileParams"];

        List<ParamsItem> ans = new List<ParamsItem>();
        foreach (XmlNode item in data.ChildNodes)
        {
            ans.Add(new ParamsItem(item.Attributes["Name"].Value)
            {
                Value = item["Value"].InnerText,
                Start = item["Start"].InnerText,
                Step = item["Step"].InnerText,
                Stop = item["Stop"].InnerText,
                IsOptimize = Convert.ToBoolean(item["IsOptimize"].InnerText)
            });
        }

        return ans;
    }
    set { if (value.Count > 0) _setFileParams = value; }
}
#endregion

In diesem Fall durchlaufen wir alle Knoten <Variable/>. Von jedem von ihnen erhalten wir den Attributwert 'Name' und füllen die Klasse ParamItem mit den Daten, die in diesem spezifischen Knoten ParamsItem enthalten sind.

Die abschließende Methode Dispose(), bei der wir beschlossen haben, Daten in einer Datei zu speichern, wird durch die folgende Implementierung dargestellt:

public virtual void Dispose()
{
    // Nested method which assists in writing of structure elements
    void WriteItem(XmlTextWriter writer, string Name, string Value)
    {
        writer.WriteStartElement("Item");

        writer.WriteStartAttribute("Name");
        writer.WriteString(Name);
        writer.WriteEndAttribute();

        writer.WriteString(Value);

        writer.WriteEndElement();
    }
    void WriteElement(XmlTextWriter writer, string Node, string Value)
    {
        writer.WriteStartElement(Node);
        writer.WriteString(Value);
        writer.WriteEndElement();
    }

    // firstly clean the file storing xml markup of the settings file
    if (document != null)
        document.RemoveAll();

    // then check if the results can be saved
    if (!_configInputData.HasValue ||
        !_optimisationInputData.HasValue ||
        _setFileParams.Count == 0)
    {
        return;
    }

    using (var xmlWriter = new XmlTextWriter(Path.Combine(PathToReportDataDirectory, SettingsFileName), null))
    {
        xmlWriter.Formatting = Formatting.Indented;
        xmlWriter.IndentChar = '\t';
        xmlWriter.Indentation = 1;

        xmlWriter.WriteStartDocument();

        xmlWriter.WriteStartElement("Settings");

        xmlWriter.WriteStartElement("OptimisationInputData");
        WriteItem(xmlWriter, "Login", _optimisationInputData.Value.Login.ToString());
        WriteItem(xmlWriter, "ForvardDate", _optimisationInputData.Value.ForvardDate.ToString(DTFormat));
        WriteItem(xmlWriter, "IsVisual", _optimisationInputData.Value.IsVisual.ToString());
        WriteItem(xmlWriter, "Deposit", _optimisationInputData.Value.Deposit.ToString());
        WriteItem(xmlWriter, "Laverage", _optimisationInputData.Value.Laverage);
        WriteItem(xmlWriter, "Currency", _optimisationInputData.Value.Currency);
        WriteItem(xmlWriter, "DepositIndex", _optimisationInputData.Value.DepositIndex.ToString());
        WriteItem(xmlWriter, "ExecutionDelayIndex", _optimisationInputData.Value.ExecutionDelayIndex.ToString());
        WriteItem(xmlWriter, "ModelIndex", _optimisationInputData.Value.ModelIndex.ToString());
        WriteItem(xmlWriter, "CurrencyIndex", _optimisationInputData.Value.CurrencyIndex.ToString());
        WriteItem(xmlWriter, "LaverageIndex", _optimisationInputData.Value.LaverageIndex.ToString());
        WriteItem(xmlWriter, "OptimisationCriteriaIndex", _optimisationInputData.Value.OptimisationCriteriaIndex.ToString());
        xmlWriter.WriteEndElement();

        xmlWriter.WriteStartElement("ConfigCreator_inputData");
        WriteItem(xmlWriter, "TF", ((int)_configInputData.Value.TF).ToString());
        WriteItem(xmlWriter, "Login", _configInputData.Value.Login.ToString());
        WriteItem(xmlWriter, "TerminalID", _configInputData.Value.TerminalID.ToString());
        WriteItem(xmlWriter, "pathToBot", _configInputData.Value.pathToBot);
        WriteItem(xmlWriter, "setFileName", _configInputData.Value.setFileName);
        WriteItem(xmlWriter, "Pass", _configInputData.Value.Pass);
        WriteItem(xmlWriter, "CertPass", _configInputData.Value.CertPass);
        WriteItem(xmlWriter, "Server", _configInputData.Value.Server);
        WriteItem(xmlWriter, "Symbol", _configInputData.Value.Symbol);
        WriteItem(xmlWriter, "ReportName", _configInputData.Value.ReportName);
        WriteItem(xmlWriter, "From", _configInputData.Value.From.ToString(DTFormat));
        WriteItem(xmlWriter, "Till", _configInputData.Value.Till.ToString(DTFormat));
        WriteItem(xmlWriter, "OptimisationMode", ((int)_configInputData.Value.OptimisationMode).ToString());
        xmlWriter.WriteEndElement();

        xmlWriter.WriteStartElement("SetFileParams");
        foreach (var item in _setFileParams)
        {
            xmlWriter.WriteStartElement("Variable");

            xmlWriter.WriteStartAttribute("Name");
            xmlWriter.WriteString(item.Variable);
            xmlWriter.WriteEndAttribute();

            WriteElement(xmlWriter, "Value", item.Value);
            WriteElement(xmlWriter, "Start", item.Start);
            WriteElement(xmlWriter, "Step", item.Step);
            WriteElement(xmlWriter, "Stop", item.Stop);
            WriteElement(xmlWriter, "IsOptimize", item.IsOptimize.ToString());

            xmlWriter.WriteEndElement();
        }
        xmlWriter.WriteEndElement();

        xmlWriter.WriteEndElement();
        xmlWriter.WriteEndDocument();
        xmlWriter.Close();
    }
}

Zu Beginn dieser Methode werden zwei verschachtelte Funktionen angelegt. Die Funktion WriteItem ermöglicht es, den sich wiederholenden Codeblock abzulösen, der zum Schreiben der Strukturelemente verwendet wird. Die Funktion WriteElement dient zum Speichern der Werte von Optimierungsparametern wie Start, Step, Stop, IsOptimize. Alle drei Tags müssen in der Einstellungsdatei vorhanden sein. Deshalb fügen wir vor dem Schreiben eine Prüfeinheit hinzu, die das Schreiben von Dateien verhindern soll, wenn nicht alle erforderlichen Parameter übergeben wurden. Anschließend werden die Daten in die Datei im zuvor beschriebenen Konstrukt 'using' geschrieben. Die Verwendung verschachtelter Funktionen ermöglichte es, die Anzahl der mit dem Schreiben von Daten in eine Datei verbundenen Daten um mehr als das Dreifache zu reduzieren.  

Testen von Schlüsselobjekten

Zum Schluss möchte ich noch ein paar Anmerkungen zum Testen der Anwendung hinzufügen. Da das Add-on weiter erweitert und modifiziert wird, entschied ich mich, Tests zu schreiben, um die wichtigsten Objekte zu überprüfen. Später werden wir in der Lage sein, ihre Leistung leicht zu überprüfen, wenn es notwendig wird, sie zu ändern. Aktuelle Tests decken teilweise die folgenden Klassen ab:

  • Config
  • ReportReader
  • OptimisationSettingsManager
  • SetFileManager
  • TerminalManager

Im nächsten Artikel werden die in weiteren Kapiteln beschriebenen Klassen modifiziert. Diese Änderungen wirken sich auf die Logik und das Ergebnis einiger Methoden aus, sodass diese Klassen nicht in Unit-Tests behandelt werden. Tests für diese Klassen werden im nächsten Artikel implementiert. Es ist auch erwähnenswert, dass trotz der Tatsache, dass die Tests als Komponententests geschrieben wurden, alle derzeit Integrationstests sind, da sie mit externen Objekten (Terminal / Dateisystem und anderen) interagieren. Die folgenden geplanten Objekte sollen unabhängig von den oben beschriebenen Objekten getestet werden, dh als Komponententests. Zu diesem Zweck gibt es vor jedem der oben beschriebenen Objekte eine Factory für deren Erstellung. Ein Beispiel für eine solche Factory ist die Factory zum Erstellen der ReportReader-Klasse:

#region ReportReaderFabric
abstract class ReportReaderCreator
{
    public abstract ReportReader Create(string path);
}

class MainReportReaderCreator : ReportReaderCreator
{
    public override ReportReader Create(string path)
    {
        return new ReportReader(path);
    }
}
#endregion

Sein Code ist einfach: Wir packen die Erstellung eines Objekts vom Typ ReportReader in die Klasse MainReportReaderCreator, die von der Klasse ReportReaderFabric abgeleitet ist. Dieser Ansatz ermöglicht die Übergabe eines Objekttyps als ReportReaderFabric an Schlüsselobjekte (beschrieben in weiteren Kapiteln). Hier kann die Implementierung einer bestimmten Factory unterschiedlich sein. So können Klassen, die mit den Dateien und dem Terminal arbeiten, in Unit-Tests durch Schlüsselobjekte ersetzt werden. Auch die Abhängigkeit der Klassen voneinander wird reduziert. Dieser Ansatz zur Bildung von Objekten wird als Factory-Method bezeichnet.

Die Durchführung zukünftiger Tests wird im nächsten Artikel ausführlich behandelt. Ein Beispiel für die Anwendung der Factory-Method zur Erzeugung von Objekten wird in späteren Kapiteln behandelt. Lassen Sie uns nun erwägen, eine Klasse zu testen, die mit Konfigurationsdateien arbeitet. Alle Tests innerhalb des aktuellen Projekts sollten in einem separaten Projekt, dem "Unit Test Project", zusammengefasst werden.


Nennen wir es "OptimisationManagerExtentionTests", da für das Projekt "OptimisationManagerExtention" Tests geschrieben werden. Im nächsten Schritt werden Links zum Projekt "OptimisationManagerExtention" hinzugefügt, d.h. zu unserer DLL mit der grafischen Oberfläche und Logik. Wir müssen Objekte testen, die nicht mit dem Zugriffsmodifikator 'public' gekennzeichnet sind. Es gibt zwei Methoden, um sie in unserem Testprojekt zur Verfügung zu stellen:

  1. Sie 'public' machen (was falsch ist, weil sie nur innerhalb des Projekts verwendet werden)
  2. Hinzufügen der Möglichkeit, interne Klassen innerhalb des spezifischen Projekts anzuzeigen (was eine bessere Methode ist)

Ich habe die zweite Methode zur Lösung dieses Problems verwendet und dem Hauptprojektcode das folgende Attribut hinzugefügt:

[assembly: InternalsVisibleTo("OptimisationManagerExtentionTests")]

Der nächste Schritt ist das Schreiben von Tests für die ausgewählten Klassen. Da das Testprojekt nur ein Hilfsprojekt ist, werden wir nicht jede Testklasse berücksichtigen. Stattdessen ist hier eine Klasse als Beispiel angegeben. Aus Gründen der Übersichtlichkeit finden Sie hier die vollständige Klasse zum Testen der Config-Klasse. Die erste Bedingung für diese Klassenprüfung ist das Hinzufügen des Attributs [TestClass]. Auch die getestete Klasse muss 'public' sein, und ihre Testmethoden sollten das Attribut [TestMethod] haben, während der gesamte Testprozess in ihnen implementiert wird. Das mit [TestInitialize] markierte Verfahren wird jedes Mal vor Beginn des Tests gestartet. Es gibt ein ähnliches Attribut[ClassInitialize], das in diesem Test nicht verwendet wird, aber in anderen Tests verwendet wird. Im Gegensatz zu einer Methode, die mit [TestInitialize] markiert ist, wird sie nur einmal vor dem ersten Teststart gestartet. Am Ende jeder der Testmethoden steht der Aufruf von eine der Methoden der Assertklasse, die den Testwert mit der gewünschten vergleichen. Somit wird der Test entweder bestätigt oder widerlegt.        

[TestClass]
public class ConfigTests
{
    private string ConfigName = $"{Environment.CurrentDirectory}\\MyTestConfig.ini";
    private string first_excention = "first getters call mast be null because file doesn't contain this key";
    Config config;

    [TestInitialize]
    public void TestInitialize()
    {
        if (File.Exists(ConfigName))
            File.Delete(ConfigName);
        config = new Config(ConfigName);
    }
    [TestMethod]
    public void StringConverter_GetSetTest()
    {
        string expected = null;
 
        // first get
        string s = config.Common.Password;
        Assert.AreEqual(expected, s, first_excention);

        // set
        expected = "MyTestPassward";
        config.Common.Password = expected;
        s = config.Common.Password;
        Assert.AreEqual(expected, s, "Login mast be equal to MyTestLogin");

        // set null
        config.Common.Login = null;
        s = config.Common.Password;
        Assert.AreEqual(expected, s, "Login mast be equal to MyTestLogin");
    }
    [TestMethod]
    public void ServerConverter_GetSetTest()
    {
        ServerAddressKeeper expected = null;

        // first get;
        ServerAddressKeeper server = config.Common.Server;
        Assert.AreEqual(expected, server);

        // set
        expected = new ServerAddressKeeper("193.219.127.76:4443"); // Open broker demo server
        config.Common.Server = expected;
        server = config.Common.Server;
        Assert.AreEqual(server.Address, expected.Address, $"Address must be {expected.Address}");
    }
    [TestMethod]
    public void BoolConverter_GetSetTest()
    {
        bool? expected = null;

        // first get
        bool? b = config.Common.ProxyEnable;
        Assert.AreEqual(expected, b, first_excention);

        // set
        Random gen = new Random();
        int prob = gen.Next(100);
        expected = prob <= 50;
        config.Common.ProxyEnable = expected;
        b = config.Common.ProxyEnable;
        Assert.AreEqual(expected.Value, b.Value, "ProxyEnables must be equal to true");

        // set null
        config.Common.ProxyEnable = null;
        b = config.Common.ProxyEnable;
        Assert.AreEqual(expected.Value, b.Value, "ProxyEnables must be equal to true");

    }
    [TestMethod]
    public void ENUMConverter_GetSetTest()
    {
        ENUM_ProxyType? expected = null;

        // first get
        ENUM_ProxyType? p = config.Common.ProxyType;
        Assert.AreEqual(expected, p, first_excention);

        // set
        Random gen = new Random();
        int prob = gen.Next(300);
        int n = prob <= 100 ? 0 : (prob > 100 && prob <= 200 ? 1 : 2);
        expected = (ENUM_ProxyType)n;

        config.Common.ProxyType = expected;
        p = config.Common.ProxyType;
        Assert.AreEqual(expected.Value, p.Value, $"ProxyType must be equal to {expected.Value}");

        // set null
        config.Common.ProxyEnable = null;
        p = config.Common.ProxyType;
        Assert.AreEqual(expected.Value, p.Value, $"ProxyType must be equal to {expected.Value}");
    }
    [TestMethod]
    public void DTConverter_GetSetTest()
    {
        DateTime? expected = null;

        // first get
        DateTime? p = config.Tester.FromDate;
        Assert.AreEqual(expected, p, first_excention);

        // set
        expected = DateTime.Now;

        config.Tester.FromDate = expected;
        p = config.Tester.FromDate;
        Assert.AreEqual(expected.Value.Date, p.Value.Date, $"ProxyType must be equal to {expected.Value}");

        // set null
        config.Common.ProxyEnable = null;
        p = config.Tester.FromDate;
        Assert.AreEqual(expected.Value.Date, p.Value.Date, $"ProxyType must be equal to {expected.Value}");
    }
    [TestMethod]
    public void DoubleConverter_GetSetTest()
    {
        double? expected = null;

        // first get
        double? p = config.Tester.Deposit;
        Assert.AreEqual(expected, p, first_excention);

        // set
        Random rnd = new Random();
        expected = rnd.NextDouble();

        config.Tester.Deposit = expected;
        p = config.Tester.Deposit;
        Assert.AreEqual(Math.Round(expected.Value, 6), Math.Round(p.Value, 6), $"Deposit must be equal to {expected.Value}");

        // set null
        config.Common.ProxyEnable = null;
        p = config.Tester.Deposit;
        Assert.AreEqual(Math.Round(expected.Value, 6), Math.Round(p.Value, 6), $"Deposit must be equal to {expected.Value}");
    }
    [TestMethod]
    public void DeleteKeyTest()
    {
        config.Common.Login = 12345;
        config.DeleteKey(ENUM_SectionType.Common, "Login");

        Assert.AreEqual(null, config.Common.Login, "Key must be deleted");
    }
    [TestMethod]
    public void DeleteSectionTest()
    {
        config.Common.Login = 12345;
        config.DeleteSection(ENUM_SectionType.Common);

        Assert.AreEqual(null, config.Common.Login, "Key must be deleted");
    }
}

Wenn wir diese spezielle Testklasse betrachten, ist zu beachten, dass sie nicht alle erforderlichen Methoden abdeckt, sondern die Klasse Config.Converter testet, die im Wesentlichen die gesamte Logik der Operationen mit der Konfigurationsdatei durchführt. Da es sich jedoch um eine 'private' Klasse handelt, müssen wir Tests nicht für die Klasse selbst, sondern für die Eigenschaften dieser Klasse schreiben. Beispielsweise testet DoubleConverter_GetSetTest() die Korrektheit der Konvertierung von 'string' in 'Double' über die Eigenschaft config.Tester.Deposit. Diese spezifische Prüfung besteht aus drei Teilen:

  1. Parameter vom Typ double abfragen aus dem nicht angelegten Feld - es sollte Null zurückgegeben werden.
  2. Schreiben eines Zufallswertes in eine Datei und Lesen derselben
  3. Eingabe von Null, die ignoriert werden soll

Wird ein Fehler in einer der Stufen erkannt, kann er leicht erkannt und korrigiert werden. Somit sind die Tests für die Anwendungsentwicklung nützlich. Nachdem Sie alle Tests erstellt haben, können Sie sie direkt aus VisualStudio heraus ausführen, der Start erfolgt über den Pfad Test => Ausführen => AlleTests


Sie können auch für Leser mit unterschiedlichen regionalen Computerstandards nützlich sein: Durch die Durchführung dieser Tests können Sie mögliche Fehler (z.B. in Bezug auf das Dezimaltrennzeichen) erkennen und beheben.

Optimierungsmanager (OptimissationManager)

Eines der Kriterien für die Anwendung ist die Erweiterbarkeit. Der Optimierungsprozess wird im nächsten Artikel geändert, während die Hauptbenutzeroberfläche des Add-ons keine wesentlichen Änderungen erfordert. Deshalb habe ich mich entschieden, den Optimierungsprozess nicht als Modellklasse, sondern als abstrakte Klasse zu implementieren, deren Implementierung von der Methode der gewünschten Optimierung abhängen kann. Diese Klasse wird nach der Werksvorlage der abstrakten Klasse geschrieben. Beginnen wir mit der Factory:

/// <summary>
/// Factory for creating classes that manage the optimization process
/// </summary>
abstract class OptimisationManagerFabric
{
    /// <summary>
    /// Constructor
    /// </summary>
    /// <param name="ManagerName">The name of the created optimization manager</param>
    public OptimisationManagerFabric(string ManagerName)
    {
        this.ManagerName = ManagerName;
    }
    /// <summary>
    /// Name reflecting the type of the created optimization manager (its features)
    /// </summary>
    public string ManagerName { get; }
    /// <summary>
    /// Method creating the optimization manager
    /// </summary>
    /// <returns>Optimization manager</returns>
    public abstract OptimisationManager Create(Dictionary<string, BotParamKeeper> botParamsKeeper,
                                               List<ViewModel.TerminalAndBotItem> selectedTerminals);
}

Wie aus der abstrakten Factory-Klasse ersichtlich, enthält sie den Namen der implementierten Klasse, die in weiteren Artikeln verwendet wird, sowie die Methode zum Erstellen des Optimierungsmanagers. Es wird davon ausgegangen, dass der Optimierungsmanager vor jeder Optimierung erstellt wird und dann mit dem Terminal in Betrieb geht. Daher werden Parameter wie ein Verzeichnis mit einer Liste von Expert Advisor und einer Liste von Terminals (d.h. Parameter, die sich in verschiedenen Optimierungen unterscheiden) an die Methode Objekterstellung übergeben. Alle anderen erforderlichen Parameter werden vom Konstruktor an die Klasse der jeweiligen Factory übergeben. Betrachten wir nun die Klasse OptimisationManager. Diese Klasse ist für das Management von Optimierungen konzipiert. Darüber hinaus ist sie für die Durchführung von Tests verantwortlich. Da der Start von Tests fast immer nach dem gleichen Algorithmus erfolgt, ist diese Funktionalität direkt in der betrachteten abstrakten Klasse implementiert. Wir werden die Implementierung der Klasse unten betrachten. Wie bei der Optimierung Start und Stop ist diese Funktionalität in zwei abstrakten Methoden implementiert, die eine Implementierung in der Unterklasse erfordern. Der Klassenkonstrukteur akzeptiert das Übermaß an Factories und kann somit mit allen oben genannten Objekten arbeiten.

public OptimisationManager(TerminalDirectory terminalDirectory,
                                   TerminalCreator terminalCreator,
                                   ConfigCreator configCreator,
                                   ReportReaderCreator reportReaderCreator,
                                   SetFileManagerCreator setFileManagerCreator,
                                   OptimisationExtentionWorkingDirectory currentWorkingDirectory,
                                   Dictionary<string, BotParamKeeper> botParamsKeeper,
                                   Action<double, string, bool> pbUpdate,
                                   List<ViewModel.TerminalAndBotItem> selectedTerminals,
                                   OptimisatorSettingsManagerCreator optimisatorSettingsManagerCreator)

Das Ereignis AllOptimisationsFinished wird verwendet, um die Modellklasse über Optimierungsabschlüsse zu informieren. Die folgende Eigenschaft ermöglicht den Zugriff auf die in diesem Optimierungsmanager enthaltenen Daten über Terminals und Roboter aus der Modellklasse.

/// <summary>
/// Dictionary where:
/// key - terminal ID
/// value - full path to the robot
/// </summary>
public virtual Dictionary<string, string> TerminalAndBotPairs
{
    get
    {
        Dictionary<string, string> ans = new Dictionary<string, string>();
        foreach (var item in botParamsKeeper)
        {
            ans.Add(item.Key, item.Value.BotName);
        }
        return ans;
    }
}

Diese Eigenschaft ist in einer abstrakten Klasse implementiert, kann aber neu geschrieben werden, da sie mit dem Schlüsselwort 'virtual' spezifiziert ist. Um der Modellklasse die Möglichkeit zu geben, herauszufinden, ob der Optimierungs-/Testprozess gestartet wurde, wurde eine entsprechende Eigenschaft angelegt. Die Werte für die Eigenschaft werden von den Methoden festgelegt, die den Optimierungs-/Testprozess starten.

public bool IsOptimisationOrTestInProcess { get; private set; } = false;

Zur Vereinfachung wird eine lange Klasse, die in den meisten Fällen in den Optimierungs- und Teststartklassen unverändert bleibt, direkt in der abstrakten Klasse implementiert. Dies ist die Methode, die eine Konfigurationsdatei bildet. 

protected virtual Config CreateConfig(ConfigCreator_inputData data,
                                      OptimisationInputData optData)
{
    DirectoryInfo termonalChangableFolder = terminalDirectory.Terminals.Find(x => x.Name == data.TerminalID);

    Config config = configCreator.Create(Path.Combine(termonalChangableFolder.GetDirectory("config").FullName, "common.ini"))
                                         .DublicateFile(Path.Combine(currentWorkingDirectory.Configs.FullName, $"{data.TerminalID}.ini"));

    // Fill the configuration file
    config.Common.Login = data.Login;
    config.Common.Password = data.Pass;
    config.Common.CertPassword = data.CertPass;
    if (!string.IsNullOrEmpty(data.Server) || !string.IsNullOrWhiteSpace(data.Server))
    {
        try
        {
            config.Common.Server = new ServerAddressKeeper(data.Server);
        }
        catch (Exception e)
        {
            System.Windows.MessageBox.Show($"Server address was incorrect. Your adress is '{data.Server}' but mast have following type 'IPv4:Port'" +
                                           $"\nError message:\n{e.Message}\n\nStack Trace is {e.StackTrace}");
            return null;
        }
    }

    bool IsOptimisation = (data.OptimisationMode == ENUM_OptimisationMode.Fast_genetic_based_algorithm ||
                           data.OptimisationMode == ENUM_OptimisationMode.Slow_complete_algorithm);

    config.Tester.Expert = data.pathToBot;
    config.Tester.ExpertParameters = data.setFileName;
    сonfig.Tester.Symbol = data.Symbol;
    config.Tester.Period = data.TF;
    config.Tester.Login = optData.Login;
    config.Tester.Model = optData.Model;
    config.Tester.ExecutionMode = optData.ExecutionDelay;
    config.Tester.Optimization = data.OptimisationMode;
    с data.From;
    config.Tester.ToDate = data.Till;
    config.Tester.ForwardMode = ENUM_ForvardMode.Custom;
    config.Tester.ForwardDate = optData.ForvardDate;
    config.Tester.ShutdownTerminal = IsOptimisation;
    config.Tester.Deposit = optData.Deposit;
    config.Tester.Currency = optData.Currency;
    config.Tester.Leverage = optData.Laverage;
    config.Tester.OptimizationCriterion = optData.GetOptimisationCriteria;
    config.Tester.Visual = optData.IsVisual;

    if (IsOptimisation)
    {
        config.Tester.Report = data.ReportName;
        config.Tester.ReplaceReport = true;
    }

    return config;
}

Zuerst erstellen wir mit der Klasse, die das Variablenverzeichnis des Terminals beschreibt und dem von der Factory erstellten Objekte vom Typ Config, ein Objekt der Konfigurationsdatei und kopieren es in das entsprechende Verzeichnis unseres Add-ons. Wir setzen seinen Namen gleich der ID des Terminals, zu dem die ursprüngliche Konfigurationsdatei gehörte. Dann wird der Abschnitt [Tester] in der kopierten Konfigurationsdatei ausgefüllt. Alle Daten, die diesen Abschnitt ausfüllen sollen, werden direkt aus den übergebenen Strukturen übernommen, die entweder im Code gebildet werden (bei Optimierung) oder aus der Datei entnommen werden (bei Teststart). Wird der Server falsch übergeben, wird eine entsprechende Nachricht als MessageBox ausgegeben, während anstelle der Konfigurationsdatei null zurückgegeben wird. Für den gleichen Zweck, um wiederholten Code zu trennen, ist in der abstrakten Klasse eine Methode implementiert, die den Terminal-Manager erstellt. Hier ist es:

protected virtual ITerminalManager GetTerminal(Config config, string TerminalID)
{
    DirectoryInfo TerminalChangebleFolder = terminalDirectory.Terminals.Find(x => x.Name == TerminalID);

    ITerminalManager terminal = terminalCreator.Create(TerminalChangebleFolder);
    terminal.Config = config;

    if (MQL5Connector.MainTerminalID == terminal.TerminalID)
        terminal.Portable = true;

     return terminal;
}

Wenn die ID des gewünschten Terminals mit der ID des Terminals übereinstimmt, von dem aus das Add-on gestartet wird, ist das Terminal so konfiguriert, dass es im Portable-Modus startet, aber die App kann nur dann ordnungsgemäß funktionieren, wenn das Terminal im Standardmodus gestartet wird. Daher hat es einen Filter, der das aktuelle Terminal ignoriert und es nicht in die Liste der verfügbaren Terminals aufnimmt.

Das Verfahren, das den Test im ausgewählten Terminal bei einem Doppelklickereignis startet, ist auch in der abstrakten Klasse implementiert:

/// <summary>
/// Method for launching a test upon a double-click event 
/// </summary>
/// <param name="TerminalID">ID of the selected terminal</param>
/// <param name="pathToBot">Path to the robot relative to the experts tab</param>
/// <param name="row">Row from the optimizations table</param>
public virtual void StartTest(ConfigCreator_inputData data,
                              OptimisationInputData optData)
{
    pbUpdate(0, "Start Test", true);

    double pb_step = 100.0 / 3;

    IsOptimisationOrTestInProcess = true;

    pbUpdate(pb_step, "Create Config File", false);
    Config config = CreateConfig(data, optData);
    config.Tester.Optimization = ENUM_OptimisationMode.Disabled;
    config.Tester.ShutdownTerminal = false;
    config.DeleteKey(ENUM_SectionType.Tester, "ReplaceReport");
    config.DeleteKey(ENUM_SectionType.Tester, "Report");

    pbUpdate(pb_step, "Create TerminalManager", false);
    ITerminalManager terminal = GetTerminal(config, data.TerminalID);

    pbUpdate(pb_step, "Testing", false);
    terminal.Run();
    terminal.WaitForStop();
    IsOptimisationOrTestInProcess = false;
    pbUpdate(0, null, true);
}

In den Eingaben werden aus der Datei mit den gespeicherten Einstellungen die Daten übernommen, die in der das Modell beschreibenden Klasse empfangen werden. Innerhalb der Methode werden die Werte der Fortschrittsanzeige und der Betriebsstatus auch über den bestandenen Delegierten gesetzt. Die erzeugte Konfigurationsdatei wird zum Ausführen des Testers angepasst: Die Schlüssel, die den Optimiererbericht beschreiben, werden gelöscht, und das automatische Abschalten des Terminals nach Beendigung des Testers wird deaktiviert. Nach dem Start des Terminals friert der Thread, der das Terminal gestartet hat, ein und wartet, bis sein Betrieb abgeschlossen ist. Somit wird das Formular über das Testende informiert. Um das Einfrieren der Form beim Optimierungs-/Prüfstart zu vermeiden, werden diese Prozesse im Zusammenhang mit dem Sekundärthreads gestartet. Wie bei der Optimierung, wie bereits erwähnt, ist dieser Prozess in einer geschützten abstrakten Methode implementiert. Es gibt jedoch eine öffentliche Methode, die in der abstrakten Klasse implementiert ist, die für einen korrekten Betrieb der Klasse erforderlich ist und nicht neu geschrieben werden kann.

/// <summary>
/// Launching optimization/testing for all planned terminals
/// </summary>
/// <param name="BotParamsKeeper">List of terminals, robots and robot parameters</param>
/// <param name="PBUpdate">The delegate editing the values of the progress bar and the status</param>
/// <param name="sturtup_status">Response from the function - only used if optimization/test could not be started 
/// reason for that is written here</param>
/// <returns>true - if successful</returns>
public void StartOptimisation()
{
    pbUpdate(0, "Start Optimisation", true);
    IsOptimisationOrTestInProcess = true;
 
    DoOptimisation();
    OnAllOptimisationsFinished();
    IsOptimisationOrTestInProcess = false;
    pbUpdate(0, null, true);
}
protected abstract void DoOptimisation();

/// <summary>
/// The method interrupting optimizations
/// </summary>
public abstract void BreakOptimisation();

Diese Methode regelt, in welcher Reihenfolge der Optimierungsprozess in Bezug auf die Aktualisierung des Fortschrittsbalkens, das Setzen von Optimierungsstart- und -endeflags sowie den Aufruf von Optimierungsdurchlauf-Abschlussereignissen ausgelöst wird.

Die letzte in der abstrakten Klasse implementierte Methode ist die Methode, die den Bericht in das Arbeitsverzeichnis des Add-ons verschiebt. Zusätzlich zur Berichtsverschiebung sollte eine Datei mit Optimierungseinstellungen erstellt werden, daher werden diese Aktionen in einer separaten Methode implementiert.

protected virtual void MoveReportToWorkingDirectery(ITerminalManager terminalManager,
                                                    string FileName,
                                                    ConfigCreator_inputData ConfigCreator_inputData,
                                                    OptimisationInputData OptimisationInputData)
{
    FileInfo pathToFile_history = new FileInfo(Path.Combine(terminalManager.TerminalChangeableDirectory.FullName, $"{FileName}.xml"));
    FileInfo pathToFile_forward = new FileInfo(Path.Combine(terminalManager.TerminalChangeableDirectory.FullName, $"{FileName}.forward.xml"));
    int _i = 0;
    while (_i <= 100 && (!pathToFile_history.Exists && !pathToFile_forward.Exists))
    {
        _i++;
        System.Threading.Thread.Sleep(500);
    }

    string botName = new FileInfo(terminalManager.Config.Tester.Expert).Name.Split('.')[0];
    DirectoryInfo terminalReportDirectory = currentWorkingDirectory.Reports.GetDirectory(terminalManager.TerminalID, true);
    if (terminalReportDirectory == null)
        throw new Exception("Can`t create directory");
    DirectoryInfo botReportDir = terminalReportDirectory.GetDirectory(botName, true);
    if (botReportDir == null)
        throw new Exception("Can`t create directory");

    FileInfo _history = new FileInfo(Path.Combine(botReportDir.FullName, "History.xml"));
    FileInfo _forward = new FileInfo(Path.Combine(botReportDir.FullName, "Forward.xml"));

    if (_history.Exists)
        _history.Delete();
    if (_forward.Exists)
        _forward.Delete();

    if (pathToFile_history.Exists)
    {
        pathToFile_history.CopyTo(_history.FullName, true);
        pathToFile_history.Delete();
    }
    if (pathToFile_forward.Exists)
    {
        pathToFile_forward.CopyTo(_forward.FullName, true);
        pathToFile_forward.Delete();
    }

    string pathToSetFile = Path.Combine(terminalManager.TerminalChangeableDirectory
                                        .GetDirectory("MQL5")
                                        .GetDirectory("Profiles")
                                        .GetDirectory("Tester").FullName,
                                        ConfigCreator_inputData.setFileName);

    using (OptimisatorSettingsManager manager =
           optimisatorSettingsManagerCreator.Create(botReportDir.FullName))
    {
        manager.OptimisationInputData = OptimisationInputData;
        manager.ConfigCreator_inputData = ConfigCreator_inputData;
        manager.SetFileParams = setFileManagerCreator.Create(pathToSetFile, false).Params;
    }
}

Zuerst erhalten wir bei dieser Methode Pfade zu Dateien mit Berichten. Warten Sie dann in einer Schleife, bis eine der gewünschten Dateien erstellt wurde (nur eine, da nicht immer diese beiden Dateien erzeugt werden - dies kann bei der historischen Optimierung ohne Vorlaufphase passieren). Dann werden die Pfade zu dem Verzeichnis gebildet, in dem die Dateien mit dem Bericht gespeichert werden. Tatsächlich enthält dieses Codeausschnitt das Layout der Unterordner des Verzeichnisses Reports. Als Nächstes erstellen wir Pfade zu zukünftigen Dateien und löschen alte, falls vorhanden. Danach werden Berichte in das Add-On-Verzeichnis kopiert. Am Ende erstellen wir eine *.xml-Datei mit den Einstellungen, die beim Start der Optimierung verwendet wurden. Da dieser Prozess schrittweise durchgeführt werden sollte und es unwahrscheinlich ist, dass er geändert wird, wurde er in eine abstrakte Klasse verschoben, und um ihn zu starten, können wir diese Methode einfach aus der Unterklasse aufrufen.

Betrachten wir nun den implementierten Optimierungsprozess. Derzeit ist es ein üblicher Terminalstart mit ausgewählten Optimierungsparametern wie in einem Standard-Tester. Die interessantesten Aspekte der Implementierung sind der Start-Prozess und der Handler des Ereignisses des Optimierungsendes.

private readonly List<ITerminalManager> terminals = new List<ITerminalManager>();
/// <summary>
/// The method interrupts the optimization process and forcibly closes the terminals
/// </summary>
public override void BreakOptimisation()
{
    foreach (var item in terminals)
    {
        if (item.IsActive)
            item.Close();
    }
}
private void UnsubscribeTerminals()
{
    if (terminals.Count > 0)
    {
        foreach (var item in terminals)
        {
            item.TerminalClosed -= Terminal_TerminalClosed;
        }
        terminals.Clear();
    }
}

protected override void DoOptimisation()
{
    UnsubscribeTerminals();

    double pb_step = 100.0 / (botParamsKeeper.Count + 1);

    foreach (var item in botParamsKeeper)
    {
        pbUpdate(pb_step, item.Key, false);

        ConfigCreator_inputData configInputData = GetConfigCreator_inputData(item.Key);
        OptimisationInputData optData = item.Value.OptimisationData;

        Config config = CreateConfig(configInputData, optData);

        ITerminalManager terminal = GetTerminal(config, item.Key);
        terminal.TerminalClosed += Terminal_TerminalClosed;
        terminal.Run();

        terminals.Add(terminal);
    }

    pbUpdate(pb_step, "Waiting for Results", false);

    foreach (var item in terminals)
    {
        if (item.IsActive)
            item.WaitForStop();
    }
}

Die Liste der Terminalmanager ist in einem Feld implementiert und kann über verschiedene Methoden aufgerufen werden. Dies ermöglicht auch die Implementierung der Methode BreakOptimisations. In der Startmethode des Optimierungsprozesses abonnieren wir nach der Erstellung des Terminals das Abschlussevent des Terminals und können so den Abschluss der Optimierung verfolgen. Nach dem Start der Optimierung halten wir den Thread in einer Schleife, bis alle gestarteten Terminals geschlossen sind. Die Methode UnsubscribeTerminals wird verwendet, um im Falle eines Neustarts der Optimierung alle zuvor angemeldeten Ereignisse abzumelden. Die Methode wird in der Klasse Destruktor aufgerufen. Die Ereignisbehandlung des Optimierungsstopps ist wie folgt implementiert:

protected virtual void Terminal_TerminalClosed(ITerminalManager terminalManager)
{
    string FileName = new FileInfo(terminalManager.Config.Tester.Expert).Name.Split('.')[0];

    ConfigCreator_inputData ConfigCreator_inputDat = GetConfigCreator_inputData(terminalManager.TerminalID);
    OptimisationInputData optData = botParamsKeeper[terminalManager.TerminalID].OptimisationData;

    MoveReportToWorkingDirectery(terminalManager, FileName, ConfigCreator_inputDat, optData);
}

private ConfigCreator_inputData GetConfigCreator_inputData(string TerminalID)
{
    ViewModel.TerminalAndBotItem settingsData = selectedTerminals.Find(x => x.TerminalID == TerminalID);
    BotParamKeeper ParamKeeper = botParamsKeeper[TerminalID];

    ConfigCreator_inputData ConfigCreator_inputDat = new ConfigCreator_inputData
    {
        TerminalID = TerminalID,
        pathToBot = ParamKeeper.BotName,
        CertPass = settingsData.CertPass,
        From = settingsData.From,
        Till = settingsData.Till,
        Login = settingsData.Login,
        OptimisationMode = settingsData.GetOptimisationMode,
        Pass = settingsData.Pass,
        Server = settingsData.Server,
        setFileName = botParamsKeeper[TerminalID].BotParams.FileInfo.Name,
        Symbol = settingsData.AssetName,
        TF = settingsData.GetTF,
        ReportName = new FileInfo(ParamKeeper.BotName).Name.Split('.')[0]
    };

    return ConfigCreator_inputDat;
}

Sein Hauptzweck ist es, die Dateien mit dem Optimierungsbericht in das entsprechende Verzeichnis zu verschieben. Damit ist die Optimierungs- und Teststartlogik implementiert. Eine der Operationen, die wir im nächsten Artikel durchführen werden, ist die Implementierung zusätzlicher Optimierungsmethoden gemäß dem beschriebenen Beispiel. Wir haben fast die gesamte erstellte Anwendung geprüft. Lassen Sie uns nun die Hauptergebnisklasse sehen, die das Modell beschreibt, auf das in ViewModel verwiesen wird.

Die resultierende Modellklasse (IExtentionGUI_M und deren Implementierung)

Dieser Teil des beschriebenen Projekts implementiert die Schnittstelle IExtentionGUI_M und ist der Ausgangspunkt, der die Logik des beschriebenen Formulars implementiert. Der grafische Teil und ViewModel beziehen sich auf diese Klasse, um Daten zu empfangen und die Ausführung verschiedener Befehle zu delegieren. Beginnen wir mit der Schnittstelle, die wie folgt implementiert ist.

/// <summary>
/// Model interface
/// </summary>
interface IExtentionGUI_M : INotifyPropertyChanged
{
    #region Properties

    bool IsTerminalsLVEnabled { get; }
    List<FileReaders.ParamsItem> BotParams { get; }
    VarKeeper<string> Status { get; }
    VarKeeper<double> PB_Value { get; }
    ObservableCollection<string> TerminalsID { get; }
    DataTable HistoryOptimisationResults { get; }
    DataTable ForvardOptimisationResults { get; }
    ObservableCollection<ViewExtention.ColumnDescriptor> OptimisationResultsColumnHeadders { get; }
    ObservableCollection<string> TerminalsAfterOptimisation { get; }
    VarKeeper<int> TerminalsAfterOptimisation_Selected { get; set; }
    ObservableCollection<string> BotsAfterOptimisation { get; }
    VarKeeper<int> BotsAfterOptimisation_Selected { get; set; }

    #endregion

    void LoadOptimisations();
    void LoadBotParams(string fullExpertName,
        string TerminalID,
        out OptimisationInputData? optimisationInputData);
    List<string> GetBotNamesList(int terminalIndex);
    uint? GetCurrentLogin(int terminalIndex);
    void StartOptimisationOrTest(List<ViewModel.TerminalAndBotItem> SelectedTerminals);
    void StartTest(ENUM_TableType TableType, int rowIndex);
    bool RemoveBotParams(string TerminalID);
    bool IsEnableToAddNewTerminal();
    void SelectNewBotsAfterOptimisation_forNewTerminal();
    void UpdateTerminalOptimisationsParams(OptimisationInputData optimisationInputData);
}

#region Accessory objects 

/// <summary>
/// Enum characterizing the type of tables with optimization results
/// </summary>
enum ENUM_TableType
{
    History,
    Forvard
}

Dies ist die Schnittstelle, mit der ViewModel arbeitet. Bei Bedarf kann die Implementierung durch eine andere Implementierung ersetzt werden. In diesem Fall ist es nicht notwendig, den grafischen Teil des Programms zu ändern. Auf der anderen Seite können wir den grafischen Teil ändern, ohne seine Logik zu ändern. Die Schnittstelle wird von der Schnittstelle INotifyPropertyChanged geerbt und so erhalten wir die Möglichkeit, ViewModel und View zu benachrichtigen, wenn sich eine der in diesem Datenmodell implementierten Eigenschaften geändert hat. Zur Vereinfachung habe ich eine universelle Wrapper-Klasse VarKeeper hinzugefügt, die zusätzlich zum Speichern eines beliebigen Typwertes auch implizit auf den gespeicherten Typ angewendet werden kann, sowie ViewModel benachrichtigt, wenn sich der gespeicherte Wert geändert hat. Hier ist die Klassenimplementierung:

/// <summary>
/// Class storing the variable _Var of type T_keeper.
/// We can implicitly cast to type T_keeper and also change the value of the stored variable
/// At the time of changing the value it notifies all those which have subscribed
/// </summary>
/// <typeparam name="T_keeper">Type of stored variable</typeparam>
class VarKeeper<T_keeper>
{
    /// <summary>
    /// Constructor specifying the variable identification name
    /// </summary>
    /// <param name="propertyName">Variable identification name</param>
    public VarKeeper(string propertyName)
    {
        this.propertyName = propertyName;
    }
    /// <summary>
    /// Constructor specifying the variable identification name 
    /// and the initial value of the variable
    /// </summary>
    /// <param name="PropertyName">Identification name of the variable</param>
    /// <param name="Var">initial value of the variable</param>
    public VarKeeper(string PropertyName, T_keeper Var) : this(PropertyName)
    {
        _Var = Var;
    }
    /// <summary>
    /// Overloading the implicit type conversion operator.
    /// Converts this type to T_keeper
    /// </summary>
    /// <param name="obj"></param>
    public static implicit operator T_keeper(VarKeeper<T_keeper> obj)
    {
        return obj._Var;
    }
    /// <summary>
    /// stored variable 
    /// </summary>
    protected T_keeper _Var;
    /// <summary>
    /// Identification name of the variable
    /// </summary>
    public readonly string propertyName;
    #region Event 
    /// <summary>
    /// Event notifying about the change of the stored variable
    /// </summary>
    public event Action<string> PropertyChanged;
    /// <summary>
    /// Method that calls the event notifying about the change of the stored variable
    /// </summary>
    protected void OnPropertyChanged()
    {
        PropertyChanged?.Invoke(propertyName);
    }
    #endregion
    /// <summary>
    /// Method which sets the value of a variable with the 'value' value
    /// </summary>
    /// <param name="value">new value of the variable</param>
    public void SetVar(T_keeper value)
    {
        SetVarSilently(value);
        OnPropertyChanged();
    }
    public void SetVarSilently(T_keeper value)
    {
        _Var = value;
    }
}

Im Klassenkonstruktor übergeben wir den Initialwert der gespeicherten Variablen und den Namen der Variablen, mit der die Wertänderung gemeldet wird. Die Variable wird im Schutzfeld dieser Klasse gespeichert. Der Name der Variablen, mit der eine Wertänderung gemeldet wird, wird in einem öffentlichen schreibgeschützten Feld propertyName gespeichert. Methoden zur Einstellung des Variablenwerts werden unterteilt in die Methode, die ihren Wert setzt und ein Ereignis aufruft, um alle Teilnehmer über diese Änderung zu informieren, und die Methode, die nur den Variablenwert setzt. Um eine implizite Klassenkonvertierung in die gespeicherte Wertart zu ermöglichen, wird darin Überladung des Gießereioperators verwendet. Diese Klasse ermöglicht es uns, Variablenwerte zu speichern, sie ohne explizite Typkonvertierung zu lesen und die Umgebung über eine Änderung des Variablenwertes zu informieren. Setzen Sie im Konstruktor der Klasse, die die Schnittstelle IExtentionGUI_M implementiert, Werte auf Eigenschaften und abonnieren Sie sie, um über ein Update dieser Eigenschaften informiert zu werden. In diesem Destruktor der Klasse, wird man von den Eigenschafts-Ereignissen abgemeldet.

public ExtentionGUI_M(TerminalCreator TerminalCreator,
                      ConfigCreator ConfigCreator,
                      ReportReaderCreator ReportReaderCreator,
                      SetFileManagerCreator SetFileManagerCreator,
                      OptimisationExtentionWorkingDirectory CurrentWorkingDirectory,
                      OptimisatorSettingsManagerCreator SettingsManagerCreator,
                      TerminalDirectory terminalDirectory)
{
    // Assign the current working directory
    this.CurrentWorkingDirectory = CurrentWorkingDirectory;
    this.terminalDirectory = terminalDirectory;
    //Create factories
    this.TerminalCreator = TerminalCreator;
    this.ReportReaderCreator = ReportReaderCreator;
    this.ConfigCreator = ConfigCreator;
    this.SetFileManagerCreator = SetFileManagerCreator;
    this.SettingsManagerCreator = SettingsManagerCreator;
    CreateOptimisationManagerFabrics();

    //--- Bestimmen der Spaltenzahl der Ergebnistabelle
    HistoryOptimisationResults.Columns.CollectionChanged += Columns_CollectionChanged;

    // Assign initial status
    Status = new VarKeeper<string>("Status", "Wait for the operation");
    Status.PropertyChanged += OnPropertyChanged;
    // Assign initial values for the progress bar
    PB_Value = new VarKeeper<double>("PB_Value", 0);
    PB_Value.PropertyChanged += OnPropertyChanged;
    // Create a variable storing the index of terminal selected from the list of available terminals for which optimization was done
    TerminalsAfterOptimisation_Selected = new VarKeeper<int>("TerminalsAfterOptimisation_Selected", 0);
    TerminalsAfterOptimisation_Selected.PropertyChanged += OnPropertyChanged;
    // Create a variable storing the index of robot selected from the list of available robots for which optimization was done
    BotsAfterOptimisation_Selected = new VarKeeper<int>("BotsAfterOptimisation_Selected", -1);
    BotsAfterOptimisation_Selected.PropertyChanged += OnPropertyChanged;

    _isTerminalsEnabled = new VarKeeper<bool>("IsTerminalsLVEnabled", true);
    _isTerminalsEnabled.PropertyChanged += OnPropertyChanged;

    // Load data on terminals installed on the computer
    FillInTerminalsID();
    FillInTerminalsAfterOptimisation();
    LoadOptimisations();
}

Die folgenden Methoden werden im Konstruktor aufgerufen:

  • CreateOptimisationManagerFabrics — Factories, die Optimierungsmanager erstellen: Sie werden dem Array hinzugefügt; später wird daraus der gewünschte Optimierungsmanager nach unseren spezifischen Kriterien ausgewählt.
  • FillInTerminalsID — füllt die Liste der Terminal-IDs, die vor der Optimierung in der Auswahlliste der Terminals angezeigt werden. Alle gefundenen Terminals mit Ausnahme des aktuellen Terminals, das in die Liste aufgenommen wurde.
  • FillInTerminalsAfterOptimisation — füllt die Liste der Terminals, in denen bereits eine der Optimierungen durchgeführt wurde und für die Daten in die Optimierungsdaten geladen werden müssen.
  • LoadOptimiations — füllt die Optimierungs-Tabelle entsprechend dem ausgewählten Terminal und Roboter (beide Parameter haben derzeit den Index Null).

Damit realisieren wir die Hauptaufgabe des Konstrukteurs: Programmvorbereitung für den Betrieb, Befüllen aller Tabellen und Variablen mit Initialwerten. Der nächste Schritt ist die Arbeit mit den Tabellen der zur Optimierung ausgewählten Terminals. Alle ausgewählten Terminals werden im vocabulary in einem der Klassenfelder gespeichert.

/// <summary>
/// Presenting the table of selected terminals at the start tab of the add-on
/// key - Terminal ID
/// value - bot params
/// </summary>
private readonly Dictionary<string, BotParamKeeper> BotParamsKeeper = new Dictionary<string, BotParamKeeper>();
/// <summary>
/// Currently selected terminal
/// </summary>
private string selectedTerminalID = null;
/// <summary>
/// List of robot parameters to be edited
/// </summary>
List<ParamsItem> IExtentionGUI_M.BotParams
{
    get
    {
        return (BotParamsKeeper.Count > 0 && selectedTerminalID != null) ?
               BotParamsKeeper[selectedTerminalID].BotParams.Params :
               new List<ParamsItem>();
    }
}

Das BotParams empfängt die Liste der EA-Parameter aus diesem vocabulary, und wenn der ausgewählte Roboter geändert wird (der Mechanismus wird weiter beschrieben), greifen wir auf einen neuen Schlüssel in diesem vocabulary zu. Der Inhalt des vocabulary wird durch die Methode LoadBotParam gesteuert, die unmittelbar nach einem Klick auf die Schaltfläche zum Hinzufügen eines neuen Terminals aufgerufen wird, das aus der Dropdown-Liste auf der ersten Registerkarte dieses Add-ons ausgewählt wurde. Diese Methode ist wie folgt implementiert:

void IExtentionGUI_M.LoadBotParams(string fullExpertName,
            string TerminalID,
            out OptimisationInputData? optimisationInputData)
{
    PBUpdate(0, "Loading params", true);
    optimisationInputData = null;

    if (!IsTerminalsLVEnabled)
        return;

    _isTerminalsEnabled.SetVar(false);

    if (!BotParamsKeeper.Keys.Contains(TerminalID))
    {
        PBUpdate(100, "Add New Terminal", false);
        AddNewTerminalIntoBotParamsKeeper(fullExpertName, TerminalID);
    }
    else
    {
        if (selectedTerminalID != null)
            BotParamsKeeper[selectedTerminalID].BotParams.SaveParams();
        else
        {
            foreach (var item in BotParamsKeeper)
            {
                item.Value.BotParams.SaveParams();
            }
        }
    }

    selectedTerminalID = TerminalID;
    optimisationInputData = BotParamsKeeper[selectedTerminalID].OptimisationData;

    if (BotParamsKeeper[selectedTerminalID].BotName != fullExpertName)
    {
        PBUpdate(100, "Load new params", false);
        BotParamKeeper param = BotParamsKeeper[selectedTerminalID];
        param.BotName = fullExpertName;
        param.BotParams = GetSetFile(fullExpertName, TerminalID);
        BotParamsKeeper[selectedTerminalID] = param;
    }
    PBUpdate(0, null, true);
    _isTerminalsEnabled.SetVar(true);
}

Wie aus dem Code ersichtlich ist, blockiert der Code nicht nur die Benutzeroberfläche während des Optimierungs-Tests (wie im Video gezeigt), sondern beinhaltet auch einen Check, ob die Liste der Roboter- (und eventuell Terminal-) Parameter aktualisiert werden kann. Wenn die Roboter- oder Terminalparameter aktualisiert werden können, dann wird die die grafische Oberfläche gesperrt. Dann wird entweder ein neuer Roboter hinzugefügt, oder Parameter, die zuvor per GUI eingegeben wurden, werden gespeichert. Danach wird die ausgewählte Terminal-ID gespeichert (ein Schlüssel im vocabulary) und die Parameter des neu ausgewählten Roboters werden an ViewModel zurückgegeben. Wenn wir den ausgewählten Roboter gegenüber dem zuvor ausgewählten Roboter geändert haben, laden Sie die Parameter für ihn über die Methode GetSetFile hoch. Das Verfahren zum Hinzufügen eines neuen Terminals ist sehr einfach und wiederholt fast vollständig die letzte bedingte Konstruktion des betrachteten Verfahrens. Die Hauptarbeit wird von der Methode GetSetFile geleistet.

private SetFileManager GetSetFile(string fullExpertName, string TerminalID)
{
    DirectoryInfo terminalChangableFolder = terminalDirectory.Terminals.Find(x => x.Name == TerminalID);

    // Creating a manager for working with the terminal
    ITerminalManager terminalManager = TerminalCreator.Create(terminalChangableFolder);

    // Creating path to the Tester folder (which is under ~/MQL5/Profiles) 
    // If there is no such folder, create it yourself
    // Files with optimization parameter settings are stored in it
    DirectoryInfo pathToMqlTesterFolder = terminalManager.MQL5Directory.GetDirectory("Profiles").GetDirectory("Tester", true);
    if (pathToMqlTesterFolder == null)
        throw new Exception("Can`t find (or create) ~/MQL5/Profiles/Tester directory");

    // Create a configuration file and copy it to the Configs folder of the current working add-on directory
    Config config = ConfigCreator.Create(Path.Combine(terminalChangableFolder.GetDirectory("config").FullName, "common.ini"))
                                 .DublicateFile(Path.Combine(CurrentWorkingDirectory.Configs.FullName, $"{TerminalID}.ini"));
    // Configure the terminal so that it launches the selected robot test and immediately shuts down
    // Thus the terminal will create a .set file with this Expert Advisor settings.
    // To have it immediately shut down, specify the test end one day lower than the start date.
    config.Tester.Expert = fullExpertName;
    config.Tester.Model = ENUM_Model.OHLC_1_minute;
    config.Tester.Optimization = ENUM_OptimisationMode.Disabled;
    config.Tester.Period = ENUM_Timeframes.D1;
    config.Tester.ShutdownTerminal = true;
    config.Tester.FromDate = DateTime.Now.Date;
    config.Tester.ToDate = config.Tester.FromDate.Value.AddDays(-1);

    // Set configuration file to the terminal manager, launch it and wait for he terminal to close
    // To enable automatic terminal shut down after testing completion, 
    // assign the true value to field config.Tester.ShutdownTerminal
    terminalManager.Config = config;
    terminalManager.WindowStyle = System.Diagnostics.ProcessWindowStyle.Minimized;
    string fileName = $"{new FileInfo(fullExpertName).Name.Split('.')[0]}.set";

    while (!terminalManager.Run())
    {
        System.Windows.MessageBoxResult mb_ans =
            System.Windows.MessageBox.Show(@"Can`t start terminal 
Close manually all MetaTrader terminals that are running now (except main terminal)",
"Can`t start terminal", System.Windows.MessageBoxButton.OKCancel);
        if (mb_ans == System.Windows.MessageBoxResult.Cancel)
            break;
    }
    terminalManager.WaitForStop();

    bool isSetFileWasCreated = pathToMqlTesterFolder.GetFiles().Any(x => x.Name == fileName);

    return SetFileManagerCreator.Create(Path.Combine(pathToMqlTesterFolder.FullName, fileName), !isSetFileWasCreated);
}

Diese Methode ist gut kommentiert. Lassen Sie uns den Hauptzweck erklären. Das Verfahren empfängt Parameter des ausgewählten Roboters, d.h. dessen SET-Datei. Diese Datei wird vom Terminal erstellt, sobald der Roboter im Tester gestartet wird, so dass die einzige Möglichkeit, die Datei zu erzeugen, darin besteht, den ausgewählten Algorithmus im Tester auszuführen. Um dies nicht explizit durchzuführen, wird das Terminal mit einem laufenden Tester in einem minimierten Modus gestartet. Damit der Tester den Betrieb schnell abschließen und abschalten kann, setzen wir das Enddatum des Tests einen Tag früher als das Startdatum des Tests. Wenn das Terminal bereits läuft, wird ein Versuch, es zu öffnen, in einer Schleife durchgeführt und eine entsprechende Meldung angezeigt. Geben Sie nach der Operation die objektorientierte Darstellung der SET-Datei zurück.

Der nächste interessante Punkt in dieser Klasse ist der Optimierungsstartprozess, der von der asynchronen Methode StartOptimisationOrTest durchgeführt wird.

async void IExtentionGUI_M.StartOptimisationOrTest(List<ViewModel.TerminalAndBotItem> SelectedTerminals)
{
    if (BotParamsKeeper.Count == 0)
       return;
    foreach (var item in BotParamsKeeper)
    {
        item.Value.BotParams.SaveParams();
    }

    SetOptimisationManager(SelectedTerminals);

    // Run the optimization and wait for it to finish
    _isTerminalsEnabled.SetVar(false);
    await System.Threading.Tasks.Task.Run(() => selectedOptimisationManager.StartOptimisation());
    _isTerminalsEnabled.SetVar(true);
}

private void SetOptimisationManager(List<ViewModel.TerminalAndBotItem> SelectedTerminals)
{
    // Select a factory to create an optimization manager from the list
    OptimisationManagerFabric OMFabric = optimisationManagerFabrics[0];
    // Unsubscribe from a previously used optimization manager
    if (selectedOptimisationManager != null)
    {
        // Check if optimization is running at the moment
        if (selectedOptimisationManager.IsOptimisationOrTestInProcess)
            return;

        selectedOptimisationManager.AllOptimisationsFinished -= SelectedOptimisationManager_AllOptimisationsFinished;
    }

    // Create an optimization manager and subscribe it to the optimization completion event
    selectedOptimisationManager = OMFabric.Create(BotParamsKeeper, SelectedTerminals);
    selectedOptimisationManager.AllOptimisationsFinished += SelectedOptimisationManager_AllOptimisationsFinished;
}

Die Implementierung demonstriert die Verwendung des Optimierungsmanagers: Er wird vor jedem Optimierungsstart neu erstellt. In dieser Implementierung wird die Erstellung nur für den ersten Manager aus dem entsprechenden Array durchgeführt. Ein komplizierterer Prozess wird im nächsten Artikel demonstriert. Der Start des Tests ist vergleichbar mit dem Start der Optimierung. Allerdings werden hier die Roboterparameter durch die durch einen Doppelklick ausgewählten ersetzt. 

async void IExtentionGUI_M.StartTest(ENUM_TableType TableType, int rowIndex)
{
    if (!IsTerminalsLVEnabled)
        return;

    string TerminalID = TerminalsAfterOptimisation[TerminalsAfterOptimisation_Selected];
    string pathToBot = BotsAfterOptimisation[BotsAfterOptimisation_Selected];
    DirectoryInfo terminalChangableFolder = terminalDirectory.Terminals.Find(x => x.Name == TerminalID);

    DataRow row = (TableType == ENUM_TableType.History ? HistoryOptimisationResults : ForvardOptimisationResults).Rows[rowIndex];

    ConfigCreator_inputData configInputData;
    OptimisationInputData OptimisatorSettings;

    DirectoryInfo BotReportDirectory = CurrentWorkingDirectory.Reports.GetDirectory(TerminalID).GetDirectory(pathToBot);
    using (OptimisatorSettingsManager settingsManager = SettingsManagerCreator.Create(BotReportDirectory.FullName))
    {
        configInputData = settingsManager.ConfigCreator_inputData;
        OptimisatorSettings = settingsManager.OptimisationInputData;

        string setFilePath = Path.Combine(terminalChangableFolder
                                          .GetDirectory("MQL5")
                                          .GetDirectory("Profiles")
                                          .GetDirectory("Tester", true).FullName,
                                           configInputData.setFileName);

        SetFileManager setFile = SetFileManagerCreator.Create(setFilePath, true);
        setFile.Params = settingsManager.SetFileParams;

        foreach (var item in setFile.Params)
        {
            if (row.Table.Columns.Contains(item.Variable))
                item.Value = row[item.Variable].ToString();
        }
        setFile.SaveParams();
    }

    _isTerminalsEnabled.SetVar(false);
    if (selectedOptimisationManager == null)
        SetOptimisationManager(new List<ViewModel.TerminalAndBotItem>());

    await System.Threading.Tasks.Task.Run(() =>
    {
        selectedOptimisationManager.StartTest(configInputData, OptimisatorSettings);
    });
    _isTerminalsEnabled.SetVar(true);
}

Diese Methode ist ebenfalls asynchron. Sie beinhaltet auch die Erstellung des Optimierungsmanagers, sofern er noch nicht angelegt wurde. Um Eingaben für den Test zu erhalten, rufen Sie die Einstellungsdatei auf, die sich neben dem Optimierungsbericht des ausgewählten Roboters befindet. Sobald die Robotereinstellungsdatei erstellt ist, finden Sie die Parameter, die im Optimierungsbericht angegeben wurden, und setzen Sie den Wert aus der ausgewählten Optimierungszeile im Parameter 'Value'. Nachdem Sie die Parameter gespeichert haben, fahren Sie mit Teststart fort. 

Um die Optimierungsergebnisse in die entsprechende Tabelle hochzuladen, wird die folgende Methode mit der geschachtelten Methode verwendet.

public void LoadOptimisations()
{
    // Internal method filling the table with data
    void SetData(bool isForvard, DataTable tb)
    {
        // Clear the table from previously added data
        tb.Clear();
        tb.Columns.Clear();

        // Get data
        string TerminalID = TerminalsAfterOptimisation[TerminalsAfterOptimisation_Selected];
        string botName = BotsAfterOptimisation[BotsAfterOptimisation_Selected];
        string path = Path.Combine(CurrentWorkingDirectory.Reports
                                                          .GetDirectory(TerminalID)
                                                          .GetDirectory(botName)
                                                          .FullName,
                                                          $"{(isForvard ? "Forward" : "History")}.xml");
        if (!File.Exists(path))
            return;

        using (ReportReader reader = ReportReaderCreator.Create(path))
        {
            if (reader.ColumnNames.Count == 0)
                return;

            // Fill the columns
            foreach (var item in reader.ColumnNames)
            {
                tb.Columns.Add(item);
            }

            // Fill the rows
            while (reader.Read(out List<KeyValuePair<string, object>> data))
            {
                DataRow row = tb.NewRow();
                foreach (var item in data)
                {
                    row[item.Key] = item.Value;
                }
                tb.Rows.Add(row);
            }
        }
    }

    if (TerminalsAfterOptimisation.Count == 0 && BotsAfterOptimisation.Count == 0)
    {
        return;
    }

    // Fill historic optimization data first, then add forward test results
    SetData(false, HistoryOptimisationResults);
    SetData(true, ForvardOptimisationResults);
}

Die Methode führt einen doppelten Aufruf der verschachtelten Funktion aus, in der die Operation ausgeführt wird. Folgendes wird in der verschachtelten Funktion ausgeführt:

  1. Leeren der übergebenen Tabelle (und ihrer Spalten) 
  2. Einstellen des Pfades zur Berichtsdatei
  3. Lesen der Berichte mit der Klasse ReportReader und Laden der Daten in die Tabelle.

Die folgende Codezeile ist im Konstruktor enthalten:

//--- Bestimmen der Spaltenzahl der Ergebnistabelle
HistoryOptimisationResults.Columns.CollectionChanged += Columns_CollectionChanged;

Es abonniert die Methode Columns_CollectionChanged, um Ereignisse in Bezug auf die historische Optimierungstabelle zu aktualisieren. Mit dieser Methode verfolgen wir das Hinzufügen von Spalten. Bei der abonnierten Methode (siehe Code in den angehängten Dateien) werden Spaltennamen automatisch hinzugefügt oder gelöscht aus der Kollektion OptimisationResultsColumnHeaders, aus der sie an ViewModel und View übergeben werden, und dann werden sie mit der oben beschriebenen Erweiterung zum automatischen Laden von Spalten zu ListView hinzugefügt. Wenn also die Liste der Spalten in der historischen Optimierungstabelle bearbeitet wird, werden die Spalten in der Ansicht in beiden Tabellen automatisch bearbeitet.  

In diesem Kapitel haben wir die Implementierungsdetails des Optimierungsstarts, des Programmladens und Ladens von Dateien mit historischen und Vorwärts-Optimierungsdurchgängen untersucht, sowie eine Methode zum Starten von Testdurchläufen bei einem Doppelklick-Ereignis analysiert. Damit ist die im Video gezeigte Anwendung fast fertig, während nur ihr Start vom Terminal aus implementiert werden muss. Dies geschieht durch den folgenden Wrapper, der als Expert Advisor implementiert ist.

//+------------------------------------------------------------------+
//|                                 OptimisationManagerExtention.mq5 |
//|                        Copyright 2019, MetaQuotes Software Corp. |
//|                                             https://www.mql5.com |
//+------------------------------------------------------------------+
#property copyright "Copyright 2019, MetaQuotes Software Corp."
#property link      "https://www.mql5.com"
#property version   "1.00"

#import "OptimisationManagerExtention.dll"
//+------------------------------------------------------------------+
//| Initialisierungsfunktion des Experten                            |
//+------------------------------------------------------------------+
int OnInit()
  {
//---   

   string data[];
   StringSplit(TerminalInfoString(TERMINAL_DATA_PATH),'\\',data);
   MQL5Connector::Instance(data[ArraySize(data)-1]);

   while(!MQL5Connector::IsWindowActive())
     {
      Sleep(500);
     }

   EventSetMillisecondTimer(500);
//---
   return(INIT_SUCCEEDED);
  }
//+------------------------------------------------------------------+
//|                                                                  |
//+------------------------------------------------------------------+
void OnTimer()
  {
   if(!MQL5Connector::IsWindowActive())
      ExpertRemove();
  }
//+------------------------------------------------------------------+
//|                                                                  |
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
  {
   EventKillTimer();
  }
//+------------------------------------------------------------------+

Nach dem Kompilieren des C#-Projekts (Release-Modus) fügen Sie es in das entsprechende Verzeichnis (~/Libraries) ein und verbinden Sie es mit dem Roboter. Um die aktuelle Terminal-ID zu erhalten, suchen Sie den Pfad zu seinem Variablenverzeichnis und teilen Sie ihn dann mit der Methode StringSplit in Einzelteile auf. Das letzte Verzeichnis enthält die Terminal-ID. Nachdem Grafikstart ist die aktuelle Thread-Verzögerung aktiviert, bis das Fenster geladen ist. Starten Sie dann den Timer. Der Timer ermöglicht die Verfolgung des Ereignisses, wenn das Fenster schließt. Wenn das Fenster geschlossen ist, muss der Expert Advisor vom Chart entfernt werden. Dadurch wird das im Video gezeigte Verhalten erreicht.

Schlussfolgerung und Anlagen

Zu Beginn dieser Forschung haben wir uns zum Ziel gesetzt, ein flexibel erweiterbares Add-on zum Terminal mit einer GUI zu erstellen, um Optimierungsprozesse zu steuern. Die Sprache C# wird für die Implementierung verwendet, da sie eine komfortable Oberfläche für die Entwicklung von grafischen Anwendungen sowie viele zusätzliche atemberaubende Optionen bietet, die den Programmiervorgang erheblich vereinfachen. In diesem Artikel haben wir den gesamten Prozess der Erstellung der Anwendung betrachtet, beginnend mit den Grundlagen des Starts des Konsolenprogramms bis hin zur Erstellung eines Wrappers für den Start von MetaTrader von einem anderen Terminal aus, das C#-Technologien verwendet. Ich hoffe, dass der Leser die Forschung interessant und nützlich findet. Ich möchte auch darauf hinweisen, dass meiner Meinung nach die in den letzten Kapiteln des aktuellen Artikels beschriebenen Klassen noch verbessert werden können.

Das angehängte Archiv enthält zwei Ordner:

  • MQL5 ist für das Hauptterminal des MetaTrader 5 vorgesehen, in dem das Add-on gestartet werden soll. Es enthält eine Datei, in der das Add-on ausgeführt wird. 
  • Visual Studio enthält die drei beschriebenen Projekte für Visual Studio. Kompilieren Sie sie vor der Verwendung. Die *.dll-Bibliothek, die durch die Kompilierung von OptimisationManagerExtention entstanden ist, sollte in das Verzeichnis Libraries des Terminals aufgenommen werden, von dem aus das Projekt gestartet wird. 


Übersetzt aus dem Russischen von MetaQuotes Software Corp.
Originalartikel: https://www.mql5.com/ru/articles/7059

Beigefügte Dateien |
Bibliothek für ein leichtes und schnelles Entwickeln vom Programmen für den MetaTrader (Teil XIV): Das Symbolobjekt Bibliothek für ein leichtes und schnelles Entwickeln vom Programmen für den MetaTrader (Teil XIV): Das Symbolobjekt

In diesem Artikel werden wir die Klasse eines Symbolobjekts anlegen, das das Basisobjekt für die Erstellung der Kollektion der Symbole sein soll. Die Klasse wird es uns ermöglichen, Daten über die benötigten Symbole für ihre weitere Analyse und ihren Vergleich zu erhalten.

Bibliothek für ein leichtes und schnelles Entwickeln vom Programmen für den MetaTrader (Teil XIII): Ereignisse des Kontoobjekts Bibliothek für ein leichtes und schnelles Entwickeln vom Programmen für den MetaTrader (Teil XIII): Ereignisse des Kontoobjekts

Der Artikel behandelt die Arbeit mit Kontoereignissen, um wichtige Änderungen in den Kontoeigenschaften zu verfolgen, die den automatisierten Handel beeinflussen. Wir haben bereits im vorherigen Artikel bei der Entwicklung der Kollektion von Kontoobjekten einige Funktionen zur Verfolgung von Kontoereignissen implementiert.

Merrill-Muster Merrill-Muster

In diesem Artikel werden wir einen Blick auf das Modell der Merrill-Muster werfen und versuchen, deren aktuelle Relevanz zu bewerten. Zu diesem Zweck werden wir ein Werkzeug entwickeln, um die Muster zu testen und das Modell auf verschiedene Datentypen wie die Schluss-, Hoch- und Tiefstpreise sowie Oszillatorwerte anzuwenden.

Hier sind der neue MetaTrader 5 und MQL5 Hier sind der neue MetaTrader 5 und MQL5

Dies ist nur ein kurzer Überblick über MetaTrader 5. Ich kann nicht alle neuen Funktionen des Systems in so kurzer Zeit beschreiben. Die Tests begannen am 09.09.2009. Das ist ein symbolisches Datum und ich bin sicher, dass es eine Glückszahl werden wird. Es sind ein paar Tage vergangen, seit ich die Beta-Version des MetaTrader-5-Terminals und MQL5 bekommen habe. Ich konnte noch nicht alle Funktionen ausprobieren, doch ich bin jetzt schon beeindruckt.