Expectancy and Trade Quality Score Dashboard in MQL5
Introduction
Win rate is the first number most traders check, and the easiest one to misread. A strategy that wins seventy percent of the time is not automatically better than one that wins thirty percent, since win rate says nothing about the size of wins versus losses. A low win rate strategy with large winners and small losers can outperform a high win rate strategy that does the opposite.
Sample size compounds the problem. A sixty-five percent win rate from eight trades and the same win rate from four hundred trades are not equally trustworthy, even though they look identical on screen. This article builds an MQL5 script that reads closed trade history and reduces it to a single figure: the Trade Quality Score. The score is based on expectancy and adjusted for the amount of evidence behind the win rate.
The tool is a script. It reads history once, computes the metrics, draws a CCanvas panel, and leaves it on the chart. It can also narrow history to a configurable hour-of-day window, used here to approximate the London session in server time, and show full-history and session-filtered numbers side by side.

Architectural diagram of the trade quality analytics dashboard. The main script pulls trades through the reader and hands them to the calculator. An optional session filter routes a second, filtered pass through that same calculator. Both results then reach the dashboard panel and the Experts tab report.
The Data Model — TradeQualityTypes.mqh
Before any calculation can happen, closed deal history has to be turned into something a calculator can reason about: one record per completed trade, not one record per deal. TradeQualityTypes.mqh defines the two structures every other file in this project depends on.
//+------------------------------------------------------------------+ //| TradeQualityTypes.mqh | //| Data structures for trade quality analytics | //+------------------------------------------------------------------+ #ifndef TRADEQUALITYTYPES_MQH #define TRADEQUALITYTYPES_MQH //+------------------------------------------------------------------+ //| Trade direction, kept independent of position/order enums so | //| the analytics layer does not depend on live position state | //+------------------------------------------------------------------+ enum ENUM_TRADE_DIRECTION { TRADE_DIRECTION_BUY = 0, TRADE_DIRECTION_SELL = 1 };
CTradeRecord holds one completed round-trip trade. It includes the position identifier, symbol, direction, entry/exit prices, entry/close times, and net profit. It also holds a pip result and, critically, a bool flag saying whether that pip result was actually computed. Pip conversion depends on knowing a symbol's pip size, and if that lookup ever fails, silently treating the pip result as zero would be indistinguishable from a real, computed pip result of zero. The flag makes the difference explicit.
//+------------------------------------------------------------------+ //| CTradeRecord | //| One completed round-trip trade, built from one or more deals | //| that share the same position identifier. | //+------------------------------------------------------------------+ class CTradeRecord { public: ulong m_position_id; string m_symbol; ENUM_TRADE_DIRECTION m_direction; double m_entry_price; double m_exit_price; datetime m_entry_time; datetime m_close_time; double m_net_profit; double m_pip_result; bool m_pip_result_defined; CTradeRecord(void); ~CTradeRecord(void); }; //+------------------------------------------------------------------+ //| Constructor: every field starts at a neutral, explicit default | //+------------------------------------------------------------------+ CTradeRecord::CTradeRecord(void) { m_position_id = 0; m_symbol = ""; m_direction = TRADE_DIRECTION_BUY; m_entry_price = 0.0; m_exit_price = 0.0; m_entry_time = 0; m_close_time = 0; m_net_profit = 0.0; m_pip_result = 0.0; m_pip_result_defined = false; } //+------------------------------------------------------------------+ //| Destructor: no owned resources | //+------------------------------------------------------------------+ CTradeRecord::~CTradeRecord(void) { }
CQualityMetrics is the pipeline output. It contains counts, rates, averages, expectancy values, the Wilson interval, the Trade Quality Score, and two labels: quality rating and sample-size adequacy. The design rule here is strict and deliberate: any numeric figure that can fail to be computed carries its own boolean "defined" flag next to it. Average win is undefined when there were no wins. Expectancy per dollar risked is undefined when there were no losses to form a risk proxy from. The Trade Quality Score is undefined when average loss itself could not be computed. Nothing defaults to zero as a stand-in for "not computed," because a zero that means "not computed" and a zero that means "actually zero" are very different facts, and conflating them is exactly the kind of quiet dishonesty this whole project is trying to avoid.
//+------------------------------------------------------------------+ //| CQualityMetrics | //| Full set of metrics produced by CTradeQualityCalculator::Compute | //| for one trade set (full universe or a filtered subset). | //+------------------------------------------------------------------+ class CQualityMetrics { public: int m_trade_count; int m_win_count; int m_loss_count; int m_breakeven_count; double m_win_rate; double m_loss_rate; double m_avg_win_currency; bool m_avg_win_currency_defined; double m_avg_loss_currency; bool m_avg_loss_currency_defined; double m_avg_win_pips; bool m_avg_win_pips_defined; double m_avg_loss_pips; bool m_avg_loss_pips_defined; double m_expectancy_currency; double m_expectancy_pips; double m_expectancy_r_multiple; bool m_expectancy_r_multiple_defined; double m_wilson_lower; double m_wilson_upper; double m_quality_score; bool m_quality_score_defined; string m_quality_rating; string m_sample_adequacy; CQualityMetrics(void); ~CQualityMetrics(void); }; //+------------------------------------------------------------------+ //| Constructor: nothing defaults to zero as a stand-in for | //| "not computed"; every optional figure is paired with a flag | //+------------------------------------------------------------------+ CQualityMetrics::CQualityMetrics(void) { m_trade_count = 0; m_win_count = 0; m_loss_count = 0; m_breakeven_count = 0; m_win_rate = 0.0; m_loss_rate = 0.0; m_avg_win_currency = 0.0; m_avg_win_currency_defined = false; m_avg_loss_currency = 0.0; m_avg_loss_currency_defined = false; m_avg_win_pips = 0.0; m_avg_win_pips_defined = false; m_avg_loss_pips = 0.0; m_avg_loss_pips_defined = false; m_expectancy_currency = 0.0; m_expectancy_pips = 0.0; m_expectancy_r_multiple = 0.0; m_expectancy_r_multiple_defined = false; m_wilson_lower = 0.0; m_wilson_upper = 0.0; m_quality_score = 0.0; m_quality_score_defined = false; m_quality_rating = "N/A"; m_sample_adequacy = "N/A"; } //+------------------------------------------------------------------+ //| Destructor: no owned resources | //+------------------------------------------------------------------+ CQualityMetrics::~CQualityMetrics(void) { }
A note on class layout: CTradeRecord and CQualityMetrics are data-transfer objects used across the project, so their fields are public. Accessors would add boilerplate without improving encapsulation. Every class in this project that owns real behavior instead of just carrying data, starting with the trade record reader below, declares its private members before its public interface, as MetaQuotes style expects.
Reading Closed Trade History — TradeRecordReader.mqh
MetaTrader's history API is deal-oriented: every fill, whether it opens, adds to, or closes a position, is its own deal. A trade quality dashboard needs to think in trades, not deals, so the first real job in this project is grouping deals by position and turning each group into one CTradeRecord. CollectPositionIds() scans every deal already selected by HistorySelect() and builds a de-duplicated list of position identifiers, optionally restricted to one symbol.
//+------------------------------------------------------------------+ //| TradeRecordReader.mqh | //| Groups closed deal history into one trade per position | //+------------------------------------------------------------------+ #ifndef TRADERECORDREADER_MQH #define TRADERECORDREADER_MQH #include "TradeQualityTypes.mqh" //+------------------------------------------------------------------+ //| CTradeRecordReader | //| Reads closed deal history for a time window and an optional | //| symbol filter, groups deals by position identifier, and produces | //| one CTradeRecord per position that shows a complete entry and a | //| complete exit inside the requested range. | //+------------------------------------------------------------------+ class CTradeRecordReader { private: ulong m_position_ids[]; int CollectPositionIds(const string symbol_filter); bool BuildRecordForPosition(const ulong position_id,const string symbol_filter,CTradeRecord &record); public: CTradeRecordReader(void); ~CTradeRecordReader(void); int ReadTrades(const datetime from,const datetime to,const string symbol_filter,CTradeRecord &trades[]); }; //+------------------------------------------------------------------+ //| Constructor | //+------------------------------------------------------------------+ CTradeRecordReader::CTradeRecordReader(void) { } //+------------------------------------------------------------------+ //| Destructor | //+------------------------------------------------------------------+ CTradeRecordReader::~CTradeRecordReader(void) { } //+-------------------------------------------------------------------+ //| CollectPositionIds | //| Scans the deals already selected by HistorySelect() and builds a | //| de-duplicated list of position identifiers, optionally restricted | //| to one symbol. | //+-------------------------------------------------------------------+ int CTradeRecordReader::CollectPositionIds(const string symbol_filter) { ArrayResize(m_position_ids,0); int total=::HistoryDealsTotal(); //--- walk every deal once and remember each new position id for(int i=0;i<total;i++) { ulong ticket=::HistoryDealGetTicket(i); if(ticket==0) continue; string deal_symbol=::HistoryDealGetString(ticket,DEAL_SYMBOL); if(symbol_filter!="" && deal_symbol!=symbol_filter) continue; ulong position_id=(ulong)::HistoryDealGetInteger(ticket,DEAL_POSITION_ID); if(position_id==0) continue; bool already_known=false; int known_count=ArraySize(m_position_ids); for(int j=0;j<known_count;j++) { if(m_position_ids[j]==position_id) { already_known=true; break; } } if(!already_known) { int new_index=ArraySize(m_position_ids); ArrayResize(m_position_ids,new_index+1); m_position_ids[new_index]=position_id; } } return(ArraySize(m_position_ids)); }
BuildRecordForPosition() rebuilds one CTradeRecord per position: the DEAL_ENTRY_IN deal supplies direction and entry price, the latest DEAL_ENTRY_OUT or DEAL_ENTRY_OUT_BY deal supplies exit price and close time, and net profit sums profit, swap, and commission across every deal in the position. It returns false when either side is missing.
//+-------------------------------------------------------------------+ //| BuildRecordForPosition | //+-------------------------------------------------------------------+ bool CTradeRecordReader::BuildRecordForPosition(const ulong position_id,const string symbol_filter,CTradeRecord &record) { int total=::HistoryDealsTotal(); bool has_entry=false; bool has_exit=false; double net_profit=0.0; datetime latest_exit_time=0; for(int i=0;i<total;i++) { ulong ticket=::HistoryDealGetTicket(i); if(ticket==0) continue; ulong deal_position_id=(ulong)::HistoryDealGetInteger(ticket,DEAL_POSITION_ID); if(deal_position_id!=position_id) continue; string deal_symbol=::HistoryDealGetString(ticket,DEAL_SYMBOL); if(symbol_filter!="" && deal_symbol!=symbol_filter) continue; double deal_profit=::HistoryDealGetDouble(ticket,DEAL_PROFIT); double deal_swap=::HistoryDealGetDouble(ticket,DEAL_SWAP); double deal_commission=::HistoryDealGetDouble(ticket,DEAL_COMMISSION); net_profit+=deal_profit+deal_swap+deal_commission; ENUM_DEAL_ENTRY entry_type=(ENUM_DEAL_ENTRY)::HistoryDealGetInteger(ticket,DEAL_ENTRY); if(entry_type==DEAL_ENTRY_IN) { ENUM_DEAL_TYPE deal_type=(ENUM_DEAL_TYPE)::HistoryDealGetInteger(ticket,DEAL_TYPE); record.m_direction=(deal_type==DEAL_TYPE_BUY)?TRADE_DIRECTION_BUY:TRADE_DIRECTION_SELL; record.m_entry_price=::HistoryDealGetDouble(ticket,DEAL_PRICE); record.m_entry_time=(datetime)::HistoryDealGetInteger(ticket,DEAL_TIME); record.m_symbol=deal_symbol; record.m_position_id=position_id; has_entry=true; } else if(entry_type==DEAL_ENTRY_OUT || entry_type==DEAL_ENTRY_OUT_BY) { datetime deal_time=(datetime)::HistoryDealGetInteger(ticket,DEAL_TIME); if(deal_time>=latest_exit_time) { latest_exit_time=deal_time; record.m_exit_price=::HistoryDealGetDouble(ticket,DEAL_PRICE); record.m_close_time=deal_time; } has_exit=true; } } record.m_net_profit=net_profit; return(has_entry && has_exit); }
ReadTrades() selects the history, calls CollectPositionIds() and BuildRecordForPosition() for each one, and drops any position that comes back incomplete rather than scoring it with partial information.
//+-------------------------------------------------------------------+ //| ReadTrades | //+-------------------------------------------------------------------+ int CTradeRecordReader::ReadTrades(const datetime from,const datetime to,const string symbol_filter,CTradeRecord &trades[]) { ArrayResize(trades,0); if(!::HistorySelect(from,to)) return(0); int position_count=CollectPositionIds(symbol_filter); int accepted=0; for(int i=0;i<position_count;i++) { CTradeRecord candidate; bool complete=BuildRecordForPosition(m_position_ids[i],symbol_filter,candidate); //--- an incompletely observed position is dropped, not partially //--- scored, because a missing entry or exit means direction, //--- entry price, exit price, or net profit would be silently //--- wrong rather than simply absent if(!complete) continue; if(candidate.m_entry_time<from || candidate.m_close_time>to) continue; int new_index=ArraySize(trades); ArrayResize(trades,new_index+1); trades[new_index]=candidate; accepted++; } return(accepted); }
The Session Filter — SessionFilter.mqh
Once trades exist as CTradeRecord objects, the dashboard can optionally restrict them to an hour-of-day window in server time. This is a genuinely small piece of logic, but it is exactly the kind of small piece of logic that quietly breaks at midnight if it is not thought through carefully, so it gets its own file and its own pure, directly testable method.
//+------------------------------------------------------------------+ //| SessionFilter.mqh | //| Hour-of-day session window filtering | //+------------------------------------------------------------------+ #ifndef SESSIONFILTER_MQH #define SESSIONFILTER_MQH #include "TradeQualityTypes.mqh" //+------------------------------------------------------------------+ //| CSessionFilter | //+------------------------------------------------------------------+ class CSessionFilter { public: CSessionFilter(void); ~CSessionFilter(void); bool IsInSessionWindow(const datetime t,const int start_hour,const int end_hour) const; int Filter(const CTradeRecord &source[],const int start_hour,const int end_hour,CTradeRecord &result[]); }; //+------------------------------------------------------------------+ //| Constructor | //+------------------------------------------------------------------+ CSessionFilter::CSessionFilter(void) { } //+------------------------------------------------------------------+ //| Destructor | //+------------------------------------------------------------------+ CSessionFilter::~CSessionFilter(void) { }
IsInSessionWindow() checks whether a timestamp's hour falls inside a half-open window, start hour inclusive, end hour exclusive. A non-wrapping window uses AND; a window that wraps past midnight, such as twenty-two to six, is really two ranges joined at the boundary and needs OR instead. Filter() applies that test across an array and returns only the matching trades.
//+--------------------------------------------------------------------+ //| IsInSessionWindow | //+--------------------------------------------------------------------+ bool CSessionFilter::IsInSessionWindow(const datetime t,const int start_hour,const int end_hour) const { MqlDateTime parts; ::TimeToStruct(t,parts); int hour=parts.hour; if(start_hour==end_hour) return(true); if(start_hour<end_hour) return(hour>=start_hour && hour<end_hour); //--- wrapping window: start_hour > end_hour return(hour>=start_hour || hour<end_hour); } //+--------------------------------------------------------------------+ //| Filter | //| Reduces source[] to only the trades whose entry time falls inside | //| the session window, preserving original order. | //+--------------------------------------------------------------------+ int CSessionFilter::Filter(const CTradeRecord &source[],const int start_hour,const int end_hour,CTradeRecord &result[]) { ArrayResize(result,0); int total=ArraySize(source); for(int i=0;i<total;i++) { if(IsInSessionWindow(source[i].m_entry_time,start_hour,end_hour)) { int new_index=ArraySize(result); ArrayResize(result,new_index+1); result[new_index]=source[i]; } } return(ArraySize(result)); }
Expectancy and the Wilson Interval
Expectancy answers what win rate cannot: Expectancy = (WinRate × AverageWin) − (LossRate × AverageLoss). A seventy percent win rate with a fifty-dollar average win against a one-hundred-and-fifty-dollar average loss nets a loss of ten dollars per trade. A thirty-five percent win rate with a $300 average win against an eighty-dollar loss nets positive fifty-three. Win rate alone would rank these backwards. This formula is algebraically identical to total net profit divided by trade count, since a breakeven trade contributes zero to both sums while still counting once in the denominator.
Currency, pips, and R-multiples are three normalizations of the same result: currency for accounting, pips for comparing a strategy across time independent of lot size, and R-multiples for comparing strategies with different position sizes. This project has no stop-loss data, so it uses average loss size as a documented proxy for risk rather than a hidden default.
Every observed win rate is an estimate, not a certainty, and a raw win rate from a small sample can be badly misleading. The Wilson score interval is a standard statistical formula that gives an honest confidence range without ever producing an impossible bound below zero or above one. For a 95% confidence level, z = 1.959963985:
denominator = 1 + z²/n center = p̂ + z²/(2n) margin = z × sqrt( p̂(1−p̂)/n + z²/(4n²) ) lower bound = (center − margin) / denominator upper bound = (center + margin) / denominator
The interval narrows as trade count grows, staying wide for small samples and tightening for large ones. The Trade Quality Score substitutes the Wilson lower bound for the raw win rate inside the expectancy formula, deliberately pulling a flattering small-sample win rate toward a more cautious figure. Dividing that conservative expectancy by average loss size makes the score dimensionless, so it stays comparable across strategies and account sizes.
The Trade Quality Calculator — TradeQualityCalculator.mqh
CTradeQualityCalculator is stateless, and nearly every method is pure. Thresholds are defined once at the top of the file:
//+------------------------------------------------------------------+ //| TradeQualityCalculator.mqh | //| Expectancy, Wilson interval, and Trade Quality Score | //+------------------------------------------------------------------+ #ifndef TRADEQUALITYCALCULATOR_MQH #define TRADEQUALITYCALCULATOR_MQH #include "TradeQualityTypes.mqh" //--- fixed, documented thresholds, defined once and reused everywhere #define CALC_WILSON_Z_95 1.959963985 #define QUALITY_THRESHOLD_FAIR 0.0 #define QUALITY_THRESHOLD_GOOD 0.15 #define QUALITY_THRESHOLD_EXCELLENT 0.35 #define SAMPLE_SIZE_LIMITED 20 #define SAMPLE_SIZE_ADEQUATE 50 #define SAMPLE_SIZE_STRONG 150 //+------------------------------------------------------------------+ //| CTradeQualityCalculator | //| Stateless calculator: every method below either takes its inputs | //| explicitly (the pure methods) or reads only from live symbol | //| information (the one impure helper), so a single instance can be | //| reused freely across any number of trade sets. | //+------------------------------------------------------------------+ class CTradeQualityCalculator { public: CTradeQualityCalculator(void); ~CTradeQualityCalculator(void); bool CalculatePipResult(const double entry_price,const double exit_price,const ENUM_TRADE_DIRECTION direction,const double pip_size,double &pip_result) const; double GetSymbolPipSize(const string symbol) const; void ClassifyTrades(const CTradeRecord &trades[],int &win_count,int &loss_count,int &breakeven_count,double &win_sum,double &loss_sum) const; void ComputeAverageCurrency(const double win_sum,const int win_count,const double loss_sum,const int loss_count,double &avg_win,bool &avg_win_defined,double &avg_loss,bool &avg_loss_defined) const; void ComputeAveragePips(const CTradeRecord &trades[],double &avg_win_pips,bool &avg_win_pips_defined,double &avg_loss_pips,bool &avg_loss_pips_defined) const; void ComputeExpectancy(const double win_rate,const double avg_win,const bool avg_win_defined,const double loss_rate,const double avg_loss,const bool avg_loss_defined,double &expectancy) const; void ComputeWilsonInterval(const int win_count,const int trade_count,const double z,double &lower,double &upper) const; void ComputeQualityScore(const double conservative_expectancy,const double avg_loss_currency,const bool avg_loss_defined,double &score,bool &score_defined) const; string ClassifyQualityRating(const double score,const bool score_defined) const; string ClassifySampleAdequacy(const int trade_count) const; void Compute(const CTradeRecord &trades[],CQualityMetrics &metrics) const; }; //+------------------------------------------------------------------+ //| Constructor | //+------------------------------------------------------------------+ CTradeQualityCalculator::CTradeQualityCalculator(void) { } //+------------------------------------------------------------------+ //| Destructor | //+------------------------------------------------------------------+ CTradeQualityCalculator::~CTradeQualityCalculator(void) { }
CalculatePipResult() converts a price differential into a pip-denominated yield based on position direction and specified pip size. For long (buy) positions, profitability occurs when the exit price exceeds the entry price; for short (sell) positions, profitability occurs when the entry price exceeds the exit price. If pip_size is less than or equal to zero, the function returns false and maintains pip_result at zero, as a non-positive divisor cannot logically normalize a price delta.
//+--------------------------------------------------------------------+ //| CalculatePipResult | //+--------------------------------------------------------------------+ bool CTradeQualityCalculator::CalculatePipResult(const double entry_price,const double exit_price,const ENUM_TRADE_DIRECTION direction,const double pip_size,double &pip_result) const { pip_result=0.0; if(pip_size<=0.0) return(false); double price_move=(direction==TRADE_DIRECTION_BUY)?(exit_price-entry_price):(entry_price-exit_price); pip_result=price_move/pip_size; return(true); }
GetSymbolPipSize() computes and returns the price increment (pip) size in points for a specified financial instrument. Because it queries real-time environment data directly from the trading terminal rather than using mock fixtures, it is excluded from synthetic unit testing.
//+--------------------------------------------------------------------+ //| GetSymbolPipSize | //+--------------------------------------------------------------------+ double CTradeQualityCalculator::GetSymbolPipSize(const string symbol) const { if(!::SymbolSelect(symbol,true)) return(0.0); long digits=::SymbolInfoInteger(symbol,SYMBOL_DIGITS); double point=::SymbolInfoDouble(symbol,SYMBOL_POINT); if(point<=0.0) return(0.0); if(digits==3 || digits==5) return(point*10.0); return(point); }
ClassifyTrades() is the single place where the sign of net profit becomes a win, a loss, or a breakeven, and every other calculation reuses this result rather than re-deriving it. A trade with exactly zero net profit gets its own branch and is never folded into either bucket.
//+--------------------------------------------------------------------+ //| ClassifyTrades | //+--------------------------------------------------------------------+ void CTradeQualityCalculator::ClassifyTrades(const CTradeRecord &trades[],int &win_count,int &loss_count,int &breakeven_count,double &win_sum,double &loss_sum) const { win_count = 0; loss_count = 0; breakeven_count = 0; win_sum = 0.0; loss_sum = 0.0; int total=ArraySize(trades); for(int i=0;i<total;i++) { double profit=trades[i].m_net_profit; if(profit>0.0) { win_count++; win_sum+=profit; } else if(profit<0.0) { loss_count++; loss_sum+=(-profit); } else { breakeven_count++; } } }
ComputeAverageCurrency() and ComputeAveragePips() divide sums by counts, leaving an explicit undefined result when a count is zero; the pip version skips any trade whose pip result was never computed. ComputeExpectancy() implements the formula generically, so Compute() calls it once for currency, once for pips, and once for the conservative Wilson-adjusted figure.
//+--------------------------------------------------------------------+ //| ComputeAverageCurrency | //| Pure. Divides each sum by its own count, leaving the average | //| explicitly undefined when the corresponding count is zero, since | //| dividing by zero would otherwise either crash or silently produce | //| a meaningless zero that looks like a real average. | //+--------------------------------------------------------------------+ void CTradeQualityCalculator::ComputeAverageCurrency(const double win_sum,const int win_count,const double loss_sum,const int loss_count,double &avg_win,bool &avg_win_defined,double &avg_loss,bool &avg_loss_defined) const { if(win_count>0) { avg_win=win_sum/win_count; avg_win_defined=true; } else { avg_win=0.0; avg_win_defined=false; } if(loss_count>0) { avg_loss=loss_sum/loss_count; avg_loss_defined=true; } else { avg_loss=0.0; avg_loss_defined=false; } } //+---------------------------------------------------------------------+ //| ComputeAveragePips | //| Pure. Uses the identical win/loss classification convention as | //| ClassifyTrades (the sign of net profit), and skips any trade whose | //| pip result was never successfully computed, so an invalid pip size | //| for one symbol cannot quietly corrupt the average for the rest. | //+---------------------------------------------------------------------+ void CTradeQualityCalculator::ComputeAveragePips(const CTradeRecord &trades[],double &avg_win_pips,bool &avg_win_pips_defined,double &avg_loss_pips,bool &avg_loss_pips_defined) const { double win_sum = 0.0; double loss_sum = 0.0; int win_count = 0; int loss_count = 0; int total=ArraySize(trades); for(int i=0;i<total;i++) { if(!trades[i].m_pip_result_defined) continue; double profit=trades[i].m_net_profit; if(profit>0.0) { win_sum+=trades[i].m_pip_result; win_count++; } else if(profit<0.0) { loss_sum+=(-trades[i].m_pip_result); loss_count++; } } if(win_count>0) { avg_win_pips=win_sum/win_count; avg_win_pips_defined=true; } else { avg_win_pips=0.0; avg_win_pips_defined=false; } if(loss_count>0) { avg_loss_pips=loss_sum/loss_count; avg_loss_pips_defined=true; } else { avg_loss_pips=0.0; avg_loss_pips_defined=false; } } //+---------------------------------------------------------------------+ //| ComputeExpectancy | //| Pure and generic: called once for currency and once for pips. | //| Implements Expectancy = (WinRate * AverageWin) - (LossRate * | //| AverageLoss). When a rate's corresponding average is undefined | //| (its count was zero), that term contributes exactly zero, which is | //| consistent because a zero count also forces that rate itself to be | //| zero; the defined flags are still honored explicitly so the | //| caller's intent is visible at the call site rather than relying on | //| the rate happening to be zero. | //+---------------------------------------------------------------------+ void CTradeQualityCalculator::ComputeExpectancy(const double win_rate,const double avg_win,const bool avg_win_defined,const double loss_rate,const double avg_loss,const bool avg_loss_defined,double &expectancy) const { double win_term=(avg_win_defined)?(win_rate*avg_win):0.0; double loss_term=(avg_loss_defined)?(loss_rate*avg_loss):0.0; expectancy=win_term-loss_term; }
ComputeWilsonInterval() implements the formula shown earlier, clamping both bounds into [0,1]. ComputeQualityScore() divides conservative expectancy by average loss size, marked undefined when average loss is undefined or zero.
//+------------------------------------------------------------------+ //| ComputeWilsonInterval | //+------------------------------------------------------------------+ void CTradeQualityCalculator::ComputeWilsonInterval(const int win_count,const int trade_count,const double z,double &lower,double &upper) const { if(trade_count<=0) { lower=0.0; upper=1.0; return; } double n=(double)trade_count; double p_hat=(double)win_count/n; double z2=z*z; double denominator=1.0+z2/n; double center=p_hat+z2/(2.0*n); double margin=z*::MathSqrt((p_hat*(1.0-p_hat)/n)+(z2/(4.0*n*n))); lower=(center-margin)/denominator; upper=(center+margin)/denominator; if(lower<0.0) lower=0.0; if(upper>1.0) upper=1.0; } //+---------------------------------------------------------------------+ //| ComputeQualityScore | //+---------------------------------------------------------------------+ void CTradeQualityCalculator::ComputeQualityScore(const double conservative_expectancy,const double avg_loss_currency,const bool avg_loss_defined,double &score,bool &score_defined) const { if(!avg_loss_defined || avg_loss_currency==0.0) { score=0.0; score_defined=false; return; } score=conservative_expectancy/avg_loss_currency; score_defined=true; }
ClassifyQualityRating() and ClassifySampleAdequacy() turn numbers into plain-language labels: below 0.0 is POOR, up to 0.15 FAIR, up to 0.35 GOOD, above that EXCELLENT, with "N/A" for an undefined score; sample counts below 20 read "Too Small To Be Reliable," below 50 "Limited," below 150 "Adequate," else "Strong."
//+--------------------------------------------------------------------+ //| ClassifyQualityRating | //+--------------------------------------------------------------------+ string CTradeQualityCalculator::ClassifyQualityRating(const double score,const bool score_defined) const { if(!score_defined) return("N/A"); if(score<QUALITY_THRESHOLD_FAIR) return("POOR"); if(score<QUALITY_THRESHOLD_GOOD) return("FAIR"); if(score<QUALITY_THRESHOLD_EXCELLENT) return("GOOD"); return("EXCELLENT"); } //+--------------------------------------------------------------------+ //| ClassifySampleAdequacy | //+--------------------------------------------------------------------+ string CTradeQualityCalculator::ClassifySampleAdequacy(const int trade_count) const { if(trade_count<SAMPLE_SIZE_LIMITED) return("Too Small To Be Reliable"); if(trade_count<SAMPLE_SIZE_ADEQUATE) return("Limited"); if(trade_count<SAMPLE_SIZE_STRONG) return("Adequate"); return("Strong"); }
Compute() orchestrates every method above into one CQualityMetrics result, working on a local copy of the trade array so pip results can be filled in without mutating the caller's data.
//+------------------------------------------------------------------+ //| Compute | //+------------------------------------------------------------------+ void CTradeQualityCalculator::Compute(const CTradeRecord &trades[],CQualityMetrics &metrics) const { int total=ArraySize(trades); //--- work on a local copy so pip results can be filled in without //--- requiring the caller's array to be non-const CTradeRecord working[]; ArrayResize(working,total); for(int i=0;i<total;i++) { working[i]=trades[i]; double pip_size=GetSymbolPipSize(trades[i].m_symbol); double pip_result=0.0; bool ok=CalculatePipResult(trades[i].m_entry_price,trades[i].m_exit_price,trades[i].m_direction,pip_size,pip_result); working[i].m_pip_result=pip_result; working[i].m_pip_result_defined=ok; } int win_count=0,loss_count=0,breakeven_count=0; double win_sum=0.0,loss_sum=0.0; ClassifyTrades(working,win_count,loss_count,breakeven_count,win_sum,loss_sum); metrics.m_trade_count = total; metrics.m_win_count = win_count; metrics.m_loss_count = loss_count; metrics.m_breakeven_count = breakeven_count; metrics.m_win_rate = (total>0)?((double)win_count/total):0.0; metrics.m_loss_rate = (total>0)?((double)loss_count/total):0.0; double avg_win=0.0,avg_loss=0.0; bool avg_win_defined=false,avg_loss_defined=false; ComputeAverageCurrency(win_sum,win_count,loss_sum,loss_count,avg_win,avg_win_defined,avg_loss,avg_loss_defined); metrics.m_avg_win_currency = avg_win; metrics.m_avg_win_currency_defined = avg_win_defined; metrics.m_avg_loss_currency = avg_loss; metrics.m_avg_loss_currency_defined = avg_loss_defined; double avg_win_pips=0.0,avg_loss_pips=0.0; bool avg_win_pips_defined=false,avg_loss_pips_defined=false; ComputeAveragePips(working,avg_win_pips,avg_win_pips_defined,avg_loss_pips,avg_loss_pips_defined); metrics.m_avg_win_pips = avg_win_pips; metrics.m_avg_win_pips_defined = avg_win_pips_defined; metrics.m_avg_loss_pips = avg_loss_pips; metrics.m_avg_loss_pips_defined = avg_loss_pips_defined; double expectancy_currency=0.0; ComputeExpectancy(metrics.m_win_rate,avg_win,avg_win_defined,metrics.m_loss_rate,avg_loss,avg_loss_defined,expectancy_currency); metrics.m_expectancy_currency=expectancy_currency; double expectancy_pips=0.0; ComputeExpectancy(metrics.m_win_rate,avg_win_pips,avg_win_pips_defined,metrics.m_loss_rate,avg_loss_pips,avg_loss_pips_defined,expectancy_pips); metrics.m_expectancy_pips=expectancy_pips; //--- expectancy per dollar risked uses average loss size as the //--- practical proxy for risk per trade; this project does not //--- track stop-loss distances, so this is a documented assumption, //--- not a hidden default if(avg_loss_defined && avg_loss!=0.0) { metrics.m_expectancy_r_multiple = expectancy_currency/avg_loss; metrics.m_expectancy_r_multiple_defined = true; } else { metrics.m_expectancy_r_multiple = 0.0; metrics.m_expectancy_r_multiple_defined = false; } double wilson_lower=0.0,wilson_upper=0.0; ComputeWilsonInterval(win_count,total,CALC_WILSON_Z_95,wilson_lower,wilson_upper); metrics.m_wilson_lower=wilson_lower; metrics.m_wilson_upper=wilson_upper; //--- conservative, confidence-adjusted expectancy: the same formula //--- as expectancy_currency, but with the Wilson lower bound standing //--- in for the observed win rate, and its complement standing in for //--- the loss rate, so both terms stay consistent with each other double conservative_expectancy=0.0; ComputeExpectancy(wilson_lower,avg_win,avg_win_defined,1.0-wilson_lower,avg_loss,avg_loss_defined,conservative_expectancy); double score=0.0; bool score_defined=false; ComputeQualityScore(conservative_expectancy,avg_loss,avg_loss_defined,score,score_defined); metrics.m_quality_score = score; metrics.m_quality_score_defined = score_defined; metrics.m_quality_rating = ClassifyQualityRating(score,score_defined); metrics.m_sample_adequacy = ClassifySampleAdequacy(total); }
Rendering the Dashboard Panel — QualityDashboardChart.mqh
CQualityDashboardChart creates its canvas lazily on the first Render() call. The object name and position are not known at construction time. Each line is drawn via DrawLabel(), which calls TextGetSize() immediately before drawing. The same font settings are applied to both CCanvas::FontSet() and TextSetFont(), so the measured width matches the rendered width. This matters because a mismatch between the font used for measurement and the font used for drawing is a classic, hard-to-notice source of visually clipped or overlapping labels.
//+------------------------------------------------------------------+ //| QualityDashboardChart.mqh | //| CCanvas-based trade quality dashboard panel | //+------------------------------------------------------------------+ #ifndef QUALITYDASHBOARDCHART_MQH #define QUALITYDASHBOARDCHART_MQH #include <Canvas\Canvas.mqh> #include "TradeQualityTypes.mqh" #define DASHBOARD_FONT_NAME "Arial" #define DASHBOARD_FONT_SIZE 16 #define DASHBOARD_FONT_FLAGS 0 #define DASHBOARD_WIDTH 360 #define DASHBOARD_HEIGHT 300 //+------------------------------------------------------------------+ //| CQualityDashboardChart | //+------------------------------------------------------------------+ class CQualityDashboardChart { private: CCanvas m_canvas; bool m_canvas_created; string m_object_name; void CreateCanvasIfNeeded(const string object_name,const int x,const int y); int DrawLabel(const int x,const int y,const string text,const uint clr); uint RatingColor(const string rating) const; public: CQualityDashboardChart(void); ~CQualityDashboardChart(void); void Render(const string object_name,const int x,const int y,const CQualityMetrics &metrics,const string title); void Clear(void); }; //+------------------------------------------------------------------+ //| Constructor | //+------------------------------------------------------------------+ CQualityDashboardChart::CQualityDashboardChart(void) { m_canvas_created = false; m_object_name = ""; } //+------------------------------------------------------------------+ //| Destructor | //+------------------------------------------------------------------+ CQualityDashboardChart::~CQualityDashboardChart(void) { }
CreateCanvasIfNeeded() builds the CCanvas object lazily on the first Render() call and configures the font consistently on both the canvas and the global text API. DrawLabel() calls TextGetSize() immediately before drawing, so measured width always matches what renders. RatingColor() maps each rating to a fixed color: red for POOR, amber for FAIR, light green for GOOD, deep green for EXCELLENT.
//+--------------------------------------------------------------------+ //| CreateCanvasIfNeeded | //+--------------------------------------------------------------------+ void CQualityDashboardChart::CreateCanvasIfNeeded(const string object_name,const int x,const int y) { if(m_canvas_created) return; m_object_name = object_name; m_canvas.CreateBitmapLabel(object_name,x,y,DASHBOARD_WIDTH,DASHBOARD_HEIGHT,COLOR_FORMAT_ARGB_NORMALIZE); m_canvas.FontSet(DASHBOARD_FONT_NAME,DASHBOARD_FONT_SIZE,DASHBOARD_FONT_FLAGS); ::TextSetFont(DASHBOARD_FONT_NAME,DASHBOARD_FONT_SIZE,DASHBOARD_FONT_FLAGS); m_canvas_created = true; } //+--------------------------------------------------------------------+ //| DrawLabel | //+--------------------------------------------------------------------+ int CQualityDashboardChart::DrawLabel(const int x,const int y,const string text,const uint clr) { uint text_width=0; uint text_height=0; ::TextGetSize(text,text_width,text_height); m_canvas.TextOut(x,y,text,clr); return((int)text_width); } //+--------------------------------------------------------------------+ //| RatingColor | //+--------------------------------------------------------------------+ uint CQualityDashboardChart::RatingColor(const string rating) const { if(rating=="POOR") return(::ColorToARGB(clrCrimson,255)); if(rating=="FAIR") return(::ColorToARGB(clrOrange,255)); if(rating=="GOOD") return(::ColorToARGB(clrYellowGreen,255)); if(rating=="EXCELLENT") return(::ColorToARGB(clrForestGreen,255)); return(::ColorToARGB(clrGray,255)); }
Render() draws the title, trade breakdown, win rate with its Wilson interval, averages, expectancy, quality score, colored rating, and sample adequacy line by line. The destructor stays empty on purpose, so a locally scoped object does not erase its own freshly drawn panel when the script function returns; Clear() is the only explicit teardown.
//+------------------------------------------------------------------+ //| Render | //+------------------------------------------------------------------+ void CQualityDashboardChart::Render(const string object_name,const int x,const int y,const CQualityMetrics &metrics,const string title) { CreateCanvasIfNeeded(object_name,x,y); m_canvas.Erase(::ColorToARGB(clrWhiteSmoke,255)); uint text_color=::ColorToARGB(clrBlack,255); int line_y=8; int line_height=20; DrawLabel(10,line_y,title,text_color); line_y+=line_height+4; string trade_line=StringFormat("Trades: %d (W:%d L:%d BE:%d)", metrics.m_trade_count,metrics.m_win_count,metrics.m_loss_count,metrics.m_breakeven_count); DrawLabel(10,line_y,trade_line,text_color); line_y+=line_height; string win_rate_line=StringFormat("Win rate: %.1f%% (95%% Wilson CI: %.1f%% - %.1f%%)", metrics.m_win_rate*100.0,metrics.m_wilson_lower*100.0,metrics.m_wilson_upper*100.0); DrawLabel(10,line_y,win_rate_line,text_color); line_y+=line_height; string avg_win_text=(metrics.m_avg_win_currency_defined)?StringFormat("%.2f",metrics.m_avg_win_currency):"n/a"; string avg_loss_text=(metrics.m_avg_loss_currency_defined)?StringFormat("%.2f",metrics.m_avg_loss_currency):"n/a"; string avg_line=StringFormat("Avg win: %s Avg loss: %s",avg_win_text,avg_loss_text); DrawLabel(10,line_y,avg_line,text_color); line_y+=line_height; string expectancy_line=StringFormat("Expectancy: %.2f (currency) %.1f pips", metrics.m_expectancy_currency,metrics.m_expectancy_pips); DrawLabel(10,line_y,expectancy_line,text_color); line_y+=line_height; string r_multiple_text=(metrics.m_expectancy_r_multiple_defined)?StringFormat("%.3fR",metrics.m_expectancy_r_multiple):"n/a"; string r_line=StringFormat("Expectancy per $ risked: %s",r_multiple_text); DrawLabel(10,line_y,r_line,text_color); line_y+=line_height; string score_text=(metrics.m_quality_score_defined)?StringFormat("%.3f",metrics.m_quality_score):"n/a"; string score_line=StringFormat("Trade Quality Score: %s",score_text); DrawLabel(10,line_y,score_line,text_color); line_y+=line_height+2; uint badge_color=RatingColor(metrics.m_quality_rating); DrawLabel(10,line_y,"Rating: "+metrics.m_quality_rating,badge_color); line_y+=line_height; DrawLabel(10,line_y,"Sample size: "+metrics.m_sample_adequacy,text_color); line_y+=line_height; m_canvas.Update(); } //+------------------------------------------------------------------+ //| Clear | //+------------------------------------------------------------------+ void CQualityDashboardChart::Clear(void) { if(!m_canvas_created) return; ::ObjectDelete(0,m_object_name); m_canvas_created=false; }
The Experts Tab Report — QualityReportPrinter.mqh
Not every trader wants to interpret a bitmap panel, and a printed log is easier to copy into a spreadsheet or a trading journal, so CQualityReportPrinter writes the same figures shown on the dashboard to the Experts tab, one PrintFormat() line per figure, every line prefixed with a caller-supplied label. That label is what makes two consecutive runs, one for the full trade universe and one for the session-filtered subset, stay distinguishable in a log that otherwise interleaves them.
//+------------------------------------------------------------------+ //| QualityReportPrinter.mqh | //| Experts tab reporting for trade quality metrics | //+------------------------------------------------------------------+ #ifndef QUALITYREPORTPRINTER_MQH #define QUALITYREPORTPRINTER_MQH #include "TradeQualityTypes.mqh" //+--------------------------------------------------------------------+ //| CQualityReportPrinter | //| Prints the same figures shown on the dashboard to the Experts tab, | //| prefixed with a caller-supplied label so that two runs (for | //| example, "All Trades" and "London Session Only") stay | //| distinguishable in the log. | //+--------------------------------------------------------------------+ class CQualityReportPrinter { public: CQualityReportPrinter(void); ~CQualityReportPrinter(void); void Print(const string label,const CQualityMetrics &metrics) const; }; //+------------------------------------------------------------------+ //| Constructor | //+------------------------------------------------------------------+ CQualityReportPrinter::CQualityReportPrinter(void) { } //+------------------------------------------------------------------+ //| Destructor | //+------------------------------------------------------------------+ CQualityReportPrinter::~CQualityReportPrinter(void) { } //+------------------------------------------------------------------+ //| Print | //| Writes one labeled block of metrics to the Experts tab. | //+------------------------------------------------------------------+ void CQualityReportPrinter::Print(const string label,const CQualityMetrics &metrics) const { ::PrintFormat("--- Trade Quality Report: %s ---",label); ::PrintFormat("%s Trades: %d (Win:%d Loss:%d Breakeven:%d)", label,metrics.m_trade_count,metrics.m_win_count,metrics.m_loss_count,metrics.m_breakeven_count); ::PrintFormat("%s Win rate: %.1f%% (95%% Wilson CI: %.1f%% - %.1f%%)", label,metrics.m_win_rate*100.0,metrics.m_wilson_lower*100.0,metrics.m_wilson_upper*100.0); if(metrics.m_avg_win_currency_defined) ::PrintFormat("%s Average win: %.2f",label,metrics.m_avg_win_currency); else ::PrintFormat("%s Average win: n/a",label); if(metrics.m_avg_loss_currency_defined) ::PrintFormat("%s Average loss: %.2f",label,metrics.m_avg_loss_currency); else ::PrintFormat("%s Average loss: n/a",label); ::PrintFormat("%s Expectancy: %.2f (currency), %.1f pips",label,metrics.m_expectancy_currency,metrics.m_expectancy_pips); if(metrics.m_expectancy_r_multiple_defined) ::PrintFormat("%s Expectancy per dollar risked: %.3fR",label,metrics.m_expectancy_r_multiple); else ::PrintFormat("%s Expectancy per dollar risked: n/a",label); if(metrics.m_quality_score_defined) ::PrintFormat("%s Trade Quality Score: %.3f (%s)",label,metrics.m_quality_score,metrics.m_quality_rating); else ::PrintFormat("%s Trade Quality Score: n/a (%s)",label,metrics.m_quality_rating); ::PrintFormat("%s Sample size: %s",label,metrics.m_sample_adequacy); } #endif // QUALITYREPORTPRINTER_MQH //+------------------------------------------------------------------+
Building the Main Script — TradeQualityDashboard.mq5
The main script ties every component together. It computes a lookback window from InpLookbackDays, reads trades with CTradeRecordReader, runs them through CTradeQualityCalculator::Compute(), and renders the full-universe dashboard and report. If InpEnableSessionFilter is enabled, it filters the same trade set down to the configured hour window with CSessionFilter, recomputes every metric on that subset with the same calculator instance, and renders a second dashboard panel and a second labeled report beside the first, so the two are visible and comparable at the same time.
//+------------------------------------------------------------------+ //| TradeQualityDashboard.mq5 | //| Main script: reads history, computes and renders the | //| full-universe and session-filtered quality dashboards | //+------------------------------------------------------------------+ #property script_show_inputs #include <TradeQualityAnalytics/TradeQualityTypes.mqh> #include <TradeQualityAnalytics/TradeRecordReader.mqh> #include <TradeQualityAnalytics/SessionFilter.mqh> #include <TradeQualityAnalytics/TradeQualityCalculator.mqh> #include <TradeQualityAnalytics/QualityDashboardChart.mqh> #include <TradeQualityAnalytics/QualityReportPrinter.mqh> input int InpLookbackDays = 90; // Lookback window, in days input string InpSymbolFilter = ""; // Symbol filter, empty = every symbol input bool InpEnableSessionFilter = true; // Also compute a session-filtered subset input int InpSessionStartHour = 7; // Session start hour (server time, inclusive) input int InpSessionEndHour = 16; // Session end hour (server time, exclusive) //+------------------------------------------------------------------+ //| Script program start function | //+------------------------------------------------------------------+ void OnStart(void) { datetime to=::TimeCurrent(); datetime from=to-(InpLookbackDays*24*60*60); CTradeRecordReader reader; CTradeRecord all_trades[]; int total=reader.ReadTrades(from,to,InpSymbolFilter,all_trades); ::PrintFormat("Loaded %d completed trades from %s to %s",total,::TimeToString(from),::TimeToString(to)); CTradeQualityCalculator calculator; CQualityMetrics all_metrics; calculator.Compute(all_trades,all_metrics); CQualityReportPrinter printer; printer.Print("All Trades",all_metrics); CQualityDashboardChart all_dashboard; all_dashboard.Render("TradeQualityDashboard_All",20,20,all_metrics,"All Trades"); if(InpEnableSessionFilter) { CSessionFilter session_filter; CTradeRecord session_trades[]; int session_total=session_filter.Filter(all_trades,InpSessionStartHour,InpSessionEndHour,session_trades); ::PrintFormat("Session filter [%02d:00-%02d:00) kept %d of %d trades", InpSessionStartHour,InpSessionEndHour,session_total,total); CQualityMetrics session_metrics; calculator.Compute(session_trades,session_metrics); printer.Print("London Session Only",session_metrics); CQualityDashboardChart session_dashboard; session_dashboard.Render("TradeQualityDashboard_Session",400,20,session_metrics,"London Session Only"); } } //+------------------------------------------------------------------+

