//+------------------------------------------------------------------+
//| 76_H1_M1_Boundary_Auditor_v1_00.mq5                            |
//| Audits closed H1 bars against an independent M1 reconstruction. |
//+------------------------------------------------------------------+
#property copyright "FXおもしろラボ"
#property version   "1.00"
#property strict
#property description "Read-only H1 boundary and M1 OHLC reconstruction audit."

#property indicator_chart_window
#property indicator_buffers 14
#property indicator_plots   14

input int InpAuditBars = 120;              // Number of closed H1 bars
input int InpExpectedOpenMinute = 0;        // Expected H1 opening minute (0..59)
input double InpTolerancePoints = 0.1;      // OHLC comparison tolerance in points
input bool InpCompareTickVolume = true;     // Compare summed M1 tick volume
input bool InpShowPanel = true;             // Show the audit summary
input bool InpMarkIssues = true;            // Mark inconsistent H1 bars

double g_status[];
double g_audited_bars[];
double g_boundary_mismatches[];
double g_ohlc_mismatches[];
double g_volume_mismatches[];
double g_missing_m1[];
double g_late_start_bars[];
double g_max_leading_minutes[];
double g_max_price_error_points[];
double g_first_issue_time[];
double g_first_issue_code[];
double g_m1_bars_used[];
double g_h1_synchronized[];
double g_m1_synchronized[];

const string OBJECT_PREFIX = "MQL76_AUDIT_";

enum ENUM_AUDIT_ISSUE
{
   ISSUE_NONE = 0,
   ISSUE_BOUNDARY = 1,
   ISSUE_NO_M1 = 2,
   ISSUE_OHLC = 4,
   ISSUE_TICK_VOLUME = 8
};

struct AuditSummary
{
   int status;
   int audited;
   int boundary_mismatches;
   int ohlc_mismatches;
   int volume_mismatches;
   int missing_m1;
   int late_start_bars;
   int max_leading_minutes;
   double max_price_error_points;
   datetime first_issue_time;
   int first_issue_code;
   int m1_bars_used;
   bool h1_synchronized;
   bool m1_synchronized;
};

void ResetSummary(AuditSummary &summary)
{
   ZeroMemory(summary);
}

datetime ExpectedHourStart(const datetime value)
{
   MqlDateTime parts = {};
   if(!TimeToStruct(value, parts))
      return 0;
   parts.min = InpExpectedOpenMinute;
   parts.sec = 0;
   datetime expected = StructToTime(parts);
   if(expected > value)
      expected -= PeriodSeconds(PERIOD_H1);
   return expected;
}

bool FiniteRate(const MqlRates &rate)
{
   return rate.time > 0 &&
          MathIsValidNumber(rate.open) &&
          MathIsValidNumber(rate.high) &&
          MathIsValidNumber(rate.low) &&
          MathIsValidNumber(rate.close) &&
          rate.open > 0.0 && rate.high > 0.0 &&
          rate.low > 0.0 && rate.close > 0.0 &&
          rate.high >= MathMax(rate.open, rate.close) &&
          rate.low <= MathMin(rate.open, rate.close);
}

void RecordIssue(AuditSummary &summary,
                 const datetime bar_time,
                 const int issue_code)
{
   if(summary.first_issue_time == 0)
   {
      summary.first_issue_time = bar_time;
      summary.first_issue_code = issue_code;
   }
}

bool CreateLabel(const string key,
                 const string text,
                 const int y,
                 const color shade,
                 const int size = 11)
{
   const string name = OBJECT_PREFIX + "LABEL_" + key;
   if(ObjectFind(0, name) < 0 && !ObjectCreate(0, name, OBJ_LABEL, 0, 0, 0))
      return false;
   return ObjectSetInteger(0, name, OBJPROP_CORNER, CORNER_LEFT_UPPER) &&
          ObjectSetInteger(0, name, OBJPROP_XDISTANCE, 25) &&
          ObjectSetInteger(0, name, OBJPROP_YDISTANCE, y) &&
          ObjectSetInteger(0, name, OBJPROP_COLOR, shade) &&
          ObjectSetInteger(0, name, OBJPROP_FONTSIZE, size) &&
          ObjectSetInteger(0, name, OBJPROP_SELECTABLE, false) &&
          ObjectSetInteger(0, name, OBJPROP_HIDDEN, true) &&
          ObjectSetString(0, name, OBJPROP_FONT, "Consolas") &&
          ObjectSetString(0, name, OBJPROP_TEXT, text);
}

