Управление оптимизацией (Часть I): Создание графического интерфейса

Andrey Azatskiy | 2 июля, 2019

Оглавление

Введение

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

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

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




Способ запуска MetaTrader и конфигурационные файлы

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

Рассмотрим запуск терминала с простейшей программки написанной на C++:

#include <iostream>

using namespace std;

int main()
{
    cout<<"Hello World";

    return 0;
}

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

#include <iostream>

using namespace std;

int main(int argc, char *argv[])
{
    cout << "Hello World" << endl;

    for(int i = 0; i < argc; i ++)
    {
        cout << argv[i] << endl; 
    }

    return 0;
}

Первый параметр argc указывает на длину массива массивов второго параметра.

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

./program "My name" "is Andrey"

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

Hello World
./program
My name
is Andrey

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

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

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

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

terminal.exe /config:c:\myconfiguration.ini /partable

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

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

[Tester]

После указанной секции идет список ключ-значение, предоставляющий описание полей, характеризующих параметры запуска программы. Также конфигурационные файлы могу содержать комментарии, начинающиеся с символов ";" или "#". Стоит сказать, что сейчас на смену конфигурационным файлам формата (*.ini) пришли файлы использующие XAML разметку, или json файлы, так как они позволяют сохранить значительно больше информации в одном файле, однако во многих давно разрабатываемых программах, к коим относится MetaTrader, до сих пор используются файлы формата (*.ini). WinApi поддерживает ряд функций по работе с конфигурационными файлами, которые мы использовали при написании класса обертки для удобной работы с требуемым форматом файла. Более подробно используемые функции и обертка для работы с конфигурационными файлами MetaTrader будет описана в одном из низлежайших разделов данной статьи. 

Функционал создаваемого дополнения и используемые технологии

Для начала стоит определиться с тем, что для работы с проектом потребуется установить на компьютер IDE (Integrated Development Environment) Visual Studio, конкретно данный проект был создан с использованием версии Community 2019 года. Дополнительно во время установки студии нужно установить .Net 4.6.1 — с его использованием написано данное дополнение. Также стоит сказать, что для того чтобы читатели, недостаточно сведущие в языке C#, могли быстро вникнуть в суть вопроса, я постараюсь подробно описывать некоторые специфические моменты данного языка и используемые мною приемы во время программирования.

Так как графический интерфейс наиболее удобно создавать с использованием языка C#, а терминал MetaTrader поддерживает удобный способ стыковки с данным языком, то стоит воспользоваться представленной возможностью. К тому же не так давно на форуме появилось несколько статей на тему создания графического интерфейса с использованием C#. Данные статьи хорошо демонстрируют способ создания графических интерфейсов на баз технологии Win Forms и стыковочной dll-библиотеки, которая через механизмы рефлекции запускает графику. Решение, использованное автором данных статей, достаточно хорошо, однако для текущей статьи я решил воспользоваться более современной версией написания графических интерфейсов — через технологию WPF. В результате удалось обойтись без стыковочной библиотеки, уместив все в одну единственную dll-библиотеку. Для решения поставленной нами задачи требуется создать тип проекта, который позволял бы хранить в себе графические объекты, описанные с использованием WPF технологии, и компилирующийся в динамическую библиотеку (*.dll файл), который позже можно было бы подгрузить в терминале. Такой тип проекта существует, это WpfCustomControlLibrary. Данный тип проектов специально был разработан для создания пользовательских графических объектов, примером того может служить библиотека, рисующая графики. Мы же будем использовать ее для собственных целей, а именно — для создания нашего расширения для терминала MetaTrader. Для создания данного проекта требуется выбрать его из списка проектов в IDEVisual Studio, как показано на скриншоте:

Назовем создаваемый проект "OptimisationManagerExtention". Изначально в проекте создается папка с темами Themes, со вложенным (*.xaml) файлом "Generic.xaml" — это файл, в котором предлагается хранить стили, задающие цвета, изначальные размеры, отступы по краям и тому подобные свойства графических объектов. Данный файл еще понадобится нам в детальнейшем, поэтому оставим его пока как есть. Другим автоматически сгенирированным файлом является файл, содержащий класс CustomControl1 — данный файл не понадобится для наших целей, поэтому удалим его. Так как на базе данной статьи будет написано еще несколько статей, то стоит изначально позаботиться о расширяемости создаваемого дополнения, а это значит, что стоит прибегнуть к шаблону программирования MVVM — для тех кто не знаком с ним, я оставлю ссылку на достаточно хорошую статью, где на мой взгляд детально описан смысл данного паттерна программирования. Для большей структурированности кода создадим папку "View", куда поместим наше графическое окно. Для создания графического окна требуется в созданную папку добавить элемент Window (WPF) как на скриншоте ниже:


Назовем создаваемое окно ExtentionGUI.xaml — оно как раз и будет тем самым графическим элементом который был заснят в видео выше. Теперь стоит поговорить о пространствах имен. Создав проект и назвав его OptimisationManagerExtention — студия автоматически сгенирировала нам главное пространство имен — "OptimisationManagerExtention". В языке C#, как и в большинстве других языках программирования, пространства имен служат некими контейнерами, в которые впакованы наши объекты. Свойства пространств имен проще всего демонстрирует следующий пример: 

Вот такая конструкция является ошибочной, так как оба класса объявлены в одном и том же пространстве имен — а именно:

namespace MyNamespace
{
    class Class_A
    {
    }

    class Class_A
    {
    }
}

А вот такое разбиение классов допустимо, так как оба класса, несмотря но одинаковое название, находятся в разном пространстве имен:

namespace MyFirstNamespace
{
    class Class_A
    {
    }
}

namespace MySecondNamespace
{
    class Class_A
    {
    }
}

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

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace MyNamespace
{
    class Class_A
    {
    }

    namespace Second
    {
        class Class_A
        {
        }
    }

    namespace First
    {
        class Class_A
        {
        }
    }
}

Но так как данная форма записи является неудобной, в С# существует сокращенная запись — более удобная для восприятия:

namespace MyNamespace
{
    class Class_A
    {
    }
}

namespace MyNamespace.First
{
    class Class_A
    {
    }
}

namespace MyNamespace.Second
{
    class Class_A
    {
    }
}

Код, представленный в двух предыдущих, примерах идентичен, однако последний вариант кода более удобен. Возвращаясь от теории к нашему дополнению, стоит заметить, что создав папку View, мы тем самым создали вложенное пространство имен, и теперь объекты, размещаемые в папке View, будут размещаться в пространстве имен "OptimisationManagerExtention.View". Соответственно и наше окно имеет данное пространство имен. Для того чтобы стили, которые мы будем описывать в файле Generic.xaml, успешно применились для всего окна в целом, нам следует отредактировать XAML разметку данного файла. Первое, что нужно сделать, — это удалить блок кода, начинающийся с тега <Style>, так как он нам не нужен. Вторым делом необходимо добавить ссылку на пространство имен нашего окна, что делается через свойство "xmlns:local". В результате получим следующее содержимое:

<ResourceDictionary
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="clr-namespace:OptimisationManagerExtention.View">

</ResourceDictionary>

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

<ResourceDictionary
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="clr-namespace:OptimisationManagerExtention.View">
    
    <!--Задаем цвет фона окна-->
    <Style TargetType="{x:Type local:ExtentionGUI}">
        <Setter Property="Background" Value="WhiteSmoke"/>
    </Style>

    <!--
    Задаем цвет фона разделительной полосы перетаскивая которую 
    меняем диапазоны горизонтально разделенных зон на первой вкладке 
    нашего окна
    -->
    <Style TargetType="GridSplitter">
        <Setter Property="Background" Value="Black"/>
    </Style>

    <!--Задаем высоту выпадающих списков-->
    <Style TargetType="ComboBox">
        <Setter Property="Height" Value="22"/>
    </Style>

    <!--Задаем высоту календарей-->
    <Style TargetType="DatePicker">
        <Setter Property="Height" Value="22"/>
    </Style>

    <!--Задаем высоту текст боксов-->
    <Style TargetType="TextBox">
        <Setter Property="Height" Value="22"/>
    </Style>

    <!--Задаем высоту кнопок-->
    <Style TargetType="Button">
        <Setter Property="Height" Value="22"/>
    </Style>

</ResourceDictionary>

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

<!--Подключаем стили-->
<Window.Resources>
    <ResourceDictionary Source="../Themes/Generic.xaml"/>
</Window.Resources>

Помимо созданной директории View, создадим еще несколько  директорий:

