preview
Custom Debugging and Profiling Tools for MQL5 Development (Part II): Profiling EAs and Testing Trading Logic

Custom Debugging and Profiling Tools for MQL5 Development (Part II): Profiling EAs and Testing Trading Logic

MetaTrader 5Integration |
840 0
Sahil Bagdi
Sahil Bagdi

Contents:

  1. Introduction
  2. From Logs to Measured Diagnostics
  3. Building a Lightweight Profiler
  4. Profiling an Expert Advisor
  5. Building a Minimal Unit-Test Harness
  6. Testing Pure Trading Logic
  7. Running the Examples and Reading the Reports
  8. Conclusion



Introduction

Part I gave us structured logs: severity levels, handlers, files, and cleaner messages than scattered Print() calls. That helps, but it does not close the diagnostic loop. A log can show that an EA reached a branch; it cannot show whether that branch became expensive or whether the rule behind it still behaves correctly after a change. That gap is where many trading projects become uncomfortable: the EA loads, the Strategy Tester finishes, and the Experts tab looks normal, yet something small has shifted.

This part adds two small tools to make those defects harder to hide. A lightweight profiler measures named sections of code and writes a CSV report. A unit-test harness checks pure trading logic with repeatable assertions. Together they answer two questions that logs alone cannot answer: where did the time go, and which assumptions still hold?

The examples are deliberately modest: they use no DLLs, no database, no external runner, and no chart interface. That is intentional. A diagnostic tool that requires a separate platform is easy to admire and just as easy to ignore. A tool that sits inside an ordinary MQL5 project can be used while the EA is being changed, which is when the feedback matters most.

The workflow has three steps:

  1. Put platform work inside the EA where it belongs.
  2. Move pure calculations into small functions.
  3. Run the tests against those functions first, then run the profiling EA in the Strategy Tester to measure the event-driven parts.

The output is a report that can be saved, compared, and discussed, not a screenshot or a feeling. The package uses these files:

  • PerfMeter.mqh
  • TestLite.mqh
  • TradeMathCore.mqh
  • UnitTestRunner.mq5
  • ProfilerExampleEA.mq5

In the attachment, the files are stored under Debugging_Profiling_Part2 to avoid cluttering the root Include, Scripts, and Experts folders:

  • MQL5\Include\Debugging_Profiling_Part2
  • MQL5\Scripts\Debugging_Profiling_Part2
  • MQL5\Experts\Debugging_Profiling_Part2

These examples are not meant to trade. They are scaffolding for a habit that should survive in a real strategy: log the event, test the rule, and measure the cost before a small defect becomes expensive to explain.


From Logs to Measured Diagnostics

Logs tell the story of an event. They are good at answering questions such as "Did the EA enter this branch?" or "Why was an order blocked?" They are much weaker at answering questions about repeated costs. If CopyBuffer() becomes slightly slower across thousands of calls, a plain log gives noise; a profiler gives count, total time, min, max, and average. Correctness has a similar problem: a log can say that a buy signal was detected, but it cannot prove that the buy signal came from the intended rule.

A moving average crossover can look reasonable in the Experts tab while using the wrong bar index. A volume rule can pass most symbols and still fail at the broker minimum. A stop-distance check can work on a five-digit forex symbol and fail on a two-digit metal quote. For those cases, it helps to separate three kinds of evidence:

  • Logging is observation: it records what the program says it did.
  • Profiling is measurement: it records how often selected work happened and how expensive it was.
  • Unit testing is verification: it checks whether selected rules still return expected results for fixed inputs.

None of these replaces the others. A practical MQL5 split is to keep each responsibility in the right place:

  • Keep platform work in the EA: OnTick() may read symbol properties, copy indicator buffers, check stops, inspect account state, and send trade requests. That event path can be profiled in the Strategy Tester.
  • Move pure rules into small functions: Point conversion, volume normalization, stop checks, and signal classification can be tested with fixed inputs and expected outputs.
  • Name profiler sections clearly: OnTick.copy_buffers is useful; a vague name such as work is not. The report must remain readable after the tester pass has finished.
  • Collect all assertion failures: If point conversion, stop validation, and volume normalization all fail after a change, the test report should show the full pattern in one run.

Together, these tools turn "I think it works" into repeatable evidence. They do not make a strategy profitable, and they do not replace market testing; they make selected behavior visible enough to compare before and after a change.


Building a Lightweight Profiler