void DrawPanel(const AuditSummary &summary)
{
   if(!InpShowPanel)
      return;

   const string panel = OBJECT_PREFIX + "PANEL";
   if(ObjectFind(0, panel) < 0 && !ObjectCreate(0, panel, OBJ_RECTANGLE_LABEL, 0, 0, 0))
      return;
   const color status_color = summary.status > 0 ? clrLime
                              : (summary.status < 0 ? clrTomato : clrGold);
   const string status_text = summary.status > 0 ? "PASS"
                              : (summary.status < 0 ? "CHECK" : "WAITING");
   ObjectSetInteger(0, panel, OBJPROP_CORNER, CORNER_LEFT_UPPER);
   ObjectSetInteger(0, panel, OBJPROP_XDISTANCE, 10);
   ObjectSetInteger(0, panel, OBJPROP_YDISTANCE, 12);
   ObjectSetInteger(0, panel, OBJPROP_XSIZE, 700);
   ObjectSetInteger(0, panel, OBJPROP_YSIZE, 176);
   ObjectSetInteger(0, panel, OBJPROP_BGCOLOR, C'5,18,10');
   ObjectSetInteger(0, panel, OBJPROP_BORDER_COLOR, clrDarkGreen);
   ObjectSetInteger(0, panel, OBJPROP_SELECTABLE, false);
   ObjectSetInteger(0, panel, OBJPROP_HIDDEN, true);

   CreateLabel("TITLE", "H1 BOUNDARY & M1 RECONSTRUCTION AUDIT", 24, status_color, 13);
   CreateLabel("SOURCE",
               StringFormat("%s H1 | confirmed %d bars | expected open :%02d",
                            _Symbol, summary.audited, InpExpectedOpenMinute),
               50, clrWhite);
   CreateLabel("BOUNDARY",
               StringFormat("boundary mismatch %d | M1 unavailable %d | M1 bars %d",
                            summary.boundary_mismatches,
                            summary.missing_m1,
                            summary.m1_bars_used),
               74, clrAqua);
   CreateLabel("VALUES",
               StringFormat("OHLC mismatch %d | tick-volume mismatch %d | max %.3f point",
                            summary.ohlc_mismatches,
                            summary.volume_mismatches,
                            summary.max_price_error_points),
               98, clrAqua);
   CreateLabel("GAPS",
               StringFormat("first M1 later than bucket: %d bars | max %d min",
                            summary.late_start_bars,
                            summary.max_leading_minutes),
               122, clrGold);
   CreateLabel("SAFE",
               StringFormat("%s | synchronized H1=%s M1=%s | read-only / no trades",
                            status_text,
                            summary.h1_synchronized ? "YES" : "NO",
                            summary.m1_synchronized ? "YES" : "NO"),
               146, status_color);
}

void MarkIssue(const datetime bar_time, const int issue_code)
{
   if(!InpMarkIssues || issue_code == ISSUE_NONE)
      return;
   const string name = OBJECT_PREFIX + "ISSUE_" + IntegerToString((long)bar_time);
   if(ObjectCreate(0, name, OBJ_VLINE, 0, bar_time, 0.0))
   {
      ObjectSetInteger(0, name, OBJPROP_COLOR, clrTomato);
      ObjectSetInteger(0, name, OBJPROP_STYLE, STYLE_DASH);
      ObjectSetInteger(0, name, OBJPROP_WIDTH, 1);
      ObjectSetInteger(0, name, OBJPROP_SELECTABLE, false);
      ObjectSetInteger(0, name, OBJPROP_HIDDEN, true);
      ObjectSetString(0, name, OBJPROP_TOOLTIP,
                      StringFormat("H1 audit issue code=%d", issue_code));
   }
}