Как вы уже наверное догадались, отвечающий за графику приложения слой описывается исключительно в XAML разметке, без использования языка C# напрямую. Создав соответствующие директории, мы создали еще 2 вложенных пространства имен, которые стоит добавить в XAML разметку нашего окна для того, чтобы иметь возможность их использования. Также создадим класс "ExtentionGUI_VM" в пространстве имен OptimisationManagerExtention.ViewModel. Данный класс станет нашим объектом-стыковкой, но для того чтобы выполнять требуемые функции, он должен унаследоваться от интерфейса "INotifyPropertyChanged" — он содержит событие PropertyChanged, через которое происходит уведомление графической части об изменении значений какого либо из полей и соответственно необходимости обновить графику. Созданный класс выглядит следующим образом:

/// <summary>
/// View Model
/// </summary>
class ExtentionGUI_VM : INotifyPropertyChanged
{
    /// <summary>
    /// Событие изменения какого либо из свойств ViewModel 
    /// и его обработчики
    /// </summary>
    #region PropertyChanged Event
    public event PropertyChangedEventHandler PropertyChanged;
    /// <summary>
    /// Обработчик события PropertyChanged
    /// </summary>
    /// <param name="propertyName">Имя обновляемой переменной</param>
    protected virtual void OnPropertyChanged(string propertyName)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }
    #endregion
}

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

<Window x:Class="OptimisationManagerExtention.View.ExtentionGUI"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:OptimisationManagerExtention.ViewModel"
        xmlns:viewExtention="clr-namespace:OptimisationManagerExtention.ViewExtention"
        mc:Ignorable="d"
        Title="ExtentionGUI" Height="450" Width="1100">

    <!--Подключаем стили-->
    <Window.Resources>
        <ResourceDictionary Source="../Themes/Generic.xaml"/>
    </Window.Resources>
    <!--Подключаем ViewModel-->
    <Window.DataContext>
        <local:ExtentionGUI_VM />
    </Window.DataContext>    

    <Grid>
        

    </Grid>
</Window>

Основные приготовления для того чтобы написать графику нашего приложения на текущий момент окончены, и мы можем начать заполнять XAML-разметку нашего окна для создания графического слоя. Все контроллы будут прописываться внутри блока <Grid/>. Для тех, кто не работал или же мало работал с XAML разметкой, рекомендую открыть ее сразу же в студии и сверяться с чтением чтобы в процессе чтения было удобнее следить за разметкой. Тем же, кто хорошо знаком с данным инструментом, думаю должно хватить тех кусков кода, что я буду вставлять здесь в процессе повествования. Проводя аналогию между двумя способами создания графический интерфейсов (WinForms / WPF), можно сказать что у них помимо очевидных различий, так же существуют и сходства. Вспомним интерфейсы WinForms, где все графические элементы представляются в виде экземпляров классов и хранятся в скрытой части абстрактного класса (к примеру, класс Button или ComboBox).

Таким образом получается, что все графическое приложение WinForms состоит из набора экземпляров объектов связанных между собой. Глядя на WPF разметку, трудно представить, что в ее основе находится тот же самый принцип, однако это так. Каждый элемент разметки,к примеру, уже знакомый нам тег "Grid" — на самом деле является классом, и при желании можно воссоздать точно такое же приложение, но без использования XAML-разметки, используя только лишь классы из соответствующего пространства имен, однако это будет некрасиво и чрезмерно громоздко. По сути, открывая тег <Grid>, мы указываем что хотим создать экземпляр данного класса, далее механизмы компилятора уже сами парсят указанную нами разметку и создают экземпляры требуемых объектов. Это свойство WPF приложений позволяет нам создавать собственные графические объекты, либо объекты расширяющие стандартный функционал. Далее в статье мы рассмотрим то каким образом это реализуется.    

Возвращаясь  к процессу создания графики, стоит отметить что блок <Grid/> относится к компоновочным, это означает, что он предназначен для удобного размещения контроллов и других компоновочных блоков. Как видно из видео, при смене вкладок  между вкладками Settings и вкладкой Optimisation Result — нижняя часть (ProgressBar) остается неизменной, это достигается путем разделения основного блока <Grid/> на 2 ряда, внутрь которых помещены Панель с основными вкладками (TabControll) и другой блок <Grid/>, который содержит в себе Строку статуса (Lable), ProgressBar и кнопку запуска процесса оптимизации. Данный вложенный контейнер так же поделен, но только уже по горизонтали и на 3 части (столбца), в каждом из отведенных столбцов содержится один из элементов управления ( Lable, ProgressBar, Button)

<Grid>
    <Grid.RowDefinitions>
        <RowDefinition/>
        <RowDefinition Height="27"/>
    </Grid.RowDefinitions>

    <!--Создаем TabControl с двумя вкладками-->
    <TabControl>
        <!--Вкладка настроек робота и запуска оптимизации или же одиночного теста-->
        <TabItem Header="Settings">
           
        </TabItem>

        <!--Вкладка просмотра результата оптимизации и запуска теста по событию двойного клика-->
        <TabItem Header="Optimisation Result">
          
        </TabItem>
    </TabControl>

    <!--Контейнер с полосой загрузки, статусом выполняемой операции и кнопкой запуска-->
    <Grid Grid.Row="1">
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="150"/>
            <ColumnDefinition/>
            <ColumnDefinition Width="100"/>
        </Grid.ColumnDefinitions>
        <!--Статус выполняемой операции-->
        <Label Content="{Binding Status, UpdateSourceTrigger=PropertyChanged}"/>
        <!--Полоса загрузки-->
        <ProgressBar Grid.Column="1" 
                                     Minimum="0" 
                                     Maximum="100"
                                     Value="{Binding PB_Value, UpdateSourceTrigger=PropertyChanged}"/>
        <!--Кнопка запуска-->
        <Button Margin="5,0,5,0" 
                                Grid.Column="2"
                                Content="Start"
                                Command="{Binding Start}"/>
    </Grid>
</Grid>

Пока мы не ушли далеко в нашем рассказе, стоит рассмотреть свойства использованные совместно с данными контроллами, а точнее — способ передачи информации из ViewModel во View. Как будет показано далее, для каждого из полей, отображающих или служащих для ввода какой либо информации, в классе ExtentionGUI_VM (наш объект ViewMpodel) будет создано определенное поле хранящее его значение. При создании WPF приложений, и тем более при использовании паттерна MVVM, не принято напрямую по имени из кода обращаться к графическим элементам, поэтому мы использовали более удобный процесс передачи значений, который к тому же требует минимум кода. К примеру, свойство Value для графического элемента ProgressBar задается с использованием технологии связки данных, за что отвечает следующая запись:

 Value="{Binding PB_Value, UpdateSourceTrigger=PropertyChanged}"

После свойства Binding указывается имя поля, которое хранит информацию и озаглавлено в классе ViewModel, а свойство UpdateSourceTrigger указывает способ обновления информации в графическом элементе. Задавая данное свойство параметром PropertyChanged, мы говорим приложению, что данное конкретное свойство данного конкретного элемента нужно обновлять только если сработало событие PropertyChanged в классе ExtentionGUI_VM, и в качестве одного из параметров данного события было передано имя переменной, с которой осуществлена связка, а именно — "PB_Value". Как можно видеть из XAML разметки, у кнопки так же есть связка данных, однако у кнопки связка осуществляется со свойством Command, которое указывает через интерфейс ICommand на команду (а точнее, на метод, определенный в классе ViewModel), вызываемую по событию клика на данную кнопку. Таким образом осуществляется связка событий нажатия на кнопку, а также другие события (к примеру, двойной клик по таблице с результатами оптимизаций). На данном этапе наша графическая часть уже приобрела основные разлиновочные черты и выгладит следующим образом:


