Управление оптимизацией (Часть 2): Создание ключевых объектов и логики приложения

Andrey Azatskiy | 9 июля, 2019

Оглавление

Введение

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



Менеджер управления терминалом извне (ITerminalManager и Config)

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

Текущая глава посвящена работе с терминалами, которая была разбита на следующие составляющие:

  1. Работа с конфигурационными файлами
  2. Работа с терминалом как со сторонним процессом

Изучение стоит начать с рассмотрения работы с конфигурационными файлами. Из инструкции по работе с терминалом как со сторонним процессам мы можем узнать точную составляющую. Первое что стоит сделать — это создать все используемые перечисления, которые мы будем применять в конфигурационном файле: числовые значения данных перечислений можно посмотреть в терминале, что я и сделал, а их реализацию можно посмотреть в файле Config.cs. Куда больший интерес представляет реализация удобного способа передачи адреса сервера, ведь он должен передаваться в определенном формате, и помимо адреса сервера обязательно должен указываться порт. Данная проблема была решена путем создания класса, который хранит в себе адрес сервера, принимаемый через конструктор, и проверяет его корректность перед тем как инстанцироваться.

/// <summary>
/// IPv4 адрес сервера и порт
/// </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 адрес сервера
/// </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}";
    }
}

Рассмотрение данного класса стоит начать со структуры IPv4Adress, которая хранит в себе IP адрес сервера. Собирая информацию перед написанием статьи, я ни разу не встретил адреса сервера, отличающегося от формата IPv4, поэтому данная структура реализует этот формат адреса. В своем конструкторе она принимает строку с адресом, затем парсит его и сохраняет в соответствующие поля. Если количество цифр в адресе меньше 4, то происходит выброс ошибки.  Конструктор основного класса имеет две перегрузки, одна из которых принимает строковое представление адреса сервера, другая — сформированные IP адрес и номер порта. Также структура IPv4Adress имеет перегруженный метод  ToString, который был унаследован от базового класса Object, от которого неявно наследуются все объекты C#. Класс ServerAddressKeeper имеет свойство Address, выполняющее ту же работу. В итоге мы получили класс-обертку, который хранит в себе в удобном виде адрес сервера и умеет собирать его в вид, необходимый для конфигурационных файлов.  

Теперь стоит подумать над средствами работы с самими конфигурационными файлами формата (*.ini). Как уже говорилось ранее, сейчас данный формат файлов является устаревшим и практически не используется. C# не имеет встроенных интерфейсов для работы с ними, как например для работы, с XML-разметкой с которой мы уже сталкивались и более детально будем работать в дальнейших главах данный статьи. Однако WinApi пока еще поддерживает функции   WritePrivateProfileString и GetPrivateProfileString для работы с файлами данного формата. Причем как пишет Microsoft:

Note:  This function is provided only for compatibility with 16-bit Windows-based applications. Applications should store initialization information in the registry.

Данные методы сохранены в WinApi лишь для обратной совместимости с 16-битными приложениями для Windows. Приложениям стоит хранить инициализационную информацию в реестре. Но тем не менее мы можем ими воспользоваться, чтобы не писать собственный велосипед. Для этого нам придется сделать импорт данных C функций в наш C# код (по сути стыковка двух языков программирования). В C# это выполняется примерно так же как и в MQL5:

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

Только в отличии от команды #import мы должны указать атрибут DLLImport, передав ему имя dll, из которой импортируем функцию, и другие необязательные параметры. В частности, при данном импорте я указал параметр  SetLastErro =true, что дает нам возможность получать ошибки из кода C++ при помощи GetLastError() в нашем коде C# и, соответственно, обрабатывать корректность исполнения данных методов. Так как способ работы со строками в C# и C различны, то мы воспользуемся методами-обертками, позволяющими удобным способом работать с экспортируемым функциями, а также обрабатывающие возможные ошибки. Я реализовал их следующим способом:   

/// <summary>
/// Удобная обертка для WinAPI функции GetPrivateProfileString
/// </summary>
/// <param name="section">наименование секции</param>
/// <param name="key">ключ</param>
/// <returns>запрашиваемый параметр или null если ключ не был найден</returns>
protected virtual string GetParam(string section, string key)
{
    //Для получения значения
    StringBuilder buffer = new StringBuilder(SIZE);
 
   //Получить значение в buffer
    if (GetPrivateProfileString(section, key, null, buffer, SIZE, Path) == 0)
        ThrowCErrorMeneger("GetPrivateProfileStrin", Marshal.GetLastWin32Error());

    //Вернуть полученное значение
    return buffer.Length == 0 ? null : buffer.ToString();
}

/// <summary>
/// Удобная обертка для WinAPI WritePrivateProfileString
/// </summary>
/// <param name="section">Секция</param>
/// <param name="key">Ключ</param>
/// <param name="value">Значение</param>
protected virtual void WriteParam(string section, string key, string value)
{
    //Записать значение в INI-файл
    if (WritePrivateProfileString(section, key, value, Path) == 0)
        ThrowCErrorMeneger("WritePrivateProfileString", Marshal.GetLastWin32Error());
}

/// <summary>
/// Выброс ошибки
/// </summary>
/// <param name="methodName">Имя метода</param>
/// <param name="er">Код ошибки</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");
        }
    }
}

В процессе работы с данными методами вскрылась интересная особенность, после поиска информации я удостоверился, что это не только моя проблема. Особенность работы с данными функциями заключается в том, что метод GetPrivateProfileString возвращает ошибку  ERROR_FILE_NOT_FOUND (код ошибки = 2) не только в случае, когда файл не найден, но и при следующих условиях:

  1. Секция не существует в читаемом файле
  2. Запрашиваемый ключ не существует

Именно из-за этой особенности мы в методе ThrowCErrorMeneger осуществляем проверку на существование читаемого файла, если таковая ошибка возникала. Для получения последней ошибки (метод GetLastError) в C# существует статический метод класса Marshal ( Marshal.GetLastWin32Error()), которым мы и воспользуемся для извлечения ошибки после каждого вызова методов чтения или записи в файл. Для удобства мы импортировали методы, читающие и пишущие лишь строки, так как любой тип данных так или иначе может быть переведен в строку. 

Следующим интересным аспектом работы с данными функциями является способ удаления данных из файла. К примеру, для удаления всей секции целиком требуется передать в метод   WriteParam имя ключа равное null. Пользуясь данной возможностью, я создал соответствующий метод, предварительно вынеся все имена секций в enum ENUM_SectionType:

/// <summary>
/// Удаление секции
/// </summary>
/// <param name="section">выбранная на удаление секция</param>
public void DeleteSection(ENUM_SectionType section)
{
    WriteParam(section.ToString(), null, null);
}

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

/// <summary>
/// Удаление ключа
/// </summary>
/// <param name="section">секция из которой будет удаляться ключ</param>
/// <param name="key">Удаляемый ключ</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);
}

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

Config myConfig = new Config("Path");

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

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

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; // путь к файлу

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

Реализация каждого из вложенных классов, описывающих конкретную секцию, однотипна, рассмотрим ее на примере класса 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);
    }
 }

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

Теперь стоит рассмотреть тот самый класс Converter, который пишет и читает данные из файла — он также является вложенным классом, и потому имеет возможность использовать методы WriteParam и GetParam от основного класса, несмотря на то, что они помечены модификатором доступа protected. Данный класс имеет перегрузки методов чтения и записи для следующих типов:

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

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

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

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

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

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

