Пользовательские инструменты отладки и профилирования для разработки на MQL5 (Часть II): Профилирование советников и тестирование торговой логики
Содержание:
- Введение
- От журналов к диагностике на основе измерений
- Создание легковесного профилировщика
- Профилирование советника
- Создание минимальной среды модульного тестирования
- Тестирование чистой торговой логики
- Запуск примеров и чтение отчетов
- Заключение
Введение
В части I мы получили структурированные журналы: уровни важности, обработчики, файлы и более понятные сообщения вместо разрозненных вызовов Print(). Это помогает, но не завершает диагностический цикл. Журнал может показать, что советник выполнил определённую ветвь кода, но не покажет, стало ли её выполнение затратным или продолжает ли заложенное в ней правило работать корректно после внесения изменений. Из-за этого пробела во многих торговых проектах возникает неопределённость: советник загружается, Тестер стратегий завершает тестирование, на вкладке "Эксперты" всё выглядит нормально, но что-то всё же немного изменилось.
В этой части представлены два небольших инструмента, с которыми таким дефектам будет труднее остаться незамеченными. Легковесный профилировщик измеряет время выполнения именованных участков кода и формирует отчёт в формате CSV. Среда модульного тестирования проверяет чистую торговую логику с помощью воспроизводимых проверок. Вместе они дают ответ на два вопроса, на которые одни только журналы не могут ответить: куда ушло время и какие допущения по-прежнему остаются верными?
Примеры намеренно просты: в них не используются ни DLL, ни база данных, ни внешняя среда запуска тестов, ни интерфейс на графике. Это сделано намеренно. Диагностическим инструментом, требующим отдельной платформы, легко восхищаться — и столь же легко им пренебречь. Инструмент, встроенный в обычный проект MQL5, можно использовать во время доработки советника — именно в этот момент обратная связь имеет наибольшее значение.
Рабочий процесс состоит из трёх этапов:
- Оставьте взаимодействие с платформой в советнике, где ему и место.
- Выносите чистые вычисления в небольшие функции.
- Сначала запустите тесты для этих функций, а затем запустите профилирующий советник в Тестере стратегий, чтобы измерить производительность событийно-ориентированных частей.
Результатом является отчет, который можно сохранить, сравнить и обсудить, а не просто скриншот или какое-то ощущение. Пакет использует следующие файлы:
- PerfMeter.mqh
- TestLite.mqh
- TradeMathCore.mqh
- UnitTestRunner.mq5
- ProfilerExampleEA.mq5
Во вложении файлы размещены в подпапках Debugging_Profiling_Part2, чтобы не загромождать корневые папки Include, Scripts и Experts:
- MQL5\Include\Debugging_Profiling_Part2
- MQL5\Scripts\Debugging_Profiling_Part2
- MQL5\Experts\Debugging_Profiling_Part2
Эти примеры не предназначены для торговли. Они помогают сформировать привычку, которую следует сохранить и в реальной стратегии: записывать события в журнал, тестировать правила и измерять затраты ресурсов, прежде чем выяснение причин небольшого дефекта станет слишком затратным.
От журналов к диагностике на основе измерений
Журналы рассказывают о том, как произошло событие. Они хорошо отвечают на такие вопросы, как "Выполнил ли советник эту ветвь кода?" или "Почему ордер был заблокирован?", но гораздо хуже помогают оценить затраты при многократном выполнении операций. Если CopyBuffer() работает чуть медленнее на протяжении тысяч вызовов, обычный журнал даёт лишь информационный шум, а профилировщик — число вызовов, суммарное, минимальное, максимальное и среднее время выполнения. С корректностью возникает аналогичная проблема: журнал может сообщить об обнаружении сигнала на покупку, но не может доказать, что этот сигнал получен по задуманному правилу.
Пересечение скользящих средних может выглядеть правдоподобно на вкладке "Эксперты", даже если используется неверный индекс бара. Правило расчёта объёма может работать для большинства символов, но давать неверный результат на минимальном объёме, установленном брокером. Проверка расстояния до стопа может работать корректно для валютной пары с пятью знаками после запятой и давать неверный результат для металла с двумя знаками после запятой в котировке. В таких случаях полезно выделить три вида доказательств:
- Ведение журнала — это наблюдение: в нём фиксируется то, что, по словам программы, она сделала.
- Профилирование — это измерение: оно фиксирует, как часто выполнялись выбранные операции и сколько ресурсов они потребовали.
- Модульное тестирование — это проверка: оно позволяет убедиться, что выбранные правила по-прежнему возвращают ожидаемые результаты при фиксированных входных данных.
Ни один из этих подходов не заменяет остальные. На практике в MQL5 удобно разделять зоны ответственности следующим образом:
- Оставьте взаимодействие с платформой в советнике: OnTick() может считывать свойства символа, копировать буферы индикаторов, проверять стопы, анализировать состояние счёта и отправлять торговые запросы. Этот путь обработки события можно профилировать в Тестере стратегий.
- Вынесите чистые правила в небольшие функции: пересчёт пунктов, нормализацию объёма, проверки стопов и классификацию сигналов можно тестировать на фиксированных входных данных с ожидаемыми результатами.
- Давайте участкам кода профилировщика понятные имена: OnTick.copy_buffers — информативное имя, а расплывчатое work — нет. Отчёт должен оставаться понятным и после завершения прогона в Тестере стратегий.
- Фиксируйте все невыполненные утверждения: если после изменения пересчёт пунктов, проверка стопов и нормализация объёма дают неверные результаты, отчёт о тестировании должен показывать полную картину за один прогон.
Вместе эти инструменты превращают "Думаю, это работает" в воспроизводимые доказательства. Они не делают стратегию прибыльной и не заменяют тестирование на рынке; они делают выбранные аспекты поведения достаточно наглядными, чтобы их можно было сравнить до и после изменения.
Создание легковесного профилировщика
У PerfMeter.mqh узкая задача: измерять время выполнения именованных участков кода, накапливать затраченное время в микросекундах и записывать CSV-отчёт для последующего сравнения. Реализация намеренно проста: отчёт должен оставаться удобным для чтения при повторных прогонах в Тестере стратегий:
- Поля отчета: в каждой строке отображаются количество вызовов, общее время, минимальное и максимальное время, среднее время, количество медленных вызовов, пороговое значение и статус.
- API измерения времени: Start() и Stop() используются для измерения времени выполнения обычных блоков кода, а RecordElapsed() — в случаях, когда затраченное время уже известно.
- История проекта: этот вспомогательный инструмент не заменяет профилирование в MetaEditor. Он позволяет сохранять сопоставимые результаты измерений на уровне проекта рядом с исходным кодом.
- Конкретные вопросы: именованные участки кода позволяют проверить практические предположения о затратах времени на копирование буфера, циклы по нескольким символам или пользовательские фильтры, выполняемые на каждом тике.
- Настраиваемый порог: выявление медленных вызовов служит предупредительным сигналом для конкретного проекта и конкретной тестовой среды, а не универсальным критерием производительности.
Цель заключается не в том, чтобы добиться впечатляющих цифр. Цель — получить сопоставимые числовые результаты, которые можно сравнить с предыдущим отчётом строка за строкой.
PerfMeter.mqh
#property strict //+------------------------------------------------------------------+ //| Structure: SPerfSection | //| Stores aggregate timing data for one named profiling section. | //+------------------------------------------------------------------+ struct SPerfSection { string name; //--- Section name shown in the CSV report ulong calls; //--- Number of completed measurements ulong total_us; //--- Total elapsed microseconds ulong min_us; //--- Fastest observed call ulong max_us; //--- Slowest observed call ulong slow_calls; //--- Calls that crossed the threshold ulong active_started_us; //--- Start timestamp for Start()/Stop() bool active; //--- True while a section is running }; //+------------------------------------------------------------------+ //| Class: CPerfMeter | //| Lightweight profiler for named script and EA code sections. | //+------------------------------------------------------------------+ class CPerfMeter { private: SPerfSection m_sections[]; long m_slow_threshold_us; //+------------------------------------------------------------------+ //| Find section by name | //+------------------------------------------------------------------+ int FindSection(const string name) const { //--- Section names are the stable keys used later in the CSV report. for(int i = 0; i < ArraySize(m_sections); i++) { if(m_sections[i].name == name) return i; } return -1; } //+------------------------------------------------------------------+ //| Return existing section or create a new one | //+------------------------------------------------------------------+ int EnsureSection(const string name) { int index = FindSection(name); if(index >= 0) return index; //--- A section is registered on first use so unused code paths stay out of the report. int size = ArraySize(m_sections); ArrayResize(m_sections, size + 1); m_sections[size].name = name; m_sections[size].calls = 0; m_sections[size].total_us = 0; m_sections[size].min_us = 0; m_sections[size].max_us = 0; m_sections[size].slow_calls = 0; m_sections[size].active_started_us = 0; m_sections[size].active = false; return size; } //+------------------------------------------------------------------+ //| Select per-call threshold | //+------------------------------------------------------------------+ long EffectiveThreshold(const long override_threshold_us) const { //--- A caller can override the class threshold for one measurement. if(override_threshold_us >= 0) return override_threshold_us; return m_slow_threshold_us; } public: //+------------------------------------------------------------------+ //| Constructor | //+------------------------------------------------------------------+ CPerfMeter(void) { //--- A zero threshold means slow-call counting is disabled until configured. m_slow_threshold_us = 0; } //+------------------------------------------------------------------+ //| Reset all profiler sections | //+------------------------------------------------------------------+ void Reset(void) { //--- Remove all collected rows so the next run starts from a clean profile. ArrayResize(m_sections, 0); } //+------------------------------------------------------------------+ //| Set slow-call threshold in microseconds | //+------------------------------------------------------------------+ void SetSlowCallThreshold(const long threshold_us) { //--- Store the default threshold used by Stop() and RecordElapsed(). m_slow_threshold_us = threshold_us; } //+------------------------------------------------------------------+ //| Return current slow-call threshold | //+------------------------------------------------------------------+ long SlowCallThreshold(void) const { //--- Expose the current threshold for status messages or test checks. return m_slow_threshold_us; } //+------------------------------------------------------------------+ //| Return number of tracked sections | //+------------------------------------------------------------------+ int SectionCount(void) const { //--- The section count is useful for quick sanity checks after a run. return ArraySize(m_sections); } //+------------------------------------------------------------------+ //| Start measuring a named section | //+------------------------------------------------------------------+ bool Start(const string section_name) { if(section_name == "") return false; //--- Store the current microsecond counter; Stop() will turn it into elapsed time. int index = EnsureSection(section_name); m_sections[index].active_started_us = GetMicrosecondCount(); m_sections[index].active = true; return true; } //+------------------------------------------------------------------+ //| Stop measuring a named section | //+------------------------------------------------------------------+ ulong Stop(const string section_name, const long slow_threshold_us = -1) { int index = FindSection(section_name); if(index < 0 || !m_sections[index].active) return 0; //--- Finish the active measurement and add it to the aggregate row. ulong now_us = GetMicrosecondCount(); ulong elapsed_us = now_us - m_sections[index].active_started_us; m_sections[index].active = false; RecordElapsed(section_name, elapsed_us, slow_threshold_us); return elapsed_us; } //+------------------------------------------------------------------+ //| Manually record an elapsed time | //+------------------------------------------------------------------+ bool RecordElapsed(const string section_name, const ulong elapsed_us, const long slow_threshold_us = -1) { if(section_name == "") return false; int index = EnsureSection(section_name); SPerfSection row = m_sections[index]; //--- Work on a local copy, then write it back after all aggregate fields are updated. row.calls++; row.total_us += elapsed_us; //--- The first call initializes the minimum; later calls can only reduce it. if(row.calls == 1 || elapsed_us < row.min_us) row.min_us = elapsed_us; if(elapsed_us > row.max_us) row.max_us = elapsed_us; //--- Slow-call counting is deliberately simple: one threshold per recorded call. long threshold = EffectiveThreshold(slow_threshold_us); if(threshold > 0 && elapsed_us >= (ulong)threshold) row.slow_calls++; m_sections[index] = row; return true; } //+------------------------------------------------------------------+ //| Write profiler results as CSV | //+------------------------------------------------------------------+ bool WriteCsvReport(const string file_name, const bool common_file = false) { if(file_name == "") return false; //--- FILE_COMMON is useful for Strategy Tester runs because the report is easier to find. int flags = FILE_WRITE | FILE_CSV | FILE_ANSI; if(common_file) flags |= FILE_COMMON; int handle = FileOpen(file_name, flags, ','); if(handle == INVALID_HANDLE) { PrintFormat("CPerfMeter: cannot open report '%s', error=%d", file_name, GetLastError()); return false; } //--- Keep the header stable so reports can be compared by scripts or spreadsheets. FileWrite(handle, "section", "calls", "total_us", "min_us", "max_us", "avg_us", "slow_calls", "threshold_us", "status"); for(int i = 0; i < ArraySize(m_sections); i++) { //--- Average is calculated at report time so the stored row stays compact. double average_us = 0.0; if(m_sections[i].calls > 0) average_us = (double)m_sections[i].total_us / (double)m_sections[i].calls; FileWrite(handle, m_sections[i].name, (long)m_sections[i].calls, (long)m_sections[i].total_us, (long)m_sections[i].min_us, (long)m_sections[i].max_us, DoubleToString(average_us, 2), (long)m_sections[i].slow_calls, m_slow_threshold_us, (m_sections[i].slow_calls > 0 ? "SLOW" : "OK")); } FileFlush(handle); FileClose(handle); return true; } //+------------------------------------------------------------------+ //| Build a short profiler summary string | //+------------------------------------------------------------------+ string Summary(void) const { //--- The summary is short enough for the Experts tab and report status messages. ulong calls = 0; ulong slow_calls = 0; for(int i = 0; i < ArraySize(m_sections); i++) { calls += m_sections[i].calls; slow_calls += m_sections[i].slow_calls; } return StringFormat("sections=%d, calls=%d, slow_calls=%d", ArraySize(m_sections), (int)calls, (int)slow_calls); } };
В коде стоит обратить внимание на шесть ключевых моментов:
- Строки данных об участках кода: SPerfSection хранит имя участка кода, количество вызовов, суммарное время выполнения, минимальное и максимальное время, количество медленных вызовов, а также временное состояние, используемое функциями Start() и Stop(). В SPerfSection хранятся только данные, необходимые для подведения итогов по участку кода после завершения прогона.
- Автоматическая регистрация: функция FindSection() находит существующую строку, а функция EnsureSection() создаёт её при первом использовании имени. Благодаря этому в отчёт не попадают неиспользуемые пути выполнения.
- Порядок измерения времени: Start() сохраняет значение счётчика микросекунд, Stop() вычисляет затраченное время, а RecordElapsed() обновляет строку сводных данных. Первый зарегистрированный вызов задаёт начальное минимальное время, чтобы время самого быстрого вызова не отображалось ошибочно как нулевое.
- Вывод отчета: функция WriteCsvReport() после выполнения записывает обычный файл в формате CSV. FILE_COMMON удобно использовать при запусках Тестера стратегий, так как выходные файлы проще найти и сравнить впоследствии.
- Стабильные имена: имя OnTick.copy_buffers полезно, а расплывчатое имя вроде work — нет. Конкретные и стабильные имена участков кода позволяют содержательно сравнивать отчеты для разных версий.
- Управляемый сброс: функция Reset() полезна для независимых сценариев, но следует избегать ее случайного вызова в обработчиках частых событий. Явные вызовы Start() и Stop() также позволяют отслеживать ход профилирования.
Профилирование советника
Файл ProfilerExampleEA.mq5 демонстрирует работу профилировщика внутри неторгового советника. Советник создает два дескриптора скользящих средних, копирует значения двух закрытых баров, определяет тип пересечения и записывает сигнал в журнал только при смене направления. В этом советнике ровно столько компонентов, чтобы он напоминал реальный советник, но при этом схема диагностики не терялась за логикой управления ордерами.
Измеряемые участки кода выбраны осознанно. Дескрипторы индикаторов следует создавать в OnInit(), а не на многократно выполняемом пути обработки тика. Для вызовов CopyBuffer() выделен отдельный участок кода, поскольку обращения к платформе могут занимать основную часть времени выполнения простой стратегии. Для расчета сигнала выделен отдельный участок кода, поскольку чистые вычисления обычно должны быть менее затратными и более предсказуемыми, чем доступ к данным. Три входных параметра позволяют управлять профилированием, чтобы оно не создавало постоянных накладных расходов:
- EnableProfiling: включает или отключает сбор диагностических данных.
- ProfileEveryNTicks: задает частоту выборочных измерений на пути обработки тика.
- SlowCallThresholdMicroseconds: отмечает вызовы, время выполнения которых превышает заданный порог.
Выборочные измерения каждые N тиков снижают шум при измерении небольших блоков кода. Обязанности обработчиков событий четко распределены: OnInit() измеряет время создания дескрипторов, OnTick() — время выполнения выбранных операций при обработке тика, а OnTester() и OnDeinit() записывают отчет. В реальном советнике эта же схема предполагает добавление постоянных имён участков кода, выполнение контролируемого прогона, анализ CSV-файла и отключение профилирования после получения ответа на поставленный вопрос.
ProfilerExampleEA.mq5
#property strict #include <Debugging_Profiling_Part2/PerfMeter.mqh> #include <Debugging_Profiling_Part2/TradeMathCore.mqh> input bool EnableProfiling = true; input int ProfileEveryNTicks = 10; input int SlowCallThresholdMicroseconds = 250; input int FastMAPeriod = 10; input int SlowMAPeriod = 30; CPerfMeter g_profiler; int g_fast_ma_handle = INVALID_HANDLE; int g_slow_ma_handle = INVALID_HANDLE; ulong g_ticks_seen = 0; TradeMathSignal g_last_signal = TM_SIGNAL_NONE; string g_report_name = "ProfilerExampleEA_Profile.csv"; //+------------------------------------------------------------------+ //| Decide whether the current tick should be sampled | //+------------------------------------------------------------------+ bool ShouldProfileTick(void) { //--- Keep the sampling rule in one place so profiling can be changed safely later. if(!EnableProfiling) return false; if(ProfileEveryNTicks <= 1) return true; return ((g_ticks_seen % (ulong)ProfileEveryNTicks) == 0); } //+------------------------------------------------------------------+ //| Write profiler CSV report | //+------------------------------------------------------------------+ void WriteProfilerReport(const string origin) { if(!EnableProfiling) return; //--- Both OnTester() and OnDeinit() call this path so the report format stays identical. bool report_written = g_profiler.WriteCsvReport(g_report_name, true); if(report_written) PrintFormat("ProfilerExampleEA: %s profile report saved: %s in FILE_COMMON. %s", origin, g_report_name, g_profiler.Summary()); else PrintFormat("ProfilerExampleEA: %s profile report could not be written: %s. %s", origin, g_report_name, g_profiler.Summary()); } //+------------------------------------------------------------------+ //| Expert initialization function | //+------------------------------------------------------------------+ int OnInit() { if(EnableProfiling) { //--- Reset at initialization so each tester run starts with an empty profile. g_profiler.Reset(); g_profiler.SetSlowCallThreshold(SlowCallThresholdMicroseconds); g_profiler.Start("OnInit.total"); g_profiler.Start("OnInit.create_indicators"); } //--- Indicator handles are created once; creating them inside OnTick() would hide cost. g_fast_ma_handle = iMA(_Symbol, _Period, FastMAPeriod, 0, MODE_EMA, PRICE_CLOSE); g_slow_ma_handle = iMA(_Symbol, _Period, SlowMAPeriod, 0, MODE_EMA, PRICE_CLOSE); if(EnableProfiling) { g_profiler.Stop("OnInit.create_indicators"); g_profiler.Stop("OnInit.total"); } if(g_fast_ma_handle == INVALID_HANDLE || g_slow_ma_handle == INVALID_HANDLE) { //--- A failed handle would make every later CopyBuffer() call meaningless. PrintFormat("ProfilerExampleEA: failed to create MA handles, error=%d", GetLastError()); return INIT_FAILED; } PrintFormat("ProfilerExampleEA initialized on %s %s, fast=%d, slow=%d, profiling=%s", _Symbol, EnumToString(_Period), FastMAPeriod, SlowMAPeriod, (EnableProfiling ? "true" : "false")); return INIT_SUCCEEDED; } //+------------------------------------------------------------------+ //| Expert tick function | //+------------------------------------------------------------------+ void OnTick() { g_ticks_seen++; bool profile_this_tick = ShouldProfileTick(); //--- The total sampled section wraps only ticks that pass the sampling policy. if(profile_this_tick) g_profiler.Start("OnTick.total_sampled"); double fast_ma[]; double slow_ma[]; //--- Series arrays place the most recent copied value at index 0. ArraySetAsSeries(fast_ma, true); ArraySetAsSeries(slow_ma, true); if(profile_this_tick) g_profiler.Start("OnTick.copy_buffers"); //--- Shift 1 skips the currently forming bar, so both copied values are closed bars. int fast_copied = CopyBuffer(g_fast_ma_handle, 0, 1, 2, fast_ma); int slow_copied = CopyBuffer(g_slow_ma_handle, 0, 1, 2, slow_ma); if(profile_this_tick) g_profiler.Stop("OnTick.copy_buffers"); if(fast_copied < 2 || slow_copied < 2) { //--- Stop the outer timer before leaving early so partial ticks do not stay active. if(profile_this_tick) g_profiler.Stop("OnTick.total_sampled"); return; } if(profile_this_tick) g_profiler.Start("OnTick.signal_calculation"); //--- Pass values in older-to-newer order even though the series array stores newest first. TradeMathSignal signal = CTradeMathCore::ClassifyMaCross(fast_ma[1], slow_ma[1], fast_ma[0], slow_ma[0], 2); if(profile_this_tick) g_profiler.Stop("OnTick.signal_calculation"); if(profile_this_tick) g_profiler.Start("OnTick.decision_block"); //--- Log only meaningful direction changes; repeated messages make the Experts tab noisy. if(signal != TM_SIGNAL_NONE && signal != TM_SIGNAL_INVALID && signal != g_last_signal) { PrintFormat("ProfilerExampleEA: %s signal at tick=%d, fast_new=%.5f, slow_new=%.5f", CTradeMathCore::SignalToString(signal), (int)g_ticks_seen, fast_ma[0], slow_ma[0]); } //--- INVALID means the rule could not be evaluated, so it should not become state. if(signal != TM_SIGNAL_INVALID) g_last_signal = signal; if(profile_this_tick) { g_profiler.Stop("OnTick.decision_block"); g_profiler.Stop("OnTick.total_sampled"); } } //+------------------------------------------------------------------+ //| Tester result function | //+------------------------------------------------------------------+ double OnTester() { if(EnableProfiling) { //--- OnTester() is a reliable place to leave a report after a Strategy Tester pass. g_profiler.Start("OnTester.total"); g_profiler.Stop("OnTester.total"); WriteProfilerReport("OnTester"); } //--- The tick count is only a sanity result for this example EA. return (double)g_ticks_seen; } //+------------------------------------------------------------------+ //| Expert deinitialization function | //+------------------------------------------------------------------+ void OnDeinit(const int reason) { if(EnableProfiling) g_profiler.Start("OnDeinit.release_indicators"); //--- Release handles explicitly so the profiler can measure cleanup as its own section. if(g_fast_ma_handle != INVALID_HANDLE) { IndicatorRelease(g_fast_ma_handle); g_fast_ma_handle = INVALID_HANDLE; } if(g_slow_ma_handle != INVALID_HANDLE) { IndicatorRelease(g_slow_ma_handle); g_slow_ma_handle = INVALID_HANDLE; } if(EnableProfiling) { g_profiler.Stop("OnDeinit.release_indicators"); WriteProfilerReport("OnDeinit"); } PrintFormat("ProfilerExampleEA deinitialized, reason=%d, ticks=%d", reason, (int)g_ticks_seen); }
После рассмотрения кода стоит проверить следующие моменты:
- Правила выборки: функция ShouldProfileTick() сосредоточивает логику выбора тиков для профилирования в одном месте, поэтому впоследствии можно перейти от профилирования каждого десятого тика к профилированию обработки нового бара или подозрительной ветви кода, не переписывая OnTick().
- Запись отчёта: функция WriteProfilerReport() выносит вывод в файл за пределы обработчиков событий. Функции OnTester() и OnDeinit() используют одну и ту же процедуру, поэтому у советника единый формат отчёта и единое сообщение о состоянии.
- Логика на закрытых барах: советник копирует данные двух баров, начиная со сдвига (индекса бара) 1. Элемент массива временного ряда с индексом 0 содержит самое последнее значение, а элемент с индексом 1 — предыдущее. Эти значения скользящих средних (MA) передаются в функцию ClassifyMaCross() в порядке от более старых к более новым.
- Удобные для чтения журналы: сообщение о сигнале выводится только при изменении допустимого направления. Повторение одного и того же сигнала при каждом тике приучило бы разработчика игнорировать вкладку "Эксперты".
- Результат теста: функция OnTester() возвращает количество тиков исключительно в качестве контрольного значения для данного примера. При реальной оптимизации сохраняйте используемый критерий оптимизации, а отчёт профилировщика записывайте параллельно.
- Интерпретация пороговых значений: сравнивайте сопоставимые результаты: символ, таймфрейм, модель, диапазон дат, компьютер и входные параметры должны быть одинаковыми. Строка с пометкой "OK" означает лишь то, что в данном прогоне выбранный порог не был превышен.
- Подозрительные строки: большое суммарное время может указывать на слишком большое число вызовов, высокое максимальное время — на разовый скачок, а рост среднего времени обычно означает, что типичная работа стала занимать больше времени.
- Гигиена измерений: выносите запись в журнал за пределы коротких измеряемых блоков, если только затраты на неё не являются предметом исследования. Иначе отчёт может приписать вычислениям затраты, вызванные диагностикой.
Создание минимальной среды модульного тестирования
TestLite.mqh — это компактный раннер утверждений для детерминированных проверок кода на MQL5. Он начинает именованную группу тестов, фиксирует результаты проверок, сохраняет сведения о неудачных проверках, записывает текстовый отчёт и выводит сводку. Он не стремится имитировать среду тестирования для настольных компьютеров. Его задача проще: сделать полезные проверки удобными для запуска из скрипта перед началом длительного прогона в Тестере стратегий.
- Набор проверок: AssertTrue, AssertFalse, AssertEqualsInt, AssertNearDouble и AssertStringEquals охватывают булевы значения, перечисления, цены, пипсы, пункты, шаги объёма и стабильные метки.
- Сбор неудачных проверок: Среда запуска тестов накапливает сведения о неудачных проверках, а не останавливается на первой из них, поэтому один отчёт позволяет увидеть все последствия нарушения одного торгового правила.
- Полезные сообщения: "Объём ниже минимума повышается до минимального значения" будет полезно и через несколько месяцев; "Тест 1 не пройден" — нет. Ожидаемое и фактическое значения уже записаны, поэтому сообщение должно объяснять, почему ожидаемое значение важно.
- Вывод в виде обычного текста: Отчёт, который открывается в любом редакторе, без искажений копируется в материалы ревью или проверяется простым скриптом, полезнее вычурно оформленного отчёта.
TestLite.mqh
#property strict //+------------------------------------------------------------------+ //| Structure: STestLiteFailure | //| Stores one failed assertion row for the text report. | //+------------------------------------------------------------------+ struct STestLiteFailure { string test_name; //--- Current test group string assertion; //--- Assertion method name string expected; //--- Expected value rendered as text string actual; //--- Actual value rendered as text string message; //--- Human-readable failure message }; //+------------------------------------------------------------------+ //| Class: CTestLite | //| Minimal assertion helper for deterministic MQL5 script tests. | //+------------------------------------------------------------------+ class CTestLite { private: string m_suite_name; string m_current_test; int m_total_assertions; int m_passed_assertions; int m_failed_assertions; STestLiteFailure m_failures[]; //+------------------------------------------------------------------+ //| Sanitize values before writing report rows | //+------------------------------------------------------------------+ string EscapeValue(const string value) const { //--- The report is comma-separated, so row values must not contain raw commas or lines. string result = value; StringReplace(result, "\r", " "); StringReplace(result, "\n", " "); StringReplace(result, ",", ";"); return result; } //+------------------------------------------------------------------+ //| Store one failed assertion | //+------------------------------------------------------------------+ void RecordFailure(const string assertion, const string expected, const string actual, const string message) { //--- Failure rows are appended so the full failure pattern is visible after one run. int size = ArraySize(m_failures); ArrayResize(m_failures, size + 1); m_failures[size].test_name = m_current_test; m_failures[size].assertion = assertion; m_failures[size].expected = expected; m_failures[size].actual = actual; m_failures[size].message = message; } //+------------------------------------------------------------------+ //| Update assertion counters and failure details | //+------------------------------------------------------------------+ bool RecordResult(const bool condition, const string assertion, const string expected, const string actual, const string message) { //--- All assertions pass through this method to keep counters and reports consistent. m_total_assertions++; if(condition) { m_passed_assertions++; return true; } m_failed_assertions++; RecordFailure(assertion, expected, actual, message); return false; } public: //+------------------------------------------------------------------+ //| Constructor | //+------------------------------------------------------------------+ CTestLite(const string suite_name = "TestLiteSuite") { Reset(suite_name); } //+------------------------------------------------------------------+ //| Reset suite counters and failure rows | //+------------------------------------------------------------------+ void Reset(const string suite_name) { //--- Reset allows the same helper object to be reused for a fresh suite. m_suite_name = suite_name; m_current_test = ""; m_total_assertions = 0; m_passed_assertions = 0; m_failed_assertions = 0; ArrayResize(m_failures, 0); } //+------------------------------------------------------------------+ //| Begin a named test group | //+------------------------------------------------------------------+ void BeginTest(const string test_name) { m_current_test = test_name; } //+------------------------------------------------------------------+ //| Assert that a condition is true | //+------------------------------------------------------------------+ bool AssertTrue(const bool actual, const string message = "") { //--- Store both the expected value and the actual value as report-friendly text. return RecordResult(actual, "AssertTrue", "true", (actual ? "true" : "false"), message); } //+------------------------------------------------------------------+ //| Assert that a condition is false | //+------------------------------------------------------------------+ bool AssertFalse(const bool actual, const string message = "") { //--- Negate the actual condition so the report still records the original value. return RecordResult(!actual, "AssertFalse", "false", (actual ? "true" : "false"), message); } //+------------------------------------------------------------------+ //| Assert integer equality | //+------------------------------------------------------------------+ bool AssertEqualsInt(const long expected, const long actual, const string message = "") { //--- Integer comparisons cover counts, enum values, and other exact results. return RecordResult(expected == actual, "AssertEqualsInt", IntegerToString(expected), IntegerToString(actual), message); } //+------------------------------------------------------------------+ //| Assert double equality within tolerance | //+------------------------------------------------------------------+ bool AssertNearDouble(const double expected, const double actual, const double tolerance, const string message = "") { //--- Floating-point values should be compared within a tolerance, not with ==. bool close_enough = (MathAbs(expected - actual) <= tolerance); return RecordResult(close_enough, "AssertNearDouble", DoubleToString(expected, 8) + " +/- " + DoubleToString(tolerance, 8), DoubleToString(actual, 8), message); } //+------------------------------------------------------------------+ //| Assert string equality | //+------------------------------------------------------------------+ bool AssertStringEquals(const string expected, const string actual, const string message = "") { //--- Stable strings matter for logs and reports that may be compared later. return RecordResult(expected == actual, "AssertStringEquals", expected, actual, message); } //+------------------------------------------------------------------+ //| Return suite name | //+------------------------------------------------------------------+ string SuiteName(void) const { //--- The suite name identifies the report when several scripts are used. return m_suite_name; } //+------------------------------------------------------------------+ //| Return total assertion count | //+------------------------------------------------------------------+ int TotalAssertions(void) const { //--- Total assertions help detect accidentally skipped test groups. return m_total_assertions; } //+------------------------------------------------------------------+ //| Return passed assertion count | //+------------------------------------------------------------------+ int Passed(void) const { //--- Passed count is reported separately from total for quick review. return m_passed_assertions; } //+------------------------------------------------------------------+ //| Return failed assertion count | //+------------------------------------------------------------------+ int Failed(void) const { //--- A non-zero failed count is the main signal that the suite needs attention. return m_failed_assertions; } //+------------------------------------------------------------------+ //| Return PASS or FAIL status | //+------------------------------------------------------------------+ string Status(void) const { //--- The whole suite is considered failed if any assertion failed. return (m_failed_assertions == 0 ? "PASS" : "FAIL"); } //+------------------------------------------------------------------+ //| Build a one-line suite summary | //+------------------------------------------------------------------+ string Summary(void) const { //--- The one-line summary is short enough to print directly in the Experts tab. return StringFormat("suite=%s, assertions=%d, passed=%d, failed=%d, status=%s", m_suite_name, m_total_assertions, m_passed_assertions, m_failed_assertions, Status()); } //+------------------------------------------------------------------+ //| Write deterministic test report | //+------------------------------------------------------------------+ bool WriteReport(const string file_name, const bool common_file = false) { //--- Text output keeps the report easy to inspect manually and easy to parse later. int flags = FILE_WRITE | FILE_TXT | FILE_ANSI; if(common_file) flags |= FILE_COMMON; int handle = FileOpen(file_name, flags); if(handle == INVALID_HANDLE) { PrintFormat("CTestLite: cannot open report '%s', error=%d", file_name, GetLastError()); return false; } //--- Summary fields appear first so a script can read status without parsing failures. FileWriteString(handle, "suite=" + m_suite_name + "\r\n"); FileWriteString(handle, "total_assertions=" + IntegerToString(m_total_assertions) + "\r\n"); FileWriteString(handle, "passed=" + IntegerToString(m_passed_assertions) + "\r\n"); FileWriteString(handle, "failed=" + IntegerToString(m_failed_assertions) + "\r\n"); FileWriteString(handle, "status=" + Status() + "\r\n"); FileWriteString(handle, "failure_count=" + IntegerToString(ArraySize(m_failures)) + "\r\n"); FileWriteString(handle, "failures_begin\r\n"); FileWriteString(handle, "test_name,assertion,expected,actual,message\r\n"); //--- Failure details follow the summary and use one stable row shape. for(int i = 0; i < ArraySize(m_failures); i++) { FileWriteString(handle, EscapeValue(m_failures[i].test_name) + "," + EscapeValue(m_failures[i].assertion) + "," + EscapeValue(m_failures[i].expected) + "," + EscapeValue(m_failures[i].actual) + "," + EscapeValue(m_failures[i].message) + "\r\n"); } FileWriteString(handle, "failures_end\r\n"); FileFlush(handle); FileClose(handle); return true; } };
Стоит обратить внимание на следующие детали:
- Централизованная обработка результатов: CTestLite хранит счётчики набора тестов и записи о неудачных проверках, а результат каждой проверки передаётся в RecordResult(). Благодаря этому методы проверки не начинают использовать разные стили отчётности.
- Сравнение значений типа double: AssertNearDouble() незаменима при проверке цен, пунктов, пипсов, значений индикаторов и результатов расчёта объёма. Допуск следует задавать в самом тесте, поскольку он документирует допустимую числовую разницу.
- Стабильный формат отчёта: WriteReport() всегда записывает одни и те же сводные поля и таблицу неудачных проверок. EscapeValue() сохраняет эту структуру, не позволяя запятым или символам перевода строки нарушать целостность строк отчёта.
- Полная картина неудачных проверок: Среда запуска тестов не останавливается на первой неудачной проверке. Одно неудачное изменение может одновременно нарушить расчёт объёма, расчёт расстояния до стопа и логику пересечений, поэтому отчёт должен показывать полную картину.
- Детерминированные тесты: Полезный тест должен завершаться неудачей из-за изменения правила, а не из-за того, что баланс счёта, спред, открытые позиции или разрешения брокера случайно оказались другими.
Тестирование чистой торговой логики
TradeMathCore.mqh — это объект тестирования, используемый как скриптом, так и советником для профилирования. В нём нет отправки торговых приказов, чтения данных счёта или вызовов функций для работы с графиком. В этом и заключается смысл такого разделения: фиксированные входные данные должны давать фиксированные выходные данные. Функция с таким свойством хорошо подходит для повторяемых тестов и может использоваться как зависимость в событийно-ориентированном коде.
Модуль реализует четыре простых, но часто используемых правила:
- пересчёт пунктов и пипсов
- нормализация объёма
- проверка расстояния до стопа
- классификация пересечений скользящих средних
Это обычные вспомогательные правила, и именно поэтому они заслуживают тестирования. Когда они разбросаны по различным обработчикам событий, мелкие ошибки трудно обнаружить, а повторить их — очень легко:
- Пересчёт пунктов и пипсов: Формат котировок различается для разных торговых символов. Если вынести пересчёт в отдельную вспомогательную функцию, это допущение станет явным и его можно будет проверить тестами.
- Нормализация объёма: Контроль риска не должен зависеть от произвольного округления. В примере объём ограничивается пределами брокера и выравнивается вниз по допустимому шагу, начиная с минимального объёма, чтобы вспомогательная функция случайно не увеличила риск.
- Проверка расстояния до стопа: Недопустимые стопы — классическая причина отклонения запросов. Вспомогательная функция проверяет цену входа, цену стопа, минимальное расстояние до стопа и размер пункта, отклоняя недопустимые значения размера пункта.
- Классификация пересечений: Ошибки в индексации баров легко упустить из виду. Функция принимает значения для более раннего и более позднего закрытых баров и возвращает BUY, SELL, NONE или INVALID.
В этих примерах намеренно выбран консервативный подход. Они не предназначены для моделирования всех правил брокера или всех типов торговых инструментов. Они показывают, как устроена торговая логика, пригодная для тестирования: небольшой объём входных данных, явно заданные выходные данные и пограничные случаи, которые можно проверить с минимальными затратами перед длительным прогоном в Тестере стратегий.
TradeMathCore.mqh
#property strict //+------------------------------------------------------------------+ //| Enum: TradeMathSignal | //| Explicit signal states returned by the crossover classifier. | //+------------------------------------------------------------------+ enum TradeMathSignal { TM_SIGNAL_INVALID = -1, //--- Not enough data or unsafe input TM_SIGNAL_NONE = 0, //--- Valid data, no crossover TM_SIGNAL_BUY = 1, //--- Bullish crossover TM_SIGNAL_SELL = 2 //--- Bearish crossover }; //+------------------------------------------------------------------+ //| Class: CTradeMathCore | //| Pure trading calculations used by tests and profiling examples. | //+------------------------------------------------------------------+ class CTradeMathCore { private: //+------------------------------------------------------------------+ //| Estimate decimal precision required by a volume step | //+------------------------------------------------------------------+ static int DigitsForStep(const double step) { //--- Invalid broker settings are handled defensively by returning a common default. if(step <= 0.0) return 2; //--- Multiply until the step becomes an integer, which reveals needed decimals. double scaled = step; for(int digits = 0; digits <= 8; digits++) { if(MathAbs(scaled - MathRound(scaled)) < 0.0000001) return digits; scaled *= 10.0; } return 8; } public: //+------------------------------------------------------------------+ //| Return points-per-pip factor for a digit format | //+------------------------------------------------------------------+ static double PipFactorFromDigits(const int digits) { //--- Three- and five-digit quotes usually use ten points per pip. if(digits == 3 || digits == 5) return 10.0; return 1.0; } //+------------------------------------------------------------------+ //| Convert pips to points | //+------------------------------------------------------------------+ static double PipsToPoints(const double pips, const int digits) { //--- Reuse the digit rule so both conversion directions stay consistent. return pips * PipFactorFromDigits(digits); } //+------------------------------------------------------------------+ //| Convert points to pips | //+------------------------------------------------------------------+ static double PointsToPips(const double points, const int digits) { double factor = PipFactorFromDigits(digits); if(factor <= 0.0) return 0.0; //--- The inverse conversion uses the same factor so tests can catch drift. return points / factor; } //+------------------------------------------------------------------+ //| Clamp and align requested volume to symbol limits | //+------------------------------------------------------------------+ static double NormalizeVolume(const double requested, const double min_volume, const double max_volume, const double volume_step) { //--- Invalid symbol settings should fail visibly instead of producing a trade size. if(min_volume <= 0.0 || max_volume < min_volume || volume_step <= 0.0) return 0.0; //--- First clamp to broker limits, then align down to the permitted step. double clamped = requested; if(clamped < min_volume) clamped = min_volume; if(clamped > max_volume) clamped = max_volume; //--- Aligning downward avoids increasing requested exposure by rounding up. double steps = MathFloor(((clamped - min_volume) / volume_step) + 0.0000001); double normalized = min_volume + steps * volume_step; if(normalized < min_volume) normalized = min_volume; if(normalized > max_volume) normalized = max_volume; return NormalizeDouble(normalized, DigitsForStep(volume_step)); } //+------------------------------------------------------------------+ //| Validate minimum stop distance in points | //+------------------------------------------------------------------+ static bool IsStopDistanceValid(const double entry_price, const double stop_price, const int min_stop_points, const double point_size) { //--- A zero point size or negative stops level means the check cannot be trusted. if(point_size <= 0.0 || min_stop_points < 0) return false; //--- Convert price distance to points before comparing with broker requirements. double distance_points = MathAbs(entry_price - stop_price) / point_size; return (distance_points + 0.0000001 >= (double)min_stop_points); } //+------------------------------------------------------------------+ //| Classify a two-bar moving average crossover | //+------------------------------------------------------------------+ static TradeMathSignal ClassifyMaCross(const double fast_older, const double slow_older, const double fast_newer, const double slow_newer, const int bars_available = 2) { //--- Two closed bars are the minimum needed to detect a crossing between bars. if(bars_available < 2) return TM_SIGNAL_INVALID; //--- A buy signal requires the fast line to move from below/equal to above. if(fast_older <= slow_older && fast_newer > slow_newer) return TM_SIGNAL_BUY; //--- A sell signal is the mirror case: from above/equal to below. if(fast_older >= slow_older && fast_newer < slow_newer) return TM_SIGNAL_SELL; return TM_SIGNAL_NONE; } //+------------------------------------------------------------------+ //| Convert signal enum to report-friendly text | //+------------------------------------------------------------------+ static string SignalToString(const TradeMathSignal signal) { //--- Stable labels make logs, reports, and tests easier to compare. switch(signal) { case TM_SIGNAL_BUY: return "BUY"; case TM_SIGNAL_SELL: return "SELL"; case TM_SIGNAL_NONE: return "NONE"; default: return "INVALID"; } } };
Этот вспомогательный модуль намеренно имеет узкую область применения. К ключевым элементам относятся:
- Явно заданные состояния сигнала: TM_SIGNAL_INVALID — не то же самое, что TM_SIGNAL_NONE. TM_SIGNAL_NONE означает, что данных было достаточно и пересечения обнаружено не было; TM_SIGNAL_INVALID означает, что правило не удалось безопасно оценить.
- Стабильные метки: функция SignalToString() обеспечивает советнику единообразный текст для журналов и отчетов, что позволяет легко сравнивать данные между разными версиями.
- Точность объёма: функция DigitsForStep() оценивает количество знаков после запятой, необходимое для шага изменения объёма, прежде чем функция NormalizeVolume() вернёт окончательное значение.
- Допустимость объёма: функция NormalizeVolume() сочетает ограничение значения допустимым диапазоном с выравниванием по шагу объёма, поскольку значение должно укладываться в установленные брокером пределы и соответствовать заданному им шагу.
- Понятная группировка тестов: UnitTestRunner.mq5 группирует проверки по семействам правил. Моделирование рынка не предусмотрено; скрипт задает фиксированные вопросы и фиксирует, соответствуют ли ответы ожидаемому поведению.
UnitTestRunner.mq5
#property strict #include <Debugging_Profiling_Part2/TestLite.mqh> #include <Debugging_Profiling_Part2/TradeMathCore.mqh> //+------------------------------------------------------------------+ //| Test pip and point conversion | //+------------------------------------------------------------------+ void TestPipPointConversion(CTestLite &test) { test.BeginTest("pip and point conversion"); //--- Five-digit symbols use fractional pips, so one pip is ten points. test.AssertNearDouble(100.0, CTradeMathCore::PipsToPoints(10.0, 5), 0.000001, "5-digit symbols use ten points per pip"); test.AssertNearDouble(10.0, CTradeMathCore::PointsToPips(100.0, 5), 0.000001, "5-digit points convert back to pips"); //--- Four- and two-digit symbols keep the point and pip units equal in this helper. test.AssertNearDouble(10.0, CTradeMathCore::PipsToPoints(10.0, 4), 0.000001, "4-digit symbols use one point per pip"); test.AssertNearDouble(12.5, CTradeMathCore::PointsToPips(12.5, 2), 0.000001, "2-digit symbols keep point and pip units equal"); } //+------------------------------------------------------------------+ //| Test stop-distance validation | //+------------------------------------------------------------------+ void TestStopDistance(CTestLite &test) { test.BeginTest("stop distance validation"); //--- Test both sides of the minimum so an off-by-one style change is visible. test.AssertTrue(CTradeMathCore::IsStopDistanceValid(1.10000, 1.09800, 150, 0.00001), "200 points is valid when minimum is 150"); test.AssertFalse(CTradeMathCore::IsStopDistanceValid(1.10000, 1.09900, 150, 0.00001), "100 points is too close"); //--- A two-digit quote checks that the formula is not tied to forex-style decimals. test.AssertTrue(CTradeMathCore::IsStopDistanceValid(1850.00, 1849.50, 50, 0.01), "gold-style two-digit quote can satisfy the minimum"); test.AssertFalse(CTradeMathCore::IsStopDistanceValid(1.10000, 1.09900, 10, 0.0), "zero point size is invalid input"); } //+------------------------------------------------------------------+ //| Test volume normalization | //+------------------------------------------------------------------+ void TestVolumeNormalization(CTestLite &test) { test.BeginTest("volume normalization"); //--- These cases protect the three main rules: clamp low, align to step, clamp high. test.AssertNearDouble(0.01, CTradeMathCore::NormalizeVolume(0.001, 0.01, 5.0, 0.01), 0.0000001, "volume below minimum clamps up"); test.AssertNearDouble(0.02, CTradeMathCore::NormalizeVolume(0.029, 0.01, 5.0, 0.01), 0.0000001, "volume rounds down to the step"); test.AssertNearDouble(5.00, CTradeMathCore::NormalizeVolume(8.0, 0.01, 5.0, 0.01), 0.0000001, "volume above maximum clamps down"); //--- Step alignment starts from the broker minimum, not from zero. test.AssertNearDouble(0.30, CTradeMathCore::NormalizeVolume(0.37, 0.10, 2.0, 0.10), 0.0000001, "step alignment starts at the minimum"); test.AssertNearDouble(0.0, CTradeMathCore::NormalizeVolume(1.0, 0.0, 2.0, 0.1), 0.0000001, "invalid symbol settings return zero"); } //+------------------------------------------------------------------+ //| Test crossover classification | //+------------------------------------------------------------------+ void TestCrossovers(CTestLite &test) { test.BeginTest("moving average crossover classification"); //--- The classifier receives older values first and newer values second. test.AssertEqualsInt(TM_SIGNAL_BUY, CTradeMathCore::ClassifyMaCross(1.0, 1.1, 1.2, 1.1), "fast line moved from below to above"); test.AssertEqualsInt(TM_SIGNAL_SELL, CTradeMathCore::ClassifyMaCross(1.2, 1.1, 1.0, 1.1), "fast line moved from above to below"); test.AssertEqualsInt(TM_SIGNAL_NONE, CTradeMathCore::ClassifyMaCross(1.2, 1.1, 1.3, 1.2), "both bars stayed above"); //--- Not enough bars is different from a valid no-signal result. test.AssertEqualsInt(TM_SIGNAL_INVALID, CTradeMathCore::ClassifyMaCross(1.0, 1.0, 1.0, 1.0, 1), "insufficient bars are reported as invalid"); test.AssertStringEquals("BUY", CTradeMathCore::SignalToString(TM_SIGNAL_BUY), "signal string conversion stays stable"); test.AssertStringEquals("INVALID", CTradeMathCore::SignalToString(TM_SIGNAL_INVALID), "invalid signal has a clear label"); } //+------------------------------------------------------------------+ //| Test floating-point tolerance | //+------------------------------------------------------------------+ void TestFloatingTolerance(CTestLite &test) { test.BeginTest("floating point tolerance checks"); //--- 0.1 + 0.2 is a familiar example where binary floating-point can surprise equality checks. double value = 0.1 + 0.2; test.AssertNearDouble(0.3, value, 0.0000001, "decimal addition should be compared with tolerance"); test.AssertFalse(MathAbs(0.3 - value) > 0.0000001, "the same tolerance can be checked with AssertFalse"); } //+------------------------------------------------------------------+ //| Script start function | //+------------------------------------------------------------------+ void OnStart() { CTestLite test("TradeMathCore deterministic tests"); //--- Each group protects one small trading rule that should not depend on market state. TestPipPointConversion(test); TestStopDistance(test); TestVolumeNormalization(test); TestCrossovers(test); TestFloatingTolerance(test); string report_name = "TradeMathCore_TestReport.txt"; //--- The report is written to MQL5\Files so it is easy to open after the script runs. bool report_written = test.WriteReport(report_name, false); if(report_written) PrintFormat("UnitTestRunner: %s. Report saved: %s", test.Summary(), report_name); else PrintFormat("UnitTestRunner: %s. Report could not be written: %s", test.Summary(), report_name); }
Тесты охватывают те правила, нарушение которых с наибольшей вероятностью пройдет незаметно:
- пересчёт пипсов в зависимости от числа знаков после запятой,
- граничные значения расстояния до стопа,
- ограничение объёма допустимым диапазоном и выравнивание по шагу,
- нехватку данных для определения пересечения,
- стабильные метки сигналов.
При работе со средой запуска тестов придерживайтесь нескольких практических рекомендаций:
- Составляйте содержательные сообщения: сообщения проверок должны объяснять торговую логику, лежащую в основе ожидаемого значения, а не просто повторять само значение.
- Сначала тестируйте проблемные участки: расчёт размера лота, проверка стопов, фильтры торговых сессий, фильтры спреда и расчёты трейлинг-стопов — более подходящие кандидаты для тестирования, чем вспомогательные функции чисто декоративного назначения.
- Используйте детерминированные входные данные: тест бизнес-правила не должен зависеть от спреда, состояния счета, разрешений или позиций, за исключением случаев, когда именно это условие платформы является предметом тестирования.
- Добавьте пары граничных значений: проверьте одно значение чуть ниже предела и одно — чуть выше него. Пары граничных значений позволяют выявить больше ошибок, чем один заведомо беспроблемный пример.
- Не стоит переоценивать значение этих тестов: они не доказывают, что стратегия является прибыльной. Они лишь подтверждают, что вспомогательные функции возвращают ожидаемые результаты.
Запуск примеров и чтение отчётов
Выполните примеры в следующем порядке:
- Запустите скрипт UnitTestRunner.mq5 из папки MQL5\Scripts\Debugging_Profiling_Part2. Он проверяет TradeMathCore на фиксированном наборе входных данных и выводит краткий отчёт об успешных и неудачных тестах. Если тесты чистой логики уже завершаются неудачей, длительный прогон в Тестере стратегий лишь отнимет больше времени и даст результаты, в которых трудно разобраться.
- Запустите файл ProfilerExampleEA.mq5 из папки MQL5\Experts\Debugging_Profiling_Part2 в Тестере стратегий. Советник не торгует, поэтому отчёт о времени выполнения сосредоточен на путях выполнения кода, а не на исполнении ордеров и сделок или ветвях кода, отвечающих за управление торговлей.
Прежде чем сравнивать отчёты профилировщика, обеспечьте одинаковые условия в Тестере стратегий. По возможности используйте один и тот же символ, таймфрейм, режим моделирования, диапазон дат, входные параметры, интервал выборки и компьютер. Если один из этих параметров изменился, пометьте прогон как отдельный сценарий, а не считайте его результаты ухудшением по сравнению с прежним эталоном.
Эталоном может служить и самый обычный вариант. Выберите одну конфигурацию Тестера стратегий, сохраните отчёт об успешном прохождении тестов и CSV-файл профилировщика, а затем используйте их в качестве эталона для следующего изменения. Польза станет очевидна позже: после небольшого изменения у вас будет конкретный эталон для сравнения, а не только смутная память о том, как советник работал раньше.
Читайте отчеты в том же порядке:
- Сначала ознакомьтесь с отчётом о модульных тестах: он показывает, по-прежнему ли выбранные правила соответствуют ожиданиям.
- Во вторую очередь изучите отчёт профилировщика: он показывает, на что было затрачено время при выполнении выбранных операций в ходе событийно-ориентированного запуска.
Смешивание этих вопросов приводит к неверным выводам. Быстрое, но некорректное правило остаётся некорректным, а корректное правило может стать слишком ресурсоёмким при слишком частом вызове. Приведённые ниже выдержки намеренно кратки: они показывают, какие поля нужно проверить и какие выводы можно с уверенностью сделать по каждому отчёту.
Выдержка из отчета о модульных тестах:
| Поле | Значение |
|---|---|
| Набор тестов | TradeMathCore deterministic tests |
| Общее количество проверок (утверждений) | 21 |
| Успешные проверки | 21 |
| Неуспешные проверки | 0 |
| Статус | PASS |
| Количество неуспешных проверок | 0 |
Интерпретируйте отчёт о модульных тестах следующим образом:
- Статус PASS имеет ограниченный смысл: он означает, что проверенные правила по-прежнему дают ожидаемые результаты. Это не означает, что стратегия хороша.
- Строки с ошибками требуют принятия мер: сначала прочтите сообщение, а затем сравните ожидаемые и фактические значения. Сообщение должно указывать, какое правило защищает проверка, а значения — показывать, как изменилось поведение.
- Стабильная структура позволяет автоматизировать обработку: в будущей версии проекта можно будет при появлении status=FAIL завершать сборку с ошибкой, останавливать пакетный запуск оптимизации или выводить предупреждение.
Выдержка из отчета профилировщика:
| Участок кода | Вызовы | Всего, мкс | Минимум, мкс | Максимум, мкс | Среднее, мкс | Медленные вызовы | Статус |
|---|---|---|---|---|---|---|---|
| OnInit.total | 1 | 51 | 51 | 51 | 51,00 | 0 | OK |
| OnInit.create_indicators | 1 | 50 | 50 | 50 | 50,00 | 0 | OK |
| OnTick.total_sampled | 1134 | 428 | 0 | 4 | 0,38 | 0 | OK |
| OnTick.copy_buffers | 1134 | 119 | 0 | 4 | 0,10 | 0 | OK |
| OnTick.signal_calculation | 1134 | 34 | 0 | 1 | 0,03 | 0 | OK |
| OnTick.decision_block | 1134 | 51 | 0 | 1 | 0,04 | 0 | OK |
| OnTester.total | 1 | 0 | 0 | 0 | 0,00 | 0 | OK |
| OnDeinit.release_indicators | 1 | 5 | 5 | 5 | 5,00 | 0 | OK |
Анализируйте отчёт профилировщика столь же методично:
- Сначала проверьте путь обработки тика: в строках OnTick с выборочными измерениями указано 1134 вызова, поскольку советник измерял каждый десятый тик во время прогона из 11 340 тиков. Убедитесь, что это соответствует настроенному интервалу.
- Не переоценивайте статус OK: в этом прогоне ни один участок кода не превысил порог в 250 микросекунд, но это относится только к данному символу, таймфрейму, модели, диапазону дат, компьютеру и набору входных параметров.
- Рассматривайте средние значения как отправную точку: если позже среднее время OnTick.copy_buffers при тех же условиях резко вырастет с 0,10 микросекунды до гораздо большего значения, такое сравнение станет основанием для принятия мер.
- Рассматривайте поля в совокупности: total_us и вызовы позволяют оценить суммарные затраты времени, а max_us указывает на всплески. Рост среднего значения обычно указывает на то, что повторяющиеся операции требуют всё больше времени.
Факты о Тестере стратегий:
| Поле | Значение |
|---|---|
| Советник | ProfilerExampleEA |
| Символ и период | EURUSD, M15 |
| Диапазон дат | 20.05.2026 — 22.05.2026 |
| Модель | OHLC на M1 |
| Сгенерированные тики | 11340 |
| Сгенерированные бары | 192 |
| Результат OnTester | 11340 |
| Общее количество сделок | 0 |
Не следует анализировать строки профилировщика, ориентируясь исключительно на статус. Рассматривайте поля в совокупности:
- Общее время отражает суммарные затраты времени.
- Среднее время отражает типичные затраты времени.
- Максимальное время показывает всплески.
- Поле slow_calls отображает случаи превышения пороговых значений.
Даже быстрая функция при слишком частых вызовах может занимать основную часть времени выполнения, тогда как медленный участок кода, выполняемый однократно, может не представлять проблемы, если он выполняется только при инициализации. Эти тесты не доказывают, что стратегия является прибыльной; они доказывают лишь то, что выбранные правила по-прежнему соответствуют выбранным ожиданиям. Именно эта ограниченность проверяемого утверждения и делает тесты полезными.
Безопаснее всего выполнять проверки в следующем порядке:
- Сначала запустите тесты, а потом проведите профилирование.
- Если тесты завершаются с ошибками, исправьте правила, прежде чем тратить время на полный бэктест.
- Если результаты профилирования ухудшаются по сравнению с базовыми показателями, проверьте участок кода, у которого изменилось суммарное, среднее или максимальное время выполнения.
Порядок имеет значение, поскольку оптимизация производительности некорректной логики — напрасная трата усилий. Не включайте запись в журнал в небольшие профилируемые блоки, если только затраты времени на неё не являются частью измерения; вывод в файл может исказить результаты микробенчмарков. Если нужно вести журнал при выполнении профилируемого пути, измеряйте время вычислений и записи в журнал отдельно, чтобы в отчёте не смешивались два разных вида затрат.
Имена отчётов также следует задавать по определённым правилам. В рабочем проекте имена отчётов могут включать торговый символ, таймфрейм, модель, диапазон дат или вариант стратегии. В этих примерах для простоты используются фиксированные имена, но при сравнении нескольких сценариев это может привести к перезаписи результатов.
В приведённом ниже обзоре файлов показаны отдельные компоненты. Профилировщик отвечает за измерения, вспомогательный инструмент тестирования — за проверки, торговое ядро — за чистые вычисления, а два запускаемых примера показывают, как используются эти компоненты.
Обзор файлов
| Файл | Местоположение | Цель |
|---|---|---|
| PerfMeter.mqh | MQL5\Include\Debugging_Profiling_Part2 | Измеряет время выполнения именованных участков кода, агрегирует данные повторяющихся вызовов и записывает отчёт о профилировании в формате CSV. |
| TestLite.mqh | MQL5\Include\Debugging_Profiling_Part2 | Предоставляет детерминированные проверки и текстовый отчёт со сводными полями и подробными сведениями о неудачных проверках. |
| TradeMathCore.mqh | MQL5\Include\Debugging_Profiling_Part2 | Отделяет чистые торговые вычисления от состояния счёта, ордеров и графика. |
| UnitTestRunner.mq5 | MQL5\Scripts\Debugging_Profiling_Part2 | Выполняет повторяемые тесты для TradeMathCore и сохраняет файл TradeMathCore_TestReport.txt. |
| ProfilerExampleEA.mq5 | MQL5\Experts\Debugging_Profiling_Part2 | Профилирует инициализацию, отдельные операции обработки тиков, завершение работы Тестера стратегий и освобождение хэндлов индикатора. |
| TradeMathCore_TestReport.txt | MQL5\Files | Показывает статус набора тестов, общее количество проверок и строки со сведениями о неудачных проверках, если они есть. |
| ProfilerExampleEA_Profile.csv | Общие файлы | Отображает количество вызовов, общее время, минимальное, максимальное и среднее время, количество медленных вызовов и статус для каждого участка кода. |
Расширьте тестирование тех аспектов стратегии, которые сопряжены с реальным риском: расчёт размера риска/позиции, фильтры сессий, фильтры спреда, трейлинг-стопы и правила определения размера позиции. Расширьте охват профилировщика там, где объём работы может увеличиваться: обработка новых баров, проверки ордеров, вызовы пользовательских индикаторов и циклы по нескольким торговым инструментам. Не стоит по умолчанию измерять всё. Профилировщик наиболее полезен в тех случаях, когда названия участков кода отражают вопросы, на которые вы действительно хотите получить ответы.
Наиболее эффективный вариант этого рабочего процесса — цикл сравнения с эталонными результатами:
- Сохраните отчёт об успешном прохождении тестов и отчёт профилировщика для известной версии.
- После внесения изменений повторите те же проверки.
- Убедитесь, что тесты по-прежнему проходят успешно.
- Убедитесь, что значения в важных строках профилировщика остаются стабильными.
Если эти проверки проходят успешно, обоснованность изменения подтверждается весомее, чем простой фразой "Тестер стратегий завершил работу".
Заключение
Часть I облегчила восприятие событий; эта часть облегчает оценку изменений. PerfMeter.mqh сохраняет историю времени выполнения выбранных путей кода, TestLite.mqh позволяет повторяемо проверять чистые правила с результатом "успех/неудача", а TradeMathCore.mqh изолирует торговые вычисления от состояния платформы, что позволяет тестировать их напрямую.
Главный вывод заключается не в том, что каждому советнику требуется обширная среда тестирования или постоянно работающий профилировщик. Главный вывод в том, что важные аспекты поведения программы должны оставлять проверяемые свидетельства. Строка журнала, успешная проверка (выполненное утверждение) и строка профилировщика дают ответы на разные вопросы. Вместе они делают отладку менее зависимой от памяти разработчика и снижают риск поддаться успокаивающей фразе "Тестер стратегий завершил работу".
Это особенно важно после обычных изменений: добавления нового фильтра, изменения периода индикатора, введения небольшого правила расчёта объёма или рефакторинга сигнальной функции. Сохраняйте инструменты компактными, не меняйте названия участков кода и расширяйте покрытие тестами там, где в проекте есть реальные риски. Когда меняется советник, свидетельства его работы должны меняться вместе с ним.
Перевод с английского произведен MetaQuotes Ltd.
Оригинальная статья: https://www.mql5.com/en/articles/19154
Предупреждение: все права на данные материалы принадлежат MetaQuotes Ltd. Полная или частичная перепечатка запрещена.
Данная статья написана пользователем сайта и отражает его личную точку зрения. Компания MetaQuotes Ltd не несет ответственности за достоверность представленной информации, а также за возможные последствия использования описанных решений, стратегий или рекомендаций.
Особенности написания Пользовательских Индикаторов
Фундаментальные модели для торговли (Часть III): Запуск генеративной ИИ-модели прогнозирования в MetaTrader 5
Конструирование признаков для машинного обучения (Часть 5): Микроструктурные признаки на Python
- Бесплатные приложения для трейдинга
- 8 000+ сигналов для копирования
- Экономические новости для анализа финансовых рынков
Вы принимаете политику сайта и условия использования