//+------------------------------------------------------------------+
//| 55_Fixed_Spread_Custom_Symbol_v1_00.mq5                         |
//| Build a tester-ready custom symbol with a fixed tick spread.     |
//+------------------------------------------------------------------+
#property copyright "FX Omoshiro Lab"
#property link      "https://fx-omoshiro-lab.com/mql-port/55-fixed-spread-custom-symbol/"
#property version   "1.00"
#property strict
#property indicator_chart_window
#property indicator_buffers 12
#property indicator_plots   0

input string InpCustomSymbolName = ""; // 空欄なら安全な名前を自動生成
input int InpFixedSpreadPoints = 30;    // 固定スプレッド（ポイント）
input int InpTicksToCopy = 20000;       // 複製する最新ティック数
input int InpTimeoutSec = 60;           // 同期・再読の上限秒
input bool InpShowPanel = true;          // 結果パネルを表示

#define OWNER_PREFIX "MQL55_FIXED_SPREAD_V1|"
#define OBJECT_PREFIX "MQL55_FIXED_SPREAD_"
#define STATUS_BUILDING  1
#define STATUS_VERIFYING 2
#define STATUS_PASSED    3
#define STATUS_FAILED   -1

double g_status_buffer[];
double g_spread_buffer[];
double g_requested_buffer[];
double g_copied_buffer[];
double g_replaced_buffer[];
double g_verified_buffer[];
double g_mismatches_buffer[];
double g_min_spread_buffer[];
double g_max_spread_buffer[];
double g_first_msc_buffer[];
double g_last_msc_buffer[];
double g_last_error_buffer[];

MqlTick g_source_ticks[];
MqlTick g_expected_ticks[];

string g_custom_symbol = "";
string g_owner_marker = "";
string g_original_comment = "";
int g_status = STATUS_BUILDING;
int g_requested = 0;
int g_copied = 0;
int g_replaced = 0;
int g_verified = 0;
int g_mismatches = 0;
int g_min_spread = 0;
int g_max_spread = 0;
int g_last_error = 0;
long g_first_msc = 0;
long g_last_msc = 0;
datetime g_started_at = 0;

bool BindBuffers();
void SetBuffersAsSeries();
bool BuffersReady();
void UpdateBuffers();
string BuildAutomaticName();
string SanitizeNamePart(const string source);
bool IsAllowedSymbolName(const string name);
bool IsAllowedNameCharacter(const ushort character);
bool PrepareOwnedCustomSymbol();
bool BuildFixedSpreadHistory();
bool VerifyFixedSpreadHistory();
bool TickPayloadMatches(const MqlTick &actual,
                        const MqlTick &expected,
                        const double tolerance);
void DrawPanel();
void SetFailed(const int error, const string message);
string StatusText();
string FormatTickTime(const long time_msc);
double SpreadInPips(const int points);

int OnInit()
{
   const double point =
      SymbolInfoDouble(_Symbol, SYMBOL_POINT);
   if(InpFixedSpreadPoints < 0 ||
      InpFixedSpreadPoints > 1000000 ||
      InpTicksToCopy < 100 ||
      InpTicksToCopy > 200000 ||
      InpTimeoutSec < 10 ||
      InpTimeoutSec > 600 ||
      point <= 0.0)
   {
      Print("Fixed-spread custom symbol: invalid inputs");
      return INIT_PARAMETERS_INCORRECT;
   }

   if(!BindBuffers())
   {
      PrintFormat("Fixed-spread custom symbol: "
                  "SetIndexBuffer failed error=%d",
                  GetLastError());
      return INIT_FAILED;
   }
   SetBuffersAsSeries();

   g_custom_symbol = InpCustomSymbolName;
   if(StringLen(g_custom_symbol) == 0)
      g_custom_symbol = BuildAutomaticName();
   if(!IsAllowedSymbolName(g_custom_symbol))
   {
      PrintFormat("Fixed-spread custom symbol: "
                  "invalid custom symbol name=%s",
                  g_custom_symbol);
      return INIT_PARAMETERS_INCORRECT;
   }

   g_owner_marker =
      OWNER_PREFIX + "source=" + _Symbol +
      "|points=" + IntegerToString(InpFixedSpreadPoints);
   g_original_comment =
      ChartGetString(0, CHART_COMMENT);
   g_requested = InpTicksToCopy;
   g_started_at = TimeLocal();
   IndicatorSetString(
      INDICATOR_SHORTNAME,
      "MQL55 Fixed Spread Custom Symbol");

   ResetLastError();
   if(!EventSetTimer(1))
   {
      PrintFormat("Fixed-spread custom symbol: "
                  "EventSetTimer failed error=%d",
                  GetLastError());
      return INIT_FAILED;
   }

   DrawPanel();
   UpdateBuffers();
   return INIT_SUCCEEDED;
}

