preview
Does This Entry Filter Really Add Edge? A Block-Permutation Test in MQL5

Does This Entry Filter Really Add Edge? A Block-Permutation Test in MQL5

MetaTrader 5Trading |
234 0
Roberto Danilo Riccio
Roberto Danilo Riccio

Introduction

Entry filters are commonly evaluated by comparing an Expert Advisor backtest with and without the filter. This comparison measures the operational effect on net profit, drawdown, and trading activity. However, it does not show whether the accepted trades form an unusually favorable subset of the trades produced by the base strategy.

This article presents FilterEdgeAnalyzer.mqh, a reusable MQL5 module that reconstructs completed Strategy Tester positions, separates accepted and rejected trades, and compares their mean net-profit outcomes with fixed-count placebo selections. Individual permutations, equal-block permutations, and circular shifts provide three null models for evaluating the observed difference.

The module coordinates the diagnostic and filtered tester passes. It stores the diagnostic result under an experiment identifier, evaluates block-size sensitivity, compares six operational metrics, and prints a structured verdict after the filtered pass. The reader receives the analyzer, a block-size sensitivity script, the three case-study CSV files, and an MQL5.zip archive that places the same files in their terminal-ready project folders.

The selection-test path is summarized below. Every transformation retains the observed acceptance count, ensuring that the actual filter is compared with placebo selections of the same size. The operational branch and final pairing are introduced after the decision rules.

Workflow

Image 1: Workflow from completed base trades to the fixed-count placebo comparison.


Operational improvement versus selection ability

Operational improvement

A standard comparison runs the base EA and then runs the EA with the filter enforced. It measures the resulting portfolio path through net profit, trade count, Profit Factor, drawdown, Recovery Factor, and the Sharpe ratio.

This question is indispensable because a filter changes more than a list of trades. Rejecting one entry may leave the EA flat, permitting a later trade that the base run could not take. Position occupancy, compounding, volume rules, and portfolio constraints can therefore produce different opportunity paths.

Selection ability

The placebo test conditions on the base run instead. The base EA calculates the acceptance label but does not enforce it. Every completed base trade receives an accepted or rejected label. The test then asks whether the accepted labels align with better realized outcomes more strongly than fixed-count placebo labels.

The two claims are related but not equivalent. A filter can improve drawdown or Profit Factor through exposure control even when its accepted set is not statistically unusual. Conversely, a filter can label better base trades yet fail to improve an operational run after occupancy and sizing effects.

Evaluating these claims separately requires fixed definitions for the analyzed trades, filter labels, monetary outcomes, and placebo transformations.


Definitions and assumptions

Base trade

A base trade is a position opened by the unfiltered EA. The target filter is calculated at entry, but it does not block the order. This definition keeps the analyzed opportunity set fixed.

Accepted and rejected trades

An accepted trade is a base trade whose entry state satisfies the filter. A rejected trade is a base trade whose entry state does not satisfy it. These terms describe labels in the diagnostic base run; a rejected trade is still executed in that run.

Net-profit outcome

The collected outcome is realized net profit in the account currency. It includes profit, commission, swap, and fee across every deal belonging to the position.

For trade i, the outcome is:

P_i = profit_i + commission_i + swap_i + fee_i

Only labeled positions with a realized exit are analyzed. A position still open when OnTester() executes has no completed outcome and is reported but excluded.

The case study uses one symbol and a fixed 0.01-lot volume; therefore, account-currency outcomes are directly comparable. The history collector does not normalize outcomes by ATR, stop distance, or initial risk. For mixed symbols, position sizes, or risk budgets, normalize outcomes before passing them to CFilterEdgeAnalyzer, or extend the collector with an explicit outcome-scaling rule.

The collector assumes one labeled entry followed by one complete exit per position; the test harness follows that lifecycle. Partial exits and scale-in entries are not validated and require additional position-volume tracking and label-allocation rules. The current implementation must not be applied to those position lifecycles without that extension.

Analyzer requirements

The analyzer requires at least 99 placebo samples, a positive block size, two complete blocks after truncation, equal outcome and label array lengths, and at least one accepted and one rejected observation in the analyzed prefix.

Block and placebo selection

An equal block contains a fixed number of consecutive acceptance labels. A placebo selection is a transformed acceptance mask applied to the unchanged outcome sequence. Every transformation preserves the number of accepted observations in the analyzed prefix.

The equal-block method uses the longest prefix divisible by the configured block size. An incomplete final block is excluded and reported as dropped. Consequently, sensitivity rows with different block sizes can use different analyzed counts (N).

With the opportunity set and truncation rule fixed, the analysis can define the statistic and the three null models.


Statistic and null models

Primary statistic

The analyzer compares the mean outcome of accepted trades with the mean outcome of rejected trades:

ΔP = Mean(P_accepted) − Mean(P_rejected)

A positive ΔP means that accepted trades had a higher mean net-profit outcome. The upper-tail test asks how often a placebo statistic is at least as large as the observed ΔP.

The production method below implements the statistic directly. It accumulates both groups, rejects masks that contain only accepted or only rejected observations, and returns the two means together with their difference.

//+------------------------------------------------------------------+
//| Calculate accepted-minus-rejected mean outcome                   |
//+------------------------------------------------------------------+
bool CFilterEdgeAnalyzer::CalculateDelta(const double &outcomes[],
      const bool &accepted[],
      const int count,
      double &accepted_mean,
      double &rejected_mean,
      double &delta,
      int &accepted_count) const
  {
//--- Accumulate the two groups while preserving the supplied time order
   double accepted_sum=0.0;
   double rejected_sum=0.0;
   accepted_count=0;

   for(int i=0;i<count;i++)
     {
      //--- Route each realized outcome through its recorded filter label
      if(accepted[i])
        {
         accepted_sum+=outcomes[i];
         accepted_count++;
        }
      else
         rejected_sum+=outcomes[i];
     }

//--- Both groups must be nonempty for a mean difference to exist
   int rejected_count=count-accepted_count;
   if(accepted_count==0 || rejected_count==0)
      return(false);

//--- Return the accepted-minus-rejected mean outcome
   accepted_mean=accepted_sum/(double)accepted_count;
   rejected_mean=rejected_sum/(double)rejected_count;
   delta=accepted_mean-rejected_mean;
   return(true);
  }