void WriteValue(double &buffer[], const int index, const double value)
{
   if(index >= 0 && index < ArraySize(buffer))
      buffer[index] = value;
}

void PublishSummary(const AuditSummary &summary)
{
   const int index = ArraySize(g_status) - 1;
   if(index < 0)
      return;
   WriteValue(g_status, index, summary.status);
   WriteValue(g_audited_bars, index, summary.audited);
   WriteValue(g_boundary_mismatches, index, summary.boundary_mismatches);
   WriteValue(g_ohlc_mismatches, index, summary.ohlc_mismatches);
   WriteValue(g_volume_mismatches, index, summary.volume_mismatches);
   WriteValue(g_missing_m1, index, summary.missing_m1);
   WriteValue(g_late_start_bars, index, summary.late_start_bars);
   WriteValue(g_max_leading_minutes, index, summary.max_leading_minutes);
   WriteValue(g_max_price_error_points, index, summary.max_price_error_points);
   WriteValue(g_first_issue_time, index, (double)summary.first_issue_time);
   WriteValue(g_first_issue_code, index, summary.first_issue_code);
   WriteValue(g_m1_bars_used, index, summary.m1_bars_used);
   WriteValue(g_h1_synchronized, index, summary.h1_synchronized ? 1.0 : 0.0);
   WriteValue(g_m1_synchronized, index, summary.m1_synchronized ? 1.0 : 0.0);
}

bool ReadSynchronization(const ENUM_TIMEFRAMES period, bool &synchronized)
{
   long value = 0;
   ResetLastError();
   if(!SeriesInfoInteger(_Symbol, period, SERIES_SYNCHRONIZED, value))
      return false;
   synchronized = value != 0;
   return true;
}