void OnDeinit(const int reason)
{
   EventKillTimer();
   Comment(g_original_comment);
}

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[])
{
   UpdateBuffers();
   return rates_total;
}

void OnTimer()
{
   if(g_status == STATUS_BUILDING)
      BuildFixedSpreadHistory();
   else if(g_status == STATUS_VERIFYING)
      VerifyFixedSpreadHistory();

   if((g_status == STATUS_BUILDING ||
       g_status == STATUS_VERIFYING) &&
      TimeLocal() - g_started_at >= InpTimeoutSec)
   {
      SetFailed(g_last_error == 0 ? 5508 : g_last_error,
                "synchronization/read-back timeout");
   }

   DrawPanel();
   UpdateBuffers();
   ChartRedraw(0);
}

bool BindBuffers()
{
   return
      SetIndexBuffer(0, g_status_buffer,
                     INDICATOR_CALCULATIONS) &&
      SetIndexBuffer(1, g_spread_buffer,
                     INDICATOR_CALCULATIONS) &&
      SetIndexBuffer(2, g_requested_buffer,
                     INDICATOR_CALCULATIONS) &&
      SetIndexBuffer(3, g_copied_buffer,
                     INDICATOR_CALCULATIONS) &&
      SetIndexBuffer(4, g_replaced_buffer,
                     INDICATOR_CALCULATIONS) &&
      SetIndexBuffer(5, g_verified_buffer,
                     INDICATOR_CALCULATIONS) &&
      SetIndexBuffer(6, g_mismatches_buffer,
                     INDICATOR_CALCULATIONS) &&
      SetIndexBuffer(7, g_min_spread_buffer,
                     INDICATOR_CALCULATIONS) &&
      SetIndexBuffer(8, g_max_spread_buffer,
                     INDICATOR_CALCULATIONS) &&
      SetIndexBuffer(9, g_first_msc_buffer,
                     INDICATOR_CALCULATIONS) &&
      SetIndexBuffer(10, g_last_msc_buffer,
                     INDICATOR_CALCULATIONS) &&
      SetIndexBuffer(11, g_last_error_buffer,
                     INDICATOR_CALCULATIONS);
}

void SetBuffersAsSeries()
{
   ArraySetAsSeries(g_status_buffer, true);
   ArraySetAsSeries(g_spread_buffer, true);
   ArraySetAsSeries(g_requested_buffer, true);
   ArraySetAsSeries(g_copied_buffer, true);
   ArraySetAsSeries(g_replaced_buffer, true);
   ArraySetAsSeries(g_verified_buffer, true);
   ArraySetAsSeries(g_mismatches_buffer, true);
   ArraySetAsSeries(g_min_spread_buffer, true);
   ArraySetAsSeries(g_max_spread_buffer, true);
   ArraySetAsSeries(g_first_msc_buffer, true);
   ArraySetAsSeries(g_last_msc_buffer, true);
   ArraySetAsSeries(g_last_error_buffer, true);
}

