Most Expert Advisor bugs that lose money are not in the trading idea. They are in small helper functions: rounding a lot size, deciding whether the current time is inside a trading window, converting a stop distance into points. These functions are easy to get almost right, and the Strategy Tester is a slow and noisy way to find out that they are not.
This tutorial shows a simple way to test those helpers directly, with a script, plain asserts and no framework.
Step 1: keep the math away from the terminal
A function that calls SymbolInfoDouble() inside itself can only be tested on a chart with that symbol and that broker. A function that receives the values as parameters can be tested anywhere.
So instead of this:
double NormalizeLots(double lots) { double step = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_STEP); // ... } write the calculation as a pure function and let the EA pass the symbol values in:
// RiskMath.mqh // Round a volume down to the broker's step and clamp it to the limits. // Returns 0.0 when the result is below the minimum volume, so the caller can skip the trade. double RoundVolumeDown(const double volume, const double step, const double minVolume, const double maxVolume) { if(step <= 0.0 || volume <= 0.0) return 0.0; double v = volume - MathMod(volume, step); if(v > maxVolume) v = maxVolume; if(v < minVolume) return 0.0; return NormalizeDouble(v, 8); } // True when minuteOfDay (0 to 1439) is inside [startMinute, endMinute). // Windows may wrap past midnight, for example 22:00 to 02:00. bool InWindow(const int minuteOfDay, const int startMinute, const int endMinute) { if(startMinute == endMinute) return false; if(startMinute < endMinute) return minuteOfDay >= startMinute && minuteOfDay < endMinute; return minuteOfDay >= startMinute || minuteOfDay < endMinute; } The EA itself stays thin: it reads SYMBOL_VOLUME_STEP, SYMBOL_VOLUME_MIN and SYMBOL_VOLUME_MAX and passes them to RoundVolumeDown().
Step 2: a test script with two tiny assert helpers
Put this in MQL5/Scripts/Tests/TestRiskMath.mq5. A script runs once in OnStart() and prints to the Experts tab, which is all a test runner needs.
#property script_show_inputs false #include <RiskMath.mqh> int g_passed = 0; int g_failed = 0; void CheckTrue(const bool condition, const string name) { if(condition) { g_passed++; return; } g_failed++; PrintFormat("FAIL %s", name); } void CheckNear(const double actual, const double expected, const string name) { if(MathAbs(actual - expected) < 0.000000001) { g_passed++; return; } g_failed++; PrintFormat("FAIL %s: expected %.8f, got %.8f", name, expected, actual); } void OnStart() { // Volume rounding CheckNear(RoundVolumeDown(0.137, 0.01, 0.01, 100.0), 0.13, "rounds down to 0.01 step"); CheckNear(RoundVolumeDown(0.30, 0.10, 0.10, 100.0), 0.30, "exact multiple stays the same"); CheckNear(RoundVolumeDown(1.10, 0.25, 0.25, 100.0), 1.00, "0.25 step"); CheckNear(RoundVolumeDown(0.24, 0.25, 0.25, 100.0), 0.00, "below minimum returns zero"); CheckNear(RoundVolumeDown(250.0, 0.01, 0.01, 100.0), 100.0, "clamped to maximum"); // Trading window, minutes since midnight CheckTrue(InWindow(480, 420, 960), "08:00 inside 07:00 to 16:00"); CheckTrue(!InWindow(960, 420, 960), "end of window is excluded"); CheckTrue(InWindow(1380, 1320, 120), "23:00 inside 22:00 to 02:00"); CheckTrue(InWindow(60, 1320, 120), "01:00 inside 22:00 to 02:00"); CheckTrue(!InWindow(600, 1320, 120), "10:00 outside 22:00 to 02:00"); CheckTrue(!InWindow(600, 600, 600), "empty window is always closed"); PrintFormat("RiskMath tests: %d passed, %d failed", g_passed, g_failed); } Compile it, drag it onto any chart, and read the Experts tab.
Step 3: watch it catch a real bug
Run the script with the RoundVolumeDown() above and one test fails:
FAIL exact multiple stays the same: expected 0.30000000, got 0.20000000 The function looks correct, but 0.3 and 0.1 cannot be stored exactly as doubles, and MathMod(0.3, 0.1) returns 0.09999999999999998 instead of zero. Subtracting that leaves 0.2. On a live account this EA would quietly trade a third less than intended on some sizes, and nothing in a backtest report would point at the reason.
The fix is to count whole steps with a small tolerance and rebuild the volume from the count:
double RoundVolumeDown(const double volume, const double step, const double minVolume, const double maxVolume) { if(step <= 0.0 || volume <= 0.0) return 0.0; double steps = MathFloor(volume / step + 0.0000001); double v = NormalizeDouble(steps / (1.0 / step), 8); if(v > maxVolume) v = maxVolume; if(v < minVolume) return 0.0; return v; } Dividing by the reciprocal of the step gives the same result as multiplying by the step. Run the script again and all eleven tests pass.
Step 4: run the tests where they matter
A script only runs when you remember to run it. Two cheap habits keep the tests alive:
- Run the test script after every change to the include file, before compiling the EA for a client or a live account.
- Add a short self check to the EA's OnInit() that calls the same helpers with a few known inputs and returns INIT_FAILED if any result is wrong. It costs microseconds and stops a broken build from ever trading.
int OnInit() { if(MathAbs(RoundVolumeDown(0.30, 0.10, 0.10, 100.0) - 0.30) > 0.000000001 || !InWindow(60, 1320, 120)) { Print("Self check failed, EA will not start"); return INIT_FAILED; } return INIT_SUCCEEDED; } What to test first
Start with the functions where a small error has a large cost:
- Lot size and volume rounding, including steps like 0.25 and minimums above 0.01.
- Stop and target distances converted to prices, on symbols with 2, 3 and 5 digits.
- Time windows, especially windows that cross midnight and the boundaries at the exact start and end minute.
- Any daily or weekly reset logic, tested with times just before and just after the reset.
None of this needs a framework. Pure functions, a script and two assert helpers catch the kind of bug that otherwise shows up as a strange position size on a live account weeks later.