bool RunAudit(AuditSummary &summary)
{
   ResetSummary(summary);
   if(!ReadSynchronization(PERIOD_H1, summary.h1_synchronized) ||
      !ReadSynchronization(PERIOD_M1, summary.m1_synchronized) ||
      !summary.h1_synchronized || !summary.m1_synchronized)
      return false;

   MqlRates hourly[];
   ArraySetAsSeries(hourly, false);
   ResetLastError();
   const int hourly_copied = CopyRates(_Symbol, PERIOD_H1, 1, InpAuditBars, hourly);
   if(hourly_copied != InpAuditBars)
      return false;
   ArraySetAsSeries(hourly, false);

   for(int index = 0; index < hourly_copied; ++index)
   {
      if(!FiniteRate(hourly[index]) ||
         (index > 0 && hourly[index].time <= hourly[index - 1].time))
         return false;
   }

   const datetime range_start = ExpectedHourStart(hourly[0].time);
   const datetime last_start = ExpectedHourStart(hourly[hourly_copied - 1].time);
   const datetime range_stop = last_start + PeriodSeconds(PERIOD_H1) - 1;
   if(range_start <= 0 || range_stop <= range_start)
      return false;

   MqlRates minute[];
   ArraySetAsSeries(minute, false);
   ResetLastError();
   const int minute_copied = CopyRates(_Symbol, PERIOD_M1, range_start, range_stop, minute);
   if(minute_copied <= 0)
      return false;
   ArraySetAsSeries(minute, false);
   for(int index = 0; index < minute_copied; ++index)
   {
      if(!FiniteRate(minute[index]) ||
         (index > 0 && minute[index].time <= minute[index - 1].time))
         return false;
   }

   const double point = SymbolInfoDouble(_Symbol, SYMBOL_POINT);
   if(!MathIsValidNumber(point) || point <= 0.0)
      return false;
   const double tolerance = point * InpTolerancePoints;
   int minute_index = 0;
   ObjectsDeleteAll(0, OBJECT_PREFIX + "ISSUE_");

   for(int hour_index = 0; hour_index < hourly_copied; ++hour_index)
   {
      const datetime expected_start = ExpectedHourStart(hourly[hour_index].time);
      const datetime expected_stop = expected_start + PeriodSeconds(PERIOD_H1);
      int issue_code = ISSUE_NONE;
      if(hourly[hour_index].time != expected_start)
      {
         ++summary.boundary_mismatches;
         issue_code |= ISSUE_BOUNDARY;
      }

      while(minute_index < minute_copied && minute[minute_index].time < expected_start)
         ++minute_index;
      int scan = minute_index;
      int bucket_count = 0;
      long tick_volume_sum = 0;
      double rebuilt_open = 0.0;
      double rebuilt_high = -DBL_MAX;
      double rebuilt_low = DBL_MAX;
      double rebuilt_close = 0.0;
      datetime first_minute_time = 0;
      while(scan < minute_copied && minute[scan].time < expected_stop)
      {
         if(minute[scan].time >= expected_start)
         {
            if(bucket_count == 0)
            {
               rebuilt_open = minute[scan].open;
               first_minute_time = minute[scan].time;
            }
            rebuilt_high = MathMax(rebuilt_high, minute[scan].high);
            rebuilt_low = MathMin(rebuilt_low, minute[scan].low);
            rebuilt_close = minute[scan].close;
            tick_volume_sum += minute[scan].tick_volume;
            ++bucket_count;
         }
         ++scan;
      }
      minute_index = scan;
      summary.m1_bars_used += bucket_count;

      if(bucket_count <= 0)
      {
         ++summary.missing_m1;
         issue_code |= ISSUE_NO_M1;
      }
      else
      {
         const int leading_minutes = (int)((first_minute_time - expected_start) / 60);
         if(leading_minutes > 0)
         {
            ++summary.late_start_bars;
            summary.max_leading_minutes = MathMax(summary.max_leading_minutes,
                                                   leading_minutes);
         }

         const double open_error = MathAbs(hourly[hour_index].open - rebuilt_open);
         const double high_error = MathAbs(hourly[hour_index].high - rebuilt_high);
         const double low_error = MathAbs(hourly[hour_index].low - rebuilt_low);
         const double close_error = MathAbs(hourly[hour_index].close - rebuilt_close);
         const double price_error = MathMax(MathMax(open_error, high_error),
                                            MathMax(low_error, close_error));
         summary.max_price_error_points = MathMax(summary.max_price_error_points,
                                                  price_error / point);
         if(price_error > tolerance)
         {
            ++summary.ohlc_mismatches;
            issue_code |= ISSUE_OHLC;
         }
         if(InpCompareTickVolume && tick_volume_sum != hourly[hour_index].tick_volume)
         {
            ++summary.volume_mismatches;
            issue_code |= ISSUE_TICK_VOLUME;
         }
      }

      if(issue_code != ISSUE_NONE)
      {
         RecordIssue(summary, hourly[hour_index].time, issue_code);
         MarkIssue(hourly[hour_index].time, issue_code);
      }
      ++summary.audited;
   }

   summary.status = summary.audited == InpAuditBars &&
                    summary.boundary_mismatches == 0 &&
                    summary.ohlc_mismatches == 0 &&
                    summary.volume_mismatches == 0 &&
                    summary.missing_m1 == 0 ? 1 : -1;
   return true;
}

void RefreshAudit()
{
   AuditSummary summary;
   if(!RunAudit(summary))
   {
      ResetSummary(summary);
      summary.status = 0;
      ReadSynchronization(PERIOD_H1, summary.h1_synchronized);
      ReadSynchronization(PERIOD_M1, summary.m1_synchronized);
   }
   PublishSummary(summary);
   DrawPanel(summary);
   IndicatorSetString(INDICATOR_SHORTNAME,
                      summary.status > 0 ? "H1/M1 audit #76 | PASS"
                      : (summary.status < 0 ? "H1/M1 audit #76 | CHECK"
                                            : "H1/M1 audit #76 | waiting"));
}