bool BuffersReady()
{
   return ArraySize(g_status_buffer) > 0 &&
          ArraySize(g_spread_buffer) > 0 &&
          ArraySize(g_requested_buffer) > 0 &&
          ArraySize(g_copied_buffer) > 0 &&
          ArraySize(g_replaced_buffer) > 0 &&
          ArraySize(g_verified_buffer) > 0 &&
          ArraySize(g_mismatches_buffer) > 0 &&
          ArraySize(g_min_spread_buffer) > 0 &&
          ArraySize(g_max_spread_buffer) > 0 &&
          ArraySize(g_first_msc_buffer) > 0 &&
          ArraySize(g_last_msc_buffer) > 0 &&
          ArraySize(g_last_error_buffer) > 0;
}

void UpdateBuffers()
{
   if(!BuffersReady())
      return;

   g_status_buffer[0] = (double)g_status;
   g_spread_buffer[0] = (double)InpFixedSpreadPoints;
   g_requested_buffer[0] = (double)g_requested;
   g_copied_buffer[0] = (double)g_copied;
   g_replaced_buffer[0] = (double)g_replaced;
   g_verified_buffer[0] = (double)g_verified;
   g_mismatches_buffer[0] = (double)g_mismatches;
   g_min_spread_buffer[0] = (double)g_min_spread;
   g_max_spread_buffer[0] = (double)g_max_spread;
   g_first_msc_buffer[0] = (double)g_first_msc;
   g_last_msc_buffer[0] = (double)g_last_msc;
   g_last_error_buffer[0] = (double)g_last_error;
}

string BuildAutomaticName()
{
   const string suffix =
      "_FS" + IntegerToString(InpFixedSpreadPoints);
   const int maximum_source_length =
      31 - StringLen("MQL55_") - StringLen(suffix);
   string source = SanitizeNamePart(_Symbol);
   if(StringLen(source) > maximum_source_length)
      source = StringSubstr(source, 0,
                            maximum_source_length);
   return "MQL55_" + source + suffix;
}

string SanitizeNamePart(const string source)
{
   string result = "";
   for(int index = 0; index < StringLen(source); ++index)
   {
      const ushort character =
         (ushort)StringGetCharacter(source, index);
      result += IsAllowedNameCharacter(character)
                   ? StringSubstr(source, index, 1)
                   : "_";
   }
   return result;
}

bool IsAllowedSymbolName(const string name)
{
   const int length = StringLen(name);
   if(length < 1 || length > 31)
      return false;
   for(int index = 0; index < length; ++index)
   {
      if(!IsAllowedNameCharacter(
            (ushort)StringGetCharacter(name, index)))
         return false;
   }
   return true;
}

bool IsAllowedNameCharacter(const ushort character)
{
   return (character >= 'A' && character <= 'Z') ||
          (character >= 'a' && character <= 'z') ||
          (character >= '0' && character <= '9') ||
          character == '.' || character == '_' ||
          character == '&' || character == '#';
}

bool PrepareOwnedCustomSymbol()
{
   bool is_custom = false;
   ResetLastError();
   const bool exists =
      SymbolExist(g_custom_symbol, is_custom);
   if(exists)
   {
      if(!is_custom)
      {
         SetFailed(5501,
                   "name belongs to a broker symbol");
         return false;
      }
      const string description =
         SymbolInfoString(g_custom_symbol,
                          SYMBOL_DESCRIPTION);
      if(StringFind(description, g_owner_marker) != 0)
      {
         SetFailed(5502,
                   "existing custom symbol is not owned by this tool");
         return false;
      }
   }
   else
   {
      ResetLastError();
      if(!CustomSymbolCreate(g_custom_symbol,
                             "MQLPort", _Symbol))
      {
         SetFailed(GetLastError(),
                   "CustomSymbolCreate failed");
         return false;
      }
   }

   ResetLastError();
   if(!CustomSymbolSetString(
         g_custom_symbol, SYMBOL_DESCRIPTION,
         g_owner_marker))
   {
      SetFailed(GetLastError(),
                "description marker failed");
      return false;
   }

   ResetLastError();
   if(!SymbolSelect(g_custom_symbol, true))
   {
      SetFailed(GetLastError(),
                "SymbolSelect failed");
      return false;
   }
   return true;
}