Mock-up dashboard. Two panels side by side: all trades versus the London session subset. Each shows the trade count, win rate, Wilson interval, expectancy, and quality score, matching what the real CCanvas panel draws on the chart.
Verification and Testing — TestTradeQualityAnalytics.mq5
A dashboard that reports confidence-adjusted numbers needs to get its arithmetic right, every time, not just look right on screen. This script checks that arithmetic directly instead of relying on a trader to eyeball the panel and hope. It builds small, synthetic trade sets with known answers, runs them through the real calculator methods, and confirms the output matches what the math says it should be.
The ASSERT macro is the backbone of every test below. It checks a condition, increments a pass or fail counter, and prints the specific message tied to whichever check failed, so a broken test points straight at the problem instead of leaving a trader to guess.
//+------------------------------------------------------------------+ //| TestTradeQualityAnalytics.mq5 | //| Verification script for the trade quality analytics components | //+------------------------------------------------------------------+ #property script_show_inputs #include <TradeQualityAnalytics/TradeQualityTypes.mqh> #include <TradeQualityAnalytics/SessionFilter.mqh> #include <TradeQualityAnalytics/TradeQualityCalculator.mqh> int g_pass_count=0; int g_fail_count=0; //+--------------------------------------------------------------------+ //| ASSERT | //| Records a pass or fail and prints a message identifying the check. | //+--------------------------------------------------------------------+ #define ASSERT(condition,message) \ do \ { \ if((condition)) \ { \ g_pass_count++; \ } \ else \ { \ g_fail_count++; \ ::PrintFormat("FAIL: %s",(message)); \ } \ } \ while(false)
NearlyEqual() compares two floating-point numbers within a small tolerance rather than for exact equality, since floating-point arithmetic rarely lands on an exact match. MakeTrade() is a small helper used throughout the tests to build one synthetic CTradeRecord with a chosen net profit, price, direction, and entry hour, so each test can construct exactly the trade data it needs without repeating the same setup code.
//+------------------------------------------------------------------+ //| NearlyEqual | //+------------------------------------------------------------------+ bool NearlyEqual(const double a,const double b,const double tolerance) { return(::MathAbs(a-b)<=tolerance); } //+------------------------------------------------------------------+ //| MakeTrade | //| Builds a synthetic CTradeRecord for use in the tests below. | //+------------------------------------------------------------------+ CTradeRecord MakeTrade(const double net_profit,const double entry_price=1.1000,const double exit_price=1.1010, const ENUM_TRADE_DIRECTION direction=TRADE_DIRECTION_BUY,const int entry_hour=10) { CTradeRecord t; t.m_net_profit=net_profit; t.m_entry_price=entry_price; t.m_exit_price=exit_price; t.m_direction=direction; MqlDateTime parts; ::TimeToStruct(::TimeCurrent(),parts); parts.hour=entry_hour; parts.min=0; parts.sec=0; t.m_entry_time=::StructToTime(parts); t.m_pip_result_defined=false; return(t); }
TestPipConversion() checks CalculatePipResult() in both directions, confirming a buy and a sell each convert a fifty-pip move correctly, and then checks that a zero pip size fails cleanly and leaves the result at zero rather than producing a division error.
//+------------------------------------------------------------------+ //| TestPipConversion | //+------------------------------------------------------------------+ void TestPipConversion(void) { CTradeQualityCalculator calc; double pip_result=0.0; bool ok_buy=calc.CalculatePipResult(1.1000,1.1050,TRADE_DIRECTION_BUY,0.0001,pip_result); ASSERT(ok_buy,"buy pip conversion should succeed"); ASSERT(NearlyEqual(pip_result,50.0,0.001),"buy pip conversion should be 50 pips"); bool ok_sell=calc.CalculatePipResult(1.1050,1.1000,TRADE_DIRECTION_SELL,0.0001,pip_result); ASSERT(ok_sell,"sell pip conversion should succeed"); ASSERT(NearlyEqual(pip_result,50.0,0.001),"sell pip conversion should be 50 pips"); bool ok_invalid=calc.CalculatePipResult(1.1000,1.1050,TRADE_DIRECTION_BUY,0.0,pip_result); ASSERT(!ok_invalid,"zero pip size should fail"); ASSERT(NearlyEqual(pip_result,0.0,0.0000001),"failed pip conversion should leave pip_result at zero"); }
TestClassification() builds five synthetic trades, two winners, two losers, and one breakeven, and checks that ClassifyTrades() counts and sums each bucket correctly.
//+------------------------------------------------------------------+ //| TestClassification | //+------------------------------------------------------------------+ void TestClassification(void) { CTradeQualityCalculator calc; CTradeRecord trades[]; ArrayResize(trades,5); trades[0]=MakeTrade(100.0); trades[1]=MakeTrade(-40.0); trades[2]=MakeTrade(0.0); trades[3]=MakeTrade(60.0); trades[4]=MakeTrade(-20.0); int win_count=0,loss_count=0,breakeven_count=0; double win_sum=0.0,loss_sum=0.0; calc.ClassifyTrades(trades,win_count,loss_count,breakeven_count,win_sum,loss_sum); ASSERT(win_count==2,"win count should be 2"); ASSERT(loss_count==2,"loss count should be 2"); ASSERT(breakeven_count==1,"breakeven count should be 1"); ASSERT(NearlyEqual(win_sum,160.0,0.001),"win sum should be 160"); ASSERT(NearlyEqual(loss_sum,60.0,0.001),"loss sum should be 60"); }
TestUndefinedAverages() checks the edge cases in ComputeAverageCurrency(): a zero win count should leave average win undefined while average loss still computes normally, and the reverse should hold true for a zero loss count.
//+------------------------------------------------------------------+ //| TestUndefinedAverages | //+------------------------------------------------------------------+ void TestUndefinedAverages(void) { CTradeQualityCalculator calc; double avg_win=0.0,avg_loss=0.0; bool avg_win_defined=false,avg_loss_defined=false; calc.ComputeAverageCurrency(0.0,0,50.0,2,avg_win,avg_win_defined,avg_loss,avg_loss_defined); ASSERT(!avg_win_defined,"average win should be undefined with zero win count"); ASSERT(avg_loss_defined,"average loss should be defined with nonzero loss count"); ASSERT(NearlyEqual(avg_loss,25.0,0.001),"average loss should be 25"); calc.ComputeAverageCurrency(100.0,4,0.0,0,avg_win,avg_win_defined,avg_loss,avg_loss_defined); ASSERT(avg_win_defined,"average win should be defined with nonzero win count"); ASSERT(!avg_loss_defined,"average loss should be undefined with zero loss count"); }
TestExpectancyIdentity() is the proof that the expectancy formula and total net profit divided by trade count really are the same number. It builds a six-trade set that includes one breakeven trade, computes expectancy through ComputeExpectancy(), computes the same figure directly from total profit, and checks the two match.
//+----------------------------------------------------------------------+ //| TestExpectancyIdentity | //+----------------------------------------------------------------------+ void TestExpectancyIdentity(void) { CTradeQualityCalculator calc; CTradeRecord trades[]; ArrayResize(trades,6); trades[0]=MakeTrade(120.0); trades[1]=MakeTrade(80.0); trades[2]=MakeTrade(-50.0); trades[3]=MakeTrade(-30.0); trades[4]=MakeTrade(-10.0); trades[5]=MakeTrade(0.0); int win_count=0,loss_count=0,breakeven_count=0; double win_sum=0.0,loss_sum=0.0; calc.ClassifyTrades(trades,win_count,loss_count,breakeven_count,win_sum,loss_sum); int total=ArraySize(trades); double win_rate=(double)win_count/total; double loss_rate=(double)loss_count/total; double avg_win=0.0,avg_loss=0.0; bool avg_win_defined=false,avg_loss_defined=false; calc.ComputeAverageCurrency(win_sum,win_count,loss_sum,loss_count,avg_win,avg_win_defined,avg_loss,avg_loss_defined); double expectancy=0.0; calc.ComputeExpectancy(win_rate,avg_win,avg_win_defined,loss_rate,avg_loss,avg_loss_defined,expectancy); double total_net_profit=win_sum-loss_sum; double direct_expectancy=total_net_profit/total; ASSERT(NearlyEqual(expectancy,direct_expectancy,0.0001), "expectancy formula should equal total net profit divided by trade count"); }
TestExpectancyZeroRateZeroTerm() checks a narrower edge case inside ComputeExpectancy(): when a rate is exactly zero but its matching average is undefined, that side of the formula must still contribute exactly zero rather than something close to it.
//+---------------------------------------------------------------------+ //| TestExpectancyZeroRateZeroTerm | //+---------------------------------------------------------------------+ void TestExpectancyZeroRateZeroTerm(void) { CTradeQualityCalculator calc; double expectancy=0.0; //--- zero win rate, undefined average win (no wins occurred at all) calc.ComputeExpectancy(0.0,0.0,false,1.0,40.0,true,expectancy); ASSERT(NearlyEqual(expectancy,-40.0,0.0001),"zero win rate with undefined avg win should contribute exactly zero"); }
TestWilsonInterval() checks ComputeWilsonInterval() two ways. First, it confirms fifty wins out of a hundred trades produces bounds close to a known reference value. Second, it confirms a ten-trade sample produces a visibly wider interval than a thousand-trade sample at the same observed proportion, proving the interval genuinely narrows as evidence accumulates.
//+------------------------------------------------------------------+ //| TestWilsonInterval | //+------------------------------------------------------------------+ void TestWilsonInterval(void) { CTradeQualityCalculator calc; double lower=0.0,upper=0.0; //--- known reference value: 50/100 wins at 95% confidence calc.ComputeWilsonInterval(50,100,CALC_WILSON_Z_95,lower,upper); ASSERT(NearlyEqual(lower,0.404,0.01),"Wilson lower bound for 50/100 should be near 0.404"); ASSERT(NearlyEqual(upper,0.596,0.01),"Wilson upper bound for 50/100 should be near 0.596"); //--- same observed proportion, much larger sample: interval should narrow double small_lower=0.0,small_upper=0.0; double large_lower=0.0,large_upper=0.0; calc.ComputeWilsonInterval(6,10,CALC_WILSON_Z_95,small_lower,small_upper); calc.ComputeWilsonInterval(600,1000,CALC_WILSON_Z_95,large_lower,large_upper); double small_width=small_upper-small_lower; double large_width=large_upper-large_lower; ASSERT(large_width<small_width,"a larger sample at the same proportion should produce a narrower interval"); }
TestQualityScoreUndefined() checks all three states of ComputeQualityScore(): undefined average loss, a defined but exactly zero average loss, and a normal nonzero average loss that should produce a defined score with the correct value.
//+------------------------------------------------------------------+ //| TestQualityScoreUndefined | //+------------------------------------------------------------------+ void TestQualityScoreUndefined(void) { CTradeQualityCalculator calc; double score=0.0; bool score_defined=false; calc.ComputeQualityScore(25.0,0.0,false,score,score_defined); ASSERT(!score_defined,"quality score should be undefined when average loss is undefined"); calc.ComputeQualityScore(25.0,0.0,true,score,score_defined); ASSERT(!score_defined,"quality score should be undefined when average loss is exactly zero"); calc.ComputeQualityScore(25.0,50.0,true,score,score_defined); ASSERT(score_defined,"quality score should be defined for a normal, nonzero average loss"); ASSERT(NearlyEqual(score,0.5,0.0001),"quality score should equal conservative expectancy divided by average loss"); }
TestQualityRatingBoundaries() walks ClassifyQualityRating() across every threshold edge, checking scores just below and exactly at each cutoff, so a rounding mistake at a boundary cannot slip through unnoticed.
//+------------------------------------------------------------------+ //| TestQualityRatingBoundaries | //+------------------------------------------------------------------+ void TestQualityRatingBoundaries(void) { CTradeQualityCalculator calc; ASSERT(calc.ClassifyQualityRating(-0.01,true)=="POOR","just below 0.0 should be POOR"); ASSERT(calc.ClassifyQualityRating(0.0,true)=="FAIR","exactly 0.0 should be FAIR"); ASSERT(calc.ClassifyQualityRating(0.149999,true)=="FAIR","just below 0.15 should be FAIR"); ASSERT(calc.ClassifyQualityRating(0.15,true)=="GOOD","exactly 0.15 should be GOOD"); ASSERT(calc.ClassifyQualityRating(0.349999,true)=="GOOD","just below 0.35 should be GOOD"); ASSERT(calc.ClassifyQualityRating(0.35,true)=="EXCELLENT","exactly 0.35 should be EXCELLENT"); ASSERT(calc.ClassifyQualityRating(0.0,false)=="N/A","undefined score should be N/A regardless of value"); }
TestSessionWindowBoundaries() checks IsInSessionWindow() for both a normal window and a midnight-wrapping window, testing the exact start hour, the exact end hour, and the hour just before the start, in each case.
//+------------------------------------------------------------------+ //| TestSessionWindowBoundaries | //+------------------------------------------------------------------+ void TestSessionWindowBoundaries(void) { CSessionFilter filter; MqlDateTime parts; ::TimeToStruct(::TimeCurrent(),parts); parts.min=0; parts.sec=0; //--- normal window [7,16) parts.hour=7; datetime t_start=::StructToTime(parts); ASSERT(filter.IsInSessionWindow(t_start,7,16),"hour 7 should be inside [7,16)"); parts.hour=16; datetime t_end=::StructToTime(parts); ASSERT(!filter.IsInSessionWindow(t_end,7,16),"hour 16 should be outside [7,16)"); parts.hour=6; datetime t_before=::StructToTime(parts); ASSERT(!filter.IsInSessionWindow(t_before,7,16),"hour 6 should be outside [7,16)"); //--- wrapping window [22,6) parts.hour=22; datetime t_wrap_start=::StructToTime(parts); ASSERT(filter.IsInSessionWindow(t_wrap_start,22,6),"hour 22 should be inside wrapping [22,6)"); parts.hour=6; datetime t_wrap_end=::StructToTime(parts); ASSERT(!filter.IsInSessionWindow(t_wrap_end,22,6),"hour 6 should be outside wrapping [22,6)"); parts.hour=0; datetime t_wrap_midnight=::StructToTime(parts); ASSERT(filter.IsInSessionWindow(t_wrap_midnight,22,6),"hour 0 should be inside wrapping [22,6)"); parts.hour=21; datetime t_wrap_before=::StructToTime(parts); ASSERT(!filter.IsInSessionWindow(t_wrap_before,22,6),"hour 21 should be outside wrapping [22,6)"); }
OnStart() runs every test function above in sequence and prints one final line summarizing how many checks passed and how many failed.
//+------------------------------------------------------------------+ //| Script program start function | //+------------------------------------------------------------------+ void OnStart(void) { TestPipConversion(); TestClassification(); TestUndefinedAverages(); TestExpectancyIdentity(); TestExpectancyZeroRateZeroTerm(); TestWilsonInterval(); TestQualityScoreUndefined(); TestQualityRatingBoundaries(); TestSessionWindowBoundaries(); ::PrintFormat("=== Test summary: %d passed, %d failed ===",g_pass_count,g_fail_count); }
Extending the Dashboard
A few directions this project deliberately leaves open, since each one is a meaningful design decision on its own rather than a small tweak:
- Real stop-loss-based risk: Replacing the average-loss-size proxy for risk with each position's actual stop-loss distance, read from the position's history if it is retained, would turn the R-multiple from an approximation into a measurement of risk as it was planned rather than risk as it happened to turn out.
- Rolling or walk-forward scoring: Computing the Trade Quality Score on a rolling window (the last 100 trades, updated as new trades close) rather than the full history would let a trader see whether a strategy's edge is improving, decaying, or holding steady, rather than seeing one static number.
- Automatic per-symbol breakdown: InpSymbolFilter already restricts a run to one symbol at a time, so comparing instruments today means re-running the script manually for each one. Looping over every distinct symbol found in history and rendering one panel per symbol in a single run, the same way the session filter already renders two panels side by side, would let a multi-symbol account see which instruments are actually carrying its edge without repeated manual runs.
- Additional session windows: The script currently supports one configurable window; running the same filter and calculator against several named windows (Asian, London, New York) and rendering a panel per window would extend the side-by-side comparison already built here.
Limitations and Design Tradeoffs
The Trade Quality Score is a summary built for comparison and triage, not a guarantee about future trades. It compresses a trading history into one number on purpose, and every compression throws information away. It says nothing about the order trades occurred in, so it cannot tell a trader whether their edge came with a punishing losing streak buried in the middle of an otherwise fine track record.
The score also stays silent on drawdown and regime dependence. Two strategies with identical scores can carry very different emotional and practical costs to actually trade, since neither figure captures how deep or how long a losing stretch felt. A strategy scored only during a trending market, and never tested in a ranging one, may carry a score that quietly assumes trending conditions will continue. None of these are flaws in the arithmetic; they are the honest cost of building one number out of many trades, and a trader should treat the score as a starting point for questions rather than an ending point for judgment.
The expectancy-per-dollar-risked figure carries its own separate limitation. It uses average loss size as a stand-in for risk, because closed deal history does not carry stop-loss distances. A strategy that sizes its stops inconsistently, wide on some trades and tight on others, will show an R-multiple that reflects the average of that inconsistency rather than any single, well-defined unit of risk.
Conclusion
Every piece of this project points at the same underlying idea: a number is only as trustworthy as the honesty of what stands behind it. Expectancy is more honest than win rate because it accounts for the size of wins and losses, not just their frequency. The Wilson interval is more honest than a raw win rate because it accounts for how much evidence that win rate is actually built on, so a strategy backed by hundreds of trades earns a tighter, more confident range than one backed by a handful. The Trade Quality Score then carries that honesty one step further, built from the Wilson interval's conservative edge rather than the flattering raw number.
That choice matters in practice. A trader comparing several strategies side by side does not need to remember which one had a thin sample or which win rate was still statistically shaky; the score already accounts for that. None of this replaces judgment, and none of it should be mistaken for a guarantee of future results. What it offers instead is a dashboard that will not quietly flatter a strategy just because its sample happened to be small and lucky, which is exactly the kind of tool a serious trader can actually rely on when deciding where to commit real risk.
Programs used in the article:
| # | Name | Type | Description |
|---|---|---|---|
| 1 | TradeQualityTypes.mqh | Include File | This file defines the CTradeRecord and CQualityMetrics classes that every other component depends on. |
| 2 | TradeRecordReader.mqh | Include File | This file groups closed deal history into one completed trade record per position. |
| 3 | SessionFilter.mqh | Include File | It filters trades to a configurable hour-of-day window, including windows that wrap past midnight. |
| 4 | TradeQualityCalculator.mqh | Include File | Computes expectancy, the Wilson confidence interval, and the Trade Quality Score. |
| 5 | QualityDashboardChart.mqh | Include File | Renders the CCanvas-based dashboard panel shown on the chart. |
| 6 | QualityReportPrinter.mqh | Include File | Prints the same figures shown on the dashboard to the Experts tab. |
| 7 | TradeQualityDashboard.mq5 | Script | This script reads trade history, computes the metrics, and renders both dashboards. |
| 8 | TestTradeQualityAnalytics.mq5 | Script | This script runs the automated verification suite and prints a pass and fail summary. |
| 9 | TradeQualityAnalytics.zip | Zip Archive | Zip archive containing all the attached files and their paths relative to the terminal's root 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.
Building Your Personal Expert Advisor (Part 6): Risk Management V — Portfolio and Correlated Risk
Defining your Edge (Part 4): Applying Isotonic Regression and PNN Price-Forecasting in an Expert Advisor
Measuring What Matters (Part 4): Reading the Spectrum — What Eigenvalues Tell You About Risk
Market Simulation: Position View (XVI)
- Free trading apps
- Over 8,000 signals for copying
- Economic news for exploring financial markets
You agree to website policy and terms of use