PerfMeter.mqh has a narrow job: measure named sections, aggregate elapsed microseconds, and write a CSV report that can be compared later. The design is intentionally plain because the report must stay readable across repeated tester runs:

  • Report fields: Each row shows calls, total time, min and max time, average time, slow-call count, threshold, and status.
  • Timing API: Start()/Stop() handle normal timed blocks, while RecordElapsed() supports cases where the elapsed value is already known.
  • Project history: This helper does not replace MetaEditor profiling. It stores stable project-level measurements beside the source code.
  • Targeted questions: Named sections answer practical suspicions such as buffer-copy cost, multi-symbol loops, or custom filters that run on every tick.
  • Controlled threshold: Slow-call detection is a warning line for one project and one test environment, not a universal performance rule.

The goal is not to create impressive numbers. The goal is to create comparable numbers that can be placed beside a previous report and reviewed row by row.

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

The code matters in six places:

  • Section rows: SPerfSection stores the section name, call count, total elapsed time, min/max time, slow-call count, and the temporary state used by Start() and Stop(). It keeps only the data needed to summarize a section after the run.
  • Automatic registration: FindSection() locates an existing row, while EnsureSection() creates one when a name is first used. That keeps unused paths out of the report.
  • Timing flow: Start() stores the microsecond counter, Stop() calculates elapsed time, and RecordElapsed() updates the aggregate row. The first recorded call initializes the minimum so the fastest value is not falsely reported as zero.
  • Report output: WriteCsvReport() writes an ordinary CSV file after the run. FILE_COMMON is useful for Strategy Tester passes because the output is easier to retrieve and compare later.
  • Stable names: OnTick.copy_buffers is useful; a vague name such as work is not. Specific and stable section names make report comparisons meaningful across versions.
  • Controlled reset: Reset() is useful for independent scenarios, but it should not be called accidentally inside frequent events. Explicit Start() and Stop() calls also keep the profiling flow visible.


Profiling an Expert Advisor

ProfilerExampleEA.mq5 shows the profiler inside a non-trading EA. The EA creates two moving average handles, copies two closed-bar values, classifies a crossover, and logs a signal only when direction changes. It has just enough moving parts to resemble a real EA without hiding the diagnostic pattern behind order management.

The measured sections are chosen deliberately. Indicator handle creation belongs in OnInit(), not inside a repeated tick path. CopyBuffer() calls have their own section because platform access can dominate a simple strategy. Signal calculation has a separate section because pure calculation should usually be cheaper and more predictable than data access. The three inputs expose profiling as controlled behavior rather than permanent overhead:

  • EnableProfiling: turns diagnostic collection on or off.
  • ProfileEveryNTicks: controls how often the tick path is sampled.
  • SlowCallThresholdMicroseconds: marks calls that cross the selected threshold.

Sampling every N ticks reduces measurement noise from tiny blocks. The event handlers keep their responsibilities clear: OnInit() measures handle creation, OnTick() measures selected tick work, and OnTester() plus OnDeinit() write the report. In a real EA, the same pattern means adding stable section names, running a controlled pass, inspecting the CSV, and switching profiling off when the question is answered.

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

After the code, these details are worth checking:

  • Sampling policy: ShouldProfileTick() keeps the sampling decision in one place, so profiling can later move from every tenth tick to new-bar processing or a suspected branch without rewriting OnTick().
  • Report writing: WriteProfilerReport() keeps file output out of the event handlers. OnTester() and OnDeinit() both use the same path, so the EA has one report format and one status message.
  • Closed-bar logic: The EA copies from shift 1 for two bars. Index 0 holds the most recent value in the series array, and index 1 holds the previous value. Those MA values are passed to ClassifyMaCross() in older-to-newer order.
  • Readable logs: The signal message is printed only when a valid direction changes. Repeating the same signal on every tick would train the developer to ignore the Experts tab.
  • Tester result: OnTester() returns the tick count only as a sanity value for this example. In a real optimization, keep the actual optimization criterion and write the profiler report beside it.
  • Threshold interpretation: Compare like with like: same symbol, timeframe, model, date range, machine, and inputs. A row marked OK only says the selected threshold was not crossed in this run.
  • Suspicious rows: A high total time may mean too many calls, a high maximum may be a spike, and a rising average usually means typical work has become more expensive.
  • Measurement hygiene: Keep logging outside tiny measured blocks unless logging cost is part of the question. Otherwise the report may blame the calculation for a cost created by diagnostics.


Building a Minimal Unit-Test Harness