Следующим шагом создания графического интерфейса рассмотрим наполнение контролами вкладки OptimisationResults. Данная вкладка содержит два выпадающих списка (Combobox) для выбора терминала, на котором производилась оптимизация, и эксперта, соответственно, а так же кнопку Update Report. Также данная вкладка содержит вложенный контрол TabControl с двумя вложенными вкладками, на каждой из которых располагается таблица (ListView), в которой отражаются результаты оптимизаций. В XAML разметке это выглядит следующим образом:

  <!--Вкладка просмотра результата оптимизации и запуска теста по событию двойного клика-->
            <TabItem Header="Optimisation Result">
                <Grid Margin="5">
                    <Grid.RowDefinitions>
                        <RowDefinition Height="50"/>
                        <RowDefinition/>
                    </Grid.RowDefinitions>

                    <Grid VerticalAlignment="Center">
                        <WrapPanel>
                            <Label Content="Terminal:"/>
                            <ComboBox Width="250" 
                                  ItemsSource="{Binding TerminalsAfterOptimisation}"
                                  SelectedIndex="{Binding TerminalsAfterOptimisation_Selected, UpdateSourceTrigger=PropertyChanged}"/>
                            <Label Content="Expert"/>
                            <ComboBox Width="100"  
                                  ItemsSource="{Binding BotsAfterOptimisation}"
                                  SelectedIndex="{Binding BotsAfterOptimisation_Selected, UpdateSourceTrigger=PropertyChanged}"/>
                        </WrapPanel>
                        <Button HorizontalAlignment="Right"
                            Content="Update Report"
                            Command="{Binding UpdateOptimisationReport}"/>
                    </Grid>
                    <!--Контейнер с таблицами результата оптимизаций-->
                    <TabControl 
                        TabStripPlacement="Bottom"
                        Grid.Row="1">
                        <!--Вкладка, на которой отображаются результаты исторической оптимизации-->
                        <TabItem Header="Backtest">
                            <!--Таблица с оптимизационными результатами-->
                            <ListView ItemsSource="{Binding HistoryOptimisationResults}"
                                  viewExtention:ListViewExtention.DoubleClickCommand="{Binding StartTestFromOptimisationResults}"
                                  viewExtention:ListViewExtention.DoubleClickCommandParameter="History"
                                  SelectedIndex="{Binding SelectedHistoryOptimisationRow}" >
                                <ListView.View>
                                    <GridView 
                                    viewExtention:GridViewColumns.ColumnsSource="{Binding OptimisationResultsColumnHeadders}"
                                    viewExtention:GridViewColumns.DisplayMemberMember="DisplayMember"
                                    viewExtention:GridViewColumns.HeaderTextMember="HeaderText"/>
                                </ListView.View>
                            </ListView>
                        </TabItem>
                        <!--Вкладка, на которой отображаются результаты форвардных 
                    оптимизационный проходов-->
                        <TabItem Header="Forvard">
                            <!--Таблица с оптимизационными результатами-->
                            <ListView ItemsSource="{Binding ForvardOptimisationResults}"
                                  viewExtention:ListViewExtention.DoubleClickCommand="{Binding StartTestFromOptimisationResults}"
                                  viewExtention:ListViewExtention.DoubleClickCommandParameter="Forvard"
                                  SelectedIndex="{Binding SelectedForvardOptimisationRow}">
                                <ListView.View>
                                    <GridView 
                                   viewExtention:GridViewColumns.ColumnsSource="{Binding OptimisationResultsColumnHeadders}"
                                   viewExtention:GridViewColumns.DisplayMemberMember="DisplayMember"
                                   viewExtention:GridViewColumns.HeaderTextMember="HeaderText"/>
                                </ListView.View>
                            </ListView>
                        </TabItem>
                    </TabControl>
                </Grid>
            </TabItem>

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

Вторым расширением является преобразование события двойного клика к интерфейсу ICommand. Второе расширение можно было бы не создавать, если бы мы не использовали шаблон разработки MVVM, согласно которому ViewModel, а тем более Model, никак не должны быть связаны с View слоем — это делается для того, чтобы при необходимости можно было бы легко изменять графический слой приложения, либо вовсе переписать его с нуля. Как видно из способов вызова данных расширений, все они находятся во вложенном пространстве имен ViewExtention, далее после двоеточия следует наименование класса, в котором находятся расширения и после оператора "точка" — имя свойства, которому задаем значение.

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

using System.Windows;

using ICommand = System.Windows.Input.ICommand;
using ListView = System.Windows.Controls.ListView;

namespace OptimisationManagerExtention.ViewExtention
{
    /// <summary>
    /// Класс расширений для ListView, который переводит события в команды (ICommand)
    /// класс помечен ключевым словом partial - т.е. его реализация разбита на несколько файлов.
    /// 
    /// Конкретно в данном классе - реализован перевод события ListView.DoubleClickEvent 
    /// в комманду типа ICommand
    /// </summary>
    partial class ListViewExtention
    {
        #region Command
        /// <summary>
        /// Зависимое свойство - содержащее в себе ссылку на коллбек команды
        /// Свойство задается через View в XAML разметке проекта
        /// </summary>
        public static readonly DependencyProperty DoubleClickCommandProperty =
            DependencyProperty.RegisterAttached("DoubleClickCommand",
                typeof(ICommand), typeof(ListViewExtention),
                new PropertyMetadata(DoubleClickCommandPropertyCallback));

        /// <summary>
        /// Setter для DoubleClickCommandProperty
        /// </summary>
        /// <param name="obj">Элемент управления</param>
        /// <param name="value">Значение, с которым осуществляется связка</param>
        public static void SetDoubleClickCommand(UIElement obj, ICommand value)
        {
            obj.SetValue(DoubleClickCommandProperty, value);
        }
        /// <summary>
        /// Geter для DoubleClickCommandProperty
        /// </summary>
        /// <param name="obj">Элемент управления</param>
        /// <returns>ссылку на сохраненную команду типа ICommand</returns>
        public static ICommand GetDoubleClickCommand(UIElement obj)
        {
            return (ICommand)obj.GetValue(DoubleClickCommandProperty);
        }
        /// <summary>
        /// Коллбек, вызываемый после задания свойства DoubleClickCommandProperty
        /// </summary>
        /// <param name="obj">Элемент управления, для которого задается свойство</param>
        /// <param name="args">события, предшествующие вызову коллбека</param>
        private static void DoubleClickCommandPropertyCallback(DependencyObject obj, DependencyPropertyChangedEventArgs args)
        {
            if (obj is ListView lw)
            {
                if (args.OldValue != null)
                    lw.MouseDoubleClick -= Lw_MouseDoubleClick;

                if (args.NewValue != null)
                    lw.MouseDoubleClick += Lw_MouseDoubleClick;
            }
        }
        /// <summary>
        /// Коллбек события, которое переводится в тип ICommand
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private static void Lw_MouseDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e)
        {
            if (sender is UIElement element)
            {
                object param = GetDoubleClickCommandParameter(element);
                ICommand cmd = GetDoubleClickCommand(element);
                if (cmd.CanExecute(param))
                    cmd.Execute(param);
            }
        }
        #endregion

        #region CommandParameter
        /// <summary>
        /// Зависимое свойство - содержащее в себе ссылку на параметры, передаваемые в коллбек типа ICommand
        /// Свойство задается через View в XAML разметке проекта
        /// </summary>
        public static readonly DependencyProperty DoubleClickCommandParameterProperty =
            DependencyProperty.RegisterAttached("DoubleClickCommandParameter",
                typeof(object), typeof(ListViewExtention));
        /// <summary>
        /// Setter для DoubleClickCommandParameterProperty
        /// </summary>
        /// <param name="obj">Элемент управления</param>
        /// <param name="value">Значение, с которым осуществляется связка</param>
        public static void SetDoubleClickCommandParameter(UIElement obj, object value)
        {
            obj.SetValue(DoubleClickCommandParameterProperty, value);
        }
        /// <summary>
        /// Geter для DoubleClickCommandParameterProperty
        /// </summary>
        /// <param name="obj">Элемент управления</param>
        /// <returns>переданный параметр</returns>
        public static object GetDoubleClickCommandParameter(UIElement obj)
        {
            return obj.GetValue(DoubleClickCommandParameterProperty);
        }
        #endregion
    }
}

Каждое свойство каждого класса из WPF графических объектов завязано на классе DependancyProperty. Данный класс позволяет осуществлять связку данных между слоями View и ViewModel. Для создания экземпляра данного класса нужно воспользоваться статическим методом DependencyProperty.RegisterAttached, возвращающим сконфигурированный класс DependencyProperty. Данный метод принимает 4 параметра, более подробно про них можно почитать здесь. Стоит упомянуть тот факт, что создаваемое свойство обязательно должно иметь модификаторы доступа public static readonly (т.е. доступный извне данного класса, возможность вызова данного свойства без необходимости создания экзампляра класса, а также модификатор static задает единство данного свойства в пределах данного конкретного приложения, а readonly делает свойство неизменяемым).

  1. Первый параметр задает имя, по которому данное свойство будет видимо в XAML разметке.
  2. Второй параметр задает тип элемента, с которым планируется осуществлять связку. Объекты именно данного типа будут храниться в созданном экземпляре класса DependancyProperty. 
  3. Третий параметр задает тип класса, в котором находится данное свойство, в нашем случае это класс ListViewExtention.
  4. Последний параметр принимает экземпляр класса PropertyMetadata — этот параметр по сути ссылается на обработчик события, вызываемого по окончанию создания экземпляра класса DependancyProperty. Данный коллбек нам необходим для осуществления подписки на событие двойного клика.

Для того чтобы мы корректно могли устанавливать и получать значения из данного свойства, нам требуется создать методы, имя которых будет состоять из имени переданного при создании экземпляра класса DependancyProperty и префиксов Set (для установки значений и Get для получения значений). Оба метода должны также быть статическими. По сути они инкапсулируют использование уже существующих методов SetValue и GetValue.

Коллбек события завершения создания зависимого свойства осуществляет подписку на событие двойного клика мышью на строку из таблицы и отписку от ранее подписанного события, если таковое существовало. Внутри обработчика события двойного клика мышью осуществляется последовательный вызов методов  CanExecute и Execute из переданного во View поля типа ICommand. Таким способом, при срабатывании события двойного клика на какой-либо из строк подписанной таблицы, мы автоматически вызываем обработчик данного события, в котором вызываются методы логики, выполняемой после наступления данного события.