int OnInit()
{
   if(_Period != PERIOD_H1 ||
      InpAuditBars < 10 || InpAuditBars > 500 ||
      InpExpectedOpenMinute < 0 || InpExpectedOpenMinute > 59 ||
      !MathIsValidNumber(InpTolerancePoints) ||
      InpTolerancePoints < 0.0 || InpTolerancePoints > 10.0)
      return INIT_PARAMETERS_INCORRECT;

   if(!SetIndexBuffer(0, g_status, INDICATOR_DATA) ||
      !SetIndexBuffer(1, g_audited_bars, INDICATOR_DATA) ||
      !SetIndexBuffer(2, g_boundary_mismatches, INDICATOR_DATA) ||
      !SetIndexBuffer(3, g_ohlc_mismatches, INDICATOR_DATA) ||
      !SetIndexBuffer(4, g_volume_mismatches, INDICATOR_DATA) ||
      !SetIndexBuffer(5, g_missing_m1, INDICATOR_DATA) ||
      !SetIndexBuffer(6, g_late_start_bars, INDICATOR_DATA) ||
      !SetIndexBuffer(7, g_max_leading_minutes, INDICATOR_DATA) ||
      !SetIndexBuffer(8, g_max_price_error_points, INDICATOR_DATA) ||
      !SetIndexBuffer(9, g_first_issue_time, INDICATOR_DATA) ||
      !SetIndexBuffer(10, g_first_issue_code, INDICATOR_DATA) ||
      !SetIndexBuffer(11, g_m1_bars_used, INDICATOR_DATA) ||
      !SetIndexBuffer(12, g_h1_synchronized, INDICATOR_DATA) ||
      !SetIndexBuffer(13, g_m1_synchronized, INDICATOR_DATA))
      return INIT_FAILED;

   ArraySetAsSeries(g_status, false);
   ArraySetAsSeries(g_audited_bars, false);
   ArraySetAsSeries(g_boundary_mismatches, false);
   ArraySetAsSeries(g_ohlc_mismatches, false);
   ArraySetAsSeries(g_volume_mismatches, false);
   ArraySetAsSeries(g_missing_m1, false);
   ArraySetAsSeries(g_late_start_bars, false);
   ArraySetAsSeries(g_max_leading_minutes, false);
   ArraySetAsSeries(g_max_price_error_points, false);
   ArraySetAsSeries(g_first_issue_time, false);
   ArraySetAsSeries(g_first_issue_code, false);
   ArraySetAsSeries(g_m1_bars_used, false);
   ArraySetAsSeries(g_h1_synchronized, false);
   ArraySetAsSeries(g_m1_synchronized, false);

   for(int plot = 0; plot < 14; ++plot)
   {
      PlotIndexSetInteger(plot, PLOT_DRAW_TYPE, DRAW_NONE);
      PlotIndexSetInteger(plot, PLOT_SHOW_DATA, false);
      PlotIndexSetDouble(plot, PLOT_EMPTY_VALUE, EMPTY_VALUE);
   }
   EventSetTimer(1);
   IndicatorSetString(INDICATOR_SHORTNAME, "H1/M1 audit #76 | waiting");
   return INIT_SUCCEEDED;
}

void OnDeinit(const int reason)
{
   EventKillTimer();
   if(InpShowPanel || InpMarkIssues)
      ObjectsDeleteAll(0, OBJECT_PREFIX);
}

void OnTimer()
{
   RefreshAudit();
   if(InpShowPanel || InpMarkIssues)
      ChartRedraw(0);
}

int OnCalculate(const int rates_total,
                const int prev_calculated,
                const datetime &time[],
                const double &open[],
                const double &high[],
                const double &low[],
                const double &close[],
                const long &tick_volume[],
                const long &volume[],
                const int &spread[])
{
   RefreshAudit();
   return rates_total;
}
//+------------------------------------------------------------------+