Непосредственно сам класс, наследующий данный интерфейс, имеет две перегрузки конструктора представленные ниже.

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

Как было описано в статье Владимира Карпутова, в изменяемой директории терминала содержится файлик origin.txt, в котором хранится путь к инсталяционной директории, чем мы и пользуемся в первой перегрузке конструктора. Данная перегрузка  ищет файл origin.txt, читает его целиком и создает класс DirectiryInfo, описывающий данную директорию, передав в его конструктор прочтенную из файла информацию. Также стоит отметить то, что по сути всю работу по подготовке класса к работе выполняет второй конструктор, который принимает 3 параметра:

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

/// <summary>
/// Флаг запуска терминала в режиме /portable
/// </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;
        }
    }
} 

Как видно из представленного Сеттера, при присвоении переданного значения, при условии что оно true, проверяется наличие директории MQL5. Если таковая директория не существует в инсталяционной директории, то мы запускаем терминал и удерживаем  поток до того, как терминал будет запущен. В момент запуска терминала, так как мы вначале установили флаг запуска терминала, то сам запуск будет произведен в режиме Portable, а при первом старте терминала в Portable-режиме (который мы осуществляем данным запуском) искомая директория будет создана. После того как терминал будет запущен, мы закрываем терминал командой Close из нашей обертки для работы с терминалом и дожидаемся закрытия терминала. По завершении данной процедуры, если нет проблем с правами доступа, искомая директория MQL5 будет создана. Наше свойство, возвращающее путь к MQL5 директории терминала, работает через условную конструкцию, возвращая путь к искомой директории либо из инсталяционной директории, либо из директории с изменяемыми файлами — в зависимости от вышеописываемого флага.

/// <summary>
/// Путь к папке MQL5
/// </summary>
public DirectoryInfo MQL5Directory => (Portable ? TerminalInstallationDirectory : TerminalChangeableDirectory).GetDirectory("MQL5");

Также стоит уделить внимание одному нюансу, касательно использования второй перегрузки конструктора. Если вдруг вы передадите вместо изменяемой директории путь к инсталяционой директории, то при условии хотя бы одного запуска в режиме Portable (или же при выставлении флага isPortable = true)  данный класс по идеи должен работать корректно, однако он будет видеть лишь инсталяционную директорию терминала, и TerminalID в данном случае будет не набором цифр и символов латинского алфавита, который указывается в изменяемой директории терминала, а будет равняться названию папки, в которой установлен терминал, т.е. имени инсталяционной директории.

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

#region .ex5 files relative paths
/// <summary>
/// Список полных имен экспертов
/// </summary>
public List<string> Experts => GetEX5FilesR(MQL5Directory.GetDirectory("Experts"));
/// <summary>
/// Список полных имен индикаторов
/// </summary>
public List<string> Indicators => GetEX5FilesR(MQL5Directory.GetDirectory("Indicators"));
/// <summary>
/// Список полных имен скриптов
/// </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;
}

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

Метод, возвращающий список найденных EX5-файлов, в своей работе использует рекурсию, рассмотрим его детальнее. Вначале он проверяет значение своего второго параметра, которое является опциональным: если оно не задано, то ему присваивается имя текущей переданной директории. Именно так мы понимаем относительно какой директории требуется выдавать пути файлов. Далее следует еще одна конструкция языка C# —  вложенные функции. Данные функции существуют лишь в пределах текущего метода. Мы прибегли к использованию данной конструкции, так как эта функция нигде в классе нам более не понадобится, и ее тело не слишком большое, что позволяет уместить ее внутри рассматриваемого метода. Данная функция принимает на вход путь к EX5-файлу и разделяет его, используя в качестве разделителя символ "\\", в результате мы получаем массив имен директорий, и в конце данного массива — имя EX5-файла. Следующим шагом мы переменной i присваиваем индекс директории, относительно которой ищем путь к файлу, и увеличиваем его на 1, тем самым сдвигая указатель на следующую директорию или же сразу файл. Переменная ans будет хранить в себе найденный адрес, для этого мы присваиваем ей значение указанной директории, и далее в цикле добавляем новую директорию или же файл, и так до тех, пор пока не выйдем из цикла (т.е. пока не добавим имя искомого файла). Непосредственно сам метод GetEX5FilesR работает по следующей схеме:

  1. Получаем пути ко всем вложенным директориям.
  2. Ищем в текущей директории EX5-файлы и сохраняем их относительные пути.
  3. В цикле запускаем рекурсию по каждой вложенной директории, при этом передавая имя директории, относительно которой требуется получить путь к эксперту, возвращаемое значение. Добавлем в список относительных путей к EX5-файлам 
  4. Возвращаем найденные пути файлов.

Таким образом, данный метод осуществляет полный поиск файлов и возвращает все найденные эксперты и другие исполняемые файлы, написанный на языке MQL5.

Теперь стоит разобраться со способом запуска сторонних приложений в языке C#. В нем имеется очень удобный функционал для запуска и работы с другими приложениями, а именно класс Process, который является оберткой для любого запускаемого внешнего процесса. Например, для того чтобы запустить блокнот из C#, требуется написать всего лишь 3 строки кода: 

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

С помощью данного класса мы как раз и реализуем процесс управления сторонними терминалами из нашего дополнения. Метод, запускающий терминал, выглядит следующим образом:

public bool Run()
{
    if (IsActive)
        return false;
    // Задаем путь к терминалу
    Process.StartInfo.FileName = Path.Combine(TerminalInstallationDirectory.FullName, "terminal64.exe");
    Process.StartInfo.WindowStyle = WindowStyle;
    // Задаем данные для запуска терминала (если таковые были установлены)
    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";

    // Уведомляем процесс о необходимости вызывать событие Exit после закрытия терминала
    Process.EnableRaisingEvents = true;

    //Запускаем процесс и сохраняем статус запуска в переменную IsActive
    return (IsActive = Process.Start());
}

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

  1. Указать путь к запускаемому исполняемому файлу.
  2. Задать тип окна запускаемого процесса.
  3. Задаем ключи (в примере с консольным приложением это были все значения указанные через пробел после имени запускаемого файла).
  4. Установить флаг Process.EnableRaisingEvents= true. Если этого не сделать, то событие завершения процесса, на которое мы подписались в конструкторе, не будет срабатывать.
  5. Запустить процесс, сохранив статус запуска в переменную IsActive.

Свойство IsActive вновь становится равным false в коллбеке, срабатывающем после закрытия терминала, в данном коллбеке мы также вызываем наше событие TerminalClosed.

/// <summary>
/// Событие закрытия терминала
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void Process_Exited(object sender, EventArgs e)
{
    IsActive = false;
    TerminalClosed?.Invoke(this);
}

Остальные методы, управляющие терминалом (ожидание остановки и закрытия терминала),  являются оберткой над стандартными методами класса Process.

public void WaitForStop()
{
    if (IsActive)
        Process.WaitForExit();
}
/// <summary>
/// Остановка процесса
/// </summary>
public void Close()
{
    if (IsActive && !Process.HasExited)
        Process.Kill();
}
/// <summary>
/// Дождаться завержения работы терминала определенное время
/// </summary>
public bool WaitForStop(int miliseconds)
{
    if (IsActive)
        return Process.WaitForExit(miliseconds);
   return true;
}