Созданный нами класс по сути является классом посредником, он берет на себя роль обработки событий и вызова методов из ViewModel, но сам при этом не выполняет никакой бизнесс логики. Возможно, данный подход кажется более запутанном по сравнению прямого вызова метода из обработчика события двойного клика (как принято в WinForms) приложениях, однако существуют довольно веские причины воспользоваться именно данным подходом — это необходимость соблюдения патерна MVVM, который гласит, что View не должна знать ничего о ViewModel и наоборот.

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

В описываемом дополнении также реализовано свойство преобразования события SelectionChanged в ICommand, а также класс посредник, автоматически создающий колонки для таблицы, исходя из сбинденного поля, хранящего в себе коллекцию имен колонок. Оба данных расширения XAML разметки реализованы описанным выше способом, и потому я не стану заострать на них внимание, если что-то будет непонятно, с удовольствием отвечу на все вопросы в комментариях к статье. Теперь, когда мы реализовали разметку вкладки Optimisation Result, наше окно выглядит следующим образом:


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

<!--Вкладка настроек робота и запуска оптимизации или же одиночного теста-->
            <TabItem Header="Settings">
                <!--Контейнер с настройками и прочим-->
                <Grid>
                    <Grid.RowDefinitions>
                        <RowDefinition/>
                        <RowDefinition Height="200"/>
                    </Grid.RowDefinitions>

                    <!--Контейнер, содержащий список выбранных терминалов-->
                    <Grid>
                        <Grid.RowDefinitions>
                            <RowDefinition Height="30"/>
                            <RowDefinition/>
                        </Grid.RowDefinitions>
                        <!--Контейнер с выбором терминалов, которые автоматически определяются-->
                        <WrapPanel HorizontalAlignment="Right" 
                                       VerticalAlignment="Center">
                            <!--Список с терминалами-->
                            <ComboBox Width="200" 
                                          ItemsSource="{Binding TerminalsID}"
                                          SelectedIndex="{Binding SelectedTerminal, UpdateSourceTrigger=PropertyChanged}"
                                          IsEnabled="{Binding IsTerminalsLVEnabled, UpdateSourceTrigger=PropertyChanged}"/>
                            <!--Кнопка добавления терминала-->
                            <Button Content="Add" Margin="5,0"
                                    Command="{Binding AddTerminal}"
                                    IsEnabled="{Binding IsTerminalsLVEnabled, UpdateSourceTrigger=PropertyChanged}"/>
                        </WrapPanel>
                        <!--Список с выбранными терминалами-->
                        <ListView Grid.Row="1"
                                  ItemsSource="{Binding SelectedTerminalsForOptimisation}"
                                  SelectedIndex="{Binding SelectedTerminalIndex, UpdateSourceTrigger=PropertyChanged}"
                                  IsEnabled="{Binding IsTerminalsLVEnabled, UpdateSourceTrigger=PropertyChanged}" >
                            <ListView.View>
                                <GridView>
                                .
                                .
                                .
                                </GridView>
                            </ListView.View>
                        </ListView>
                    </Grid>
                    <!--Контейнер? содержащий параметры для редактирования и 
                    настройки оптимизации-->
                    <TabControl
                                Grid.Row="2" 
                                Margin="0,0,0,5"
                                TabStripPlacement="Right">
                        <!--Вкладка параметров робота-->
                        <TabItem Header="Bot params" >
                            <!--Список с параметрами робота-->
                            <ListView 
                                    ItemsSource="{Binding BotParams, UpdateSourceTrigger=PropertyChanged}">
                                <ListView.View>
                                    <GridView>
                                    .
                                    .
                                    .
                                    </GridView>
                                </ListView.View>
                            </ListView>
                        </TabItem>
                        <!--Вкладка настроек оптимизации-->
                        <TabItem Header="Settings">
                            <Grid MinWidth="700"
                                          MinHeight="170"
                                          MaxWidth="750"
                                          MaxHeight="170">
                                <Grid.ColumnDefinitions>
                                    <ColumnDefinition/>
                                    <ColumnDefinition/>
                                    <ColumnDefinition/>
                                </Grid.ColumnDefinitions>
                                <Grid.RowDefinitions>
                                    <RowDefinition/>
                                    <RowDefinition/>
                                    <RowDefinition/>
                                </Grid.RowDefinitions>
                                <!--Логин который видит робот-->
                                <StackPanel 
                                            Margin="2"
                                            VerticalAlignment="Center">
                                    <Label Content="Login:"/>
                                    <TextBox Text="{Binding TestLogin, UpdateSourceTrigger=PropertyChanged}"/>
                                </StackPanel>
                                <!--Тип исполнения-->
                                <StackPanel 
                                            Margin="2"
                                            VerticalAlignment="Center"
                                            Grid.Column="1"
                                            Grid.Row="1">
                                    <Label Content="Execution:"/>
                                    <ComboBox 
                                            DataContext="{Binding ExecutionList}"
                                            ItemsSource="{Binding ItemSource}"
                                            SelectedIndex="{Binding SelectedIndex, UpdateSourceTrigger=PropertyChanged}"/>
                                </StackPanel>
                                <!--Тип подачи истории для тестов-->
                                <StackPanel 
                                            Margin="2"
                                            VerticalAlignment="Center"
                                            Grid.Column="2"
                                            Grid.Row="1">
                                    <Label Content="Model:"/>
                                    <ComboBox 
                                            DataContext="{Binding ModelList}"
                                            ItemsSource="{Binding ItemSource}"
                                            SelectedIndex="{Binding SelectedIndex, UpdateSourceTrigger=PropertyChanged}"/>
                                </StackPanel>
                                <!--Критерии оптимизации-->
                                <StackPanel 
                                            Margin="2"
                                            VerticalAlignment="Center"
                                            Grid.Column="2"
                                            Grid.Row="2">
                                    <Label Content="Optimisation criteria:"/>
                                    <ComboBox DataContext="{Binding OptimisationCriteriaList}"
                                                  ItemsSource="{Binding ItemSource}"
                                                  SelectedIndex="{Binding SelectedIndex, UpdateSourceTrigger=PropertyChanged}"/>
                                </StackPanel>
                                <!--Дата начала форвардного периода-->
                                <StackPanel 
                                            Margin="2"
                                            VerticalAlignment="Center"
                                            Grid.Column="1"
                                            Grid.Row="0">
                                    <Label Content="Forvard date:"/>
                                    <DatePicker SelectedDate="{Binding ForvardDate, UpdateSourceTrigger=PropertyChanged}"/>
                                </StackPanel>
                                <!--Депозит-->
                                <StackPanel 
                                            Margin="2"
                                            VerticalAlignment="Center"
                                            Grid.Column="0"
                                            Grid.Row="1">
                                    <Label Content="Deposit:"/>
                                    <ComboBox DataContext="{Binding Deposit}" 
                                                  ItemsSource="{Binding ItemSource}"
                                                  SelectedIndex="{Binding SelectedIndex, UpdateSourceTrigger=PropertyChanged}"/>
                                </StackPanel>
                                <!--Валюта измерения прибыли-->
                                <StackPanel 
                                            Margin="2"
                                            VerticalAlignment="Center"
                                            Grid.Column="0"
                                            Grid.Row="2">
                                    <Label Content="Currency:"/>
                                    <ComboBox DataContext="{Binding CurrencyList}"
                                                  ItemsSource="{Binding ItemSource}"
                                                  SelectedIndex="{Binding SelectedIndex, UpdateSourceTrigger=PropertyChanged}"/>
                                </StackPanel>
                                <!--Плечо-->
                                <StackPanel 
                                            Margin="2"
                                            VerticalAlignment="Center"
                                            Grid.Column="1"
                                            Grid.Row="2">
                                    <Label Content="Laverage:"/>
                                    <ComboBox DataContext="{Binding LaverageList}"
                                                  ItemsSource="{Binding ItemSource}"
                                                  SelectedIndex="{Binding SelectedIndex, UpdateSourceTrigger=PropertyChanged}"/>
                                </StackPanel>
                                <!--Использовать ли визуализатор теста ?-->
                                <CheckBox Content="Visual mode"
                                              Margin="2"
                                              VerticalAlignment="Center"
                                              Grid.Column="2"
                                              Grid.Row="0"
                                              IsChecked="{Binding IsVisual, UpdateSourceTrigger=PropertyChanged}"/>
                            </Grid>
                        </TabItem>
                    </TabControl>

                    <!--Разделительная полоса, благодаря которой можно изменять размеры 
                    одной области относительтно другой-->
                    <GridSplitter Height="3" VerticalAlignment="Bottom" HorizontalAlignment="Stretch"/>

                </Grid>
            </TabItem>

