Непрерывная скользящая оптимизация (Часть 1): Механизм работы с отчетами оптимизации

21 ноября 2019, 09:51
Andrey Azatskiy
6
2 105

Введение

В прошлых статьях (Управление оптимизацией (Часть I) и Управление оптимизацией (Часть 2) ) был рассмотрен механизм запуска оптимизаций в терминале через сторонний процесс. Это позволяет создать некий Менеджер оптимизаций, который выполнял бы данный процесс как торговый алгоритм выполняет свой — т.е. автоматизировано и без вмешательства пользователя. Данная тема посвящена созданию алгоритма, который со стороны управляет процессом скользящей оптимизацией — когда форвардные и исторические промежутки постепенно сдвигаются на заданный интервал, наслаиваясь друг на друга.

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

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

Так как для работы нашей программе, которая является сторонним процессом и написана на C#, понадобится создавать и читать созданные (*xml) документы наравне с программами, написанными на MQL5, то  было принято сразу вынести блок создания отчета в DLL-библиотеку, которая будет использоваться совместно как в MQL5, так и в C# коде. Учитывая, что для написания MQL5 кода нам понадобится данная библиотека, то сначала в данной статье мы опишем процесс ее создания, а уже в следующей статье будет описание MQL5 кода, работающего с созданной библиотекой и формирующего параметры оптимизации, о которых будет идти речь уже в данной статье.

Структура отчета и требуемые коэффициенты

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

  • Настройки тестера (едины для всего отчета)
  • Настройки робота (уникальны для каждого прохода оптимизации)
  • Коэффициенты, описывающие результативность торгов (уникальны для каждого прохода оптимизации)
<?xml version="1.0"?>
<Optimisatin_Report Created="06.10.2019 10:39:02">
        <Optimiser_Settings>
                <Item Name="Bot">StockFut\StockFut.ex5</Item>
                <Item Name="Deposit" Currency="RUR">100000</Item>
                <Item Name="Laverage">1</Item>
        </Optimiser_Settings>

Как видно, каждый из параметров занесен в однотипный блок "Item", но разделяется по атрибуту "Name". Для параметра депозита наименование его валюты записываем в атрибут "Currency"

Исходя из этого, структура файла должна содержать 2 основных отдела — настройки тестера и непосредственно описание оптимизационных проходов. Для первого раздела нам нужно сохранить 3 параметра:

  1. Путь до робота относительно папки с экспертами.
  2. Валюта депозита и депозит
  3. Кредитное плечо

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

<Optimisation_Results>
                <Result Symbol="SBRF Splice" TF="1" Start_DT="1481327340" Finish_DT="1512776940">
                        <Coefficients>
                                <VaR>
                                        <Item Name="90">-1055,18214207419</Item>
                                        <Item Name="95">-1323,65133343373</Item>
                                        <Item Name="99">-1827,30841143882</Item>
                                        <Item Name="Mx">-107,03475</Item>
                                        <Item Name="Std">739,584549199836</Item>
                                </VaR>
                                <Max_PL_DD>
                                        <Item Name="Profit">1045,9305</Item>
                                        <Item Name="DD">-630</Item>
                                        <Item Name="Total Profit Trades">1</Item>
                                        <Item Name="Total Loose Trades">1</Item>
                                        <Item Name="Consecutive Wins">1</Item>
                                        <Item Name="Consecutive Loose">1</Item>
                                </Max_PL_DD>
                                <Trading_Days>
                                        <Mn>
                                                <Item Name="Profit">0</Item>
                                                <Item Name="DD">0</Item>
                                                <Item Name="Number Of Profit Trades">0</Item>
                                                <Item Name="Number Of Loose Trades">0</Item>
                                        </Mn>
                                        <Tu>
                                                <Item Name="Profit">0</Item>
                                                <Item Name="DD">0</Item>
                                                <Item Name="Number Of Profit Trades">0</Item>
                                                <Item Name="Number Of Loose Trades">0</Item>
                                        </Tu>
                                        <We>
                                                <Item Name="Profit">1045,9305</Item>
                                                <Item Name="DD">630</Item>
                                                <Item Name="Number Of Profit Trades">1</Item>
                                                <Item Name="Number Of Loose Trades">1</Item>
                                        </We>
                                        <Th>
                                                <Item Name="Profit">0</Item>
                                                <Item Name="DD">0</Item>
                                                <Item Name="Number Of Profit Trades">0</Item>
                                                <Item Name="Number Of Loose Trades">0</Item>
                                        </Th>
                                        <Fr>
                                                <Item Name="Profit">0</Item>
                                                <Item Name="DD">0</Item>
                                                <Item Name="Number Of Profit Trades">0</Item>
                                                <Item Name="Number Of Loose Trades">0</Item>
                                        </Fr>
                                </Trading_Days>
                                <Item Name="Payoff">1,66020714285714</Item>
                                <Item Name="Profit factor">1,66020714285714</Item>
                                <Item Name="Average Profit factor">0,830103571428571</Item>
                                <Item Name="Recovery factor">0,660207142857143</Item>
                                <Item Name="Average Recovery factor">-0,169896428571429</Item>
                                <Item Name="Total trades">2</Item>
                                <Item Name="PL">415,9305</Item>
                                <Item Name="DD">-630</Item>
                                <Item Name="Altman Z Score">0</Item>
                        </Coefficients>
                        <Item Name="_lot_">1</Item>
                        <Item Name="USymbol">SBER</Item>
                        <Item Name="Spread_in_percent">3.00000000</Item>
                        <Item Name="UseAutoLevle">false</Item>
                        <Item Name="max_per">174</Item>
                        <Item Name="comission_stock">0.05000000</Item>
                        <Item Name="shift_stock">0.00000000</Item>
                        <Item Name="comission_fut">4.00000000</Item>
                        <Item Name="shift_fut">0.00000000</Item>
                </Result>
        </Optimisation_Results>
</Optimisatin_Report>

Внутри блока "Optimisation_Results" будут повторяться блоки "Result",  каждый из которых содержит i-тый оптимизационный проход. В каждом из блоков  "Result" содержится 4 атрибута:

  • Symbol
  • TF
  • Start_DT
  • Finish_DT

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

  • VaR
  1. 90 - квантиль 90
  2. 95 - квантиль 95
  3. 99 - квантиль 99
  4. Mx - мат. ожидание
  5. Std - стандартно-квадратическое отклонение
  • Max_PL_DD
  1. Profit - Суммарная прибыль
  2. DD - Суммарная просадка
  3. Total Profit Trades - Общее кол - во прибыльных трейдов
  4. Total Loose Trades - Общее кол - во убыточных трейдов
  5. Consecutive Wins - Выигрышей подряд
  6. Consecutive Loose - Убытков подряд
  • Trading_Days - отчет торгов по дням 
  1. Profit - средние прибыли в течении дня
  2. DD - средние убытки в течении для
  3. Number Of Profit Trades - кол - во прибыльных трейдов
  4. Number Of Loose Trades - кол - во убыточных трейдов

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

Класс-обертка отчета оптимизаций, класс-хранитель диапазона дат оптимизаций, а также структура результатов оптимизаций в C#.

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

public struct ReportItem
{
    public Dictionary<string, string> BotParams; // Список параметров робота
    public Coefficients OptimisationCoefficients; // Коэффициенты робота
    public string Symbol; // Символ
    public int TF; // Таймфрейм
    public DateBorders DateBorders; // Границы дат
}

Все коэффициенты робота хранятся в словаре в строковом формате. Файл с параметрами робота не сохраняет тип данных, поэтому строковый формат наиболее оптимален.  Список коэффициентов робота вынесен в другую структуру, точно так же с другими блоками которые сгруппированы в (*xml) отчете оптимизации. Отчеты торгов по дням также хранятся в словаре.

public Dictionary<DayOfWeek, DailyData> TradingDays;

Однако в качестве ключа перечисление DayOfWeek и данный словарь всегда должен содержать 5 дней (с Пн по Пт) — как в (*xml) файле. В структуре хранения данных наиболее интересным является класс DateBorders. Так же, как дата группируется в структуру, которая содержит в себе поля , описывающие каждый из параметров даты, так и в структуре DateBorders мы храним границы диапазона дат. 

public class DateBorders : IComparable
{
    /// <summary>
    /// Конструктор
    /// </summary>
    /// <param name="from">Дата начала границы</param>
    /// <param name="till">Дата окончания границы</param>
    public DateBorders(DateTime from, DateTime till)
    {
        if (till <= from)
            throw new ArgumentException("Date 'Till' is less or equal to date 'From'");

        From = from;
        Till = till;
    }
    /// <summary>
    /// С
    /// </summary>
    public DateTime From { get; }
    /// <summary>
    /// По
    /// </summary>
    public DateTime Till { get; }
}

Для полноценной работы с диапазоном дат нам нужно просто иметь возможность сравнивать два диапазона дат, для этого требуется перегрузить 2 оператора "==" и "!=". 