Individual permutation

Individual permutation applies a Fisher-Yates shuffle to the positions of the complete analyzed-prefix Boolean mask. It preserves the total accepted count but destroys label runs and other local temporal structure. It is intentionally included as a naive benchmark, not as the primary dependent-data model.

Equal-block permutation

Equal-block permutation divides the acceptance mask into consecutive blocks and shuffles the block order. The order of labels inside each block remains unchanged. This preserves dependence inside each block, but it does not preserve arbitrary dependence across block boundaries or the complete temporal dependence structure.

Circular shift

A circular shift moves the complete analyzed-prefix mask by a nonzero offset and wraps the displaced labels around the opposite end. It preserves the complete cyclic label pattern while changing its alignment with outcomes. The module samples nonzero shifts with replacement, making this model a structural robustness check rather than an exhaustive enumeration.

Finite-sample–corrected p-value

For B placebo samples, the upper-tail p-value is:

p_{upper} = (1 + #{T_b ≥ T_{observed}}) / (B + 1)

Here, T_b is the statistic from placebo sample b, T_{observed} is the observed ΔP, and #{...} counts placebo statistics at least as large as that observed statistic. The plus-one correction prevents a Monte Carlo p-value of zero. With B = 10,000 placebo samples, the minimum possible value is 1/10001, or approximately 0.0001.

Decision rules

The automatic verdict uses alpha as a declared significance threshold; the case study sets it to 0.05. Selection ability is ESTABLISHED only when the observed delta is positive. It also requires both dependent-data tests to have upper-tail p-values at or below alpha, and all configured sensitivity sizes to support the same conclusion.

A nonpositive observed delta is classified as NOT_ESTABLISHED. For a positive delta, NOT_ESTABLISHED requires both dependent-data tests to exceed alpha and no sensitivity size to support selection. Other positive-delta combinations are MODEL_DEPENDENT. The individual permutation remains a reported naive benchmark and does not determine the verdict.

Data status is FILTER_EDGE_DATA_INVALID when the stored analysis is incomplete, a label is malformed, or an analyzed group is empty. Open labeled positions produce FILTER_EDGE_DATA_WITH_EXCLUSIONS because only their missing outcomes are excluded; the log retains their count for review.

The operational classification compares net profit, Profit Factor, expected payoff, maximum equity drawdown, Recovery Factor, and the Sharpe ratio. Higher is preferred for every metric except drawdown. CompareHigher() treats a difference as unchanged when its absolute size is at most 1e-9 times the larger of 1.0 and the two absolute metric values.

IMPROVED means that at least one metric improved and none deteriorated; DETERIORATED is the reverse; MIXED contains both directions; UNCHANGED means that all six differences fall within this numerical tolerance. Trade count is reported but not scored because lower activity is neither intrinsically better nor worse.

These explicit rules prevent a lower drawdown from being interpreted as statistical selection evidence and map directly to the result, sensitivity, tester-metric, and verdict structures in the module.


MQL5 module architecture

Result and history structures

SNullSummary stores the mean, median, fifth percentile, ninety-fifth percentile, and two finite-sample–corrected tail p-values. SFilterEdgeResult stores the analyzed and dropped counts, group means, observed delta, and all three null summaries.

These complete production definitions make the result contract explicit:

struct SNullSummary
  {
   double            mean;              // Mean statistic across placebo samples
   double            median;            // Median statistic across placebo samples
   double            lower_05;          // Fifth percentile of the placebo distribution
   double            upper_95;          // Ninety-fifth percentile of the placebo distribution
   double            p_value_lower;     // Lower-tail empirical p-value with finite-sample correction
   double            p_value_upper;     // Upper-tail empirical p-value with finite-sample correction
  };

struct SFilterEdgeResult
  {
   bool              valid;              // True when the complete analysis succeeded
   int               observations;       // Number of observations included in every test
   int               dropped;            // Number of tail observations excluded for equal blocks
   int               accepted;           // Number of accepted observations in the analyzed prefix
   int               rejected;           // Number of rejected observations in the analyzed prefix
   double            accepted_mean;      // Mean outcome among accepted observations
   double            rejected_mean;      // Mean outcome among rejected observations
   double            observed_delta;     // Accepted mean minus rejected mean
   SNullSummary      individual;         // Individual-permutation null summary
   SNullSummary      block;              // Equal-block-permutation null summary
   SNullSummary      circular;           // Circular-shift null summary
  };

The verdict layer uses four independent status dimensions rather than one undifferentiated pass/fail flag. The following production excerpt shows the complete enum contract; SFilterEdgeVerdict then carries those classifications together with the paired diagnostic and filtered metrics.

enum ENUM_FILTER_EDGE_DATA_STATUS
  {
   FILTER_EDGE_DATA_INVALID=0,            // Analysis cannot support a conclusion
   FILTER_EDGE_DATA_VALID,                // All labeled trades have completed outcomes
   FILTER_EDGE_DATA_WITH_EXCLUSIONS       // Open labeled trades were excluded
  };

enum ENUM_FILTER_EDGE_SELECTION_STATUS
  {
   FILTER_EDGE_SELECTION_INVALID=0,       // Diagnostic state is incomplete
   FILTER_EDGE_SELECTION_NOT_ESTABLISHED, // Dependent-data tests do not support selection ability
   FILTER_EDGE_SELECTION_MODEL_DEPENDENT, // The conclusion changes across null models or block sizes
   FILTER_EDGE_SELECTION_ESTABLISHED      // Dependent-data tests and sensitivity runs agree
  };

enum ENUM_FILTER_EDGE_SENSITIVITY_STATUS
  {
   FILTER_EDGE_SENSITIVITY_INVALID=0,     // No valid sensitivity summary is available
   FILTER_EDGE_SENSITIVITY_NOT_ESTABLISHED, // No tested block size supports selection ability
   FILTER_EDGE_SENSITIVITY_MODEL_DEPENDENT, // Support changes with the tested block size
   FILTER_EDGE_SENSITIVITY_ESTABLISHED    // Every tested block size supports selection ability
  };

enum ENUM_FILTER_EDGE_OPERATIONAL_STATUS
  {
   FILTER_EDGE_OPERATIONAL_INVALID=0,     // Tester metrics are unavailable
   FILTER_EDGE_OPERATIONAL_UNCHANGED,     // Differences are within numerical tolerance
   FILTER_EDGE_OPERATIONAL_IMPROVED,      // Tracked metrics improved without deterioration
   FILTER_EDGE_OPERATIONAL_MIXED,         // Some tracked metrics improved and others deteriorated
   FILTER_EDGE_OPERATIONAL_DETERIORATED   // Tracked metrics deteriorated without improvement
  };

SFilterTradeRecord stores the position identifier, entry and exit times, symbol, acceptance label, closed state, and accumulated net profit. SFilterCollectionStats reports labeled entries, closed and open trades, and malformed labels. SFilterStrategyKey limits collection to registered symbol-and-magic pairs.

Statistical engine

CFilterEdgeRandom supplies a deterministic linear congruential generator. CFilterEdgeAnalyzer validates the configuration, calculates the observed delta, generates three placebo distributions, applies the finite-sample correction, and exports sorted distributions.

The following production excerpt shows the complete sampling and result-population portion of Analyze(). It begins after configuration, array-size, prefix, and observed-delta validation; those surrounding checks remain in the attached module.

//--- Isolate the random streams with deterministic seed offsets
   CFilterEdgeRandom individual_random;
   CFilterEdgeRandom block_random;
   CFilterEdgeRandom circular_random;
   individual_random.Seed(m_seed+101);
   block_random.Seed(m_seed+211);
   circular_random.Seed(m_seed+307);

//--- Generate equally sized placebo selections under all three models
   for(int sample=0;sample<m_permutations;sample++)
     {
      //--- Restore the observed labels before the individual permutation
      for(int i=0;i<usable_count;i++)
         individual_mask[i]=accepted[i];

      //--- Transform labels while preserving the accepted count
      ShuffleMask(individual_mask,usable_count,individual_random);
      ShuffleBlocks(accepted,block_mask,usable_count,block_random);
      int shift=1+circular_random.Index(usable_count-1);
      CircularShift(accepted,circular_mask,usable_count,shift);

      double first_mean=0.0;
      double second_mean=0.0;
      int selected=0;
      CalculateDelta(outcomes,individual_mask,usable_count,first_mean,
                     second_mean,individual_statistics[sample],selected);
      CalculateDelta(outcomes,block_mask,usable_count,first_mean,
                     second_mean,block_statistics[sample],selected);
      CalculateDelta(outcomes,circular_mask,usable_count,first_mean,
                     second_mean,circular_statistics[sample],selected);
     }

//--- Summarize every placebo distribution against the observed statistic
   if(!Summarize(individual_statistics,observed_delta,result.individual) ||
      !Summarize(block_statistics,observed_delta,result.block) ||
      !Summarize(circular_statistics,observed_delta,result.circular))
     {
      m_last_error="A placebo distribution could not be summarized.";
      return(false);
     }

//--- Retain sorted distributions for optional CSV export
   ::ArrayCopy(m_individual,individual_statistics);
   ::ArrayCopy(m_block,block_statistics);
   ::ArrayCopy(m_circular,circular_statistics);

//--- Publish the shared sample counts and observed group statistics
   result.valid=true;
   result.observations=usable_count;
   result.dropped=outcome_count-usable_count;
   result.accepted=accepted_count;
   result.rejected=usable_count-accepted_count;
   result.accepted_mean=accepted_mean;
   result.rejected_mean=rejected_mean;
   result.observed_delta=observed_delta;
   return(true);

The equal-block operation copies complete source blocks into a randomized destination order. The following excerpt reproduces the production logic exactly:

//+------------------------------------------------------------------+
//| Shuffle equal-sized mask blocks                                  |
//+------------------------------------------------------------------+
void CFilterEdgeAnalyzer::ShuffleBlocks(const bool &source[],
                                        bool &destination[],
                                        const int count,
                                        CFilterEdgeRandom &random) const
  {
//--- Build one index for every complete equal-sized block
   int block_count=count/m_block_size;
   int order[];
   ::ArrayResize(order,block_count);

   for(int block=0;block<block_count;block++)
      order[block]=block;

//--- Randomize block order without changing labels inside each block
   for(int block=block_count-1;block>0;block--)
     {
      int other=random.Index(block+1);
      int value=order[block];
      order[block]=order[other];
      order[other]=value;
     }

//--- Copy complete source blocks into their shuffled destinations
   for(int destination_block=0;destination_block<block_count;destination_block++)
     {
      int source_block=order[destination_block];
      for(int offset=0;offset<m_block_size;offset++)
        {
         //--- Preserve every label's offset within its original block
         int destination_index=destination_block*m_block_size+offset;
         int source_index=source_block*m_block_size+offset;
         destination[destination_index]=source[source_index];
        }
     }
  }

History reconstruction

CFilterTradeTest builds compact order comments, selects registered deals, groups them through DEAL_POSITION_ID, and accumulates every monetary component. An entry comment contains the marker FE|F1: the tag identifies the diagnostic record and F1 records acceptance. F0 records rejection. The parser searches for the marker inside the complete comment, so existing text can remain before or after it. The configurable tag must contain 1 to 8 characters and cannot contain a pipe.

The following production excerpt shows how the collector adds all deal-level monetary components without further normalization:

      //--- Accumulate realized profit and every recorded trading cost
      m_trades[trade_index].net_profit+=
         ::HistoryDealGetDouble(ticket,DEAL_PROFIT)+
         ::HistoryDealGetDouble(ticket,DEAL_COMMISSION)+
         ::HistoryDealGetDouble(ticket,DEAL_SWAP)+
         ::HistoryDealGetDouble(ticket,DEAL_FEE);

Entry and exit deals are joined by DEAL_POSITION_ID, not by order ticket. This distinction is required because even the supported single-entry, full-exit lifecycle contains related entry and exit deals with different tickets.

After collection, AnalyzeHistory() converts completed records into parallel net-profit and acceptance arrays. The method then delegates the statistical work to CFilterEdgeAnalyzer:

//+------------------------------------------------------------------+
//| Analyze actual completed trade outcomes                          |
//+------------------------------------------------------------------+
bool CFilterTradeTest::AnalyzeHistory(SFilterEdgeResult &result)
  {
//--- Clear derived arrays before rebuilding history
   m_last_error="";
   ::ArrayFree(m_outcomes);
   ::ArrayFree(m_accepted);
   if(!CollectHistory())
      return(false);

//--- Allocate parallel outcome and decision arrays for closed positions
   ::ArrayResize(m_outcomes,m_stats.closed_trades);
   ::ArrayResize(m_accepted,m_stats.closed_trades);
   int completed=0;
   for(int i=0;i<::ArraySize(m_trades);i++)
     {
      //--- Exclude open positions because they have no completed outcome
      if(!m_trades[i].closed)
         continue;

      m_outcomes[completed]=m_trades[i].net_profit;
      m_accepted[completed]=m_trades[i].accepted;
      completed++;
     }

//--- Trim the arrays and delegate the completed sample to the analyzer
   ::ArrayResize(m_outcomes,completed);
   ::ArrayResize(m_accepted,completed);
   if(!m_analyzer.Analyze(m_outcomes,m_accepted,result))
     {
      m_last_error=m_analyzer.LastError();
      return(false);
     }

   return(true);
  }

The retained arrays allow AnalyzeSensitivity() to repeat the same completed-history analysis across caller-supplied block sizes. It counts positive deltas and the block and circular results that satisfy alpha, while retaining the minimum and maximum upper-tail p-values.

Verdict engine

CFilterEdgeVerdictEngine writes a versioned diagnostic row under FILE_COMMON and reloads it during the filtered pass. It rejects incompatible experiment IDs, symbols, magic numbers, currencies, or alpha values before comparing tester metrics. The classification methods below are complete production code and implement the declared rules directly.

//+------------------------------------------------------------------+
//| Classify evidence that the filter selected better outcomes       |
//+------------------------------------------------------------------+
ENUM_FILTER_EDGE_SELECTION_STATUS CFilterEdgeVerdictEngine::SelectionStatus(
   const SFilterEdgeDiagnosticState &state,
   const ENUM_FILTER_EDGE_SENSITIVITY_STATUS sensitivity) const
  {
//--- Reject conclusions built from invalid data or sensitivity state
   if(DataStatus(state)==FILTER_EDGE_DATA_INVALID ||
      sensitivity==FILTER_EDGE_SENSITIVITY_INVALID)
      return(FILTER_EDGE_SELECTION_INVALID);

//--- A nonpositive observed difference cannot establish positive selection
   if(state.selection.observed_delta<=0.0)
      return(FILTER_EDGE_SELECTION_NOT_ESTABLISHED);

//--- Evaluate the two dependence-aware primary null models
   bool block_supports=(state.selection.block.p_value_upper<=state.alpha);
   bool circular_supports=(state.selection.circular.p_value_upper<=state.alpha);

//--- Require complete agreement between primary and sensitivity evidence
   if(block_supports && circular_supports &&
      sensitivity==FILTER_EDGE_SENSITIVITY_ESTABLISHED)
      return(FILTER_EDGE_SELECTION_ESTABLISHED);
   if(!block_supports && !circular_supports &&
      sensitivity==FILTER_EDGE_SENSITIVITY_NOT_ESTABLISHED)
      return(FILTER_EDGE_SELECTION_NOT_ESTABLISHED);

//--- Conflicting models or block sizes produce a model-dependent verdict
   return(FILTER_EDGE_SELECTION_MODEL_DEPENDENT);
  }

SelectionStatus() combines the dependence-aware p-values with block-size sensitivity and returns the statistical evidence status. OperationalStatus() addresses the separate A/B question: it compares the six tester metrics and classifies their aggregate direction without treating operational changes as proof of selection ability.

//+------------------------------------------------------------------+
//| Classify the operational base-versus-filtered comparison         |
//+------------------------------------------------------------------+
ENUM_FILTER_EDGE_OPERATIONAL_STATUS CFilterEdgeVerdictEngine::OperationalStatus(
   const SFilterEdgeTesterMetrics &base,
   const SFilterEdgeTesterMetrics &filtered) const
  {
//--- Require complete metric sets from both tester passes
   if(!base.valid || !filtered.valid)
      return(FILTER_EDGE_OPERATIONAL_INVALID);

//--- Compare six tracked metrics in their preferred directions
   int comparisons[6];
   comparisons[0]=CompareHigher(base.net_profit,filtered.net_profit);
   comparisons[1]=CompareHigher(base.profit_factor,filtered.profit_factor);
   comparisons[2]=CompareHigher(base.expected_payoff,
                                filtered.expected_payoff);
   comparisons[3]=CompareLower(base.equity_drawdown_max,
                               filtered.equity_drawdown_max);
   comparisons[4]=CompareHigher(base.recovery_factor,
                                filtered.recovery_factor);
   comparisons[5]=CompareHigher(base.sharpe_ratio,filtered.sharpe_ratio);

//--- Count improvements and deteriorations after numerical tolerance
   int improved=0;
   int deteriorated=0;
   for(int i=0;i<6;i++)
     {
      if(comparisons[i]>0)
         improved++;
      else
         if(comparisons[i]<0)
            deteriorated++;
     }

//--- Convert the aggregate direction counts into one operational status
   if(improved==0 && deteriorated==0)
      return(FILTER_EDGE_OPERATIONAL_UNCHANGED);
   if(improved>0 && deteriorated==0)
      return(FILTER_EDGE_OPERATIONAL_IMPROVED);
   if(improved==0 && deteriorated>0)
      return(FILTER_EDGE_OPERATIONAL_DETERIORATED);

   return(FILTER_EDGE_OPERATIONAL_MIXED);
  }

With history, sensitivity, and verdict rules defined, the remaining work is to connect the module to the EA event handlers.


EA integration

Configure and register the strategy

The historical case study was generated with a fixed-volume, long-only test harness. A completed M15 bar above the upper Bollinger Band opens a position; a completed bar below the middle line closes it. The Bollinger parameters are 190 and 2.0, and a 140-period D1 SMA supplies the acceptance label.

With InpEnforceFilter=false, the harness executes every qualifying base entry regardless of the acceptance label. With InpEnforceFilter=true, it produces the operational filtered run used for the conventional performance comparison.

The SMA value is read at the timestamp of the completed M15 entry bar. It can therefore belong to the D1 bar still forming at that timestamp. Both the diagnostic and enforced-filter runs use the same timing convention, so their entry-state comparison remains aligned.

The integration inputs have four roles. InpSymbol and InpMagic define the Strategy Tester history to scan. InpEnforceFilter and InpExportCsv control the test mode and its files. InpPermutations, InpBlockSize, InpSeed, and InpAlpha configure the statistical analysis. InpExperimentId pairs the diagnostic and filtered passes and must change whenever the strategy settings, test interval, symbol data, or execution model changes.

The archive installs the module under MQL5\Include\FilterEdge. The following simplified module-facing listing uses that project path and combines the required declarations and initialization calls. Configure() initializes the permutation engine, while AddStrategy() registers each symbol-and-magic pair included in the history scan. The short tag passed to Configure() identifies the acceptance marker stored in entry comments. The verdict engine is independent of the EA inputs; the host passes the experiment identity explicitly when it saves or evaluates a state. The complete EA retains its surrounding strategy initialization.

#include <FilterEdge\FilterEdgeAnalyzer.mqh>

input string          InpSymbol          = "";          // Empty value selects the tester symbol
input long            InpMagic           = 654323;      // Strategy magic number
input bool            InpEnforceFilter   = false;       // Reject entries that fail the filter
input int             InpPermutations    = 10000;       // Placebo samples per null model
input int             InpBlockSize       = 20;          // Trades in each equal block
input uint            InpSeed            = 20260723;    // Reproducible experiment seed
input double          InpAlpha           = 0.05;        // Upper-tail decision threshold
input string          InpExperimentId    = "FE_CASE_01";// Change when experiment settings change
input bool            InpExportCsv       = true;        // Export trades and distributions

CFilterTradeTest g_filter_test;                      // History and permutation analysis
CFilterEdgeVerdictEngine g_verdict_engine;           // Cross-pass verdict generation
string           g_symbol="";                        // Resolved strategy symbol

//+------------------------------------------------------------------+
//| Build the cross-pass state-file name                             |
//+------------------------------------------------------------------+
string VerdictStateFile(void)
  {
//--- Bind the state filename to the declared experiment identifier
   return("FilterEdge_"+InpExperimentId+"_state.csv");
  }

//+------------------------------------------------------------------+
//| Initialize the history analyzer                                  |
//+------------------------------------------------------------------+
int OnInit(void)
  {
//--- Resolve the strategy symbol used by both tester passes
   g_symbol=(InpSymbol=="" ? ::Symbol() : InpSymbol);

//--- Configure the label parser and deterministic permutation engine
   if(!g_filter_test.Configure(InpPermutations,InpBlockSize,
         InpSeed,"FE"))
     {
      ::Print(g_filter_test.LastError());
      return(INIT_FAILED);
     }

//--- Restrict history collection to this symbol-and-magic pair
   if(!g_filter_test.AddStrategy(g_symbol,InpMagic))
     {
      ::Print(g_filter_test.LastError());
      return(INIT_FAILED);
     }

   return(INIT_SUCCEEDED);
  }

Calculate and optionally enforce the filter

OnTick() evaluates the target filter at the same completed-bar entry state in both runs. A completed entry-bar close is accepted only when it is strictly above the time-aligned D1 SMA; equality is rejected. The diagnostic base run keeps InpEnforceFilter=false, whereas the operational run sets it to true.

The harness uses a fixed 0.01-lot volume, sends no physical stop or take profit, and closes the long position when a completed bar falls below the Bollinger middle line. The absence of risk-based sizing is deliberate: the harness tests the filter-analysis workflow rather than presenting a production trading system.

Immediately before submitting a base entry, calculate its Boolean acceptance label and store it in the order comment through BuildComment(). The diagnostic run records the label without blocking the trade; only the operational filtered run rejects an entry when accepted is false.

The case-study fragment below is the insertion point inside the existing entry branch. Replace the first expression with the target filter rule. When using MqlTradeRequest, assign the generated string to request.comment as shown. When using CTrade, pass the same string through the comment parameter of the selected entry method.

//--- Evaluate the filter at the same completed-bar state in both runs
   bool accepted=(close_price>filter_sma);

//--- Reject the entry only during the operational filtered pass
   if(InpEnforceFilter && !accepted)
      return;

//--- Persist the contemporaneous decision for later history analysis
   request.comment=g_filter_test.BuildComment(accepted);

CFilterTradeTest creates no indicator handles and keeps no file open, so the module requires no call from OnDeinit().

Analyze completed history

OnTester() runs after each Strategy Tester pass. The diagnostic base run reconstructs completed positions, executes the three primary null models and seven block-size sensitivity runs, captures the base tester metrics, and saves one state file under FILE_COMMON. The filtered run does not repeat the placebo analysis: it captures its tester metrics, loads the matching state, classifies the four verdict dimensions, and prints the conclusion.

The following module-facing code is complete when used with the declarations and VerdictStateFile() helper shown above. The seven sensitivity sizes are a host-level decision; AnalyzeSensitivity() accepts any positive integer array supported by the available sample length. Because every run requires two complete blocks, the displayed maximum size of 60 requires at least 120 completed trades.

//+------------------------------------------------------------------+
//| Print one null-model summary                                     |
//+------------------------------------------------------------------+
void PrintNullSummary(const string name,const SNullSummary &summary)
  {
//--- Print descriptive statistics and both empirical tail probabilities
   ::PrintFormat("%s: mean=%.6f, P05=%.6f, P95=%.6f, lower-p=%.6f, upper-p=%.6f",
                 name,summary.mean,summary.lower_05,summary.upper_95,
                 summary.p_value_lower,summary.p_value_upper);
  }

//+------------------------------------------------------------------+
//| Analyze completed base-EA trades after the Strategy Tester run   |
//+------------------------------------------------------------------+
double OnTester(void)
  {
//--- Complete the paired verdict during the operational filtered pass
   if(InpEnforceFilter)
     {
      //--- Capture the filtered pass objective for the tester result
      double net_profit=::TesterStatistics(STAT_PROFIT);
      ::PrintFormat("FILTER EDGE OPERATIONAL: net_profit=%.2f",
                    net_profit);

      //--- Pair current metrics with the matching diagnostic state
      SFilterEdgeVerdict verdict;
      if(g_verdict_engine.EvaluateFiltered(
            VerdictStateFile(),InpExperimentId,
            g_symbol,InpMagic,InpAlpha,verdict))
         g_verdict_engine.Print(verdict);
      else
         ::Print("FILTER EDGE VERDICT: evaluation failed: ",
                 g_verdict_engine.LastError());
      return(net_profit);
     }

//--- Reconstruct and analyze completed trades in the diagnostic pass
   SFilterEdgeResult result;
   if(!g_filter_test.AnalyzeHistory(result))
     {
      ::Print("FILTER EDGE: analysis failed: ",
              g_filter_test.LastError());
      return(0.0);
     }

//--- Print collection diagnostics and the three primary null summaries
   SFilterCollectionStats stats=g_filter_test.CollectionStats();
   ::PrintFormat("FILTER EDGE: labeled=%d, closed=%d, open=%d, invalid_labels=%d",
                 stats.labeled_entries,stats.closed_trades,stats.open_trades,
                 stats.invalid_labels);
   ::PrintFormat("FILTER EDGE: N=%d, dropped=%d, accepted=%d, rejected=%d",
                 result.observations,result.dropped,result.accepted,
                 result.rejected);
   ::PrintFormat("FILTER EDGE: currency=%s, accepted_mean=%.6f, rejected_mean=%.6f, delta=%.6f",
                 ::AccountInfoString(ACCOUNT_CURRENCY),
                 result.accepted_mean,result.rejected_mean,
                 result.observed_delta);
   PrintNullSummary("FILTER EDGE / individual",result.individual);
   PrintNullSummary("FILTER EDGE / block",result.block);
   PrintNullSummary("FILTER EDGE / circular",result.circular);

//--- Test the same history across the declared block-size grid
   int block_sizes[]= {5,10,15,20,30,40,60};
   SFilterEdgeSensitivitySummary sensitivity;
   if(!g_filter_test.AnalyzeSensitivity(block_sizes,InpAlpha,sensitivity))
     {
      ::Print("FILTER EDGE: sensitivity failed: ",
              g_filter_test.LastError());
      return(result.observed_delta);
     }

//--- Report directional stability and dependent-model support counts
   ::PrintFormat("FILTER EDGE / sensitivity: tested=%d, positive_delta=%d, block_support=%d, circular_support=%d",
                 sensitivity.tested,sensitivity.positive_delta,
                 sensitivity.block_supporting,
                 sensitivity.circular_supporting);
   ::PrintFormat("FILTER EDGE / sensitivity: block-p=[%.6f, %.6f], circular-p=[%.6f, %.6f], alpha=%.4f",
                 sensitivity.block_p_min,sensitivity.block_p_max,
                 sensitivity.circular_p_min,sensitivity.circular_p_max,
                 InpAlpha);

//--- Save the diagnostic evidence and base metrics for the filtered pass
   if(g_verdict_engine.SaveDiagnostic(
         VerdictStateFile(),InpExperimentId,
         g_symbol,InpMagic,InpAlpha,stats,result,
         sensitivity))
      ::PrintFormat("FILTER EDGE: diagnostic state saved as %s.",
                    VerdictStateFile());
   else
      ::Print("FILTER EDGE: diagnostic state save failed: ",
              g_verdict_engine.LastError());

//--- Optionally export the collected trades and placebo distributions
   if(InpExportCsv)
     {
      //--- Write both exports into the terminal common data folder
      bool trades_exported=g_filter_test.ExportTrades(
                              "FilterEdge_actual_trades.csv",true);
      if(!trades_exported)
         ::Print("Trade export failed: ",g_filter_test.LastError());

      bool distribution_exported=g_filter_test.ExportDistribution(
                                    "FilterEdge_actual_distributions.csv",
                                    true);
      if(!distribution_exported)
         ::Print("Distribution export failed: ",
                 g_filter_test.LastError());

      if(trades_exported && distribution_exported)
         ::PrintFormat("FILTER EDGE: CSV files saved under %s\\Files.",
                       ::TerminalInfoString(TERMINAL_COMMONDATA_PATH));
     }

//--- Return the observed selection statistic as the tester objective
   return(result.observed_delta);
  }

The diagnostic summary reports collection counts, analyzed and dropped observations, accepted and rejected counts, group means, all three null summaries, and the sensitivity support counts and p-value ranges. The filtered summary reports data quality, selection evidence, sensitivity stability, operational classification, every paired metric, and the final interpretation.

FILE_COMMON places the paired state and, when CSV export is enabled, FilterEdge_actual_trades.csv and FilterEdge_actual_distributions.csv in the terminal common data folder under Files. FILE_WRITE replaces files with the same names.

The state filename contains InpExperimentId, and the filtered pass rejects a mismatch in experiment ID, symbol, magic, account currency, or alpha. The shown single-symbol integration records one primary symbol and magic. If a host registers several pairs, the identifier must uniquely represent the complete registered set because the singular symbol and magic checks cannot detect a changed secondary pair.

RunBlockSizeSensitivity.mq5 remains useful when the reader needs every sensitivity row rather than the automatic summary. The archive installs the script under MQL5\Scripts\FilterEdge and the published CSV files under MQL5\Files\FilterEdge. With InpUseCommonFolder=false, its default paths read the packaged trade file and replace the packaged sensitivity output in that local project folder.

An EA using the integration above writes its own trade file under the terminal common Files folder. To analyze that generated file, set InpUseCommonFolder=true and set InpTradeFile to FilterEdge_actual_trades.csv; set InpOutputFile to FilterEdge_block_sensitivity.csv to write the new result beside it.

Practical integration and test sequence

Use the following sequence to apply the analyzer to another EA and obtain both measurements:

  1. Extract MQL5.zip into the terminal directory, include FilterEdgeAnalyzer.mqh through FilterEdge\FilterEdgeAnalyzer.mqh, declare the nine integration inputs, and create global CFilterTradeTest and CFilterEdgeVerdictEngine instances.
  2. In OnInit(), call Configure() and register every analyzed symbol-and-magic pair through AddStrategy(). Choose a short tag that does not already occur in the EA's order comments.
  3. At every qualifying base entry, calculate accepted and attach BuildComment(accepted) to the entry order. Preserve the marker in the final comment if the EA already stores other text there.
  4. Choose an InpExperimentId for the exact settings, registered strategy set, data, interval, and execution model. Run the Strategy Tester with InpEnforceFilter=false and InpExportCsv=true. This diagnostic run must execute both accepted and rejected base entries; OnTester() analyzes history, runs sensitivity, writes the CSV files, and saves the paired state.
  5. Before interpreting the result, confirm that invalid_labels is zero, review every open trade, and verify that accepted plus rejected equals N and that N plus dropped equals closed. Correct collection problems before using the p-values.
  6. Keep the same identifier, alpha, test period, symbol, timeframe, execution settings, and volume rule. Change only InpEnforceFilter to true and run the filtered pass. The module loads the diagnostic state and prints the structured verdict automatically.
  7. Save both Strategy Tester reports and the final FILTER EDGE VERDICT lines. Treat the operational and selection classifications as separate dimensions even though the module presents them together.
  8. For a row-by-row audit of the published sample, run the installed RunBlockSizeSensitivity.mq5 script with its default local paths. For a newly generated common-folder export, enable InpUseCommonFolder and use the generated filenames. Compare rows together with their N and dropped values because different block sizes can analyze different prefixes.
  9. Pool multiple symbols or position sizes only after applying an explicit normalization rule; otherwise, analyze comparable monetary outcomes separately.

The next section applies this complete workflow to historical strategy trades.


Actual-trade case study

This section uses the fixed-volume test harness described above on USDJPY data. The Strategy Tester interval was configured from January 1, 2020 through July 22, 2026; the available custom history and the recorded runtime ended on April 3, 2026. The reports use fixed volume 0.01, Bollinger parameters 190 and 2.0, and a 140-period D1 SMA label.

The analyzed observations are completed historical trades, not fixed-horizon signal proxies. The results demonstrate the analysis workflow rather than validate the harness as a production trading system.

Collection diagnostics

The history scan found 396 labeled entries, all of which were completed. No label was malformed, and no position remained open. With block size 20, the analyzer used the first 380 completed trades and reported 16 dropped tail observations.

The analyzed prefix contained 251 accepted and 129 rejected trades. Their mean net-profit outcomes were 1.305179 USD and 0.624109 USD. The observed difference was therefore 0.681071 USD per trade.

Placebo comparison

The individual upper-tail p-value was 0.193581. The primary equal-block upper-tail p-value was 0.239276, and the circular-shift upper-tail p-value was 0.142186. None of the three null models approached the conventional 5% threshold.

The positive observed statistic describes a better average outcome for accepted trades in this sample, but its position inside every placebo distribution does not provide conventional statistical evidence of selection ability.

Block Distribution

Image 2: The observed 0.681071 USD mean-profit difference compared with 10,000 equal-block placebo statistics.

Operational comparison

The enforced-filter run reduced net profit from 429.33 USD to 366.04 USD and reduced the trade count from 396 to 280.

Profit Factor increased from 1.63 to 1.82, and expected payoff increased from 1.08 to 1.31. Maximum equity drawdown fell from 94.10 USD to 64.72 USD, while Recovery Factor rose from 4.56 to 5.66 and the Sharpe ratio from 2.28 to 2.87.

The Strategy Tester report displays the filtered Sharpe ratio as 2.87, while the verdict line produced by PrintFormat() displays 2.86. This 0.01 display difference does not change the improvement direction used by OperationalStatus().


Image 3: Relative changes from the base run to the filtered run. Net profit and trade count decreased, while efficiency metrics improved and maximum equity drawdown fell by 31.2%.

The filtered pass loaded the diagnostic state and printed the following final classification:

FILTER EDGE VERDICT: experiment=FE_CASE_01, data=VALID
FILTER EDGE VERDICT: selection=NOT_ESTABLISHED, delta=0.681071, alpha=0.0500, block-p=0.239276, circular-p=0.142186
FILTER EDGE VERDICT: sensitivity=STABLE_NOT_ESTABLISHED, tested=7, block-support=0, circular-support=0
FILTER EDGE VERDICT: operational=MIXED, net_profit=429.33->366.04, trades=396->280
FILTER EDGE VERDICT: PF=1.63->1.82, payoff=1.08->1.31, equity_DD=94.10->64.72, recovery=4.56->5.66, Sharpe=2.28->2.86
FILTER EDGE VERDICT: conclusion=The operational result is mixed and selection ability was not established; no general edge claim is supported.

The automatic verdict matches the manual reading: net profit deteriorated while Profit Factor, expected payoff, maximum equity drawdown, Recovery Factor, and the Sharpe ratio improved. The combined result supports no general filter-edge claim.

Block-size sensitivity still matters because each block length preserves a different local label structure and can change the analyzed prefix.


Block-size sensitivity

Block length determines which local label structure is preserved and how many blocks can be rearranged. Block size 20 was declared as the primary setting because it is the midpoint of the sensitivity grid. In the case study, it retains 19 complete blocks. It is a dependence assumption rather than an optimized parameter.

The sensitivity experiment reran the MQL5 analyzer with sizes 5, 10, 15, 20, 30, 40, and 60, using 10,000 placebo samples and the same seed.

Block size N Dropped Observed delta Block upper p Circular upper p
5 395 1 0.693561 USD 0.163484 0.146585
10 390 6 0.716619 USD 0.166683 0.149385
15 390 6 0.716619 USD 0.164484 0.149385
20 380 16 0.681071 USD 0.239276 0.142186
30 390 6 0.716619 USD 0.155384 0.149385
40 360 36 0.510264 USD 0.138786 0.199480
60 360 36 0.510264 USD 0.183682 0.199480

The equal-block upper-tail p-value ranges from 0.138786 to 0.239276. Every tested size remains above 5%, so no alternative block length changes the inferential conclusion. The variation still quantifies the dependence of the numerical p-value on the selected null model.

The circular result is constant only when block sizes share the same analyzed prefix because circular shifts do not otherwise use the block length. Sizes 10, 15, and 30 use the same 390-trade prefix; sizes 40 and 60 use the same 360-trade prefix. Sizes 5 and 20 use distinct prefixes of 395 and 380 trades.

The chart below places both upper-tail series against the conventional 5% reference line.

Block Sensitivity

Image 4: Equal-block and circular upper-tail p-values remain above the 5% reference line for every tested block size.

The sensitivity analysis therefore supports one stable interpretation: the sample difference is positive, but none of the tested dependence models makes it statistically unusual at the conventional 5% level.


Limitations and safeguards

The history collector returns net profit in the account currency and assumes one labeled entry followed by one complete exit. This design is appropriate for the fixed-volume, single-symbol case study. Variable sizing, mixed instruments, partial exits, or scale-in entries require an explicit outcome-normalization rule together with remaining-volume and label-allocation logic.

The placebo analysis conditions on trades completed by the base EA. Enforcing the filter can change later opportunities through position occupancy, so the selection test cannot replace the operational A/B comparison. The two tests answer different questions even when they use the same entry rule.

The structured verdict applies declared rules; it is not an economic utility function. The operational status counts metric directions without weighting their magnitudes. Its 1e-9 comparison threshold suppresses floating-point noise; it is not an economic materiality threshold. MIXED deliberately leaves the trade-off to the deployment objective.

The experiment identifier prevents accidental cross-pairing only if it is changed after any material change in settings, interval, data, or the execution model. The shared state-file workflow is intended for two sequential single tests, not parallel optimization passes that can target the same filename.

Equal-block permutation preserves label dependence inside each block but not arbitrary dependence across block boundaries. It also excludes the incomplete final block, so every result must retain its corresponding N and dropped count. Circular shifts preserve the cyclic label pattern, but the implementation samples nonzero shifts with replacement.

The SMA label is evaluated at the completed M15 entry-bar timestamp and can therefore use the D1 bar still forming at that time. Both Strategy Tester passes apply the same convention. History quality, tick generation, spread assumptions, and execution modeling still affect the trades supplied to the analyzer.

Permutation testing reuses the observed trade sequence and does not establish performance on later data. Selecting filters, thresholds, or block sizes after inspecting the results adds researcher degrees of freedom. A predeclared holdout period, walk-forward process, and multiple-testing control remain necessary before generalizing the result to other data or strategy configurations.


Conclusion and possible improvements

FilterEdgeAnalyzer.mqh turns entry-filter evaluation into two coordinated tests. The operational A/B run measures the portfolio path after enforcement, while the fixed-count placebo analysis measures whether accepted labels align with better completed base-trade outcomes than structurally transformed alternatives. The MQL5-only verdict layer now pairs both passes and prints the decision dimensions without external post-processing.

In the case study, enforcement reduced net profit and trading activity while improving trade-quality and risk-adjusted metrics. Accepted trades had a higher mean outcome, but that difference was not statistically unusual under the individual, equal-block, circular, or block-size sensitivity comparisons. Keeping these findings separate prevents an operational improvement from being misreported as proof of selection ability.

The resulting workflow is direct: record the filter label at entry, reconstruct completed positions, compare the observed mean difference with fixed-count placebo models, inspect block-size sensitivity, and validate the complete decision process out of sample. Future extensions can expand individual stages without changing this separation of responsibilities.

Proposed enhancements are grouped below by the part of the workflow they extend:

Category Proposed enhancement Operational objective
Position accounting Partial-exit and scale-in tracking Track remaining volume and assign entry labels consistently across multi-deal position lifecycles.
Outcome scaling User-defined normalization Compare heterogeneous symbols, volumes, or risk budgets through an explicit common outcome scale.
Dependence models Stationary and moving-block variants Test the observed association under additional assumptions about temporal dependence.
Validation automation Predeclared walk-forward evaluation Automate holdout windows and multiple-testing control when several filters or thresholds are compared.



Attached files

The archive contains the following module, script, and case-study data files:

File name Description
MQL5\Include\FilterEdge\FilterEdgeAnalyzer.mqh Reusable filter-edge analyzer: completed-trade reconstruction, fixed-count placebo tests, diagnostic state, and structured verdicts
MQL5\Scripts\FilterEdge\RunBlockSizeSensitivity.mq5 Block-size sensitivity runner: equal-block and circular placebo tests across the configured block lengths
MQL5\Files\FilterEdge\FilterEdge_actual_trades.csv Case-study completed-trade outcomes and filter acceptance labels
MQL5\Files\FilterEdge\FilterEdge_actual_distributions.csv Case-study placebo distributions for individual permutations, equal-block permutations, and circular shifts
MQL5\Files\FilterEdge\FilterEdge_block_sensitivity.csv Case-study sensitivity results for the seven tested block lengths
Attached files |
MQL5.zip (193.04 KB)
Larry Williams Market Secrets (Part 16): Detecting and Trading the Oops Gap Reversal Pattern Larry Williams Market Secrets (Part 16): Detecting and Trading the Oops Gap Reversal Pattern
Learn how to build an MQL5 Expert Advisor that detects and trades Larry Williams’ Oops Gap Reversal pattern using objective gap rules and later-bar confirmation. The EA tracks setup expiration, prepares stop-loss and take-profit levels, supports manual or risk-based position sizing, executes market orders, and is evaluated through historical testing.
Automating Chart Patterns in MQL5 (Part 1): The Multi-Timeframe Swing Structure Engine Automating Chart Patterns in MQL5 (Part 1): The Multi-Timeframe Swing Structure Engine
This article presents CSwingEngine, a reusable MQL5 class that detects H4 swing highs and lows, labels them HH, LH, HL, or LL, and classifies market structure as trend or range. Swings are always computed on H4, regardless of the attached chart, and each point draws correctly on lower timeframes via native datetime anchoring. The engine exposes a clean interface to query the current trend and retrieve the swing array for context-aware pattern logic.
Features of Experts Advisors Features of Experts Advisors
Creation of expert advisors in the MetaTrader trading system has a number of features.
Measuring Market Efficiency with Lempel-Ziv Complexity Measuring Market Efficiency with Lempel-Ziv Complexity
This article presents a compact MQL5 library for market-complexity analysis: LZ76 complexity and Normalized Compression Distance built on a SAX symbolizer, exposed through a simple facade and an efficiency indicator. It explains the discretization choices, normalization, and distance formulation, and validates the code with unit checks and an independent cross-check. You get a ready-to-use library and indicator, plus a disciplined way to interpret readings with a shuffle null and a direction check.