Во-первых, стоит сказать о способе реализации динамически изменяемых областей. Данное поведение формы реализуется путем формирования двух строк в основном <Grid/> и добавления элемента <GridSplitter/>. Именно его мы перетягиваем для того, чтобы область со списком терминалов и область с остальными таблицами меняла свои размеры. В первую строку образовавшейся таблицы мы вставляем новый <Grid/>, который опять делим на 2 части, первая из которых содержит еще один элемент компановки — WrapPanel, в котором находится список терминалов и кнопка добавления нового терминала. Вторая часть содержит таблицу со списком добавленных терминалов.

Помимо текста в таблицу так же добавлены элементы управления, которые позволяют изменять данные в этой таблице. Благодаря технологии связки данных для изменения/добавления значений в таблицу нам не приходится писать ни строчки кода, так как таблица напрямую ассоциирована с коллекцией данных элементов. Нижняя часть изменяемого блока <Grid/> содержит TabControl, в котором находятся настройки тестера и таблица со списком параметров робота.

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

Опишем этот аспект на примере таблицы с параметрами роботов, как видно из тестера MrtaTrader, она должна иметь следующие поля:

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

/// <summary>
/// Класс описывающий строки для таблицы с настройками параметров робота перед оптимизацией
/// </summary>
class ParamsItem
{
    /// <summary>
    /// Конструктор класса
    /// </summary>
    /// <param name="Name">Наименование переменной</param>
    public ParamsItem(string Name) => Variable = Name;
    /// <summary>
    /// Признак нужно ли оптимизировать данную переменную робота
    /// </summary>
    public bool IsOptimize { get; set; }
    /// <summary>
    /// Наименование переменной
    /// </summary>
    public string Variable { get; }
    /// <summary>
    /// Значение переменной выбранное для теста
    /// </summary>
    public string Value { get; set; }
    /// <summary>
    /// Начала перебора параметров
    /// </summary>
    public string Start { get; set; }
    /// <summary>
    /// Шаг перебора параметров
    /// </summary>
    public string Step { get; set; }
    /// <summary>
    /// Окончание перебора параметров
    /// </summary>
    public string Stop { get; set; }
}

Как видно из его структуры, каждое свойство данного класса содержит информацию по определенной колонке. Теперь если копнуть глубже, можно рассмотреть то, как меняется контекст данных. Во время создания окна приложения мы в самом начале указывали, что источником данных для окна будет являться класс ExtentionGUI_VM — это основной DataContext для данного окна, именно в нем должна находиться коллекция, с которой ассоциируется таблица. Однако для каждой конкретной строки данной конкретной таблицы DataContext меняется с класса ExtentionGUI_VM на  класс  ParamsItem. Данный нюанс достаточно важен, так если нам к примеру придется обновить какую-либо ячейку данной таблицы из кода программы, то мы уже будем должны вызывать событие PropertyChanged не на классе ExtentionGUI_VM, а на классе контекста данной конкретной строки.

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


ViewModel и коннектор соединяющий MetaTrader и реализованную dll

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

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

class A
{
    /// <summary>
    /// Это простое публичное поле, которому можно задавать и из которого можно читать значения 
    /// Но нет возможности выполнять проверку или прочие действия.
    /// </summary>
    public int MyField = 5;
    /// <summary>
    /// Это свойство, которое дает возможность обрабатывать информацию перед записью или чтением данных
    /// </summary>
    public int MyGetSetProperty
    {
        get
        {
            MyField++;
            return MyField;
        }
        set
        {
            MyField = value;
        }
    }

    // Это свойство только для чтения
    public int GetOnlyProperty => MyField;
    /// <summary>
    /// Это свойство только для записи
    /// </summary>
    public int SetOnlyProperty
    {
        set
        {
            if (value != MyField)
                MyField = value;
        }
    }
}

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