bool BuildFixedSpreadHistory()
{
   MqlTick source_ticks[];
   ResetLastError();
   const int copied =
      CopyTicks(_Symbol, source_ticks,
                COPY_TICKS_ALL, 0,
                (uint)InpTicksToCopy);
   if(copied != InpTicksToCopy)
   {
      g_copied = copied > 0 ? copied : 0;
      g_last_error = GetLastError();
      return false;
   }

   for(int index = 0; index < copied; ++index)
   {
      if(source_ticks[index].time_msc <= 0 ||
         source_ticks[index].bid <= 0.0 ||
         (index > 0 &&
          source_ticks[index].time_msc <
             source_ticks[index - 1].time_msc))
      {
         SetFailed(5503,
                   "invalid or unordered source tick");
         return false;
      }
   }

   if(ArrayCopy(g_source_ticks, source_ticks) != copied ||
      ArrayCopy(g_expected_ticks, source_ticks) != copied)
   {
      SetFailed(5504, "tick array copy failed");
      return false;
   }

   const double point =
      SymbolInfoDouble(_Symbol, SYMBOL_POINT);
   const int digits =
      (int)SymbolInfoInteger(_Symbol, SYMBOL_DIGITS);
   for(int index = 0; index < copied; ++index)
   {
      g_expected_ticks[index].ask =
         NormalizeDouble(
            g_expected_ticks[index].bid +
            InpFixedSpreadPoints * point,
            digits);
      g_expected_ticks[index].flags |=
         TICK_FLAG_BID | TICK_FLAG_ASK;
   }

   if(!PrepareOwnedCustomSymbol())
      return false;

   g_copied = copied;
   g_first_msc = g_expected_ticks[0].time_msc;
   g_last_msc =
      g_expected_ticks[copied - 1].time_msc;
   ResetLastError();
   g_replaced =
      CustomTicksReplace(g_custom_symbol,
                         g_first_msc, g_last_msc,
                         g_expected_ticks,
                         (uint)copied);
   if(g_replaced != copied)
   {
      SetFailed(GetLastError(),
                "CustomTicksReplace count mismatch");
      return false;
   }

   g_status = STATUS_VERIFYING;
   g_last_error = 0;
   PrintFormat(
      "Fixed-spread custom symbol: source=%s target=%s "
      "copied=%d spread_points=%d status=VERIFYING",
      _Symbol, g_custom_symbol, g_copied,
      InpFixedSpreadPoints);
   return true;
}

bool VerifyFixedSpreadHistory()
{
   MqlTick actual_ticks[];
   ResetLastError();
   const int copied =
      CopyTicksRange(g_custom_symbol, actual_ticks,
                     COPY_TICKS_ALL,
                     (ulong)g_first_msc,
                     (ulong)g_last_msc);
   if(copied != g_replaced)
   {
      g_verified = copied > 0 ? copied : 0;
      g_last_error = GetLastError();
      return false;
   }

   const double point =
      SymbolInfoDouble(_Symbol, SYMBOL_POINT);
   const double tolerance = point * 0.1;
   int mismatches = 0;
   int minimum_spread = INT_MAX;
   int maximum_spread = INT_MIN;
   for(int index = 0; index < copied; ++index)
   {
      const int spread_points =
         (int)MathRound(
            (actual_ticks[index].ask -
             actual_ticks[index].bid) / point);
      minimum_spread =
         MathMin(minimum_spread, spread_points);
      maximum_spread =
         MathMax(maximum_spread, spread_points);
      if(!TickPayloadMatches(actual_ticks[index],
                             g_source_ticks[index],
                             tolerance) ||
         spread_points != InpFixedSpreadPoints)
      {
         ++mismatches;
      }
   }

   g_verified = copied;
   g_mismatches = mismatches;
   g_min_spread = minimum_spread;
   g_max_spread = maximum_spread;
   if(mismatches != 0)
   {
      SetFailed(5505,
                "independent tick read-back mismatch");
      return false;
   }

   g_status = STATUS_PASSED;
   g_last_error = 0;
   PrintFormat(
      "Fixed-spread custom symbol: source=%s target=%s "
      "copied=%d replaced=%d verified=%d "
      "spread_min=%d spread_max=%d mismatches=%d "
      "status=PASS",
      _Symbol, g_custom_symbol, g_copied,
      g_replaced, g_verified, g_min_spread,
      g_max_spread, g_mismatches);
   return true;
}