Таким образом, используя стандартный класс Process, мы создали удобную обертку, работающую именно с терминалом MetaTrader 5, и в дальнейшем по всей программе мы сможем работать с терминалом более комфортно, чем если бы мы использовали класс Process напрямую.

Объекты представляющие структуру директорий

Так как в прошлой части данной статьи мы уже затронули тему работы с директориями, то стоит рассмотреть способы работы с файловой системой, используемые в данном дополнении. Первое, что хотелось бы упомянуть — это способ, которым создаются пути к файлам и директориям. Для этого в языке C# существует удобный класс  Path. Благодаря данному классу мы получаем возможность безопасно составлять пути к файлам и директориям — тем самым избегая возможных ошибок в данной области. Для представления директории используется класс  DirectoryInfo, благодаря которому мы можем оперативно получать сведения о вложенных директориях, родительской директории, имени и полному пути к данной директории, а еще ко множеству полезных свойств. Например, данный класс позволяет получить все файлы, находящиеся в данной директории , путем вызова одного лишь метода. Для объектно-ориентированного представления любого из файлов используется класс  FileInfo, который по своему функционалу является аналогом класса DirectoryInfo. В результате вся работа с файлами и директориями по сути сводится к работе с представленными классами, благодаря чему в процессе разработки можно сосредоточиться на самой задаче, почти не создавая промежуточные функции и методы.

Также стоит отметить, что в прошлом описанном классе TerminalManager часто использовался метод GetDirectory на экземпляре класса DirectoryInfo. Данный метод не входит в стандартную компоновку класса DirectoryInfo и был добавлен для удобства. В C# существует способ расширения функционала стандартных и собственных классов путем добавления к ним методов-расширений. Данным функционалом языка C# мы и воспользовались для добавления метода расширения GetDirectory, рассмотрим его реализацию.

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

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

  1. Если параметр createIfNotExists=false (или не указан), то он возвращает вложенную папку с переданным именем приведенную к типу DirectoryInfo (если таковая существует) либо null.
  2. Если же параметр createIfNotExists=true, то при условии что папка не создана, она будет создана и в качестве ответа — возвращается данная папка, приведенная к типу DirectoryInfo. 

Также для удобства манипулирования папками изменяемых директорий терминалов был создан класс, являющийся объектно-ориентированным представлением директории 

~\AppData\Roaming\MetaQuotes\Terminal

Данный класс реализован следующим образом.

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

Как видно, он содержит три поля:

  1. Terminals
  2. Common
  3. Community

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

  1. Наличие файла "origin.txt" в корне рассматриваемой директории — благодаря данному файлу мы получаем путь к директории с терминалом.
  2. Наличие запускаемого файла терминала в соответствующей директории. 

Также стоит упомянуть тот немаловажный факт, то данное дополнение заточено на работу с 64-битной версией терминала, для работы с 32-битной версией требуется повсюду в программе (а именно, класс TerminalManager и данный рассматриваемый класс) переименовать  "terminal64.exe" на "terminal.exe". Таким образом игнорируются директории, для которых  не могут быть найдены исполняемые файлы терминала.

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

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

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

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

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

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

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

  1. Директория, имя которой равняется ID терминала. 
  2. Директория по имени робота, которая содержит следующее:
    • Settings.xml — файл с настройками оптимизации (формируется внутри программки)
    • History.xml — скопированный файл исторической оптимизации (формируется терминалом)
    • Forward.xml — скопированный файл форвардной оптимизации (формируется терминалом)

Таким образом мы создали два класса, которые являются отправными точками для работы с файловой системой, а далее в коде работа с файловой системой производится при помощи стандартных классов языка C#, что позволяет не допустить ошибок в путях к файлам и существенно ускоряет разработку.

Объекты работающие с файлами отчетов и настроек робота (OptimisatorSettingsManager, ReportReader, SetFileManager)

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

Начнем наше рассмотрение с файла с параметрами робота для оптимизации. Файлы настроек параметров робота, имеют расширение (*.set), однако существует несколько файлов с настройками, это файлы настроек робота при запуске на графике и файлы настроек робота при запуске тестера. Нас интересует второй формат файлов, они хранятся в изменяемой директории терминала по пути 

~\MQL5\Profiles\Tester

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

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

Иначе говоря, в данных файлах ключ — наименование параметра робота, а значение ключа может принимать список его значений, имена которых в приведенном примере идентичны колонкам в тестере стратегий. Последнее значение переменной может принимать одно из двух значений (Y/N) и обозначает включение/отключение оптимизации данного параметра робота. Исключением из данного правила является способ записи строковых параметров, они имеют формат как в INI-файле:

Variable_name=Value

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

; 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

Для работы с данными файлами нам нужно создать класс-обертку, позволяющий читать данные файлы, а также класс, хранящий значения каждой прочитанной строки. Этот класс уже рассматривался во время описания View части данной статьи, поэтому мы не станем рассматривать его здесь. Рассмотрим основной класс, читающий файлы и пишуший в него заданные из графического интерфейса параметры — SetFileManager.  Реализация данного класса следующая:

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

Первое, на что стоит обратить внимание — это проверка на формат файла, выставленная мною в конструкторе данного класса. Если формат файла будет отличен от формата SET-файла, то данный класс будет выбрасывать ошибку, так как мы пытаемся работать с файлом, который скорее всего не поймет терминал. Сам файл хранится в публичном readonly свойстве —  FileInfo. Непосредственно чтение файла осуществляется в методе  UpdateParams, который в конструкции using читает файл от первой строки до последней, игнорируя строки комментариев. Стоит так же обратить внимание на то как осуществляется задание параметров читаемого файла. Сначала прочтенная строка разделяется на две, в качестве разделителя используется знак равенства ("=") — тем самым мы отделяем имя переменной от ее значений. Следующим этапом осуществляется разделение значений переменной на массив состоящий из 4 элементов [Value, Start, Step, Stop, IsOptimise]. В случае строк данный массив не будет разделен на эти элементы, так как не будут найдены разделяющие символы две двойные черты ("||"). Чтобы не было ошибок со строками, не рекомендуется использовать в них данный символ. Если для каждого нового элемента нашей строки не хватает данных в массиве, то ему присваивается значение null, иначе используется значение из массива.

Сохранение значений производится в методе SaveParams. Стоит уделить внимание формату записи данных в файл, которая осуществляется данной строкой кода:

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

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

Доступ и задание параметров прочтенному фалйу осуществляется через свойство Params. Так как по сути вся работа с данными файла осуществляется через описываемое свойство, для удобства в геттере мы осуществили проверку на то, был ли прочтен файл ранее или же нет. Если файл не был прочтен, то вызывается уже рассмотренный нами метод   UpdateParams. В целом, порядок работы с данным классом предполагается следующим:

  1. Инстанцируем, тем самым получая ООП-представление файла
  2. Читаем, вызывая метод Params (или UpdateParams при необходимости, например — если вдруг файл был изменен извне)
  3. Задаем собственные значения через Setter или же просто меняем, работая с массивом полученным через Getter.
  4. Сохраняем изменения через метод SaveParams 

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