В процессе реализации класс ExtentionGUI_VM я разбил его на ряд блоков (конструкции #reguin #endregion), и именно по ним я буду рассматривать процесс его создания. Так как первым делом в части View мы рассматривали создание вкладки Optimisation Result, то и в данном классе начнем с рассмотрения свойств и методов созданных для данной вкладки. Для удобства сперва приведу код, отвечающий за данные отображаемые на этой вкладке, и после буду писать пояснения.

#region Optimisation Result

/// <summary>
/// Таблица с результатами исторической оптимизацией
/// </summary>
public DataTable HistoryOptimisationResults => model.HistoryOptimisationResults;
/// <summary>
/// Таблица с резальтатами форвардной оптимизацией
/// </summary>
public DataTable ForvardOptimisationResults => model.ForvardOptimisationResults;
/// <summary>
/// Наблюдаемая коллекция со списком колонок оптимизации
/// </summary>
public ObservableCollection<ColumnDescriptor> OptimisationResultsColumnHeadders =>
       model.OptimisationResultsColumnHeadders;

#region Start test from optimisation results
/// <summary>
/// Запуск теста для выбранного оптимизационныго прозода
/// </summary>
public ICommand StartTestFromOptimisationResults { get; }
/// <summary>
/// Метод запускающий тест по двойному клику
/// </summary>
/// <param name="type"></param>
private void StartTestFromOptimisationResultsAction(object type)
{
    ENUM_TableType tableType = (string)type == "History" ?
        ENUM_TableType.History : ENUM_TableType.Forvard;
    int ind = tableType == ENUM_TableType.History ?
        SelectedHistoryOptimisationRow : SelectedForvardOptimisationRow;

    model.StartTest(tableType, ind);
}
#endregion

/// <summary>
/// индекс выбранной строки из таблицы исторических оптимизаций
/// </summary>
public int SelectedHistoryOptimisationRow { get; set; } = 0;
/// <summary>
/// Индекс выбранной строки форвардной оптимизации
/// </summary>
public int SelectedForvardOptimisationRow { get; set; } = 0;

#region UpdateOptimisationReport

#region TerminalsAfterOptimisation
public ObservableCollection<string> TerminalsAfterOptimisation => model.TerminalsAfterOptimisation;
public int TerminalsAfterOptimisation_Selected
{
    get => model.TerminalsAfterOptimisation_Selected;
    set
    {
        model.TerminalsAfterOptimisation_Selected.SetVarSilently(value);
        if (value > -1)
           model.SelectNewBotsAfterOptimisation_forNewTerminal();
    }
}
        
public ObservableCollection<string> BotsAfterOptimisation => model.BotsAfterOptimisation;
public int BotsAfterOptimisation_Selected
{
    get => model.BotsAfterOptimisation_Selected;
    set => model.BotsAfterOptimisation_Selected.SetVarSilently(value);
}
#endregion
public ICommand UpdateOptimisationReport { get; }

        private void UpdateReportsData(object o)
        {
            model.LoadOptimisations();
        }
        #endregion
        #endregion

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

Также стоит уделить внимание используемому типу данных для свойства, содержащего список колонок (OptimisationResultsColumnHeadders) — ObservableCollection<T>. Данный класс является одним из стандартных классов языка С#, хранящих в себе динамически изменяемые коллекции, но в отличии от списков (List<T>) данный класс содержит событие CollectionChanged, которое вызывается каждый раз при изменении/удалении/добавлении данных в коллекцию. Таким образом, создав свойство типизированное данным классом, мы получаем автоматическое уведомление View о том, что источник данных был изменен, и тем самым нам уже не требуется вручную уведомлять графику о необходимости перезаписи отображаемых данных, что достаточно удобно в ряде случаев. 

Разобравшись с таблицами, стоит уделить внимание выпадающим спискам с выбором терминалов и роботов, а также подойти к реализации обработчиков событий нажатия на кнопки и кликов по таблице. Блок работы с выпадающими списками и загрузкой результатов оптимизаций содержится в области помеченной как  #region UpdateOptimisationReport. Вначале рассмотрим источник данных для первого выподающего списка, который содержит в себе список терминалов. Это список ID терминалов, по которым была произведена оптимизация, и индекс выбранных терминалов. Так как забота о составлении списка терминалов опять же ложится на модель, то мы просто будем ссылаться на соответствующее поле в модели. С выбором индекса выбранного терминала задача чуть сложнее, и тут было использовано то преимущество свойств над полями, о котором говорилось ранее. После выбора терминала из выпадающего списка, происходит обращение к сеттеру свойства  TerminalsAfterOptimisation_Selected, в котором мы осуществляем ряд действий:

  1. Сохраняем новый выбранный индекс в модели
  2. Обновляем все значения второго выпадающего списка, где хранится список роботов, прошедших оптимизацию в данном терминале.

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

Следующим способом передачи событий из графического слоя во ViewModel является использование команд. Некоторые графические элементы, например Button, поддерживают так называемые команды. При использовании команд мы связываем свойство command со свойством из ViewModel параметризированным типом ICommand. Сам интерфейс ICommand является одним из стандартных интерфейсов языка C# и выглядит следующим образом:

public interface ICommand
{
    //
    // Summary:
    //     Occurs when changes occur that affect whether or not the command should execute.
    event EventHandler CanExecuteChanged;
 
    //
    // Summary:
    //     Defines the method that determines whether the command can execute in its current
    //     state.
    //
    // Parameters:
    //   parameter:
    //     Data used by the command. If the command does not require data to be passed,
    //     this object can be set to null.
    //
    // Returns:
    //     true if this command can be executed; otherwise, false.
    bool CanExecute(object parameter);
    //
    // Summary:

    //     Defines the method to be called when the command is invoked.
    //
    // Parameters:
    //   parameter:
    //     Data used by the command. If the command does not require data to be passed,
    //     this object can be set to null.
    void Execute(object parameter);
}

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

/// <summary>
/// Реализация интерфейса ICommand - используемая для
/// связи команд с методами из ViewModel
/// </summary>
class RelayCommand : ICommand
{
    #region Fields 
    /// <summary>
    /// Делегат непосредственно выполняющий действие
    /// </summary>
    readonly Action<object> _execute;
    /// <summary>
    /// Делегат осуществляющий проверку на возможность выполнения действия
    /// </summary>
    readonly Predicate<object> _canExecute;
    #endregion // Fields

    /// <summary>
    /// Конструктор
    /// </summary>
    /// <param name="execute">Метод, передаваемый по делегату, который является коллбеком</param>
    public RelayCommand(Action<object> execute) : this(execute, null) { }
    /// <summary>
    /// Конструктор
    /// </summary>
    /// <param name="execute">
    /// Метод, передаваемый по делегату, который является коллбеком
    /// </param>
    /// <param name="canExecute">
    /// Метод, передаваемый по делегату, проверяющий возможность выполнения действия
    /// </param>
    public RelayCommand(Action<object> execute, Predicate<object> canExecute)
    {
        if (execute == null)
            throw new ArgumentNullException("execute");
        _execute = execute; _canExecute = canExecute;
    }

    /// <summary>
    /// Проверка на возможность выполнения действия
    /// </summary>
    /// <param name="parameter">передаваемый из View параметр</param>
    /// <returns></returns>
    public bool CanExecute(object parameter)
    {
        return _canExecute == null ? true : _canExecute(parameter);
    }
    /// <summary>
    /// Событие - вызываемое всякий раз когда меняется возможность исполнения коллбека.
    /// При срабатывании данного события форма вновь вызывает метод "CanExecute"
    /// Событие запускается из ViewModel по мере необходимости
    /// </summary>
    public event EventHandler CanExecuteChanged
    {
        add { CommandManager.RequerySuggested += value; }
        remove { CommandManager.RequerySuggested -= value; }
    }
    /// <summary>
    /// Метод, вызывающий делегат, который в свою очередь выполняет действие
    /// </summary>
    /// <param name="parameter">передаваемый из View параметр</param>
    public void Execute(object parameter) { _execute(parameter); }
}

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

UpdateOptimisationReport = new RelayCommand(UpdateReportsData);

Где UpdateReportsData — это private  метод из класса ExtentionGUI_VM вызывающий метод  LoadOptimisations() из класса ExtentionGUI_M (т.е. класс нашей модели). Абсолютно аналогичным способом осуществляется связь между свойством  StartTestFromOptimisationResults и событием двойного клика по выбранной пользователем строке таблицы. Но только в данном случае передача события клика выполняется не через стандартно реализованное свойство как у кнопки (класс Button), а через уже описанное и созданное нами расширение " ListV iewExtention.DoubleClickCommand". Как видно из сигнатуры методов Execute и CanExecute, они могут принимать значение типа Object. В случае с кнопкой мы не передаем в них никакие значения, однако в случае с событием двойного клика мы передаем в них имя таблицы, что можно увидить из способа биндинга с данными свойствами в XAML разметке:    

viewExtention:ListViewExtention.DoubleClickCommand="{Binding StartTestFromOptimisationResults}"
viewExtention:ListViewExtention.DoubleClickCommandParameter="History"

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

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

#region SelectedTerminalsForOptimisation && SelectedTerminalIndex (first LV params)
/// <summary>
/// Список терминалов выбранных для оптимизации отображаемый в таблице терминалов
/// </summary>
public ObservableCollection<TerminalAndBotItem> SelectedTerminalsForOptimisation { get; private set; } =
    new ObservableCollection<TerminalAndBotItem>();
/// <summary>
/// Индекс выбранной строки
/// </summary>
private int selectedTerminalIndex = 0;
public int SelectedTerminalIndex
{
    get { return selectedTerminalIndex; }
    set
    {
        // Присваиваем значение ново выбранного индекса
        selectedTerminalIndex = value;

        //((RelayCommand)Start).OnCanExecuteChanged();

        // Заполняем список параметров выбранного в текущей строке робота
        if (value == -1)
        {
            return;
        }
        TerminalAndBotItem terminal_item = SelectedTerminalsForOptimisation[value];
        if (terminal_item.Experts.Count > 0)
        {
            FillInBotParams(terminal_item.Experts[terminal_item.SelectedExpert],
                terminal_item.TerminalID);
        }
    }
}
        #endregion

Как видно, сам список терминалов представлен в виде наблюдаемой коллекции, типизированной классом  TerminalAndBotItem. Данная коллекция хранится в самом класса ViewModel. Также во ViewModel было вынесено свойство задания и получения индекса выбранной строки, это было сделано для того чтобы иметь возможность реагировать на событие выбора какого-либо терминала из списка. Как было видно на видео при клике на строку происходит динамическая подгрузка параметров данного робота. Данное поведение как раз и реализуется в сеттере свойства  SelectedTerminalIndex.

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

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

class TerminalAndBotItem
{
    
    public TerminalAndBotItem(List<string> botList,
        string TerminalID,
        Action<string, string> FillInBotParams,
        Action<TerminalAndBotItem> DeleteCommand)
    {
        // Заполняем поля делегатов
        #region Delegates
        this.FillInBotParams = FillInBotParams;
        this.DeleteCommand = new RelayCommand((object o) => DeleteCommand(this));
        #endregion
    }

    #region Delegates
    /// <summary>
    /// Поле с делегатом обновления параметров выбранного робота
    /// </summary>
    private readonly Action<string, string> FillInBotParams;
    /// <summary>
    /// Коллбек для команды удаления терминала из списка (кнопка Delete в таблице)
    /// </summary>
    public ICommand DeleteCommand { get; }
    #endregion

    /// <summary>
    /// индекс выбранного эксперта
    /// </summary>
    private int selectedExpert;
    /// <summary>
    /// Property для индекса выбранного эксперта
    /// </summary>
    public int SelectedExpert
    {
        get { return selectedExpert; }
        set
        {
            selectedExpert = value;
            // Запускаем коллбек загружки параметров для выбранного эксперта 
            if (Experts.Count > 0)
                FillInBotParams(Experts[selectedExpert], TerminalID);
        }
    }
}

Как видно из данного отрывка в процессе реализации поставленной задачи была использована еще одна конструкция языка C# — лямбда выражения. Тем, кто знаком с С++ или с C#, этот отрывок кода не покажется странным, для остальных же поясню, что лямбда выражения можно рассматривать как те же самые функции, но основным отличием от них является тот факт, что они не имеют традиционного объявления. Данные конструкции широко используются в C# и почитать про них можно здесь. Сам же вызов коллбека осуществляется с использованием ICommand. Следующим интересным моментом реализации данного класса, является обновление параметров робота при выборе нового робота в выпадающем списке со всеми роботами. Во-первых, метод, обновляющий список параметров робота, находится в модели, а сама реализация обертки данного метода для ViewModel — так же как и метод удаляющий терминал — находится во ViewModel. На выручку опять же приходят делегаты, однако в данном случае мы вместо использования ICommand помещаем отклик на событие выбора нового робота в сеттер свойства  SelectedExpert.

Сам же метод обновляющий параметры экспертов тоже имеет некоторые нюансы, а именно — асинхронность.

private readonly object botParams_locker = new object();
/// <summary>
/// Получение и заполнение параметров робота
/// </summary>
/// <param name="fullExpertName">Полное имя эксперта относительно папки ~/Experts</param>
/// <param name="Terminal">ID терминала</param>
private async void FillInBotParams(string fullExpertName, string Terminal)
{
    await System.Threading.Tasks.Task.Run(() =>
    {
        lock (botParams_locker)
        {
            model.LoadBotParams(fullExpertName, Terminal, out OptimisationInputData? optimisationData);
            if (!optimisationData.HasValue)
                return;

            IsSaveInModel = false;
            TestLogin = optimisationData.Value.Login;
            IsVisual = optimisationData.Value.IsVisual;
            ForvardDate = optimisationData.Value.ForvardDate;
            CurrencyList.SelectedIndex = optimisationData.Value.CurrencyIndex;
            Deposit.SelectedIndex = optimisationData.Value.DepositIndex;
            ExecutionList.SelectedIndex = optimisationData.Value.ExecutionDelayIndex;
            LaverageList.SelectedIndex = optimisationData.Value.LaverageIndex;
            ModelList.SelectedIndex = optimisationData.Value.ModelIndex;
            OptimisationCriteriaList.SelectedIndex = optimisationData.Value.OptimisationCriteriaIndex;
            IsSaveInModel = true;
        }
    });


    OnPropertyChanged("BotParams");
}

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

class Program
{
    static void Main(string[] args)
    {
        Console.WriteLine($"Main before Method() = {Thread.CurrentThread.ManagedThreadId}");
        Method();
        Console.WriteLine($"Main after Method() = {Thread.CurrentThread.ManagedThreadId}");

        Console.ReadLine();
    }
    private static async void Method()
    {
        Console.WriteLine($"Before Await = {Thread.CurrentThread.ManagedThreadId}");
        await Task.Run(() => { Thread.Sleep(100); Console.WriteLine($"In Avait 1 = {Thread.CurrentThread.ManagedThreadId}"); });
        Console.WriteLine($"After Await 1 = {Thread.CurrentThread.ManagedThreadId}");
      Thread.Sleep(100);

            await Task.Run(() => { Console.WriteLine($"In Avait 2 = {Thread.CurrentThread.ManagedThreadId}"); });
            Console.WriteLine($"After Await 2 = {Thread.CurrentThread.ManagedThreadId}");
        }

    }

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

Main bofore Method() = 1

Before Await = 1

Main After Method() = 1

In Await 1 = 3

After Await 1 = 3

In Await 2 = 4

After Await 2 = 4

Как видно из вывода данной программки, ID у потока Main и у самого первого вывода из асинхронного метода Method() одинаковы. Это говорит нам о том что метод Method() не совсем асинхронен, и действительно, сама асинхронность данного метода начинается лишь после вызова асинхронной операции при помощи статического метода Task.Run(). Если бы метод Method() был полностью синхронным, то тогда следующее сообщение вновь выводящее ID основного потока было бы вызвано после выводи четырех следующих сообщений, однако это не так. 

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

Это были особенности работы с моделью асинхронного программирования Async Await и асинхронностью в целом. Возвращаясь к нашему методу можно сказать, что все действия, прописанные в нем, выполняются в контексте вторичного потока, и соответственно, есть такая вероятность,что он будет вызван дважды, что может привести к ошибки. Для этого используется конструкция lock(locker_object){}. Данная конструкция создает нечто наподобие очереди выполнения вызовов, как мы видели в нашем тестовом примере, но только в отличии от тестового примера, где очередь формируется самостоятельно через механизмы C#, здесь мы используем разделяемый ресурс, который служит переключателем. Если он задействован в конструкции lock(), то любой другой вызов данного метода застрянет на этапе блокировки разделяемого ресурса до тех пор, пока он не будет освобожден. Таким образом, мы избегаем ошибки двойного вызова метода, не дождавшись его завершения.

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

#region Optimisation and Test settings

/// <summary>
/// Логин, который робот видит во время теста (нужно если есть ограничения по логину)
/// </summary>
private uint? _tertLogin;
public uint? TestLogin
{
    get => _tertLogin;
    set
    {
        _tertLogin = value;

        OnPropertyChanged("TestLogin");
        CB_Action(GetSetActionType.Set_Index);
    }
}
/// <summary>
/// Задержка исполнения ордеров
/// </summary>
public ComboBoxItems<string> ExecutionList { get; }
/// <summary>
/// Тип используемых котировок (каждый  тик OHLC 1M ...)
/// </summary>
public ComboBoxItems<string> ModelList { get; }
/// <summary>
/// Критерий оптимизации
/// </summary>
public ComboBoxItems<string> OptimisationCriteriaList { get; }
/// <summary>
/// Депозит
/// </summary>
public ComboBoxItems<int> Deposit { get; }
/// <summary>
/// Валюта рассчета прибыли
/// </summary>
public ComboBoxItems<string> CurrencyList { get; }
/// <summary>
/// Кредитное плечо
/// </summary>
public ComboBoxItems<string> LaverageList { get; }
/// <summary>
/// Дата начала форвардного теста
/// </summary>
private DateTime _DTForvard = DateTime.Now;
public DateTime ForvardDate
{
    get => _DTForvard;
    set
    {
        _DTForvard = value;

        OnPropertyChanged("ForvardDate");
        CB_Action(GetSetActionType.Set_Index);
    }
}
/// <summary>
/// Признак запуска тестера в графическом режиме
/// </summary>
private bool _isVisualMode = false;
/// <summary>
/// Признак запуска тестера в визуальном режиме
/// </summary>
public bool IsVisual
{
    get => _isVisualMode;
    set
    {
        _isVisualMode = value;

        OnPropertyChanged("IsVisual");
        CB_Action(GetSetActionType.Set_Index);
    }
}
/// <summary>
/// скрытая переменная, хранящая значение флага IsSaveInModel
/// </summary>
private bool isSaveInModel = true;
/// <summary>
/// Разделяемый рессурс для асинхронного доступа к свойству IsSaveInModel
/// </summary>
private readonly object SaveModel_locker = new object();
/// <summary>
/// Флаг если True - то при изменении параметров для тестера они будут сохраняться
/// </summary>
private bool IsSaveInModel
{
    get
    {
        lock (SaveModel_locker)
            return isSaveInModel;
    }
    set
    {
        lock (SaveModel_locker)
            isSaveInModel = value;
    }
}
/// <summary>
/// Коллбек, сохраняющий изменения в параметрах тестера
/// </summary>
/// <param name="actionType"></param>
private void CB_Action(GetSetActionType actionType)
{
    if (actionType == GetSetActionType.Set_Index && IsSaveInModel)
    {
        model.UpdateTerminalOptimisationsParams(new OptimisationInputData
        {
            Login = TestLogin,
            IsVisual = IsVisual,
            ForvardDate = ForvardDate,
            CurrencyIndex = CurrencyList.SelectedIndex,
            DepositIndex = Deposit.SelectedIndex,
            ExecutionDelayIndex = ExecutionList.SelectedIndex,
            LaverageIndex = LaverageList.SelectedIndex,
            ModelIndex = ModelList.SelectedIndex,
            OptimisationCriteriaIndex = OptimisationCriteriaList.SelectedIndex,
            Deposit = Deposit.ItemSource[Deposit.SelectedIndex],
            Currency = CurrencyList.ItemSource[CurrencyList.SelectedIndex],
            Laverage = LaverageList.ItemSource[LaverageList.SelectedIndex]
        });
    }
}
#endregion

Важным моментом данных настроек является то, как реализовано сохранение параметров оптимизатора. Забегая несколько вперед, стоит сказать что в модели для каждого робота хранится свой экземпляр настроек тестера, это позволяет конфигурировать тестер каждого из выбранных терминалов по своему. Именно для этого был создан метод CB_Action, который вызывается в каждом сеттере, что обеспечивает моментальное сохранение результатов в модели после внесения изменений в каком-либо из параметров. Также стоит рассмотреть класс  ComboBoxItems<T>, созданный мною специально для хранения данных для выпадающих списков. По сути он является контекстом данных для ComboBox с которыми он связан. Данные класс имеет следующую незамысловатую реализацию:

/// <summary>
/// Класс - обертка для данных ComboBox списков
/// </summary>
/// <typeparam name="T">Тип данных, хранимых в ComboBox</typeparam>
class ComboBoxItems<T> : INotifyPropertyChanged
{
    /// <summary>
    /// Коллекция эллементов списка
    /// </summary>
    private List<T> items;
    public List<T> ItemSource
    {
        get
        {
            OnAction(GetSetActionType.Get_Value);
            return items;
        }
        set
        {
            items = value;
            OnAction(GetSetActionType.Set_Value);
        }
    }
    /// <summary>
    /// Выбранный индекс в списке
    /// </summary>
    int selectedIndex = 0;
    public int SelectedIndex
    {
        get
        {
            OnAction(GetSetActionType.Get_Index);
            return selectedIndex;
        }
        set
        {
            selectedIndex = value;
            OnAction(GetSetActionType.Set_Index);
        }
    }

    public event Action<GetSetActionType> Action;
    public event PropertyChangedEventHandler PropertyChanged;

    private void OnAction(GetSetActionType type)
    {
        switch (type)
        {
            case GetSetActionType.Set_Value:
                PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("ItemSource"));
                break;
            case GetSetActionType.Set_Index:
                PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("SelectedIndex"));
                break;
        }
        Action?.Invoke(type);
    }
}
enum GetSetActionType
{
    Get_Value,
    Set_Value,
    Get_Index,
    Set_Index
}

 Однако его особенностью является событие, вызываемое всякий раз при редактировании одного из его событий, либо при получении информации у его событий. Следующей его особенностью является автоматическое уведомление View об изменении какого-либо из его свойств. Таким образом он способен оповещать как ViewModel, так и View об изменении своих свойств, благодаря этому свойству мы во ViewModel обновляем в модели информацию об измененных свойствах настроек оптимизатора и вызываем автосохранение, к тому же мы получаем возможность сделать код более читабельным, ведь на каждый ComboBox мы выносим во ViewModel 2 его свойства (индекс выбранного элемента и список всех элементов). Не используй мы данный класс, наш класс ExtentionGUI_VM разросся бы еще больше.  