bool TickPayloadMatches(const MqlTick &actual,
                        const MqlTick &expected,
                        const double tolerance)
{
   return actual.time == expected.time &&
          actual.time_msc == expected.time_msc &&
          MathAbs(actual.bid - expected.bid) <= tolerance &&
          MathAbs(actual.last - expected.last) <= tolerance &&
          actual.volume == expected.volume &&
          MathAbs(actual.volume_real -
                  expected.volume_real) <= 0.0000001;
}

void DrawPanel()
{
   if(!InpShowPanel)
      return;

   string panel =
      "FIXED-SPREAD CUSTOM SYMBOL\n";
   panel += StringFormat(
      "SOURCE: %s  ->  TARGET: %s\n",
      _Symbol, g_custom_symbol);
   panel += StringFormat(
      "STATUS: %s  |  TERMINAL BUILD: %d\n",
      StatusText(),
      (int)TerminalInfoInteger(TERMINAL_BUILD));
   panel += StringFormat(
      "REQUESTED: %d  |  COPIED: %d  |  REPLACED: %d\n",
      g_requested, g_copied, g_replaced);
   panel += StringFormat(
      "VERIFIED: %d  |  MISMATCHES: %d\n",
      g_verified, g_mismatches);
   panel += StringFormat(
      "FIXED SPREAD: %d points (%.1f pips)  |  "
      "READ-BACK MIN / MAX: %d / %d\n",
      InpFixedSpreadPoints,
      SpreadInPips(InpFixedSpreadPoints),
      g_min_spread, g_max_spread);
   panel += StringFormat(
      "FROM: %s\nTO:   %s\n",
      FormatTickTime(g_first_msc),
      FormatTickTime(g_last_msc));
   panel += StringFormat(
      "LAST ERROR: %d\n", g_last_error);
   panel +=
      "PASS requires a later full tick read-back.\n";
   panel +=
      "Use the displayed interval when testing this custom symbol.";
   Comment(panel);
}

void SetFailed(const int error, const string message)
{
   g_status = STATUS_FAILED;
   g_last_error = error;
   PrintFormat("Fixed-spread custom symbol: %s "
               "target=%s error=%d status=FAIL",
               message, g_custom_symbol, error);
}

string StatusText()
{
   if(g_status == STATUS_BUILDING)
      return "BUILDING";
   if(g_status == STATUS_VERIFYING)
      return "VERIFYING";
   if(g_status == STATUS_PASSED)
      return "PASS";
   return "FAIL";
}

string FormatTickTime(const long time_msc)
{
   if(time_msc <= 0)
      return "-";
   return TimeToString((datetime)(time_msc / 1000),
                       TIME_DATE | TIME_MINUTES |
                       TIME_SECONDS) +
          StringFormat(".%03d", (int)(time_msc % 1000));
}

double SpreadInPips(const int points)
{
   const int digits =
      (int)SymbolInfoInteger(_Symbol, SYMBOL_DIGITS);
   return (digits == 3 || digits == 5)
             ? points / 10.0
             : (double)points;
}