Следующим на очереди рассмотрим класс RepirtReader, который производит чтение отчетов оптимизации формируемых терминалом, парсит их, чтобы можно было вывести информацию в таблицу. Файл с историей оптимизации представлен в формате XML, созданного для MS Excel. Его корневая нода (самый первый тег) — <Workbook/> — описывает книгу. Следующая нода  <DocumentProperties/> описывает параметры, при которых производилась оптимизация. В данной ноде содержится такая полезная информация как:

  1. Заголовок, который формируется из имени робота, имени актива, таймфрейма и периода оптимизаций.
  2. Дата создания
  3. Имя сервера, на котором производилась оптимизация
  4. Депозит и валюта депозита
  5. Плечо

Следующая нода <Styles/> нам не полезна, она по большей мере создана для Excel, за которой следует нода  <Worksheet />, описывающая рабочий лист с выгрузкой проходов оптимизации. В данной ноде существует нода <Table/>, которая хранит искомую нами информацию — список результатов оптимизации, разбитых по столбцам как после завершения перебора параметров в тестере. Стоит упомянуть, что первая строка таблицы содержит заголовки таблицы, за ней уже идут значения. Каждая нода <Row/>  содержит список значений данной таблицы, занесенных в тег <Cell/>. Также каждый тег <Cell/> содержит аттрибут Type, указывающий на тип значения в данной ячейке. Так как данный файлик даже в урезанном формате достаточно громоздкий, я не стану приводить его пример, но вы можете изучить его, прооптимизировав какого-либо робота и открыв результаты его оптимизации из папки Reports нашего дополнения. Сейчас стоит перейти к рассмотрению описываемого класса, начнем данное рассмотрение с обзора свойств, описывающих файл оптимизации. 

#region DocumentProperties and column names
/// <summary>
/// Имена колонок документа
/// </summary>
protected List<string> columns = new List<string>();
/// <summary>
/// Доступ извне к коллекции колонок документа, возвращается копия коллекции,
/// для того что бы защитить изначальную коллекцию от модификации 
/// </summary>
public List<string> ColumnNames => new List<string>(columns);
/// <summary>
/// Заголовок документа
/// </summary>
public string Title { get; protected set; }
/// <summary>
/// Автор документа
/// </summary>
public string Author { get; protected set; }
/// <summary>
/// Дата создания документа
/// </summary>
public DateTime Created { get; protected set; }
/// <summary>
/// Сервер, на котором производилась оптимизация
/// </summary>
public string Server { get; protected set; }
/// <summary>
/// Изначальный депозит 
/// </summary>
public Deposit InitialDeposit { get; protected set; }
/// <summary>
/// Кредитное плече
/// </summary>
public int Leverage { get; protected set; }
#endregion

Как видно, данные свойства берутся их ноды <DocumentProperties/>. Данные свойства заполняются в следующем методе:

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

Как видно, средствами языка C# работа с (*.xml) файлами осуществляется почти так же легко, как и с массивами. Объект document является экземпляром класса XmlDocument, он хранит в себе прочтенный файл и обеспечивает удобную работу с ним. Поле  enumerator, которому присваивается значение, нам понадобится в методе Read, читающем документ построчно, мы еще вернемся к его рассмотрению. Сейчас же стоит сказать пару слов об интерфейсе IDisposable, используемым при объявлении класса: 

class ReportReader : IDisposable

Данный интерфейс содержит в себе лишь один метод Dispose() и требуется нам для возможности использования данного класса в конструкции using. Конструкция using, с которой мы уже сталкивались при записи в файл в прошлом рассматриваемом классе, обеспечивает корректную работу, а это означает, что нам на каждом чтении из файла не нужно самим закрывать файл, вместо этого закрытие происходит в методе Dispose(), который вызывается автоматически после выхода из блока фигурных скобок, в котором мы производим работу с файлом. В данном конкретном случае мы будем в методе Dispose очищать поле document для того, чтобы не хранить в памяти кучу информации по прочитанному файлу, когда она нам более не нужна. Реализация данного метода выглядит следующим образом:

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

Теперь стоит рассмотреть упомянутый нами выше интерфейс IEnumerator, который опять же является стандартным интерфейсом языка C#. Он выглядит следующим образом:

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

Как видно, он состоит из двух методов и одного свойства. Данный интерфейс служит некой оберткой для коллекции, перебираемой по одному значению за раз. Метод MoveNext служит методом, сдвигающим курсор на одно значение вперед, и так до тех пор, пока коллекция не закончится. Если мы попытаемся вызвать данный метод, когда уже прошлись по всей коллекции, то он выдаст false в качестве ответа, что будет означать конец перебора. Метод Reset нужен для того, чтобы перезапустить перебор в начало, иначе говоря, он сдвигает курсор на нулевой индекс коллекции. Свойство Current содержит текущий выбранный элемент коллекции для индекса, который был получен путем сдвига через MoveNext. Данный интерфейс широко используется в C#, в частности, на нем основаны циклы foreach, однако нам он нужен для реализации метода Read. 

/// <summary>
/// Команда на чтение строки из таблицы с оптимизациями
/// </summary>
/// <param name="row">
/// Прочитанная строка key - заголовок столбца; value - значение ячейки</param>
/// <returns>
/// true - если прочтена строка
/// false - если строка не была прочтена
/// </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;
}

Как видно, задача метода Read очень похожа на задачу метода MoveNext(), только данный метод через переданный ему параметр еще и возвращает результат своей работы. Так как он должен возвращать только строки со значениями, мы при задании значения переменной enumerator вызываем один раз метод MoveNext, тем самым сдвигая курсор с нулевого положения (заголовки таблицы) на индекс 1 (первый ряд со значениями). Во время чтения данных мы также используем метод ConvertToType, который конвертирует прочтенные значения из строкового формата в формат, заданный аттрибутом Type. Именно поэтому в возвращаемом списке в качестве типа значения указан тип object — тем самым мы получаем возможность приводить любой из типов к возвращаемому типу. Сама реализация метода ConvertToType представлена ниже.

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

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

Для возможности перезапуска ридера был создан метод ResetReader, который является оберткой для метода IEnumerator.Reset и реализован следующим образом:

public void ResetReader()
{
    if (enumerator != null)
    {
        enumerator.Reset(); // Сбрасываем
        enumerator.MoveNext(); // Пропускаем заголовки
    }
}

Таким образом, используя удобную обертку для парсинга XML файлов предоставляемую языком C#, без особого труда смогли написать класс обертку для парсинга файлов отчетов, и[ чтения и получения дополнительной информации.

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

Первым из сохраняемых объектов является структура, в которой хранятся данные настроек оптимизатора (представленные на вкладке Settings нижней области основной вкладки Settings). Данная структура реализована следующим образом.

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

Изначально данная структура создавалась как контейнер для передачи данных от View в Model, поэтому она содержит кроме самих данных индексыдля ComboBox. Для удобства работы с данной структурой в модели, а также в остальных классах,  были написаны  методы преобразования значений перечислений(enum), которые хранятся в структуре по номеру индекса, к самим типам искомых перечислений. Данное преобразование, работает следующим образом: в Модели данных для вывода в ComboBox значений данных списков они хранятся в строковом удобочитаемом виде, для обратного преобразования используется метод  GetEnum<T>. Данный метод является Generic методом, которые являются аналогом шаблонов из C++. В данном методе для получения искомого Enum нам потребуется вначале узнать конкретное значение переданного типа, для чего использутся класс Type, хранящий в себе значение типа. И далее воспользоваться сначала разложением данного типа перечисления на список строк, а после воспользоваться обратным преобразованием из строки в enum — для получения значения конкретного перечисления уже не в строковом виде, а в виде искомого перечисления.