В заключении стоит рассмотреть способ инстанцирования модели нашего дополнения и способ запуска написанной графики в терминале MetaTrader 5. Класс модели данных должен быть также независим от ViewModel, как и сам ViewModel от View, поэтому и в целях возможности тестирования, мы сделаем модель через интерфейс IExtentionGUI_M. Структуру данного интерфейса, а также его реализацию, мы рассмотрим, когда будем описывать саму модель данных, сейчас же стоит знать лишь то, что класс ExtentionGUI_VM не знает о конкретной реализации модели данных, вместо этого он работает с интерфейсом IExtentionGUI_M, и инстанцирование класса модели происходит следующим образом:

private readonly IExtentionGUI_M model = ModelCreator.Model;

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

/// <summary>
/// Фабрика по подстановки модели в графический интерфейс
/// </summary>
class ModelCreator
{
    /// <summary>
    /// Модель
    /// </summary>
    private static IExtentionGUI_M testModel;
    /// <summary>
    /// Property возвращающее либо модель (если она не была подменена) либо побмененную (для тестов) модель
    /// </summary>
    internal static IExtentionGUI_M Model => testModel ?? new ExtentionGUI_M(new MainTerminalCreator(),
                                                                             new MainConfigCreator(),
                                                                             new MainReportReaderCreator(),
                                                                             new MainSetFileManagerCreator(),
                                                                             new OptimisationExtentionWorkingDirectory("OptimisationManagerExtention"),
                                                                             new MainOptimisatorSettingsManagerCreator(),
                                                                             new TerminalDirectory(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "MetaQuotes", "Terminal")));

    /// <summary>
    /// Метод подмены модели. подставляет тестовую модель что бы можно было отдельно тестировать графику от логики
    /// </summary>
    /// <param name="model">тестовая модель - подставляется извне</param>
    [System.Diagnostics.Conditional("DEBUG")]
    public static void SetModel(IExtentionGUI_M model)
    {
        testModel = model;
    }
}

