//+------------------------------------------------------------------+
//|                                  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                                                      |
//+------------------------------------------------------------------+
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                                                |
//+------------------------------------------------------------------+
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                                               |
//+------------------------------------------------------------------+
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                                            |
//+------------------------------------------------------------------+
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                                               |
//| Verifies WinRate*AvgWin - LossRate*AvgLoss equals TotalNetProfit /   |
//| TradeCount for a synthetic trade set including a breakeven trade.    |
//+----------------------------------------------------------------------+
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                                      |
//| A rate that is exactly zero must contribute exactly zero even when  |
//| its corresponding average is undefined.                             |
//+---------------------------------------------------------------------+
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                                               |
//+------------------------------------------------------------------+
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                                        |
//+------------------------------------------------------------------+
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                                      |
//+------------------------------------------------------------------+
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                                      |
//+------------------------------------------------------------------+
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)");
  }

//+------------------------------------------------------------------+
//| 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);
  }
//+------------------------------------------------------------------+