Следующим объектом, содержащим сохраняемые данные, является ConfigCreator_inputData. Данная структура содержит в себе данные из таблицы с выбранным терминалом и используется лишь в рассматриваемом далее классе OptimisationManager для создания конфигурационного файла, отсюда взялось ее наименование. Данная структура выглядит следующим образом:

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

Последним третьим из сохраняемых данных является список параметров робота, типизированный списком элементом ParamItem (List<ParamsItem>). Теперь, когда рассмотрены данные которые требуется сохранять, стоит рассмотреть, файл который составляется в ходе работы рассматриваемого класса:

<?xml version="1.0"?>
<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>

Представленный файл был создан в ходе работы примера с видео для одного из роботов. Как видно из его структуры, корневая нода файла — <Settings/>, и внутри нее содержатся еще три ноды: <OptimisationInputData/><ConfigCreator_inputData/><SetFileParams/>. Типы данных в каждой из нод, соответствуют их названиям. В нодах, хранящих информацию о настройках тестера, конечным элементом является тег Item, который содержит аттрибут Name, через который мы задаем имя сохраняемого параметра. Для списка параметров робота есть тег  <Variable/>, у которых также есть аттрибут Name , хранящий имя параметра, а во вложенных тегах сохраняется соответствующее значение параметров оптимизации. Для создания данного файла класс OptimisatorSettingsManager опять же наследуется от интерфейса IDisposable, а в методе Dispose заданные значения будут сохраняться в файл. Для чтения данных из файла используются Геттеры соответствующих свойств.

#region OptimisationInputData
/// <summary>
/// Структура OptimisationInputData занесенная для сохранения
/// </summary>
private OptimisationInputData? _optimisationInputData = null;
/// <summary>
/// Получение и сохранение структуры OptimisationInputData
/// </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

В данном конкретном примере в Геттере происходит получение структуры OptimisationInputData, значения для которой берутся из представленного выше файла. Как видно, повсюду в геттере применяется метод GetItem для получения данных из файла. Данный метод принимает 2 параметра :

  1. Тип ноды, из которой берутся данные 
  2. Имя параметра, который указан в Атрибуте Name. 

Реализация данного метода следующая:

/// <summary>
/// Получение элемента из файла с настройками
/// </summary>
/// <param name="NodeName">Тип структуры</param>
/// <param name="Name">Имя поля</param>
/// <returns>
/// Значение поля
/// </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;
}

Данный способ получения данных примечателен использованием языка Xpath, который является чем-то сродни SQL, но только для XML формата. Для получения данных из интересуемой нас ноды по указанному значению аттрибута мы указываем  полный путь к данной ноде, а затем в конечной ноде Item указываем условие, что атрибут  Name должен быть равен переданному имени. Таким образом происходит чтение из файла для всех структур, для списка параметров используется иной способ, так как структура данной ноды более сложная.

#region SetFileParams
/// <summary>
/// Список параметров для сохранения
/// </summary>
private List<ParamsItem> _setFileParams = new List<ParamsItem>();
/// <summary>
/// Получаем и задаем параметры (.set) файла для сохранения
/// </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

В данном случае мы пробегаемся в цикле по всем нодам <Variable/>, из каждой достаем значение атрибута Name и заполняем класс ParamItem данными, содержащимися в данной конкретной ноде ParamsItem.

Завершающий метод Dispose(), в котором как мы условились сохранять данные в файле, представлен следующей реализацией:

public virtual void Dispose()
{
    // Вложенный метод, помогающий писать элементы структур
    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();
    }

    // сперва отчистить класс, хранящий xml разметку файла с настройками
    if (document != null)
        document.RemoveAll();

    // затем проверяем, можно ли сохранить результаты
    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();
    }
}

В начале данного метода создано две вложенные функции. Функция WriteItem предназначена для того, чтобы вынести повторяющийся блок кода, предназначенный для записи элементов структур. Функция WriteElement предназначена для записи значений параметров оптимизации, таких как Start, Step, Stop, IsOptimize. Так как нам требуется наличие в файле настроек всех трех тегов, то перед записью у нас установлен проверяющий блок, задача которого — не допустить запись файла, если не все требуемые параметры были переданы. Далее в конструкции using, смысл которой мы уже рассматривали ранее, происходит запись данных в файл. Благодаря вынесенным вложенным функциям та часть рассматриваемого метода, что отвечает за запись данных в файл, сократилась более чем в три раза.  

Тестирование ключевых объектов

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

В следующей статье планируется изменение классов, которые будут описываться в следующих главах, и данные изменения коснутся логики и результата работы некоторых из методов, поэтому данные классы не покрыты Unit-тестами. Тесты на данные классы будут реализованы в следующей статье. Также стоит отметить, что несмотря на то, что тесты написаны как юнит-тесты, по сути все они на текущий момент являются интеграционными тестами, так как производят взаимодействия с внешними объектами (терминал/файловая система и прочие). Следующие запланированные объекты планируется тестировать уже без зависимостей от описанных нами выше объектов, то есть как Unit-тесты. Именно для этого перед каждым из объектов, описанных выше, располагаются фабрики по их созданию. Примером такой фабрики может служить фабрика для создания класса ReportReader:

#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

Её код прост, по сути мы оборачиваем создание объекта типа ReportReader в класс MainReportReaderCreator, который наследуется от класса ReportReaderFabric. Подобный подход обеспечивает возможность передачи в ключевые объекты (рассматриваются в главах ниже) объекта, типизированного как ReportReaderFabric, а реализация конкретной фабрики уже может быть различной. Таким образом, мы получим возможность в Unit-тестах на ключевые объекты подменять классы, которые работают с файлами и терминалом, а так же понизим зависимости классов друг от друга. Подобный подход образования объектов называется Фабричным методом.

Более подробно реализацию будущих тестов мы рассмотрим в следующей статье. Пример использования фабричного метода для создания объектов будет рассмотрен в следующих главах, а в текущей главе рассмотрим для большей конкретики тест, написанный на класс, работающий с конфигурационными файлами. Для начала стоит сказать, что все тесты текущего проекта стоит выносить в отдельный проект — "Unit Test Project"


Назовем его "OptimisationManagerExtentionTests", так как тесты будут писаться для проекта "OptimisationManagerExtention", следующим шагом будет добавление ссылок на проект "OptimisationManagerExtention", т.е. на нашу DLL с графическим интерфейсом и логикой. Так как мы будем тестировать объекты, которые не помечены модификатором доступа public, то существует два варианта как сделать их доступными в нашем тестовом проекте:

  1. Сделать их публичными (что неправильно, ведь мы используем их только лишь внутри проекта)
  2. Добавить возможность видимости внутренних классов в конкретном проекте (что более предпочтительно по сравнению с первым способом)

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

[assembly: InternalsVisibleTo("OptimisationManagerExtentionTests")]