У данного класса есть private поле типизированное интерфейсом модели данных. Изначально данное поле равняется null, чем мы и воспользовались при написании статического свойства, получающего запрашиваемую модель. Как видно из кода, мы проверяем — если сейчас testModel равно null, то мы инстанцируем и возвращаем реализацию модели, которая содержит бизнес логику; если же testModel  не равно null (значит мы подменили модель), то мы возвращаем подмененную модель — ту, что хранится в testModel. Для подмены модели используется статический метод SetModel. Данный метод декорирован атрибутом  [System.Diagnostics.Conditional( "DEBUG")], что запрещает его использование в Release версии данной программы.

Процесс запуска графического интерфейса схож с процессом запуска графики из dll, предложенный в ранее упомянутой статье. Специально для стыковки с MetaTrader был написан public класс MQLConnector. 

/// <summary>
/// Класс для связки графического интерфейса с MetaTrader
/// </summary>
public class MQL5Connector
{
    /// <summary>
    /// Поле хранящее в себе указатель на запущенный графический интерфейс
    /// </summary>
    private static View.ExtentionGUI instance;
    /// <summary>
    /// Метод осуществляющий запуск графического интерфейса. 
    /// Причем запуск осуществляется лишь одного интерфейса из одного робота. 
    /// т.е. при запуске осуществляется проверка были ли ранее запущен графический интерфейс, 
    /// если да - то запуск нового отклоняется
    /// </summary>
    /// <param name="pathToTerminal">Путь к изменяемой папке терминала</param>
    public static void Instance(string terminalID)
    {
        // проверка был ли ранее запущен графический интерфейс
        if (instance == null)
        {
            // Переменная вторичного потока - потока графического интерфейса (графика запускается во вторичном потоке)
            // и ее инстанцирование с передачей лямбда выражения описываюзщего порядок запуска графики
            Thread t = new Thread(() =>
            {
                // Инстанцирование класса графического интерфейса и его отображение (запуск графики)
                instance = new View.ExtentionGUI();
                instance.Show();
                // Подписка на событие закрытия окна графики - если окно закрыто то 
                // полю где хранилась ссылка на графический интерфейс присваивается значение null
                instance.Closed += (object o, EventArgs e) => { instance = null; };

                // Запуск Диспетчера потока графического интерфейса
                Dispatcher.Run();
            });
            MainTerminalID = terminalID;		

            // Запуск вторичного потока
            t.SetApartmentState(System.Threading.ApartmentState.STA);
            t.Start();
        }
    }     
    /// <summary>
    /// Получает информацию активно ли окно
    /// </summary>
    /// <returns>true если активно и false если закрыто</returns>
    public static bool IsWindowActive() => instance != null;
    /// <summary>
    /// Main Terminal ID
    /// </summary>
    internal static string MainTerminalID { get; private set; }
    internal static Dispatcher CurrentDispatcher => ((instance == null) ? Dispatcher.CurrentDispatcher : instance.Dispatcher);
}

Данный класс обязательно должен быть помечен модификатором доступа public — это необходимо для того, чтобы он был доступен из робота в MetaTrader. Также методы, которые предполагается использовать в терминале, должны быть статическими и иметь модификатор доступа public, ведь терминал позволяет использовать лишь статические методы. Данный класс также имеет 2 свойства c модификатором доступа Internal — данный модификатор доступа скрывает их видимость от терминала, так как они предназначены для использования только лишь внутри создаваемой dll. Как видно из реализации, наше окно предполагается хранить в частном статическом поле — это нужно для того чтобы получать к нему доступ из других свойств и методов, а так же для того чтобы в терминале в одном роботе можно было бы создать только один экземпляр данного приложения.

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

Класс Dispatcher был создан для того чтобы решать конфликты многопоточности в WPF приложениях. Дело в том, что все элементы графического окна принаддежат потоку графического окна, когда мы пытаемся изменить значение какого-либо из элементов графики из другого потока, то получим ошибку cross thread exception. Класс Dispatcher же запускает операцию, переданную ему через делегат в контексте потока графического интерфейса, тем самым не допускает озаглавленной ошибки. После завершения описывания лямбда выражения для запуска графики мы должны сконфигурировать поток как Single Threaded Apartment и запустить его, тем самым запустив графику, но перед этим сохраняем значение переданного ID текущего терминала.

Теперь стоит разобраться для чего нам все это нужно? Ответ будет достаточно тривиален — это требуется для возможности дебагинга графики отдельно от логики. Мы написали графический интерфейс, однако, чтобы осуществить его дебагинг нам требуется класс, представляющий модель. Модель имеет ряд собственных нюансов реализации и поэтому ее стоит отлаживать отдельно от графики. Теперь, имея способ подстановки тестовой модели даных, мы в состоянии реализовать класс тестовой модели данных и подставить ее во ViewModel через статическую фабрику. В итоге получим возможность отладить графику на тестовых данных, запустить графический интерфейс и проверить реакцию коллбеков, оформление и прочие нюансы. Я сделал это следующим образом. Сперва нам требуется создать консольное приложение в текущем Solution для того чтобы напрямую из VisualStudio запускать графику — это даст доступ к инструментам дебагинга.


Назовем его "Test" и добавим в нем ссылку на нашу dll, которую пишем для MetaTrader. В результате получим консольное приложение, которое сможет использовать public классы нашей dll. Однако у нашей dll существует только один public класс — это класс MQL5Connector, но помимо него нам нужно создать фейковую модель данных и подставить ее во ViewModel как описывалось ранее — для этого требуется получить доступ к классам, доступным только внутри dll — для этого тоже существует решение. Для этого необходимо добавить в любом месте нашей dll следующий атрибут:

[assembly: InternalsVisibleTo("Test")]

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

 class Program
 {
    static void Main(string[] args)
    {
        ModelCreator.SetModel(new MyTestModel());

        MQL5Connector.Instance("ID основного терминала");
    }
}

class MyTestModel : IExtentionGUI_M
{
    // Тут реализация интерфейса IExtentionGUI_M
}

и теперь у нас есть возможность запустить графику отдельно от логики, отладить ее и просто посмотреть как она выглядит визуально.

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

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