TestLite.mqh is a small assertion runner for deterministic MQL5 checks. It begins a named test group, records assertions, stores failures, writes a text report, and prints a summary. It is not trying to imitate a desktop testing framework. Its job is simpler: make useful checks easy to run from a script before a long tester pass begins.

  • Assertion set: AssertTrue, AssertFalse, AssertEqualsInt, AssertNearDouble, and AssertStringEquals cover booleans, enumerations, prices, pips, points, volume steps, and stable labels.
  • Failure collection: The runner collects failures instead of stopping at the first one, so one broken trading rule can reveal its full damage in a single report.
  • Useful messages: "Volume below minimum clamps up" is useful months later; "Case 1 failed" is not. The expected and actual values are already recorded, so the message should explain why the expected value matters.
  • Plain text output: A report that opens in any editor, copies cleanly into a review, or can be checked by a simple script is more useful than a decorative format.

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

The useful details are:

  • Central result handling: CTestLite keeps suite counters and failure rows, and every assertion goes through RecordResult(). This prevents the assertion methods from drifting into different reporting styles.
  • Double comparisons: AssertNearDouble() is essential for prices, points, pips, indicators, and volume calculations. The tolerance belongs in the test because it documents the acceptable numerical difference.
  • Stable report shape: WriteReport() always writes the same summary fields and failure table. EscapeValue() protects that shape by preventing commas or line breaks from breaking rows.
  • Complete failure pattern: The runner does not stop at the first failure. One bad change can break volume, stop distance, and crossover logic at the same time, so the report should show the full pattern.
  • Deterministic tests: A useful test should fail because a rule changed, not because account balance, spread, open positions, or broker permissions happened to be different.


Testing Pure Trading Logic

TradeMathCore.mqh is the test target used by both the script and the profiling EA. It has no order sending, account reads, or chart calls. That separation is the point: fixed inputs should produce fixed outputs. Once a function has that property, it becomes a good candidate for repeatable tests and a suitable dependency for event-driven code.

The module covers four small but common rules:

  • pip/point conversion
  • volume normalization
  • stop-distance validation
  • moving average crossover classification

These are ordinary helper rules, which is why they deserve tests. When they are scattered across event handlers, small mistakes are difficult to find and easy to repeat:

  • Pip/point conversion: Symbols are not quoted the same way. Keeping the conversion in one helper makes the assumption visible and testable.
  • Volume normalization: Risk control should not depend on casual rounding. The example clamps to broker limits and aligns downward to the valid step so the helper does not increase exposure accidentally.
  • Stop-distance validation: Invalid stops are a classic source of rejected requests. The helper checks entry price, stop price, minimum stop distance, and point size, and it rejects invalid point sizes.
  • Crossover classification: Bar indexing mistakes are easy to miss. The function expects older and newer closed-bar values and returns BUY, SELL, NONE, or INVALID.

The examples are intentionally conservative. They do not attempt to model every broker rule or every symbol type. They show the shape of testable trading logic: small inputs, explicit outputs, and edge cases that are cheap to check before a long Strategy Tester run.

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

The core helper is deliberately narrow. The important pieces are:

  • Explicit signal states: TM_SIGNAL_INVALID is not the same as TM_SIGNAL_NONE. TM_SIGNAL_NONE means the data was sufficient and no crossover was found; TM_SIGNAL_INVALID means the rule could not be evaluated safely.
  • Stable labels: SignalToString() gives the EA consistent text for logs and reports, which keeps comparisons readable across versions.
  • Volume precision: DigitsForStep() estimates the decimal places needed for the volume step before NormalizeVolume() returns the final value.
  • Volume validity: NormalizeVolume() combines clamping with step alignment because a value must fit broker limits and match the broker step.
  • Readable test groups: UnitTestRunner.mq5 groups assertions by rule family. There is no market simulation; the script asks fixed questions and records whether the answers match the expected behavior.

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

The tests cover the rules most likely to break silently:

  • digit-dependent pip conversion,
  • stop distance boundaries,
  • volume clamping and step alignment,
  • insufficient crossover data,
  • stable signal labels.

Use the runner with a few practical habits:

  • Write meaningful messages: Assertion messages should explain the trading reason behind the expected value, not merely restate the value.
  • Test damaged areas first: lot sizing, stop validation, session filters, spread filters, and trailing-stop math are stronger candidates than decorative helpers.
  • Keep inputs deterministic: A business-rule test should not depend on spread, account state, permissions, or positions unless that platform condition is the subject of the test.
  • Add boundary pairs: Test one value just below a limit and one just above it. Boundary pairs catch more mistakes than a single comfortable example.
  • Keep the promise narrow: these tests do not prove a strategy is profitable. They only confirm that helper functions return the agreed answers.