Следующим этапом предстоит написание самих тестов на выбранные нами классы. Так как тестовый проект — лишь вспомогательный — мы не будем рассматривать каждый тестовый класс, вместо этого я приведу в качестве примера один класс, и на его примере постараюсь описать основные нюансы работы с тестами. Для удобства приведем ниже полный класс, тестирующий класс Config. Как видно, первое условие для того, чтобы данный класс стал тестовым — это необходимость наличия у него атрибута  [TestClass], также тестируемый класс должен быть публичным, а его тестовые методы должны иметь аттрибут   [TestMethod] — именно в них будет происходить процесс тестирования. Метод помеченный атртибутом  [TestInitialize]  запускается каждый раз перед началом очередного теста, также существует схожий по смыслу атрибут [ClassInitialize], он не используется в данном тесте, но используется в других, и в отличии от метода, помеченного аттртбутом   [TestInitialize], запускается лишь раз перед запуском самого первого теста. Каждый из тестовых методов в конце теста содержит вызов  одного из методов класса Assert , которые сравнивают тестовое значение с требуемым, тем самым тест подтверждается либо опровергается.        

[TestClass]
public class ConfigTests
{
    private string ConfigName = $"{Environment.CurrentDirectory}\\MyTestConfig.ini";
    private string first_excention = "first getters call mast be null becouse file doesn`t contains 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, $"Adress mast 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 mast be equal to true");

        // set null
        config.Common.ProxyEnable = null;
        b = config.Common.ProxyEnable;
        Assert.AreEqual(expected.Value, b.Value, "ProxyEnables mast 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 mast be equal to {expected.Value}");

        // set null
        config.Common.ProxyEnable = null;
        p = config.Common.ProxyType;
        Assert.AreEqual(expected.Value, p.Value, $"ProxyType mast 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 mast be equal to {expected.Value}");

        // set null
        config.Common.ProxyEnable = null;
        p = config.Tester.FromDate;
        Assert.AreEqual(expected.Value.Date, p.Value.Date, $"ProxyType mast 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 mast 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 mast 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 mast be deleted");
    }
    [TestMethod]
    public void DeleteSectionTest()
    {
        config.Common.Login = 12345;
        config.DeleteSection(ENUM_SectionType.Common);

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

Если рассматривать конкретно данный тестовый класс, то стоит сказать, что он не покрывает все требуемые методы, вместо этого он тестирует класс Config.Converter, который по сути выполняет всю логику работы с файлом конфигураций, однако, так как он является private классом, нам необходимо писать тесты не на него напрямую, а на свойства, использующие данный класс. Например, тест   DoubleConverter_GetSetTest() тестирует верность перевода string в Double через свойство   config.Tester.Deposit.  Если рассмотреть данный конкретный тест поподробнее, то можно увидеть что он состоит из 3 частей:

  1. Запрос параметра типа double у поля, которое не создано —должно вернуться значение null.
  2. Запись рандомного значения в файл и его чтение
  3. Запись null, которая должна быть проигнорирована

Если на каком либо из этапов обнаружится ошибка, то ее легко отловить и затем править, так что тесты достаточно полезны в разработке приложений. После создания всех тестов их можно запустить прямо из VisualStudio, запуск выполняется по пути Test => Run => AllTests


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

Менеджер оптимизаций (OptimissationManager)

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

/// <summary>
/// Фабрика для создания классов, управляющих процессом оптимизации
/// </summary>
abstract class OptimisationManagerFabric
{
    /// <summary>
    /// Конструктор
    /// </summary>
    /// <param name="ManagerName">Имя создаваемого менеджера оптимизаций</param>
    public OptimisationManagerFabric(string ManagerName)
    {
        this.ManagerName = ManagerName;
    }
    /// <summary>
    /// Имя отражающее тип создаваемого менеджера оптимизации (в чем его особенность)
    /// </summary>
    public string ManagerName { get; }
    /// <summary>
    /// Метод, создающий менеджера оптимизации
    /// </summary>
    /// <returns>Менеджер оптимизации</returns>
    public abstract OptimisationManager Create(Dictionary<string, BotParamKeeper> botParamsKeeper,
                                               List<ViewModel.TerminalAndBotItem> selectedTerminals);
}

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

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)

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

/// <summary>
/// Словарь где:
/// key - ID терминала
/// value - полный путь до робота
/// </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;
    }
}

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

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

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

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

    // Заполняем конфигурационный файл
    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 adress 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;
}

Первым делом мы, пользуясь классом, описывающим изменяемую директорию терминалов, а так же фабрикой по созданию объектов типа Config, создаем объект конфигурационного файла и копируем его в соответствующую директорию нашего дополнения, присваивая ему в качестве имени ID терминала, которому принадлежал оригинальный конфигурационный файл. Далее происходит заполнение секции [Tester] скопированного конфигурационного файла. Все данные для заполнения этой секции берутся напрямую из переданных структур, которые либо формируются в коде (в случае оптимизации), либо берутся из файла (в случае запуска теста). Если неверно передан сервер, то выводится соответствующее сообщение в виде MessageBox и вместо конфигурационного файла возвращается null. С той же целью — для вынесения повторяющегося кода — в абстрактном классе был реализован метод, создающий менеджер терминалов. Рассмотрим его далее:

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

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

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

/// <summary>
/// Метод запуска теста по событию двойного клика 
/// </summary>
/// <param name="TerminalID">ID выбранного терминала</param>
/// <param name="pathToBot">Путь к роботу относительно папки экспертов</param>
/// <param name="row">Строка из таблицы оптимизации</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);
}

В своих входных параметрах он принимает те самые данные, которые в классе описывающем модель получаются из файла с сохраненными настройками. Внутри метода также происходит установка значений Progress Bar и статуса операции через переданный делегат. Сформированный конфигурационный файл корректируется для запуска тестера, а именно — удаляются ключи, описывающие отчет оптимизацтора (за ненадобностью), а также отключается автоматическое выключение терминала по завершению работы тестера. После запуска терминала поток, запустивший его, зависает дожидаясь завершения его работы — таким образом осуществляется нотификация формы о завершении теста. Забегая вперед стоит сказать, что для того что бы сама форма не зависала при запуске оптимизации/теста, данные процессы запускаются в контексте вторичного потока. Что касается оптимизации, то как уже было упомянуто ранее, ее процесс вынесен в protected абстрактный метод, однако, также есть и один публичный, метод реализованный в абстрактном классе — он необходим для корректной работы самого класса и не подлежит перезаписи.

/// <summary>
/// Запуск процесса оптимизации / теста по всем запланированным терминалам
/// </summary>
/// <param name="BotParamsKeeper">Список терминалов, роботов и параметров по роботам</param>
/// <param name="PBUpdate">Делегат, редактирующий значения полосы загрузки и статуса</param>
/// <param name="sturtup_status">Ответ из функции - используется в случае, если не удалось запустить оптимизацию / тест, 
/// в данную строку пишется причина неудачного запуска</param>
/// <returns>true - в случае успеха</returns>
public void StartOptimisation()
{
    pbUpdate(0, "Start Optimisation", true);
    IsOptimisationOrTestInProcess = true;
 
    DoOptimisation();
    OnAllOptimisationsFinished();
    IsOptimisationOrTestInProcess = false;
    pbUpdate(0, null, true);
}
protected abstract void DoOptimisation();

/// <summary>
/// Метод, директивно прерывающий оптимизацю
/// </summary>
public abstract void BreakOptimisation();

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

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

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

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

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