Критериями равенства будет являться равенство обоих дат на двух переданных диапазонах, т.е. дата начала должна быть равна дате начала торгов второго диапазона, аналогично с датой завершения торгов, однако так как тип данного объекта class, то он может быть равен null, и сперва требуется добавить возможность сравнения с null —для этого используем ключевое слово is. Затем уже можно сравнивать параметры друг с другом, иначе при попытке сравнения с null мы получим "null reference exception".

#region Equal
/// <summary>
/// Оператор сравнения на равенство
/// </summary>
/// <param name="b1">Элемент 1</param>
/// <param name="b2">Элемент 2</param>
/// <returns>Результат</returns>
public static bool operator ==(DateBorders b1, DateBorders b2)
{
    bool ans;
    if (b2 is null && b1 is null) ans = true;
    else if (b2 is null || b1 is null) ans = false;
    else ans = b1.From == b2.From && b1.Till == b2.Till;

    return ans;
}
/// <summary>
/// Оператор сравнения на неравенство
/// </summary>
/// <param name="b1">Элемент 1</param>
/// <param name="b2">Элемент 2</param>
/// <returns>Результат сравнения</returns>
public static bool operator !=(DateBorders b1, DateBorders b2) => !(b1 == b2);
#endregion

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

#region (Grater / Less) than
/// <summary>
/// Сравнение текущий элемент больше прошлого
/// </summary>
/// <param name="b1">Элемент 1</param>
/// <param name="b2">Элемент 2</param>
/// <returns>Результат</returns>
public static bool operator >(DateBorders b1, DateBorders b2)
{
    if (b1 == null || b2 == null)
        return false;

    if (b1.From == b2.From)
        return (b1.Till > b2.Till);
    else
        return (b1.From > b2.From);
}
/// <summary>
/// Сравнение текущий элемент меньше прошлого
/// </summary>
/// <param name="b1">Элемент 1</param>
/// <param name="b2">Элемент 2</param>
/// <returns>Результат</returns>
public static bool operator <(DateBorders b1, DateBorders b2)
{
    if (b1 == null || b2 == null)
        return false;

    if (b1.From == b2.From)
        return (b1.Till < b2.Till);
    else
        return (b1.From < b2.From);
}
#endregion

Если любой из переданных параметров в оператор равняется null, то сравнение становится невозможным, поэтому возвращаем False. Иначе — делаем поэтапное сравнение. Если первый временной интервал совпадает, то мы производим сравнение по второму временному интервалу. Если же они не равны, то по первому. Тем самым, если описывать логику сравнения на примере оператора "Больше" — большим интервалом считается тот интервал, который старше по времени чем предыдущий — либо по дате своего начала, либо по дате своего окончания (в случае если даты начала равны). Логика сравнения на меньшинство одного из переданных интервалов аналогична сравнению на большинство. 

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

#region Equal or (Grater / Less) than
/// <summary>
/// Сравнение больше или равно
/// </summary>
/// <param name="b1">Элемент 1</param>
/// <param name="b2">Элемент 2</param>
/// <returns>Результат</returns>
public static bool operator >=(DateBorders b1, DateBorders b2) => (b1 == b2 || b1 > b2);
/// <summary>
/// Сравнение меньше или равно
/// </summary>
/// <param name="b1">Элемент 1</param>
/// <param name="b2">Элемент 2</param>
/// <returns>Результат</returns>
public static bool operator <=(DateBorders b1, DateBorders b2) => (b1 == b2 || b1 < b2);
#endregion

Как видно, перегрузка данных операторов уже не требует описания внутренней логики сравнения, вместо этого мы используем уже перегруженные операторы "==" и ">", "<". Однако, помимо перегрузок данных операторов, как нам подсказывает Visual Studio при компиляции, нам стоит перегрузить еще ряд функций унаследованных от базового класса "object".

#region override base methods (from object)
/// <summary>
/// Перегрузка сравнения на кавенство
/// </summary>
/// <param name="obj">Элемент с которым сравниваем</param>
/// <returns></returns>
public override bool Equals(object obj)
{
    if (obj is DateBorders other)
        return this == other;
    else
        return base.Equals(obj);
}
/// <summary>
/// Приводим данный класс к строке и возвращаем его хешкод
/// </summary>
/// <returns>Хешкод строки</returns>
public override int GetHashCode()
{
    return ToString().GetHashCode();
}
/// <summary>
/// Перевод в строку текущего класса
/// </summary>
/// <returns>Строка дата С - дата По</returns>
public override string ToString()
{
    return $"{From}-{Till}";
}
#endregion
/// <summary>
/// Сравниваем текущий элемент с переданным
/// </summary>
/// <param name="obj"></param>
/// <returns></returns>
public int CompareTo(object obj)
{
    if (obj == null) return 1;

    if (obj is DateBorders borders)
    {
        if (this == borders)
            return 0;
        else if (this < borders)
            return -1;
        else
            return 1;
    }
    else
    {
        throw new ArgumentException("object is not DateBorders");
    }
}

Метод "Equals" - перегружаем, используя либо перегруженный оператор "==" (в случае если переданный объект типа DateBorders), либо базовую имплементация данного метода.

Метод "ToString" - перегружаем как строковое представление двух дат разделенных дефисом. Это поможет нам перегрузить метод GetHashCode.

Метод GetHashCode - перегружаем, приводя текущий объект вначале к строке, а после возвращая хешкод данной строки. Дело в том, что в языке C# при создании нового экземпляра класс его хешкод будет уникальным, независимо от наполнения данного класса. Т.е. если не перегрузить данный метод и создать два экземпляра нашего класса DateBorders с одинаковыми датами С и По внутри, то они будут иметь разный хешкод, несмотря на свое идентичное наполнение. На строки данная закономерность не распространяется, так как в C# есть механизм, который не создает новые экземпляры класса String, если ранее уже была создана данная строка — тем самым их хешкоды для идентичных строк будут совпадать. Используя нашу перегрузку метода ToString и беря хешкод строки, мы добились того же поведения хешкодов для нашего класса, что есть у класса String. Теперь при использовании метода IEnumerable.Distinct мы можем гарантировать, что логика получения уникального списка границ дат будет верной, так как данный метод основывается на хешкодах сравниваемых объектов.

Реализуя интерфейс IComparable, от которого наследуется наш класс, мы реализуем метод CompareTo, который сравнивает текущий экземпляр класса с переданным. Его реализация довольно тривиальна и использует перегрузки ранее перегруженных операторов. 

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

  • Сравнивать два экземпляра на равенство
  • Сравнивать два экземпляра на больше/меньше
  • Сравнивать два экземпляра на больше или равно / меньше или равно
  • Сортировать по возрастанию / убыванию
  • Получать уникальные значения из списка границ дат
  • Использовать метод IEnumerable.Sort, который сортирует по убыванию список и использует интерфейс IComparable.

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

/// <summary>
/// Метод сопоставляющий форвардные и исторические оптимизации
/// </summary>
/// <param name="History">Массив исторических оптимизаций</param>
/// <param name="Forward">Массив форвардных оптимизаций</param>
/// <returns>Сортированный список историческая - форвардная оптимизации</returns>
public static Dictionary<DateBorders, DateBorders> CompareHistoryToForward(List<DateBorders> History, List<DateBorders> Forward)
{
    // массив сопоставимых оптимизаций
    Dictionary<DateBorders, DateBorders> ans = new Dictionary<DateBorders, DateBorders>();

    // Сортируем переданные параметры
    History.Sort();
    Forward.Sort();

    // Создаем цикл по историческим оптимизациям
    int i = 0;
    foreach (var item in History)
    {
if(ans.ContainsKey(item))
       	    continue;

        ans.Add(item, null); // Добавляем историческую оптимизацию
        if (Forward.Count <= i)
            continue; // Если массив форвардных оптимизаций меньше индекса - продолжаем цикл

        // Цикл по форвардным оптимизациям
        for (int j = i; j < Forward.Count; j++)
        {
            // Если в массиве результатов содержится текущяя форвардная оптимизация - то пропускаем
            if (ans.ContainsValue(Forward[j]) ||
                Forward[j].From < item.Till)
            {
                continue;
            }

            // Сопоставляем форвардную и историческую оптимизации
            ans[item] = Forward[j];
            i = j + 1;
            break;
        }
    }

    return ans;
}

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

В начале цикла по историческим  данным, мы всегда добавляем в коллекцию с результатами исторические границы (key), на место форвардных интервалов мы временно устанавливаем null. Цикл по форвардным результатам начинается с параметра i — это необходимо для того. чтобы не повторять цикл по ранее использованным элементам форвардного списка. Т.е. форвардный интервал всегда должен следовать за историческим, т.е. он должен быть > исторического. По этому мы организовываем цикл по форвардным интервалам, на случай, если в переданном списке в его начале для самого первого исторического интервала, мы будем иметь список форвардных интервалов, которые предшествуют самому первому историческому интервалу. Проще эту мысль донести в виде таблицы:

Историческая Форвардная
С По С По
10.03.2016 09.03.2017 12.12.2016 09.03.2017
10.06.2016 09.06.2017 10.03.2017 09.06.2017
10.09.2016 09.09.2017 10.06.2017 09.09.2017

То есть первый исторический интервал заканчивается на 09.03.2017, а первый форвардный интервал начинается с 12.12.2016, очевидно, что они не соотносятся друг с другом, поэтому в цикле по форвардным интервалам, мы пропускаем его так как условие будет выполнено. Также мы пропускаем тот форвардный интервал, который уже содержится в результирующем словаре. Если же j- тая форвардная дата не существует еще в результирующем словаре и дата начала форвардного интервала >= дате окончания текущего исторического интервала, то мы сохраняем полученное значение и выходим из цикла по форвардным интервалам так как значение уже было найдено. Однако перед выходом, мы присваиваем i (переменной начала итераций по форвардному списку) значение следующего за выбранным сейчас форвардного интервала, так как текущий интервал уже не потребуется (из за первоначальной сортировки данных).

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

Key Value
10.03.2016-09.3.2017 10.03.2017-09.06.2017
10.06.2016-09.06.2017 10.06.2017-09.09.2017
10.09.2016-09.09.2017 null

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

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

/// <summary>
/// Отчет прозода оптимизации
/// </summary>
public ReportItem report;
/// <summary>
/// коэффициент сортировки
/// </summary>
public double SortBy;

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

Также данная структура содержит перегрузки приведения типов:

/// <summary>
/// Оператор неявного приведения типа от прозода оптимизации к текущему типу
/// </summary>
/// <param name="item">Отчет прозода оптимизации</param>
public static implicit operator OptimisationResult(ReportItem item)
{
    return new OptimisationResult { report = item, SortBy = 0 };
}
/// <summary>
/// Оператор явного приведения типов от текущего к структуре оптимизационного прохода
/// </summary>
/// <param name="optimisationResult">текущий тип</param>
public static explicit operator ReportItem(OptimisationResult optimisationResult)
{
    return optimisationResult.report;
}

В результате мы имеем возможность неявно приводить тип ReportItem к его обертке и уже явно приводить обертку ReportItem к самому элементу отчета торгов. Это может быть более удобно, чем заполнять поля последовательно. Также, так как в структуре ReportItem все ее поля разбиты на категории, иногда в коде, чтобы получить какое-либо значение, может потребоваться очень длительная запись. Для экономии места и для создания более универсального геттера был создан метод, получающий запрашиваемые данные коэффициентов робота через передаваемый ему enum SourtBy,  описанный выше в коде GetResult(SortBy resultType). Его реализация тривиальна, но слишком длинна, по этому не будем приводить ее здесь. Данный метод в конструкции switch case перебирает переданные enum, и исходя из них возвращает значение запрашиваемого коэффициента. Так как большинство коэффициентов типа double, и так как данный тип может вместить в себя все остальные числовые типы, то мы приводим значение коэффициентов именно к нему.

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

/// <summary>
/// Перегрузка оператора сравнения на равенство
/// </summary>
/// <param name="result1">сравниваемый параметр 1</param>
/// <param name="result2">сравниваемый параметр 2</param>
/// <returns>результат сравнения</returns>
public static bool operator ==(OptimisationResult result1, OptimisationResult result2)
{
    foreach (var item in result1.report.BotParams)
    {
        if (!result2.report.BotParams.ContainsKey(item.Key))
            return false;
        if (result2.report.BotParams[item.Key] != item.Value)
            return false;
    }

    return true;
}
/// <summary>
/// Перегрузка оператора сравнения на неравенство
/// </summary>
/// <param name="result1">сравниваемый параметр 1</param>
/// <param name="result2">сравниваемый параметр 2</param>
/// <returns>результат сравнения</returns>
public static bool operator !=(OptimisationResult result1, OptimisationResult result2)
{
    return !(result1 == result2);
}
/// <summary>
/// Перегрузка оператора сравнения базового типа
/// </summary>
/// <param name="obj"></param>
/// <returns></returns>
public override bool Equals(object obj)
{
    if (obj is OptimisationResult other)
    {
        return this == other;
    }
    else
        return base.Equals(obj);
}

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

Создание файла для хранения отчета оптимизации

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

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

/// <summary>
/// временный хранитель (накопитель) данных
/// </summary>
private static ReportItem ReportItem;
/// <summary>
/// очистка временного хранителя данных
/// </summary>
public static void ClearReportItem()
{
    ReportItem = new ReportItem();
}

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

/// <summary>
/// Добавление параметра робота
/// </summary>
/// <param name="name">Имя параметра</param>
/// <param name="value">Значение параметра</param>
public static void AppendBotParam(string name, string value);

/// <summary>
/// Добавление основного списка коэффициентов
/// </summary>
/// <param name="payoff"></param>
/// <param name="profitFactor"></param>
/// <param name="averageProfitFactor"></param>
/// <param name="recoveryFactor"></param>
/// <param name="averageRecoveryFactor"></param>
/// <param name="totalTrades"></param>
/// <param name="pl"></param>
/// <param name="dd"></param>
/// <param name="altmanZScore"></param>
public static void AppendMainCoef(double payoff,
                                  double profitFactor,
                                  double averageProfitFactor,
                                  double recoveryFactor,
                                  double averageRecoveryFactor,
                                  int totalTrades,
                                  double pl,
                                  double dd,
                                  double altmanZScore);

/// <summary>
/// Добавление VaR
/// </summary>
/// <param name="Q_90"></param>
/// <param name="Q_95"></param>
/// <param name="Q_99"></param>
/// <param name="Mx"></param>
/// <param name="Std"></param>
public static void AppendVaR(double Q_90, double Q_95,
                             double Q_99, double Mx, double Std);

/// <summary>
/// Добавление суммарных PL / DD и сопутствующих значений
/// </summary>
/// <param name="profit"></param>
/// <param name="dd"></param>
/// <param name="totalProfitTrades"></param>
/// <param name="totalLooseTrades"></param>
/// <param name="consecutiveWins"></param>
/// <param name="consecutiveLoose"></param>
public static void AppendMaxPLDD(double profit, double dd,
                                 int totalProfitTrades, int totalLooseTrades,
                                 int consecutiveWins, int consecutiveLoose);

/// <summary>
/// Добавление конертного для
/// </summary>
/// <param name="day"></param>
/// <param name="profit"></param>
/// <param name="dd"></param>
/// <param name="numberOfProfitTrades"></param>
/// <param name="numberOfLooseTrades"></param>
public static void AppendDay(int day,
                             double profit, double dd,
                             int numberOfProfitTrades,
                             int numberOfLooseTrades);

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