Running the Examples and Reading the Reports

Run the examples in this order:

  1. Run UnitTestRunner.mq5 from MQL5\Scripts\Debugging_Profiling_Part2. It asks TradeMathCore fixed questions and writes a short pass/fail report. If the pure rules are already failing, a long Strategy Tester run will only spend more time producing confusing evidence.
  2. Run ProfilerExampleEA.mq5 from MQL5\Experts\Debugging_Profiling_Part2 in the Strategy Tester. The EA does not trade, so the timing report stays focused on code paths rather than order execution, fills, and trade-management branches.

Before comparing profiler reports, control the tester environment. Keep the same symbol, timeframe, modeling mode, date range, inputs, sampling interval, and machine where possible. If one of those changes, label the run as a different scenario instead of treating it as a regression against the old baseline.

A baseline can be ordinary. Choose one tester setup, save the passing test report and profiler CSV, and treat them as the reference for the next change. The value appears later, when a small edit gives you something concrete to compare instead of a vague memory of how the EA used to behave.

Read the reports in the same order:

  1. Check the unit-test report first; it answers whether selected rules still match expectations.
  2. Check the profiler report second; it answers where selected work spent time during an event-driven run.

Mixing those questions leads to poor conclusions. A fast-but-broken rule remains broken, and a correct rule can become too expensive when called too often. The extracts below are deliberately small; they show the fields to inspect and the conclusion that is safe to draw from each report.

Unit-test report extract:

Field Value
Suite TradeMathCore deterministic tests
Total assertions 21
Passed 21
Failed 0
Status PASS
Failure count 0

Read the unit-test report this way:

  • PASS is narrow: It means the checked rules still match their expected results. It does not say the strategy is good.
  • Failure rows are actionable: Read the message first, then compare expected and actual. The message should name the protected rule, while the values show how behavior moved.
  • Stable shape enables automation: A future version of the project can fail a build, stop an optimization batch, or print a warning when status=FAIL appears.

Profiler report extract:

Section Calls Total us Min us Max us Average us Slow calls Status
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

Read the profiler report with the same discipline:

  • Check the path first: The sampled OnTick rows have 1134 calls because the EA measured every tenth tick during a 11340-tick run. Confirm that this matches the configured interval.
  • Do not overstate OK: No section crossed the 250-microsecond threshold in this run, but that only describes this symbol, timeframe, model, date range, machine, and input set.
  • Treat averages as a baseline: If OnTick.copy_buffers later jumps from 0.10 microseconds average to a much larger value under the same conditions, the comparison becomes actionable.
  • Read fields together: total_us and calls explain cumulative cost, while max_us highlights spikes. A rising average usually points to repeated work becoming heavier.

Strategy Tester facts:

Field Value
Expert ProfilerExampleEA
Symbol and period EURUSD, M15
Date range 2026.05.20 to 2026.05.22
Model 1 minute OHLC
Ticks generated 11340
Bars generated 192
OnTester result 11340
Total trades 0

Do not read profiler rows by status alone. Read the fields together:

  • Total time shows cumulative cost.
  • Average time shows typical cost.
  • Maximum shows spikes.
  • The slow_calls field shows threshold breaches.

A cheap function called too often can still dominate the run, while a slow one-time section may be harmless if it happens only during initialization. The tests do not prove the strategy is profitable; they prove only that selected rules still match selected expectations. That narrower claim is why they are useful.

The safest workflow is to run the checks in this order:

  1. Run tests first, then profile.
  2. If tests fail, fix rules before wasting time on a full backtest.
  3. If profiling regresses against a baseline, inspect the section with the changed total, average time, or max time.

The order matters because performance work on incorrect logic is wasted effort. Keep logging out of tiny profiled blocks unless logging cost is part of the measurement; file output can distort micro-benchmarks. If you need to log a profiled path, measure the calculation and the logging separately so the report does not mix two different costs.

Report names also deserve discipline. A production project may include the symbol, timeframe, model, date range, or strategy variant. These examples use fixed names for simplicity, but fixed names can overwrite evidence when several scenarios are compared.

The file overview below separates the moving parts. The profiler owns measurement, the test helper owns assertions, the trading core owns pure calculations, and the two runnable examples show how the pieces are exercised.

File overview