private readonly List<ITerminalManager> terminals = new List<ITerminalManager>();
/// <summary>
/// Метод, прерывающий процесс оптимизации и силой закрывающий терминалы
/// </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();
    }
}

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

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

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

Результирующий класс модели (IExtentionGUI_M и его реализация)

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

/// <summary>
/// Интерфейс модели
/// </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 характеризующий тип таблиц с результатами оптимизаций
/// </summary>
enum ENUM_TableType
{
    History,
    Forvard
}

Именно с данным интерфейсом работает VewModel, и при необходимости его реализацию, которую мы будем расматривыать далее, можно заменить на другую. Причем, не меняя графическую часть программы, а также можно изменить графику программы не затрагивая ее логику. Благодаря тому, что данный интерфейс наследуется от интерфейса   INotifyPropertyChanged, мы получили возможность уведомлять ViewModel, а соответственно и View части нашего приложения, об изменении какого-либо из свойств, реализованных в нашей модели данных. Для удобства написания кода в модели я добавил класс-универсальную обертку   VarKeeper, который, кроме того, что хранит значение любого типа, также умеет неявно приводиться к хранимому типу и при изменении хранимого значения — уведомлять ViewModel о совершенном изменении. Реализация данного класса следующая:

/// <summary>
/// Класс - хранящий в себе переменную _Var типа T_keeper.
/// Можем неявно приводиться к типу T_keeper а так же менять значение хранимой переменной
/// В момент изменения значения - уведомляет об этом всех подписавшихся
/// </summary>
/// <typeparam name="T_keeper">Тип хранимой переменной</typeparam>
class VarKeeper<T_keeper>
{
    /// <summary>
    /// Конструктор задающи идентификационное имя переменной
    /// </summary>
    /// <param name="propertyName">идентификационное имя переменной</param>
    public VarKeeper(string propertyName)
    {
        this.propertyName = propertyName;
    }
    /// <summary>
    /// Конструктор задающий идентификационное имя переменной 
    /// и первоночальное значение переменной
    /// </summary>
    /// <param name="PropertyName">идентификационное имя переменной</param>
    /// <param name="Var">первоночальное значение переменной</param>
    public VarKeeper(string PropertyName, T_keeper Var) : this(PropertyName)
    {
        _Var = Var;
    }
    /// <summary>
    /// Перегрузка оператора неявного приведения типов.
    /// Переводит данный тип к T_keeper
    /// </summary>
    /// <param name="obj"></param>
    public static implicit operator T_keeper(VarKeeper<T_keeper> obj)
    {
        return obj._Var;
    }
    /// <summary>
    /// хранимая переменная 
    /// </summary>
    protected T_keeper _Var;
    /// <summary>
    /// Идентификационное имя переменной
    /// </summary>
    public readonly string propertyName;
    #region Event 
    /// <summary>
    /// Событие уведомляющее об изменении хранимой переменной
    /// </summary>
    public event Action<string> PropertyChanged;
    /// <summary>
    /// Метод вызывающий событие уведомляющее об изменении хранимой переменной
    /// </summary>
    protected void OnPropertyChanged()
    {
        PropertyChanged?.Invoke(propertyName);
    }
    #endregion
    /// <summary>
    /// Метод устанавливающий значение переменной значением value
    /// </summary>
    /// <param name="value">новое значение переменной</param>
    public void SetVar(T_keeper value)
    {
        SetVarSilently(value);
        OnPropertyChanged();
    }
    public void SetVarSilently(T_keeper value)
    {
        _Var = value;
    }
}

В конструкторе данного класса  происходит прередача изначального значения хранимой переменной, а также имени переменной, которое будет использоваться при уведомлении о изменении ее значения. Сама  переменная хранится в protected поле данного класса. Имя переменой , используемое при уведомлении об изменении ее значения, хранится в публичном, доступном только для чтения поле  ropertyName. Методы для установки значения переменной делятся на  метод, устанавливающий ее значения и вызывающий событие уведомляющее всех подписавшихся о произведенном изменении, и на  метод, который только лишь устанавливает значение переменной. Для возможности неявного перевода данного класса к типу значения хранимого в нем используется  перегрузка оператора приведения типов. Благодаря данному классу мы получили возможность хранить в нем значения переменных, считывать их, не используя явного приведения типов, а также уведомлять окружение о изменении значения данной переменной. В конструкторе класса, реализующего интерфейс IExtentionGUI_M, мы задаем значения свойствам типизированным рассмотренным нами типом, а также подписываемся на событие уведомления об обновлении данных свойств. В деструкторе данного класса мы, напротив, отписываемся от событий данных свойств.

public ExtentionGUI_M(TerminalCreator TerminalCreator,
                      ConfigCreator ConfigCreator,
                      ReportReaderCreator ReportReaderCreator,
                      SetFileManagerCreator SetFileManagerCreator,
                      OptimisationExtentionWorkingDirectory CurrentWorkingDirectory,
                      OptimisatorSettingsManagerCreator SettingsManagerCreator,
                      TerminalDirectory terminalDirectory)
{
    // Присваиваем текущую рабочию директорию
    this.CurrentWorkingDirectory = CurrentWorkingDirectory;
    this.terminalDirectory = terminalDirectory;
    //Создаем фабрики
    this.TerminalCreator = TerminalCreator;
    this.ReportReaderCreator = ReportReaderCreator;
    this.ConfigCreator = ConfigCreator;
    this.SetFileManagerCreator = SetFileManagerCreator;
    this.SettingsManagerCreator = SettingsManagerCreator;
    CreateOptimisationManagerFabrics();

    // подписываемся на событие обновления коллекции колонок в таблице с историческими оптимизациями
    HistoryOptimisationResults.Columns.CollectionChanged += Columns_CollectionChanged;

    // Присваиваем начальный статус
    Status = new VarKeeper<string>("Status", "Wait for the operation");
    Status.PropertyChanged += OnPropertyChanged;
    // Присваиваем начальные значения прогресс бара
    PB_Value = new VarKeeper<double>("PB_Value", 0);
    PB_Value.PropertyChanged += OnPropertyChanged;
    // Создаем переменную, хранящую в себе индекс выбранного терминала из списка доступных терминалов, на которых была оптимизация
    TerminalsAfterOptimisation_Selected = new VarKeeper<int>("TerminalsAfterOptimisation_Selected", 0);
    TerminalsAfterOptimisation_Selected.PropertyChanged += OnPropertyChanged;
    // Создаем переменную, хранящую в себе индекс выбранного робота из списка доступных роботов, на которых была оптимизация
    BotsAfterOptimisation_Selected = new VarKeeper<int>("BotsAfterOptimisation_Selected", -1);
    BotsAfterOptimisation_Selected.PropertyChanged += OnPropertyChanged;

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

    // Загружаем сведения о терминалах, установленных на компьютере
    FillInTerminalsID();
    FillInTerminalsAfterOptimisation();
    LoadOptimisations();
}

Также стоит обратить внимание на то, что в конструкторе мы вызываем ряд методов:

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