/// <summary>
/// Метод создающий файл если он не был создан
/// </summary>
/// <param name="pathToBot">Путь до робота</param>
/// <param name="currency">Валюта депозита</param>
/// <param name="balance">Баланс</param>
/// <param name="laverage">Кредитное плечо</param>
/// <param name="pathToFile">Путь до файла</param>
private static void CreateFileIfNotExists(string pathToBot, string currency, double balance, int laverage, string pathToFile)
{
    if (File.Exists(pathToFile))
        return;
    using (var xmlWriter = new XmlTextWriter(pathToFile, null))
    {
        // установка формата документа
        xmlWriter.Formatting = Formatting.Indented;
        xmlWriter.IndentChar = '\t';
        xmlWriter.Indentation = 1;

        xmlWriter.WriteStartDocument();

        // Создаем корень документа
        #region Document root
        xmlWriter.WriteStartElement("Optimisatin_Report");

        // Пишем дату создания
        xmlWriter.WriteStartAttribute("Created");
        xmlWriter.WriteString(DateTime.Now.ToString("dd.MM.yyyy HH:mm:ss"));
        xmlWriter.WriteEndAttribute();

        #region Optimiser settings section 
        // Настройки оптимизатора
        xmlWriter.WriteStartElement("Optimiser_Settings");

        // Путь к роботу
        WriteItem(xmlWriter, "Bot", pathToBot);
        // Депозит
        WriteItem(xmlWriter, "Deposit", balance.ToString(), new Dictionary<string, string> { { "Currency", currency } });
        // Кредитное плечо
        WriteItem(xmlWriter, "Laverage", laverage.ToString());

        xmlWriter.WriteEndElement();
        #endregion

        #region Optimisation resultssection
        // корневая нода списка результатов оптимизации
        xmlWriter.WriteStartElement("Optimisation_Results");
        xmlWriter.WriteEndElement();
        #endregion

        xmlWriter.WriteEndElement();
        #endregion

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

/// <summary>
/// Запись элемента в файл
/// </summary>
/// <param name="writer">Писатель</param>
/// <param name="Name">Имя элемента</param>
/// <param name="Value">Значение элемента</param>
/// <param name="Attributes">Аттрибыты</param>
private static void WriteItem(XmlTextWriter writer, string Name, string Value, Dictionary<string, string> Attributes = null)
{
    writer.WriteStartElement("Item");

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

    if (Attributes != null)
    {
        foreach (var item in Attributes)
        {
            writer.WriteStartAttribute(item.Key);
            writer.WriteString(item.Value);
            writer.WriteEndAttribute();
        }
    }

    writer.WriteString(Value);

    writer.WriteEndElement();
}

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

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

<?xml version="1.0"?>
<Optimisatin_Report Created="24.10.2019 19:10:08">
        <Optimiser_Settings>
                <Item Name="Bot">Path to bot</Item>
                <Item Name="Deposit" Currency="Currency">1000</Item>
                <Item Name="Laverage">1</Item>
        </Optimiser_Settings>
        <Optimisation_Results />
</Optimisatin_Report>

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

/// <summary>
/// Запись аттрибутов в файл
/// </summary>
/// <param name="item">нода</param>
/// <param name="xmlDoc">Документ</param>
/// <param name="Attributes">Аттрибуты</param>
private static void FillInAttributes(XmlNode item, XmlDocument xmlDoc, Dictionary<string, string> Attributes)
{
    if (Attributes != null)
    {
        foreach (var attr in Attributes)
        {
            XmlAttribute attribute = xmlDoc.CreateAttribute(attr.Key);
            attribute.Value = attr.Value;
            item.Attributes.Append(attribute);
        }
    }
}

/// <summary>
/// Добавить секцию
/// </summary>
/// <param name="xmlDoc">Документ</param>
/// <param name="xpath_parentSection">xpath для выбора родительской ноды</param>
/// <param name="sectionName">Имя секции</param>
/// <param name="Attributes">Аттрибут</param>
private static void AppendSection(XmlDocument xmlDoc, string xpath_parentSection,
                                  string sectionName, Dictionary<string, string> Attributes = null)
{
    XmlNode section = xmlDoc.SelectSingleNode(xpath_parentSection);
    XmlNode item = xmlDoc.CreateElement(sectionName);

    FillInAttributes(item, xmlDoc, Attributes);

    section.AppendChild(item);
}

/// <summary>
/// Запись элемента
/// </summary>
/// <param name="xmlDoc">Документ</param>
/// <param name="xpath_parentSection">xpath для выбора родительской ноды</param>
/// <param name="name">Имя элемента</param>
/// <param name="value">значение</param>
/// <param name="Attributes">Аттрибуты</param>
private static void WriteItem(XmlDocument xmlDoc, string xpath_parentSection, string name,
                              string value, Dictionary<string, string> Attributes = null)
{
    XmlNode section = xmlDoc.SelectSingleNode(xpath_parentSection);
    XmlNode item = xmlDoc.CreateElement(name);
    item.InnerText = value;

    FillInAttributes(item, xmlDoc, Attributes);

    section.AppendChild(item);
}

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

/// <summary>
/// Запись результатов торгов в файл
/// </summary>
/// <param name="pathToBot">Путь к боту</param>
/// <param name="currency">Валюта депозита</param>
/// <param name="balance">Баланс</param>
/// <param name="laverage">Кредитное плечо</param>
/// <param name="pathToFile">Путь до файла</param>
/// <param name="symbol">Символ</param>
/// <param name="tf">Таймфрейм</param>
/// <param name="StartDT">Дата начала торгов</param>
/// <param name="FinishDT">Дата завершения торгов</param>
public static void Write(string pathToBot, string currency, double balance,
                         int laverage, string pathToFile, string symbol, int tf,
                         ulong StartDT, ulong FinishDT)
{
    // Создаем файл если он не существует
    CreateFileIfNotExists(pathToBot, currency, balance, laverage, pathToFile);
            
    ReportItem.Symbol = symbol;
    ReportItem.TF = tf;

    // Создаем дакумент и читем с его помощью файл
    XmlDocument xmlDoc = new XmlDocument();
    xmlDoc.Load(pathToFile);

    #region Append result section
    // Пишем запрос на переход в секцию с резкльтатами оптимизаций 
    string xpath = "Optimisatin_Report/Optimisation_Results";
    // Добавляем новую секцию с резкльтатами оптимизации
    AppendSection(xmlDoc, xpath, "Result",
                  new Dictionary<string, string>
                  {
                      { "Symbol", symbol },
                      { "TF", tf.ToString() },
                      { "Start_DT", StartDT.ToString() },
                      { "Finish_DT", FinishDT.ToString() }
                  });
    // Добавляем секцию с коэффициентами оптимизаций
    AppendSection(xmlDoc, $"{xpath}/Result[last()]", "Coefficients");
    // Добавляем секцию с  VaR
    AppendSection(xmlDoc, $"{xpath}/Result[last()]/Coefficients", "VaR");
    // Добавляем секцию с суммарными PL / DD
    AppendSection(xmlDoc, $"{xpath}/Result[last()]/Coefficients", "Max_PL_DD");
    // Добавляем секцию с результатами торгов по дням
    AppendSection(xmlDoc, $"{xpath}/Result[last()]/Coefficients", "Trading_Days");
    // Добавляем секцию с результатами торгов в Пн
    AppendSection(xmlDoc, $"{xpath}/Result[last()]/Coefficients/Trading_Days", "Mn");
    // Добавляем секцию с результатами торгов во Вт
    AppendSection(xmlDoc, $"{xpath}/Result[last()]/Coefficients/Trading_Days", "Tu");
    // Добавляем секцию с результатами торгов в Ср
    AppendSection(xmlDoc, $"{xpath}/Result[last()]/Coefficients/Trading_Days", "We");
    // Добавляем секцию с результатами торгов в Чт
    AppendSection(xmlDoc, $"{xpath}/Result[last()]/Coefficients/Trading_Days", "Th");
    // Добавляем секцию с результатами торгов в Пт
    AppendSection(xmlDoc, $"{xpath}/Result[last()]/Coefficients/Trading_Days", "Fr");
    #endregion

    #region Append Bot params
    // Пробегаемся по параметрам робота
    foreach (var item in ReportItem.BotParams)
    {
        // Пишем выбранный параметр робота
        WriteItem(xmlDoc, "Optimisatin_Report/Optimisation_Results/Result[last()]",
                  "Item", item.Value, new Dictionary<string, string> { { "Name", item.Key } });
    }
    #endregion

    #region Append main coef
    // Задаем путь к ноде с коэффициентами
    xpath = "Optimisatin_Report/Optimisation_Results/Result[last()]/Coefficients";

    // Сохраняем коэфициенты
    WriteItem(xmlDoc, xpath, "Item", ReportItem.OptimisationCoefficients.Payoff.ToString(), new Dictionary<string, string> { { "Name", "Payoff" } });
    WriteItem(xmlDoc, xpath, "Item", ReportItem.OptimisationCoefficients.ProfitFactor.ToString(), new Dictionary<string, string> { { "Name", "Profit factor" } });
    WriteItem(xmlDoc, xpath, "Item", ReportItem.OptimisationCoefficients.AverageProfitFactor.ToString(), new Dictionary<string, string> { { "Name", "Average Profit factor" } });
    WriteItem(xmlDoc, xpath, "Item", ReportItem.OptimisationCoefficients.RecoveryFactor.ToString(), new Dictionary<string, string> { { "Name", "Recovery factor" } });
    WriteItem(xmlDoc, xpath, "Item", ReportItem.OptimisationCoefficients.AverageRecoveryFactor.ToString(), new Dictionary<string, string> { { "Name", "Average Recovery factor" } });
    WriteItem(xmlDoc, xpath, "Item", ReportItem.OptimisationCoefficients.TotalTrades.ToString(), new Dictionary<string, string> { { "Name", "Total trades" } });
    WriteItem(xmlDoc, xpath, "Item", ReportItem.OptimisationCoefficients.PL.ToString(), new Dictionary<string, string> { { "Name", "PL" } });
    WriteItem(xmlDoc, xpath, "Item", ReportItem.OptimisationCoefficients.DD.ToString(), new Dictionary<string, string> { { "Name", "DD" } });
    WriteItem(xmlDoc, xpath, "Item", ReportItem.OptimisationCoefficients.AltmanZScore.ToString(), new Dictionary<string, string> { { "Name", "Altman Z Score" } });
    #endregion

    #region Append VaR
    // Задаем путь к ноде с VaR
    xpath = "Optimisatin_Report/Optimisation_Results/Result[last()]/Coefficients/VaR";

    // Созраняем результаты VaR
    WriteItem(xmlDoc, xpath, "Item", ReportItem.OptimisationCoefficients.VaR.Q_90.ToString(), new Dictionary<string, string> { { "Name", "90" } });
    WriteItem(xmlDoc, xpath, "Item", ReportItem.OptimisationCoefficients.VaR.Q_95.ToString(), new Dictionary<string, string> { { "Name", "95" } });
    WriteItem(xmlDoc, xpath, "Item", ReportItem.OptimisationCoefficients.VaR.Q_99.ToString(), new Dictionary<string, string> { { "Name", "99" } });
    WriteItem(xmlDoc, xpath, "Item", ReportItem.OptimisationCoefficients.VaR.Mx.ToString(), new Dictionary<string, string> { { "Name", "Mx" } });
    WriteItem(xmlDoc, xpath, "Item", ReportItem.OptimisationCoefficients.VaR.Std.ToString(), new Dictionary<string, string> { { "Name", "Std" } });
    #endregion

    #region Append max PL and DD
    // Задаем путь к ноде с суммарной PL / DD
    xpath = "Optimisatin_Report/Optimisation_Results/Result[last()]/Coefficients/Max_PL_DD";

    // Сохраняем коэффициенты
    WriteItem(xmlDoc, xpath, "Item", ReportItem.OptimisationCoefficients.MaxPLDD.Profit.Value.ToString(), new Dictionary<string, string> { { "Name", "Profit" } });
    WriteItem(xmlDoc, xpath, "Item", ReportItem.OptimisationCoefficients.MaxPLDD.DD.Value.ToString(), new Dictionary<string, string> { { "Name", "DD" } });
    WriteItem(xmlDoc, xpath, "Item", ReportItem.OptimisationCoefficients.MaxPLDD.Profit.TotalTrades.ToString(), new Dictionary<string, string> { { "Name", "Total Profit Trades" } });
    WriteItem(xmlDoc, xpath, "Item", ReportItem.OptimisationCoefficients.MaxPLDD.DD.TotalTrades.ToString(), new Dictionary<string, string> { { "Name", "Total Loose Trades" } });
    WriteItem(xmlDoc, xpath, "Item", ReportItem.OptimisationCoefficients.MaxPLDD.Profit.ConsecutivesTrades.ToString(), new Dictionary<string, string> { { "Name", "Consecutive Wins" } });
    WriteItem(xmlDoc, xpath, "Item", ReportItem.OptimisationCoefficients.MaxPLDD.DD.ConsecutivesTrades.ToString(), new Dictionary<string, string> { { "Name", "Consecutive Loose" } });
    #endregion

    #region Append Days
    foreach (var item in ReportItem.OptimisationCoefficients.TradingDays)
    {
        // Задаем путь к ноде конкретного дня
        xpath = "Optimisatin_Report/Optimisation_Results/Result[last()]/Coefficients/Trading_Days";
        // Выбираем день
        switch (item.Key)
        {
            case DayOfWeek.Monday: xpath += "/Mn"; break;
            case DayOfWeek.Tuesday: xpath += "/Tu"; break;

            case DayOfWeek.Wednesday: xpath += "/We"; break;
            case DayOfWeek.Thursday: xpath += "/Th"; break;
            case DayOfWeek.Friday: xpath += "/Fr"; break;
        }

        // Сохраняем результаты
        WriteItem(xmlDoc, xpath, "Item", item.Value.Profit.Value.ToString(), new Dictionary<string, string> { { "Name", "Profit" } });
        WriteItem(xmlDoc, xpath, "Item", item.Value.DD.Value.ToString(), new Dictionary<string, string> { { "Name", "DD" } });
        WriteItem(xmlDoc, xpath, "Item", item.Value.Profit.Trades.ToString(), new Dictionary<string, string> { { "Name", "Number Of Profit Trades" } });
        WriteItem(xmlDoc, xpath, "Item", item.Value.DD.Trades.ToString(), new Dictionary<string, string> { { "Name", "Number Of Loose Trades" } });
    }
    #endregion

    // Перезаписываем файл с внесенными изменениями
    xmlDoc.Save(pathToFile);

    // Отчищаем переменную где хранились записанные в файл результаты
    ClearReportItem();
}

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

$"{xpath}/Result[last()]/Coefficients"

В переменной xpath уже записан путь до ноды, где хранятся элементы оптимизационных проходов. В данной ноде хранятся ноды с результатами оптимизаций, которые можно представить в виде массива структур. Данная конструкция  Result[last()] выбирает последний элемент представленного массива, после которой передаем путь к вложенной ноде  /Coefficients. По описанному принципу мы выбираем требуемую ноду с результатами оптимизаций. 

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

/// <summary>
/// Запись в файл с блокировкой через именованный мьютекс
/// </summary>
/// <param name="mutexName">Имя мьютекса</param>
/// <param name="pathToBot">Путь к боту</param>
/// <param name="currency">Валюта депозита</param>
/// <param name="balance">Баланс</param>
/// <param name="laverage">Кредитное плечо</param>
/// <param name="pathToFile">Путь до файла</param>
/// <param name="symbol">Символ</param>
/// <param name="tf">Таймфрейм</param>
/// <param name="StartDT">Дата начала торгов</param>
/// <param name="FinishDT">Дата завершения торгов</param>
/// <returns></returns>
public static string MutexWriter(string mutexName, string pathToBot, string currency, double balance,
                                 int laverage, string pathToFile, string symbol, int tf,
                                 ulong StartDT, ulong FinishDT)
{
    string ans = "";
    // Блокировка мьютекса
    Mutex m = new Mutex(false, mutexName);
    m.WaitOne();
    try
    {
        // запись в файл
        Write(pathToBot, currency, balance, laverage, pathToFile, symbol, tf, StartDT, FinishDT);
    }
    catch (Exception e)
    {
        // Ловим ошибку если она была
        ans = e.Message;
    }

    // Освобождаем мьютекс
    m.ReleaseMutex();
    // Возвращаем текст ошибки
    return ans;
}

Данный метод записывает данные при помощи предыдущего метода, однако процесс записи обернут по мьютекс и в блок try-catch, последнее нужно для того чтобы независимо от возможных ошибок в процессе записи, мьютекс освобождался — в противном случае будет потенциальное зависание процесса и оптимизация не сможет продолжиться. Данные методы используются также и в структуре OptimisationResult в методе WriteResult.

/// <summary>
/// Метод добавляющий параметр текущий параметр в существующий файл или же создает новый файл с текущим параметром
/// </summary>
/// <param name="pathToBot">Относительный путь до робота от папки с экспертами</param>
/// <param name="currency">Валюта депозита</param>
/// <param name="balance">Баланс</param>
/// <param name="laverage">Кредитное плече</param>
/// <param name="pathToFile">Путь до файла</param>
public void WriteResult(string pathToBot,
                        string currency, double balance,
                        int laverage, string pathToFile)
{
    try
    {
        foreach (var param in report.BotParams)
        {
            ReportWriter.AppendBotParam(param.Key, param.Value);
        }
        ReportWriter.AppendMainCoef(GetResult(ReportManager.SortBy.Payoff),
                                    GetResult(ReportManager.SortBy.ProfitFactor),
                                    GetResult(ReportManager.SortBy.AverageProfitFactor),
                                    GetResult(ReportManager.SortBy.RecoveryFactor),
                                    GetResult(ReportManager.SortBy.AverageRecoveryFactor),
                                    (int)GetResult(ReportManager.SortBy.TotalTrades),
                                    GetResult(ReportManager.SortBy.PL),
                                    GetResult(ReportManager.SortBy.DD),
                                    GetResult(ReportManager.SortBy.AltmanZScore));

        ReportWriter.AppendVaR(GetResult(ReportManager.SortBy.Q_90), GetResult(ReportManager.SortBy.Q_95),
                               GetResult(ReportManager.SortBy.Q_99), GetResult(ReportManager.SortBy.Mx),
                               GetResult(ReportManager.SortBy.Std));

        ReportWriter.AppendMaxPLDD(GetResult(ReportManager.SortBy.ProfitFactor), GetResult(ReportManager.SortBy.MaxDD),
                                  (int)GetResult(ReportManager.SortBy.MaxProfitTotalTrades),
                                  (int)GetResult(ReportManager.SortBy.MaxDDTotalTrades),
                                  (int)GetResult(ReportManager.SortBy.MaxProfitConsecutivesTrades),
                                  (int)GetResult(ReportManager.SortBy.MaxDDConsecutivesTrades));


        foreach (var day in report.OptimisationCoefficients.TradingDays)
        {
            ReportWriter.AppendDay((int)day.Key, day.Value.Profit.Value, day.Value.Profit.Value,
                                   day.Value.Profit.Trades, day.Value.DD.Trades);
        }

        ReportWriter.Write(pathToBot, currency, balance, laverage, pathToFile, report.Symbol, report.TF,
                           report.DateBorders.From.DTToUnixDT(), report.DateBorders.Till.DTToUnixDT());
    }
    catch (Exception e)
    {
        ReportWriter.ClearReportItem();
        throw e;
    }
}

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

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

public static void ReportWriter(this IEnumerable<OptimisationResult> results, string pathToBot,
                                string currency, double balance,
                                int laverage, string pathToFile)
{
    // Удаляем файл если таковой существует
    if (File.Exists(pathToFile))
        File.Delete(pathToFile);

    // Создаем писатель 
    using (var xmlWriter = new XmlTextWriter(pathToFile, null))
    {
        // Установка формата документа
        xmlWriter.Formatting = Formatting.Indented;
        xmlWriter.IndentChar = '\t';
        xmlWriter.Indentation = 1;

        xmlWriter.WriteStartDocument();

        // Корневая нода документа
        xmlWriter.WriteStartElement("Optimisatin_Report");

        // Пишем аттрибуты
        WriteAttribute(xmlWriter, "Created", DateTime.Now.ToString("dd.MM.yyyy HH:mm:ss"));

        // Пишем настройки оптимизатора в файл
        #region Optimiser settings section 
        xmlWriter.WriteStartElement("Optimiser_Settings");

        WriteItem(xmlWriter, "Bot", pathToBot); // путь к роботу
        WriteItem(xmlWriter, "Deposit", balance.ToString(), new Dictionary<string, string> { { "Currency", currency } }); // Валюта и депозит
        WriteItem(xmlWriter, "Laverage", laverage.ToString()); // кредитное плече

        xmlWriter.WriteEndElement();
        #endregion

        // Пишем в файл сами результаты оптимизаций
        #region Optimisation result section
        xmlWriter.WriteStartElement("Optimisation_Results");

        // Цикл по результатам оптимизаций
        foreach (var item in results)
        {
            // Пишем конкретный результат
            xmlWriter.WriteStartElement("Result");

            // Пишем аттрибуты данного оптимизационного прозода
            WriteAttribute(xmlWriter, "Symbol", item.report.Symbol); // Символ
            WriteAttribute(xmlWriter, "TF", item.report.TF.ToString()); // Таймфрейм
            WriteAttribute(xmlWriter, "Start_DT", item.report.DateBorders.From.DTToUnixDT().ToString()); // Дата начала оптимизации
            WriteAttribute(xmlWriter, "Finish_DT", item.report.DateBorders.Till.DTToUnixDT().ToString()); // Дата завершения оптимизации

            // Запись результата оптимизации
            WriteResultItem(item, xmlWriter);

            xmlWriter.WriteEndElement();
        }

        xmlWriter.WriteEndElement();
        #endregion

        xmlWriter.WriteEndElement();

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

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

/// <summary>
/// Запись конкретного оптимизационного прозода
/// </summary>
/// <param name="resultItem">Значение оптимизационного прохода</param>
/// <param name="writer">Писатель</param>
private static void WriteResultItem(OptimisationResult resultItem, XmlTextWriter writer)
{
    // Запись коэффициентов
    #region Coefficients
    writer.WriteStartElement("Coefficients");

    // Пишем VaR
    #region VaR
    writer.WriteStartElement("VaR");

    WriteItem(writer, "90", resultItem.GetResult(SortBy.Q_90).ToString()); // Квантиль 90
    WriteItem(writer, "95", resultItem.GetResult(SortBy.Q_95).ToString()); // Квантиль 95
    WriteItem(writer, "99", resultItem.GetResult(SortBy.Q_99).ToString()); // Квантиль 99
    WriteItem(writer, "Mx", resultItem.GetResult(SortBy.Mx).ToString()); // Среднее по PL
    WriteItem(writer, "Std", resultItem.GetResult(SortBy.Std).ToString()); // среднеквадратическое отклонение по PL

    writer.WriteEndElement();
    #endregion

    // Пишем параметры PL / DD - крайние точки
    #region Max PL DD
    writer.WriteStartElement("Max_PL_DD");
    WriteItem(writer, "Profit", resultItem.GetResult(SortBy.MaxProfit).ToString()); // Суммарная прибыль
    WriteItem(writer, "DD", resultItem.GetResult(SortBy.MaxDD).ToString()); // Суммарный убыток
    WriteItem(writer, "Total Profit Trades", ((int)resultItem.GetResult(SortBy.MaxProfitTotalTrades)).ToString()); // Общее кол - во прибыльных трейдов
    WriteItem(writer, "Total Loose Trades", ((int)resultItem.GetResult(SortBy.MaxDDTotalTrades)).ToString()); // Общее кол - во убыточных трейдов
    WriteItem(writer, "Consecutive Wins", ((int)resultItem.GetResult(SortBy.MaxProfitConsecutivesTrades)).ToString()); // Прибыльных трейдов подряд
    WriteItem(writer, "Consecutive Loose", ((int)resultItem.GetResult(SortBy.MaxDDConsecutivesTrades)).ToString()); // Убыточный трейдов подряд
    writer.WriteEndElement();
    #endregion

    // Пишем результаты торгов по дням
    #region Trading_Days

    // Метод пишуший результаты торгов
    void AddDay(string Day, double Profit, double DD, int ProfitTrades, int DDTrades)
    {
        writer.WriteStartElement(Day);

        WriteItem(writer, "Profit", Profit.ToString()); // прибыли
        WriteItem(writer, "DD", DD.ToString()); // убытки
        WriteItem(writer, "Number Of Profit Trades", ProfitTrades.ToString()); // кол - во прибыльных трейдов
        WriteItem(writer, "Number Of Loose Trades", DDTrades.ToString()); // кол - во убыточных трейдов

        writer.WriteEndElement();
    }

    writer.WriteStartElement("Trading_Days");

    // Пн
    AddDay("Mn", resultItem.GetResult(SortBy.AverageDailyProfit_Mn),
                 resultItem.GetResult(SortBy.AverageDailyDD_Mn),
                 (int)resultItem.GetResult(SortBy.AverageDailyProfitTrades_Mn),
                 (int)resultItem.GetResult(SortBy.AverageDailyDDTrades_Mn));
    // Вт
    AddDay("Tu", resultItem.GetResult(SortBy.AverageDailyProfit_Tu),
                 resultItem.GetResult(SortBy.AverageDailyDD_Tu),
                 (int)resultItem.GetResult(SortBy.AverageDailyProfitTrades_Tu),
                 (int)resultItem.GetResult(SortBy.AverageDailyDDTrades_Tu));
    // Ср
    AddDay("We", resultItem.GetResult(SortBy.AverageDailyProfit_We),
                 resultItem.GetResult(SortBy.AverageDailyDD_We),
                 (int)resultItem.GetResult(SortBy.AverageDailyProfitTrades_We),
                 (int)resultItem.GetResult(SortBy.AverageDailyDDTrades_We));
    // Чт
    AddDay("Th", resultItem.GetResult(SortBy.AverageDailyProfit_Th),
                 resultItem.GetResult(SortBy.AverageDailyDD_Th),
                 (int)resultItem.GetResult(SortBy.AverageDailyProfitTrades_Th),
                 (int)resultItem.GetResult(SortBy.AverageDailyDDTrades_Th));
    // Пт
    AddDay("Fr", resultItem.GetResult(SortBy.AverageDailyProfit_Fr),
                 resultItem.GetResult(SortBy.AverageDailyDD_Fr),
                 (int)resultItem.GetResult(SortBy.AverageDailyProfitTrades_Fr),
                 (int)resultItem.GetResult(SortBy.AverageDailyDDTrades_Fr));

    writer.WriteEndElement();
    #endregion

    // Пишем остальные коэййициенты
    WriteItem(writer, "Payoff", resultItem.GetResult(SortBy.Payoff).ToString());
    WriteItem(writer, "Profit factor", resultItem.GetResult(SortBy.ProfitFactor).ToString());
    WriteItem(writer, "Average Profit factor", resultItem.GetResult(SortBy.AverageProfitFactor).ToString());
    WriteItem(writer, "Recovery factor", resultItem.GetResult(SortBy.RecoveryFactor).ToString());
    WriteItem(writer, "Average Recovery factor", resultItem.GetResult(SortBy.AverageRecoveryFactor).ToString());
    WriteItem(writer, "Total trades", ((int)resultItem.GetResult(SortBy.TotalTrades)).ToString());
    WriteItem(writer, "PL", resultItem.GetResult(SortBy.PL).ToString());
    WriteItem(writer, "DD", resultItem.GetResult(SortBy.DD).ToString());
    WriteItem(writer, "Altman Z Score", resultItem.GetResult(SortBy.AltmanZScore).ToString());

    writer.WriteEndElement();
    #endregion

    // Пишем коэффициенты робота
    #region Bot params
    foreach (var item in resultItem.report.BotParams)
    {
        WriteItem(writer, item.Key, item.Value);
    }
    #endregion
}

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

Чтение файла с отчетом оптимизации

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

public class ReportReader : IDisposable
    {
        /// <summary>
        /// Конструктор
        /// </summary>
        /// <param name="path">Путь к файлу</param>
        public ReportReader(string path);

        /// <summary>
        /// Провайдер формата двоичных чисел
        /// </summary>
        private readonly NumberFormatInfo formatInfo = new NumberFormatInfo { NumberDecimalSeparator = "." };

        #region DataKeepers
        /// <summary>
        /// Представление файла с отчетом в формете ООП
        /// </summary>
        private readonly XmlDocument document = new XmlDocument();

        /// <summary>
        /// Еллекция нодов докусента (коллекция строк в excel таблице)
        /// </summary>
        private readonly System.Collections.IEnumerator enumerator;
        #endregion

        /// <summary>
        /// прочитанный текущий элемент отчета
        /// </summary>
        public ReportItem? ReportItem { get; private set; } = null;

        #region Optimiser settings
        /// <summary>
        /// Путь до робота
        /// </summary>
        public string RelativePathToBot { get; }

        /// <summary>
        /// Баланс
        /// </summary>
        public double Balance { get; }

        /// <summary>
        /// Валюта
        /// </summary>
        public string Currency { get; }

        /// <summary>
        /// Плече
        /// </summary>
        public int Laverage { get; }
        #endregion

        /// <summary>
        /// Дата роздания файла
        /// </summary>
        public DateTime Created { get; }

        /// <summary>
        /// Метод читающий файл
        /// </summary>
        /// <returns></returns>
        public bool Read();

        /// <summary>
        /// Метод мозврящающий строку выбирающую элемент по его имени (аттрибут Name)
        /// </summary>
        /// <param name="Name"></param>
        /// <returns></returns>
        private string SelectItem(string Name) => $"Item[@Name='{Name}']";

        /// <summary>
        /// Получаем значение результата торгов выбранного дня
        /// </summary>
        /// <param name="dailyNode">Нода данного дня</param>
        /// <returns></returns>
        private DailyData GetDay(XmlNode dailyNode);

        /// <summary>
        /// Сброс читателя котировок
        /// </summary>
        public void ResetReader();

        /// <summary>
        /// Отчищаем документ
        /// </summary>
        public void Dispose() => document.RemoveAll();
    }

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

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

Описываемый класс для реализации построчного чтения документа использует Enumerator , полученный из прочтенного документа. Прочитанные значения заносятся в специально отведенное свойство, тем самым предоставляя доступ к данным. Помимо этого, при инстанцировании класса заполняются свойства указывающие основные настройки оптимизатора, а также дату и время создания файла. Для нивелирования влияния настроек локализации OC — как при записи, так и при чтении файла — указывается формат разделителя чисел с двойной точностью. При первом прочтении файла данный класс следует сбросить в начало списка, если потребуется повторное прочтение, поэтому объявлен метод ResetReader, который скидывает указанный ранее Enumerator в начало списка. Конструктор данного класса реализован таким образом, чтобы заполнить все требуемые свойства, а также подготовить класс к последующему использованию.

public ReportReader(string path)
{
    // загружаем дакумент
    document.Load(path);

    // Получаем дату создания файла
    Created = DateTime.ParseExact(document["Optimisatin_Report"].Attributes["Created"].Value, "dd.MM.yyyy HH:mm:ss", null);
    // Получаем enumerator
    enumerator = document["Optimisatin_Report"]["Optimisation_Results"].ChildNodes.GetEnumerator();

    // Функция получения параметра
    string xpath(string Name) { return $"/Optimisatin_Report/Optimiser_Settings/Item[@Name='{Name}']"; }

    // Получаем путь до робота
    RelativePathToBot = document.SelectSingleNode(xpath("Bot")).InnerText;

    // Получаем баланс и валюту депозита
    XmlNode Deposit = document.SelectSingleNode(xpath("Deposit"));
    Balance = Convert.ToDouble(Deposit.InnerText.Replace(",", "."), formatInfo);
    Currency = Deposit.Attributes["Currency"].Value;

    // Получаем кредитное плечо
    Laverage = Convert.ToInt32(document.SelectSingleNode(xpath("Laverage")).InnerText);
}

Первым делом он загружает переданный документ и заполняет дату его создания. Enumerator, получаемый при инстацировании классов, принадлежит дочерним узлам документа, что находятся в разделе  Optimisatin_Report/Optimisation_Results, иначе говоря тем узлам, что имеют тег <Result/>. Для получения искомых параметров настроек оптимизатора используется указание пути до требуемого узла документа при помощи xpath разметки. Аналогом этой встроенной функции, но только с более коротким путем, является метод SelectItem, который указывает путь до элемента среди узлов документа, имеющих тег <Item/> согласно его атрибуту Name. Метод GetDay, который преобразует переданный узел документа в соответствующую структуры отчета торгов по дням. Среди всех методов данного класса неописанным остался лишь метод чтения данных, так как его реализация длинна -  приведем ее в несколько сокращенном виде.   

public bool Read()
{
    if (enumerator == null)
        return false;

    // Читаем следующий элемент
    bool ans = enumerator.MoveNext();
    if (ans)
    {
        // Текущая нода
        XmlNode result = (XmlNode)enumerator.Current;
        // текущий элемент отчета
        ReportItem = new ReportItem[...]

        // Заполняем параметры робота
        foreach (XmlNode item in result.ChildNodes)
        {
            if (item.Name == "Item")
                ReportItem.Value.BotParams.Add(item.Attributes["Name"].Value, item.InnerText);
        }

    }
    return ans;
}

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

Мультифакторная фильтрация и сортировка отчета с отпимизациями

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

/// <summary>
/// Тип фильтрации
/// </summary>
[Flags]
public enum CompareType
{
    GraterThan = 1, // больше 
    LessThan = 2, // меньше
    EqualTo = 4 // равно
}

А типы коэффициентов, по которым возможна фильтрация и сортировка, описываются уже ранее рассмотренным перечислением — OrderBy. Возвращаясь рассматриваемым методам сортировки и фильтрации, стоит сказать, что все они реализованы в виде методов расширения коллекций, наследуемых от интерфейса IEnumerable<OptimisationResult>. Механизм фильтрации данных тривиален, в нем мы проверяем каждый из коэффициентов поэлементно на заданные условия и отвергаем те из проходов оптимизаций, где какой-либо коэффициент не соответствует заданным условиям. Причем для фильтрации мы применяем условный поэлементный цикл Where, который содержится в интерфейсе IEnumerable. Сам метод реализован следующим образом.

/// <summary>
/// Метод фильтрующий оптимизации
/// </summary>
/// <param name="results">текущая коллекция</param>
/// <param name="compareData">Коллекция коэффициентов и типов фильтрации</param>
/// <returns>Отфильтрованная коллекция</returns>
public static IEnumerable<OptimisationResult> FiltreOptimisations(this IEnumerable<OptimisationResult> results,
                                                                  IDictionary<SortBy, KeyValuePair<CompareType, double>> compareData)
{
    // Функция сортирующая результаты
    bool Compare(double _data, KeyValuePair<CompareType, double> compareParams)
    {
        // Результат сравнения
        bool ans = false;
        // сравнение на равенство
        if (compareParams.Key.HasFlag(CompareType.EqualTo))
        {
            ans = compareParams.Value == _data;
        }
        // Сравнение на больше чем текущий
        if (!ans && compareParams.Key.HasFlag(CompareType.GraterThan))
        {
            ans = _data > compareParams.Value;
        }
        // Сравнение на меньше чем текущий
        if (!ans && compareParams.Key.HasFlag(CompareType.LessThan))
        {
            ans = _data < compareParams.Value;
        }

        return ans;
    }
    // Условие сортировки
    bool Sort(OptimisationResult x)
    {
        // Цикл по переданным параметрам сортировки
        foreach (var item in compareData)
        {
            // проверка на соответствие переданного и текущего параметра
            if (!Compare(x.GetResult(item.Key), item.Value))
                return false;
        }

        return true;
    }

    // Фильтрация
    return results.Where(x => Sort(x));
}

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

  • Compare — ее задача сравнить между собой переданное значение представленное в виде KeyValuePair и значение указанное в искомом методе. Так как нам в процессе сравнения может потребоваться сравнить данные не только на предмет большинства или же их равенства, но и на смежные условия, то мы для краткости реализации поставленной задачи воспользовались преимуществами предоставляемыми флагами. Как известно, флаг представляет собой один бит, а поле int хранит в себе 8 бит, соответственно для поля int мы можем иметь до восьми одновременно установленных или же снятых флагов. Проверка флагов может осуществляться последовательно, без необходимости написания ряда циклов или же громоздких условий, поэтому мы смогли обойтись тремя условиями. К тому же в графическом интерфейсе, который мы рассмотрим позже, также удобно использовать флаги для задания параметров сравнения. Внутри рассматриваемой функции мы поочередно проверяем флаги и соответствие сопоставляемых данных запрашиваемым флагам.  
  • Sort — предназначен в отличии от прошлого метода для сравнения на сопоставимость результатам не какого-либо одного параметра, а ряда записанных параметров. Для поставленной задачи мы пробегаемся в поэлементном цикле по всем переданным к фильтрации флагам и используем уже ранее описанную функцию для того, чтобы узнать — соответствует ли выбранный параметр заданным ограничениям. Для того чтобы в цикле без применения "Switch case" оператора можно было взять значение конкретного выбранного элемента, используется ранее рассмотренный метод OptimidationResult.GetResult(OrderBy item). Если переданное значение не удовлетворяет запрашиваемому, то мы возвращаем ложь, тем самым отфильтровывая неподходящие данные.

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

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

Profit Profit factor Recovery factor
5000 1 9
15000 1.2 5
-11000 0.5 -2
0 0 0
10000 2 5
7000 1 4

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


Profit factor  Recovery factor
Min  0 -2  
Max  2 9

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

Profit Profit factor Recovery factor Normalized summ 
5000 0.5 1  0.75
15000 0.6 0.64  0.62
-11000 0.25 0  0.13
0 0 0.18  0.09
10000 1 0.64  0.82
7000 0.5 0.55  0.52

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

Код, реализующий описанный механизм, представлен в виде двух методов, первый из которых указывает на то, каким образом должна происходить сортировка (по возрастанию либо по убывания), а второй метод реализует сам механизм сортировки. Так как первый из описываемых методов — SortMethod GetSortMethod(SortBy sortBy) — имеет весьма тривиальную реализацию, то мы не станем его описывать, а перейдем сразу к рассмотрению действующего метода.

public static IEnumerable<OptimisationResult> SortOptimisations(this IEnumerable<OptimisationResult> results,
                                                                OrderBy order, IEnumerable<SortBy> sortingFlags,
                                                                Func<SortBy, SortMethod> sortMethod = null)
{
    // Получаем уникальный список флагов для сортировки
    sortingFlags = sortingFlags.Distinct();
    // Проверяем наличие флагов
    if (sortingFlags.Count() == 0)
        return null;
    // Если флаг один то сортируем чисто по этому показателю
    if (sortingFlags.Count() == 1)
    {
        if (order == OrderBy.Ascending)
            return results.OrderBy(x => x.GetResult(sortingFlags.ElementAt(0)));
        else
            return results.OrderByDescending(x => x.GetResult(sortingFlags.ElementAt(0)));
    }

    // Формируем границы минимум и максимум по переданным флагам оптимизации
    Dictionary<SortBy, MinMax> Borders = sortingFlags.ToDictionary(x => x, x => new MinMax { Max = double.MinValue, Min = double.MaxValue });

    #region create Borders min max dictionary
    // Цикл по списку проходов оптимизаций
    for (int i = 0; i < results.Count(); i++)
    {
        // Цикл по флагам сортировки
        foreach (var item in sortingFlags)
        {
            // получаем значение текущего коэффициента
            double value = results.ElementAt(i).GetResult(item);
            MinMax mm = Borders[item];
            // Задаем значения минимум и максимум
            mm.Max = Math.Max(mm.Max, value);
            mm.Min = Math.Min(mm.Min, value);
            Borders[item] = mm;
        }
    }
    #endregion

    // Вес взвешенной суммы нормированных коэффициентов
    double coef = (1.0 / Borders.Count);

    // Переводим список результатов оптимизации к массиву типа List
    // Так как с ним быстрее работать
    List<OptimisationResult> listOfResults = results.ToList();
    // Цикл по результатам оптимизации
    for (int i = 0; i < listOfResults.Count; i++)
    {
        // Присваиваем значение текущему коэффициент
        OptimisationResult data = listOfResults[i];
        // Зануляем текущий коэффициент сортировки
        data.SortBy = 0;
        // Проводим цикл сформированным границам максимумов и минимумов
        foreach (var item in Borders)
        {
            // Получаем значение текущего результата
            double value = listOfResults[i].GetResult(item.Key);
            MinMax mm = item.Value;

            // Если минимум меньше нуля - сдвигаемвсе данные на велечину отрицательного минимума
            if (mm.Min < 0)
            {
                value += Math.Abs(mm.Min);
                mm.Max += Math.Abs(mm.Min);
            }

            // Если максимум больше нуля - делаем подсччеты
            if (mm.Max > 0)
            {
                // В зависимости от метода сортировки - высчитываем коэффициент
                if ((sortMethod == null ? GetSortMethod(item.Key) : sortMethod(item.Key)) == SortMethod.Decreasing)
                {
                    // высчитываем коэффициент для сортировки по убыванию
                    data.SortBy += (1 - value / mm.Max) * coef;
                }
                else
                {
                    // Высчитываем коэффициент для сортировки по возрастанию
                    data.SortBy += value / mm.Max * coef;
                }
            }
        }
        // Замещаем значение текущего коэффициента коэффициентом с параметром сортировки
        listOfResults[i] = data;
    }

    // Сортируем в зависимости от переданного типа сортировки
    if (order == OrderBy.Ascending)
        return listOfResults.OrderBy(x => x.SortBy);
    else
        return listOfResults.OrderByDescending(x => x.SortBy);
}

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

Далее формируется вес для взвешенного суммирования и производится операция по нормированию ряда и его сумме. Для достижения описываемой цели вновь используются два цикла, где во внутреннем цикле производятся описанные операции — все в точности по рассмотренной выше схеме. Взвешенная сумма, полученная в итоге проделанной работы, заносится в переменную SortBy, рассматриваемого элемента массива. В конце данной операции, когда уже сформирован итоговый коэффициент, по которому будет производиться сортировка данных, мы прибегаем к уже ранее рассмотренному методу сортировки через стандартный метод массива List<T>.OrderBy или List<T>. OrderByDescending   — в случае когда требуется сортировка по убыванию. Метод сортировки отдельных членов взвешенной суммы задается через делегат, передаваемый в качестве одного из параметров функции. Если данный делегат оставить параметризированным значением по умолчанию, то будет использован метод, упомянутый в начале рассмотрения данного фрагмента кода, в противном случае — используется переданный делегат. 
  

Заключение

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


Прикрепленные файлы |
Stanislav Korotky
Stanislav Korotky | 23 ноя 2019 в 17:15
Good Beer:

Где-то, в описании Metatrader-4 я видел фразу, смысл которой был в том, что MQL4 создан специально, чтобы любая домохозяйка могла описать свою стратегию и запустить на терминале. Не все же тут инженеры-программисты.

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

Была статья про пошаговую форвард-оптимизацию с помощью MQL, без сторонных средств, в единственном экземпляре терминала:

Статьи

Walk-Forward оптимизация в MetaTrader 5 - своими руками

Stanislav Korotky, 2017.06.08 15:03

В статье рассматриваются подходы, позволяющие достаточно точно эмулировать walk-forward оптимизацию с помощью встроенного тестера и вспомогательных библиотек, реализованных на MQL.

Andrey Azatskiy
Andrey Azatskiy | 23 ноя 2019 в 18:12
Good Beer:

Где-то, в описании Metatrader-4 я видел фразу, смысл которой был в том, что MQL4 создан специально, чтобы любая домохозяйка могла описать свою стратегию и запустить на терминале. Не все же тут инженеры-программисты.

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

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

Andrey Azatskiy
Andrey Azatskiy | 23 ноя 2019 в 18:13
Stanislav Korotky:

Была статья про пошаговую форвард-оптимизацию с помощью MQL, без сторонных средств, в единственном экземпляре терминала:


Возможно, однако сторонние средства так же дают свои преимущества. Позже они будут освящены.

Good Beer
Good Beer | 23 ноя 2019 в 18:49
Stanislav Korotky:

Была статья про пошаговую форвард-оптимизацию с помощью MQL, без сторонных средств, в единственном экземпляре терминала:


.....c подключением платной библиотеки. Кажется она на SQLite работает?
Stanislav Korotky
Stanislav Korotky | 23 ноя 2019 в 21:58
Good Beer:
.....c подключением платной библиотеки. Кажется она на SQLite работает?

Нет никаких зависимостей, только MQL.

В той статье подробно описаны 2 способа проведения Walk-Forward анализа штатным тестером, которые можно реализовать в упрощенном виде для себя бесплатно. Библиотека просто содержит некоторые полезности в готовом виде, вроде расчета всех основных торговых показателей и построения "сшитых" html-отчетов.

Библиотека для простого и быстрого создания программ для MetaTrader (Часть XXV): Обработка ошибок, возвращаемых торговым сервером Библиотека для простого и быстрого создания программ для MetaTrader (Часть XXV): Обработка ошибок, возвращаемых торговым сервером

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

Разработка Pivot Mean Oscillator: новый осциллятор на кумулятивном скользящем среднем Разработка Pivot Mean Oscillator: новый осциллятор на кумулятивном скользящем среднем

В статье описывается осциллятор Pivot Mean Oscillator (PMO), который представляет собой реализацию торговых сигналов на основе индикатора кумулятивного скользящего среднего для платформ MetaTrader. В частности, сначала будет рассмотрено понятие Pivot Mean (PM) — индекс нормализации временных рядов, который вычисляет соотношение между любой точкой данных и скользящей CMA. Затем построим осциллятор PMO как разницу между скользящими средними, построенными по двум сигналам PM. Также в статье будут показаны эксперименты на символе EURUSD, которые проводились для проверки эффективности индикатора.

Библиотека для простого и быстрого создания программ для MetaTrader (Часть XXVI): Работа с отложенными торговыми запросами - первая реализация Библиотека для простого и быстрого создания программ для MetaTrader (Часть XXVI): Работа с отложенными торговыми запросами - первая реализация

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

Расширяем функционал Конструктора стратегий Расширяем функционал Конструктора стратегий

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