File Location Purpose
PerfMeter.mqh MQL5\Include\Debugging_Profiling_Part2 measures named sections, aggregates repeated calls, and writes a CSV profile report.
TestLite.mqh MQL5\Include\Debugging_Profiling_Part2 provides deterministic assertions and a text report with summary fields and failure details.
TradeMathCore.mqh MQL5\Include\Debugging_Profiling_Part2 keeps pure trading calculations separate from account, order, and chart state.
UnitTestRunner.mq5 MQL5\Scripts\Debugging_Profiling_Part2 runs repeatable tests against TradeMathCore and writes TradeMathCore_TestReport.txt.
ProfilerExampleEA.mq5 MQL5\Experts\Debugging_Profiling_Part2 profiles initialization, selected tick work, tester finalization, and indicator release.
TradeMathCore_TestReport.txt MQL5\Files shows the test suite status, assertion totals, and any failure rows.
ProfilerExampleEA_Profile.csv Common Files shows calls, total time, min, max, average, slow-call count, and status for each section.

Extend the tests where the strategy has real risk: risk sizing, session filters, spread filters, trailing stops, and position-sizing rules. Extend the profiler where work can grow: new-bar processing, order checks, custom indicator calls, and multi-symbol loops. Avoid measuring everything by default. A profiler is most useful when the section names reflect questions you actually want answered.

The strongest version of this workflow uses a baseline loop:

  1. Save a passing test report and a profiler report for a known version.
  2. After a change, run the same checks again.
  3. Confirm that the tests still pass.
  4. Confirm that the important profiler rows remain stable.

When those checks pass, the change has stronger evidence behind it than a casual "the tester completed."


Conclusion

Part I made events easier to read; this part makes changes easier to judge. PerfMeter.mqh gives selected code paths a timing history, TestLite.mqh gives pure rules a repeatable pass/fail check, and TradeMathCore.mqh keeps trading calculations away from platform state so they can be tested directly.

The main lesson is not that every EA needs a large testing framework or a permanent profiler. The lesson is that important behavior should leave evidence. A log line, a passing assertion, and a profiler row answer different questions. Together they make debugging less dependent on memory and less vulnerable to the comforting phrase "the tester completed."

This matters most after ordinary changes: a new filter, a different indicator period, a small volume rule, or a refactored signal function. Keep the tools small, keep the section names stable, and add coverage where the project has real risk. When the EA changes, the evidence should change with it.

Attached files |
MQL5.zip (11.32 KB)
PerfMeter.mqh (10.24 KB)
TestLite.mqh (12.02 KB)
TradeMathCore.mqh (6.88 KB)
UnitTestRunner.mq5 (6.06 KB)
Formulating Dynamic Multi-Pair EA (Part 9): Market Microstructure Execution Noise Filtering Formulating Dynamic Multi-Pair EA (Part 9): Market Microstructure Execution Noise Filtering
This article presents a multi-symbol execution filter that scores real-time market quality before any trade is allowed. It measures spread behavior, tick velocity, quote gaps, micro-volatility, and a slippage estimate, then classifies the state to block degraded conditions. Once noise settles, a liquidity sweep continuation model evaluates structure shifts so entries occur only when execution is mechanically stable.
Position Management: Scaling Into Winners With A Falling-Risk Pyramid Position Management: Scaling Into Winners With A Falling-Risk Pyramid
We introduce CPyramidBridge, a thin MQL5 layer that maps bet-sizing results to CPyramidEngine. The bridge applies probability to initial lot sizing, enforces a capacity-aware entry gate, promotes add-ons from dynamic divergence, adapts the trailing stop to reserve estimates, and syncs signals on close, allowing an Expert Advisor to convert model confidence and concurrency into a structured, decreasing-risk pyramid.
Building an EquiVolume Indicator in MQL5 Building an EquiVolume Indicator in MQL5
We implement an EquiVolume indicator in MQL5 that converts standard candlesticks into volume-weighted boxes. The workflow includes selecting volume type, detecting the maximum volume within a lookback range, normalizing all values against it, and mapping them into proportional box widths. The result is a chart-based structure that visualizes trading activity intensity alongside price movement in MetaTrader 5.
MQL5 Wizard Techniques you should know (Part 92): Using B-Tree Indexing and a Bayesian NN in a Custom Signal Class MQL5 Wizard Techniques you should know (Part 92): Using B-Tree Indexing and a Bayesian NN in a Custom Signal Class
In this article we present yet another custom MQL5 Signal Class that we are labelling ‘CSignalBTreeBayesian’. We are marrying the algorithm of a balanced tree with a neural network that is built on Bayesian principles to formulate yet another custom signal testable independently or with other signals thanks to the MQL5 Wizard.