/// <summary>
/// Представление таблицы выбранных терминалов со стартовой вкладки данного дополнения
/// key - Terminal ID
/// value - bot params
/// </summary>
private readonly Dictionary<string, BotParamKeeper> BotParamsKeeper = new Dictionary<string, BotParamKeeper>();
/// <summary>
/// Выбранный терминал на текущий момент
/// </summary>
private string selectedTerminalID = null;
/// <summary>
/// Список Параметров робота для редактирования
/// </summary>
List<ParamsItem> IExtentionGUI_M.BotParams
{
    get
    {
        return (BotParamsKeeper.Count > 0 && selectedTerminalID != null) ?
               BotParamsKeeper[selectedTerminalID].BotParams.Params :
               new List<ParamsItem>();
    }
}

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

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

Как видно из кода, кроме того, что мы блокируем пользовательский интерфейс в момент оптимизации и теста (что было видно на видео), в коде так же существует  проверка на то, можно ли нам выполнить обновление списка параметров робота (и, возоможно, терминалов) или же нет. Если мы можем выполнить обновление параметров робота или терминала, то  блокируем графический интерфейс. Далее идет либо добавление нового робота, либо сохранение ранее введенных параметров из графического интерфейса. Затем происходит сохранение выбранного ID терминала (ключ в нашем словаре) и передача назад во ViewModel параметров нового выбранного робота. Если мы сменили выбранного робота по сравнению с ранее выбранным, то мы загружаем для него параметры через метод  GetSetFile. Метод, добавляющий новый терминал, достаточно прост и практически полностью повторяет последнюю условную конструкцию рассматриваемого метода. Основную работу в нем выполняет метод GetSetFile.

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

    // Создаем менеджер по работе с терминалом
    ITerminalManager terminalManager = TerminalCreator.Create(terminalChangableFolder);

    // Составляем путь до папки Tester (которая находится в директории ~/MQL5/Profiles) 
    // Если ее нет (иногда MetaTrader не создает ее при первой установке) то создаем ее самостоятельно
    // В ней хранятся файлы с настройками оптимизационных параметров
    DirectoryInfo pathToMqlTesterFolder = terminalManager.MQL5Directory.GetDirectory("Profiles").GetDirectory("Tester", true);
    if (pathToMqlTesterFolder == null)
        throw new Exception("Can`t find (or create) ~/MQL5/Profiles/Tester directory");

    // Создаем конфигурационный файл и сразу копируем его в папку Configs текущей рабочей директори данного дополнения
    Config config = ConfigCreator.Create(Path.Combine(terminalChangableFolder.GetDirectory("config").FullName, "common.ini"))
                                 .DublicateFile(Path.Combine(CurrentWorkingDirectory.Configs.FullName, $"{TerminalID}.ini"));
    // Настраиваем терминал так, чтобы он запустил тест выбранного робота и сразу же выключился
    // Это необходимо для того, чтобы терминал создал .set файл с настройками данного советника.
    // Для того, чтобы он сразу же выключился, указываем дату окончания теста на день ниже чем дата начала.
    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);

    // Задаем менеджеру терминала конфигурационный файл, запускаем его и дожидаемся закрытия терминала
    // Для того чтобы терминал закрылся автоматически после завершения теста, 
    // присваеваем полю config.Tester.ShutdownTerminal значение true
    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 by hands all Metatrader terminals that 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);
}

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

Следующим интересным моментом в данном классе является процесс запуска оптимизаций, который выполняется асинхронным методом StartOptimisationOrTest.

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

    // Запускаем оптимизацию и дожидаемся ее окончания
    _isTerminalsEnabled.SetVar(false);
    await System.Threading.Tasks.Task.Run(() => selectedOptimisationManager.StartOptimisation());
    _isTerminalsEnabled.SetVar(true);
}

private void SetOptimisationManager(List<ViewModel.TerminalAndBotItem> SelectedTerminals)
{
    // Выбираем фабрику для создания оптимизационного менеджера из списка
    OptimisationManagerFabric OMFabric = optimisationManagerFabrics[0];
    //Отписываемся от ранее исспользованного оптимизационного менеджера
    if (selectedOptimisationManager != null)
    {
        // Проверяем запущена ли оптимизация в данный момент
        if (selectedOptimisationManager.IsOptimisationOrTestInProcess)
            return;

        selectedOptimisationManager.AllOptimisationsFinished -= SelectedOptimisationManager_AllOptimisationsFinished;
    }

    // Создаем менеджера оптимизаций и подписываем его на событие заверщшения оптимизации
    selectedOptimisationManager = OMFabric.Create(BotParamsKeeper, SelectedTerminals);
    selectedOptimisationManager.AllOptimisationsFinished += SelectedOptimisationManager_AllOptimisationsFinished;
}

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

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

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

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

public void LoadOptimisations()
{
    // Внутренний метод, заполняющий переданную в него таблицу данными
    void SetData(bool isForvard, DataTable tb)
    {
        // Очищаем таблицу от ранее заполненных данных
        tb.Clear();
        tb.Columns.Clear();

        // Получаем данные
        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;

            // Заполняем колонки
            foreach (var item in reader.ColumnNames)
            {
                tb.Columns.Add(item);
            }

            // Заполняем строки
            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;
    }

    // Заполняем поочередно сначала исторические, а затем и форвардные оптимизации
    SetData(false, HistoryOptimisationResults);
    SetData(true, ForvardOptimisationResults);
}

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

  1. очистка переданной таблицы (и ее колонок) 
  2. Задание пути к загружаемому файлу отчета
  3. Чтение отчета с помощью класса ReportReader и загрузка его данных в таблицу.

Стоит упомянуть, что в конструкторе содержится следующая строчка кода:

// подписываемся на событие обновления коллекции колонок в таблице с историческими оптимизациями
HistoryOptimisationResults.Columns.CollectionChanged += Columns_CollectionChanged;

Которая подписывает метод Columns_CollectionChanged на событие обновления колонок таблицы с историческими оптимизациями. Данное событие помогает отслеживать добавление колонок. В подписанном методе, реализацию которого можно посмотреть в коде (он сильно большой и прост в реализации), происходит автоматическое добавление или удаление имен колонок в коллекции  OptimisationResultsColumnHeadders, откуда данные поступают во ViewModel и во View, где через описанное ранее расширение для автоматической подгрузки колонок добавляются в ListView. Таким образом, при редактировании списка колонок в таблице исторических оптимизаций происходит автоматическое редактирование колонок во View в обоих таблицах.  

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

//+------------------------------------------------------------------+
//|                                 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"
//+------------------------------------------------------------------+
//| Expert initialization function                                   |
//+------------------------------------------------------------------+
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();
  }
//+------------------------------------------------------------------+

После компиляции нашего проекта на C# (режиме Release) нам потребуется разместить его в соответствующей директории (~/Libraries) и подключить к нашему роботу. Для того чтобы получить ID текущего терминала, нам нужно получить  путь к его изменяемой директории, а далее, воспользовавшись методом StringSplit, разделить его на составляющие пути. Последняя директория как раз и будет содержать искомый нами ID Терминала. После  запуска графики устанавливается задержка текущего потока до тех пор, пока окно не будет загружено. Дале мы запускаем таймер. Таймер нужен нам для того что бы отследить событие закрытия окна,  при закрытии окна мы должны будем удалить эксперта с графика. Таким нехитрым способом мы добиваемся поведения, описанного в видео примере.

Заключение и прилагаемые файлы

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

В приложении находится архив которых содержит две папки: