Property-Based Testing for Trading Rules in MQL5 (Part 1): Generate Market Scenarios, Test Invariants, Find Edge Cases
Contents are listed below.
- Introduction: one example is not a rule
- From example checks to executable properties
- A pure risk-volume model
- Generating scenarios and checking properties
- Finding, replaying, and shrinking a defect
- Conclusion
Introduction: one example is not a rule
A risk-based volume function can pass three examples and still increase a request at a rounding boundary. A trader who has checked a small account, a familiar currency pair, and a normal stop distance has evidence for those inputs. Fractional steps, minimum-volume transitions, unusual tick values, and large caps remain separate questions. A profitable backtest does not systematically exercise those arithmetic boundaries.
This article builds a deterministic MQL5 test harness for a simplified pre-cost sizing rule. Eight properties check generated scenarios, while fixed tests cover known boundaries and invalid inputs. Deliberately wrong outputs test whether the checker catches the errors it is meant to detect.
A separate demonstration finds, replays, and shrinks an intentional nearest-step rounding defect. The scripts place no orders and require no broker connection. When a check fails, we investigate the test machinery as well as the sizing rule; the failure alone does not identify which component is wrong.
The starting problem is confidence based on a few selected examples. The result is a repeatable way to challenge a stated calculation contract and preserve useful failures. That distinction matters for a trader maintaining an Expert Advisor: arithmetic evidence can support a sizing component, but it cannot establish profitability, successful execution, or the maximum loss of a live position.
From example checks to executable properties
An example test supplies one input and an expected output. A property supplies a requirement that can be checked across many inputs. For instance, a normalized quantity must not exceed the requested quantity, and widening a stop while keeping the budget unchanged must not increase the chosen volume. The original QuickCheck paper by Koen Claessen and John Hughes describes executable specifications tested with generated data.
- Generator: constructs inputs within a declared domain.
- Property: states a required relationship or outcome.
- Counterexample: an input for which a check fails.
- Shrinker: searches for a simpler input that continues to fail.
Generated testing is useful when an exact list of interesting inputs is difficult to enumerate. Fixed examples remain useful when a boundary is already known. We use both. Random generation searches a broad finite sample; deterministic regressions ensure that important cases cannot disappear because a new seed happens to miss them. Passing either campaign is evidence for the tested requirements, not a proof that the implementation is free from defects.
MQL5 already has examples of deterministic utility testing. Here we generate constrained scenarios and transform selected inputs to check how the result should change. We also challenge the checker with wrong outputs and shrink a reproducible failure to a simpler example. Fixed regression tests remain part of this workflow.
The generator needs private, reproducible state. MQL5 has MathSrand() and MathRand(). This package instead uses CSeededRng, whose state is unaffected by unrelated random calls. SeededRng.mqh implements the xorshift32 update described in George Marsaglia's Xorshift RNGs paper. The state update is shown below.
//+------------------------------------------------------------------+ //| Advance the xorshift32 state | //+------------------------------------------------------------------+ uint NextUIntInternal(void) { //--- Advance the private xorshift32 state. uint value = m_state; value ^= value << 13; value ^= value >> 17; value ^= value << 5; m_state = value; return value; }
Xorshift32 cannot escape an all-zero state, so SetSeed() substitutes a documented nonzero seed. NextInt() rejects values in the upper tail before mapping the remaining values to an integer range. These choices are reproducible; we make no claim of exact uniformity or statistical independence. The generator is not intended for cryptography.
The suite seed identifies a run. A property seed selects one deterministic stream, and a case seed records its state immediately before generating a case. Distinct property streams avoid deliberately replaying the same base sequence for every check. The report also saves the input values with 17 significant digits, because replay from a seed alone depends on preserving the generator and its call order.
//+------------------------------------------------------------------+ //| Derive distinct nonzero streams independently of property order | //+------------------------------------------------------------------+ static uint PropertySeed(const uint suite_seed, const int property_index) { uint master = (suite_seed == 0 ? 1831565813 : suite_seed); uint seed = master ^ ((uint)(property_index + 1) * 0x9e3779b9); if(seed == 0) seed = master; //--- Invertible mixing separates stream starts; no order-dependent RNG draws. seed ^= seed >> 16; seed *= 0x7feb352d; seed ^= seed >> 15; seed *= 0x846ca68b; seed ^= seed >> 16; return seed; }
Coverage has several units. A property check is one evaluation of a requirement. A base input is the original scenario, while a transformed input is a controlled follow-up. Distinct input records can be counted, but being distinct does not mean being statistically independent. Seed count and trial count should therefore be reported separately from the regions of input space exercised.
A pure risk-volume model
TradeRiskMath.mqh receives equity, risk percentage, entry and stop prices, and a SymbolContract. It reads no account, symbol, chart, clock, or order pool. The contract supplies one positive tick size, one positive account-currency tick value per lot, and volume bounds with a step. Test cases can describe every input without constructing a simulated terminal.
This model uses a step-based volume grid. Positive candidates are n multiplied by volume_step, where n is an integer from 1 through 2,147,483,647. The grid is anchored at zero, not at volume_min; zero itself is reserved for a no-trade result. Minimum and maximum are bounds on the positive candidates and need not be grid points. A contract is invalid if no candidate fits both bounds or if its maximum-to-step ratio is outside the supported finite range. These are model rules, not a description of every broker's quantity restrictions.
The input and result structures keep monetary, price, tick, and volume quantities separate. The result preserves intermediate calculations for diagnosis, but the checker does not treat those fields as authoritative.
//+------------------------------------------------------------------+ //| Output from a risk-based volume calculation | //+------------------------------------------------------------------+ struct RiskVolumeResult { RiskVolumeStatus status; // Model outcome: OK, below minimum, or invalid. double risk_budget; // Stored pre-cost loss budget in account currency. double stop_ticks; // Stored absolute price distance in ticks. double risk_per_lot; // Stored symmetric-tick pre-cost loss for one lot. double raw_volume; // Stored budget/loss ratio before grid selection. double volume; // Largest feasible grid lots; zero means no trade. double planned_risk; // Stored pre-cost loss for the selected volume. };
The evaluation order is part of the numerical contract.
- budget = (equity × risk_percent) / 100.
- stop_ticks = |entry_price - stop_price| / tick_size.
- loss_per_lot = stop_ticks × tick_value.
- raw_volume = budget / loss_per_lot.
- Select a grid candidate no greater than raw_volume or volume_max, with computed loss_per_lot × volume no greater than budget.
- Return its volume and computed planned loss, or a zero-volume BELOW_MINIMUM result.
All inputs used as divisors must be finite and positive. Equity and prices must be positive, risk percentage must be greater than zero and at most 100, and entry must differ from stop. Nonfinite, zero, or unrepresentable required intermediate results are invalid. Keeping the order explicit prevents a later rearrangement of multiplication and division from silently changing the model near a boundary.
Volume selection uses bounded binary search over integer step counts. Increasing the count cannot decrease the candidate volume or its computed nonnegative loss. The search retains the largest count that satisfies the comparisons and moves the upper bound down when a candidate fails. Each step-count search takes at most 31 iterations. The full calculation also searches for the contract's grid limit.
//+------------------------------------------------------------------+ //| Find the greatest candidate satisfying stored risk inequalities | //+------------------------------------------------------------------+ static double SelectRiskVolume(const double raw, const double loss, const double budget, const SymbolContract &s, const long last) { long low = 1, high = last, best = 0; while(low <= high) { long middle = low + (high - low) / 2; double volume = middle * s.volume_step; double risk = loss * volume; if(volume <= raw && MathIsValidNumber(risk) && risk <= budget) { best = middle; low = middle + 1; } else high = middle - 1; } double selected = best * s.volume_step; return (selected >= s.volume_min ? selected : 0.0); }
The standalone normalizer uses the same grid definition but checks only the requested volume and contract bounds. The risk calculation adds the monetary constraint. This extra check matters because a rounded division can produce a raw request whose subsequent multiplication is just above the stored budget. Volume normalization alone is not the final budget check.
In the excerpt, last is the largest supported count whose stored product with volume_step does not exceed volume_max. GridLimit() establishes that bound before the search. The search can inspect counts below the minimum, then rejects the final choice if it is too small. Putting the minimum into the search predicate would break its required pattern: a rejected low count could be followed by an accepted larger count.
//+------------------------------------------------------------------+ //| Align a non-negative volume down without increasing risk | //+------------------------------------------------------------------+ static double NormalizeVolumeDown(const double requested, const SymbolContract &symbol) { //--- Invalid contracts cannot produce a meaningful normalized volume. if(!IsContractValid(symbol) || !MathIsValidNumber(requested) || requested <= 0.0) return 0.0; long last = 0; GridLimit(symbol, last); long low = 1, high = last, best = 0; while(low <= high) { long middle = low + (high - low) / 2; if(middle * symbol.volume_step <= requested) { best = middle; low = middle + 1; } else high = middle - 1; } double volume = best * symbol.volume_step; return (volume >= symbol.volume_min ? volume : 0.0); }
MQL5 double values use 64-bit IEEE 754 representation, and many decimal fractions are not stored exactly. The official real-type documentation explains this limitation. Here the safety bounds are comparisons between stored values, not approximate equalities. No positive epsilon permits additional volume or modeled loss.
For example, the representable value immediately below a 0.01 minimum must not be increased to 0.01. It produces no trade. Similarly, a decimal-looking boundary can select a smaller step if the candidate's stored product exceeds the stored request. This conservative policy is intentional. It does not promise the largest volume under ideal decimal arithmetic.
The function keeps three outcomes distinct. INVALID_INPUT returns false and initialized invalid output. BELOW_MINIMUM returns true, preserves valid intermediate values, and sets volume and planned loss to zero. OK returns true with a positive admissible volume. Calculation occurs in a temporary result before it is committed, so an invalid intermediate does not leave a partially populated trading result. ZeroMemory() initializes the output at entry.
The budget is a pre-cost modeled loss budget, expressed in account currency. It is a monetary limit, not a stop-loss order or price level. The model excludes commission, slippage, spread changes, currency conversion, asymmetric tick values, margin availability, and exchange-specific quantity rules. Real stop execution may differ from the assumed stop price. A production adapter needs its own contract and terminal-level validation; OrderCalcProfit() can calculate profit estimates in account currency for supported symbol/account conditions. It does not remove execution uncertainty.
Generating scenarios and checking properties
CTradeCaseGenerator combines curated tick sizes, tick values, and volume steps with varied equity, risk percentages, and stop distances. Equity is sampled across orders of magnitude instead of concentrating nearly all cases near one large account size. Limits and prices are constructed coherently. These choices make the generated scenarios useful, but the curated sets remain finite, and their omissions are part of the test scope.
RiskRuleRegression.mq5 handles the cases that should not depend on chance. It tests values immediately below, at, and above volume and budget boundaries; half-step requests; status transitions; nonstandard and fine steps; invalid numbers; contradictory limits; and numerical range failures. Adjacent-value fixtures distinguish a genuinely smaller stored request from a value that merely prints with the same decimal digits.
//+------------------------------------------------------------------+ //| Adjacent binary64 requests at a decimal minimum | //+------------------------------------------------------------------+ RiskVolumeInput s = Baseline(); NormalizeCase("next_binary64_below_0.01", 0.0099999999999999985, s.symbol); NormalizeCase("at_0.01", 0.01, s.symbol); NormalizeCase("next_binary64_above_0.01", 0.010000000000000002, s.symbol); NormalizeCase("below_0.03", 0.029999999999999995, s.symbol);
The checker in RiskRuleChecks.mqh uses the input equity, percentage, prices, and contract to recalculate the required intermediate values. It checks the returned fields against those expectations, reconstructs the grid count, and calculates loss from the selected volume. It does not call production normalization helpers or compare planned_risk only with result.risk_budget.
//+------------------------------------------------------------------+ //| Independently derive the ordered stored binary64 model | //+------------------------------------------------------------------+ static bool Model(const RiskVolumeInput &s, double &b, double &t, double &loss, double &raw, long &first, long &last) { if(!Grid(s.symbol, first, last) || !MathIsValidNumber(s.equity) || s.equity <= 0.0 || !MathIsValidNumber(s.risk_percent) || s.risk_percent <= 0.0 || s.risk_percent > 100.0 || !MathIsValidNumber(s.entry_price) || s.entry_price <= 0.0 || !MathIsValidNumber(s.stop_price) || s.stop_price <= 0.0) return false; double product = s.equity * s.risk_percent; b = product / 100.0; double distance = MathAbs(s.entry_price - s.stop_price); t = distance / s.symbol.tick_size; loss = t * s.symbol.tick_value; if(!MathIsValidNumber(product) || product <= 0.0 || !MathIsValidNumber(b) || b <= 0.0 || !MathIsValidNumber(distance) || distance <= 0.0 || !MathIsValidNumber(t) || t <= 0.0 || !MathIsValidNumber(loss) || loss <= 0.0) return false; raw = b / loss; if(!MathIsValidNumber(raw) || raw <= 0.0) return false; //--- Only the selected positive trade must have positive stored planned risk. long selected = SelectedWitness(s.symbol, raw, loss, b, last); return !(selected >= first && loss * (selected * s.symbol.volume_step) == 0.0); }
A consistent internal error illustrates the difference. Suppose the requested budget is 100, but a defective calculation returns a budget and planned loss of 200. Comparing those returned fields passes. Recalculating 100 from the input rejects the output. Formula repetition is appropriate when it expresses the specification; independence requires avoiding the implementation's helpers and checking the test itself, not avoiding arithmetic.
Safety checks alone would accept an implementation that always returns zero. The checker also requires the correct status and a maximality witness. For an OK result, the next grid point must violate the request, maximum, or modeled-loss bound. A no-trade result must have no feasible minimum candidate. Fixed unclipped scenarios require visible changes when the budget increases or the stop widens.
The regression script deliberately corrupts otherwise plausible outputs: it doubles the budget, rounds upward, forces minimum volume, ignores the maximum, returns an off-grid volume, changes stop/tick dependencies, or returns zero unnecessarily. Every predefined wrong output must be rejected by the same checker used for normal results. These are tests of the checker, not a claim to have enumerated all possible program mutations.
The main suite retains eight properties. Each result also undergoes the input-derived checks, so a metamorphic relation cannot pass merely because two outputs share the same defect.
| Property | Required relation | Diagnostic purpose |
|---|---|---|
| risk_budget_never_exceeded | Computed loss from input-derived loss per lot and output volume is no greater than the input-derived budget. | Detects excess modeled loss without trusting returned monetary fields. |
| volume_respects_symbol_contract | Volume, status, grid membership, and next-point witness satisfy the specified bounds. | Detects cap, grid, unjustified no-trade, and unnecessary under-sizing errors. |
| wider_stop_never_increases_volume | A strictly wider stored stop distance cannot increase volume with the other economic inputs unchanged. | Challenges the dependence on stop distance. |
| larger_budget_never_reduces_volume | A larger stored modeled budget cannot reduce volume with the contract and stop unchanged. | Challenges the dependence on the requested budget. |
| price_translation_preserves_volume | An exactly represented common offset preserves the result for the binary-exact price cohort. | Detects dependence on price level rather than distance. |
| price_scale_preserves_volume | Power-of-two scaling of prices and tick size preserves the result within the tested normal range. | Detects inconsistent price/tick units. |
| direction_symmetry_preserves_volume | Swapping entry and stop preserves the result under the symmetric tick-value model. | Detects signed-distance errors. |
| normalization_never_rounds_up | Normalized volume does not exceed the stored request or contract maximum; the next grid point is inadmissible. | Detects upward normalization and unjustified smaller results. |
Metamorphic testing compares an input with a controlled transformation. The technical report by T. Y. Chen, S. C. Cheung, and S. M. Yiu describes generating follow-up tests through such relations. We restrict translation and scaling so representation changes do not become accidental counterexamples. General decimal translation near a volume boundary is not promised to preserve a discrete output.
//+------------------------------------------------------------------+ //| Exact dyadic price cohort for translation, scale, and reflection | //+------------------------------------------------------------------+ static void GenerateExact(CSeededRng &rng, RiskVolumeInput &scenario) { Generate(rng, scenario); scenario.symbol.tick_size = MathPow(2.0, -rng.NextInt(0, 3)); int entry = rng.NextInt(20000, 2000000); int distance = rng.NextInt(1, 5000); int direction = (rng.NextBool() ? -1 : 1); scenario.entry_price = entry * scenario.symbol.tick_size; scenario.stop_price = (entry + direction * distance) * scenario.symbol.tick_size; } //+------------------------------------------------------------------+ //| Translate prices within the qualified exact cohort | //+------------------------------------------------------------------+ static void TranslatePrices(const RiskVolumeInput &source, const double offset, RiskVolumeInput &target) { target = source; target.entry_price += offset; target.stop_price += offset; }
The exact cohort uses tick sizes 1, 1/2, 1/4, or 1/8. Prices and offsets are small integer multiples of those sizes. Such fractions are called dyadic because their denominator is a power of two. In the stated range, the common offset preserves the stored distance exactly. Scaling prices and tick size by 1/2, 1, 2, 4, or 8 also preserves the tick count. This is a controlled fixture, not a promise about arbitrary decimal prices.
Monotonic checks allow equal volumes because a grid creates plateaus and a maximum creates a cap. Equality alone is weak evidence that the dependency is correct. The unclipped deterministic examples and wrong-output campaigns address that weakness. Each relation also has an explicit domain: swapping prices checks absolute-distance symmetry, not the behavior of instruments with different profit/loss tick values.
A second reference check is performed during validation with a Python rational-arithmetic model. It rounds at the declared binary64 operation boundaries and uses direct bounds with neighboring-candidate checks instead of copying the production binary search. Native CSV traces supply the actual stored inputs and outputs. Agreement between implementations adds evidence, although both still depend on the same stated economic specification.
CPropertyRunner records one summary row per property and retains its first failure. The detailed trace distinguishes base and transformed inputs so validation can count distinct base records rather than infer coverage from loop totals. The scripts write their reports inside the terminal's MQL5\Files sandbox.
Finding, replaying, and shrinking a defect
PropertyFailureDemo.mq5 deliberately uses nearest-step rounding where the requirement forbids increasing a request. Its defect is separate from the corrected sizing implementation. A passing demonstration means discovery, replay, and reduction succeeded; it does not mean this normalizer is valid.
//+------------------------------------------------------------------+ //| Deliberately defective normalizer for the failure demo | //+------------------------------------------------------------------+ double NormalizeVolumeNearestDefect(const double requested, const double volume_step) { //--- MathRound can move volume upward and therefore increase planned risk. return MathRound(requested / volume_step) * volume_step; } //+------------------------------------------------------------------+ //| Return true when the defective normalizer violates the property | //+------------------------------------------------------------------+ bool ViolatesNoRoundUp(const double requested, const double volume_step, double &normalized) { normalized = NormalizeVolumeNearestDefect(requested, volume_step); SymbolContract symbol; symbol.tick_size = 1.0; symbol.tick_value = 1.0; symbol.volume_min = volume_step; symbol.volume_max = 1000.0 * volume_step; symbol.volume_step = volume_step; string reason; return !CRiskRuleChecks::CheckNormalization(requested, symbol, normalized, reason); }
With seed 424242, the published demonstration finds its first failure on the first trial. At step 0.001, a request of approximately 0.054566689 becomes 0.055. The case seed recreates the same request and output. The saved values make the direction of the error visible even without rerunning the generator.
The shrinker removes whole step units, searches a bounded interval, and checks that the reduced request still fails. The resulting request is 0.0005, and the defective output is 0.001. This half-step example isolates nearest-step rounding. The procedure is specific to this one-dimensional defect; it does not minimize arbitrary price records, invalid contracts, or sequences of trading events, and it does not establish global minimality.
The corrected rule chooses an admissible step count rather than rounding to the nearest step. For a real failure, first freeze the full counterexample and version, replay it directly, and inspect the generator and checker before editing production code. Preserve the original failing input as a fixed regression even if a smaller example is easier to explain.
Seeds, trial counts, and report names are script inputs. The primary suite declares the following settings.
//+------------------------------------------------------------------+ //| Reproducible suite settings and report filenames | //+------------------------------------------------------------------+ input uint SuiteSeed = 20260711; // Master seed for distinct streams. input int TrialsPerProperty = 2000; // Base trials for each property. input string TextReportFile = "TradingRuleProperties_Report.txt"; // Key-value report in MQL5\Files. input string CsvReportFile = "TradingRuleProperties_Results.csv"; // One property per CSV row. input string TraceFile = "TradingRuleProperties_Trace.csv"; // Every base and transformed result.
Install the files and run the scripts in this order.
- Extract the archive's MQL5 folder into the terminal data folder, merging its Include and Scripts project subfolders.
- Compile TradingRuleProperties.mq5, RiskRuleRegression.mq5, and PropertyFailureDemo.mq5 in MetaEditor.
- Run RiskRuleRegression on a chart and inspect its report. Fixed requirements and checker challenges should pass before interpreting random results.
- Run TradingRuleProperties with SuiteSeed 20260711 and TrialsPerProperty 2000, then run PropertyFailureDemo with its default seed.
- Inspect the property summary, case trace, regression report, and demonstration report in MQL5\Files. Keep the sources and inputs with any failure.
The verified default run checked eight properties with 2,000 checks each: 16,000 property checks, eight passed properties, and no failed properties. Its trace contains 16,000 distinct base inputs and 12,000 transformed outputs. These are counted records, not statistically independent scenarios.
The additional campaign used seeds 1, 42, 123456789, and 4000000000 at 20,000 checks per property, for 640,000 further property checks and 480,000 transformed outputs. The regression campaign separately passed 98 deterministic cases, including eight paired fixture qualifications, and rejected all 40 predefined wrong outputs. These totals describe executed checks, not a probability of detecting every defect.
The published source files and the files extracted from the final ZIP were compiled separately. Validation ran in an isolated MT5 terminal with trading and DLL imports disabled. Runtime duration is diagnostic metadata, not a performance claim. Meaningful comparisons would require a defined benchmark controlling report I/O, machine load, compiler build, and trace settings.
Conclusion
The trader now has a pure sizing contract, an input-derived checker, eight reproducible properties, fixed boundary and invalid-input regressions, and a demonstration of failure replay and domain-specific shrinking. The checker is itself challenged with known wrong outputs. The usable result is a testing workflow and an explicit pre-cost numerical model, not approval to submit the calculated volume as a live order.
For another rule, write down its inputs, units, rounding policy, and expected outcomes before designing a generator. Add a few known examples, especially status and grid boundaries. Then generate controlled variations, verify that meaningful wrong outputs are rejected, and save the entire counterexample. Review the specification and test machinery whenever a failure appears.
Later work can add broker-aware profit estimates, stateful order transitions, or portfolio exposure rules with their own assumptions. Those extensions require new checks rather than broader claims for this model. A passing suite supports only the requirements, inputs, and versions that were actually tested.
Files for download:
The source archive preserves the installation folders. Every source file is also supplied individually; the table describes the complete inventory.
| # | Name | Type | Description |
|---|---|---|---|
| 1 | PropertyRunner.mqh | Include file | Collects property results and writes reproducible test reports. |
| 2 | SeededRng.mqh | Include file | Provides deterministic pseudo-random generation from explicit seeds. |
| 3 | TradeCaseGenerator.mqh | Include file | Generates constrained risk scenarios and metamorphic transformations. |
| 4 | TradeRiskMath.mqh | Include file | Calculates risk-limited volume on the specified step-based volume grid. |
| 5 | RiskRuleChecks.mqh | Include file | Checks outputs independently against the risk-volume specification. |
| 6 | RiskTrace.mqh | Include file | Writes round-trip numerical CSV traces for independent reference checks. |
| 7 | PropertyFailureDemo.mq5 | Script | Finds, replays, and reduces a deliberately defective normalization result. |
| 8 | TradingRuleProperties.mq5 | Script | Runs the randomized risk-volume property suite with explicit configuration. |
| 9 | RiskRuleRegression.mq5 | Script | Runs deterministic boundary, invalid-input, and wrong-output regression checks. |
| 10 | MQL5.zip | Source archive | Contains every source above in its installation folder. Extract the MQL5 folder into the terminal data folder. |
Warning: All rights to these materials are reserved by MetaQuotes Ltd. Copying or reprinting of these materials in whole or in part is prohibited.
This article was written by a user of the site and reflects their personal views. MetaQuotes Ltd is not responsible for the accuracy of the information presented, nor for any consequences resulting from the use of the solutions, strategies or recommendations described.
Per-Setup Trade Ledger in MQL5: Find Out Which of Your EA's Entries Is Losing Money
Neural Networks in Practice: When to Use an Artificial Neuron and How It Works in MQL5
How to Implement R/S Analysis and the Hurst Indicator in MQL5
From Basic to Intermediate: Subwindows (I)
- Free trading apps
- Over 8,000 signals for copying
- Economic news for exploring financial markets
You agree to website policy and terms of use