//+------------------------------------------------------------------+
//|                                    ProfitByHourReportService.mq5 |
//|  Service: "Profit by deal entry in hour" XLSX report generator   |
//+------------------------------------------------------------------+
#property service
#property strict
#property copyright "© 2026, Ryan Lawrence Johnson"
#property link      "https://www.mql5.com/en/users/rjo"
#property version   "1.24"
#property description "Generates 'Profit by deal entry in hour' XLSX"
#property description "reports automatically after each Strategy"
#property description "Tester backtest run (parses the tester journal)."

//--- How it works --------------------------------------------------------------
//  1. The service runs in the background (OnStart + while(!IsStopped()) loop).
//  2. It tails the tester journal  <data>\Tester\logs\YYYYMMDD.log  where the
//     Strategy Tester records every run: expert, symbol, timeframe, dates,
//     inputs (the user's tester settings), every deal and the result.
//  3. When a test session completes ("Test passed"), its deals are paired
//     (position open/close) and the P/L of each closed position is attributed
//     to the hour of day (0-23) at which the position was ENTERED.
//  4. An XLSX workbook is written to
//     MQL5\Files\ProfitByHour\<expert>_<symbol>_<period>_....xlsx with:
//       - a table (Hour / Deals / Profit / Abs Profit)
//       - a vertical bar chart: hours on X, |profit| on Y; profit bars are
//         blue, loss bars are RED and all bars point upward.
//     The .xlsx is a ZIP (STORE method + CRC32) of the OOXML parts,
//     assembled entirely in pure MQL5.
//  5. Additionally, if MT5 has saved report XMLs into Tester\reports\*
//     (auto-save or manual "Save report"), those are parsed too and take
//     priority (they contain exact profit/swap/commission).
//  6. Processed sessions/files are remembered so each run is handled once.
//  7. Files outside the sandbox are read via WinAPI CreateFileA+ReadFile;
//     if that is unavailable, msvcrt fopen/fread is used as a fallback.
//-------------------------------------------------------------------------------

//--- inputs --------------------------------------------------------------------
input group "=== General ==="
input int    InPollIntervalSec = 5;      // Poll interval (seconds)
input bool   InProcessExisting = false;  // Process tests finished before start
input int    InReplayDays       = 7;      // Replay days for existing tests (0 = all)
input bool   InVerbose         = true;   // Verbose logging

//+------------------------------------------------------------------+
//|                                                                  |
//+------------------------------------------------------------------+
input group "=== Paths (empty = auto detect) ==="
input string InReportsFolder   = "";     // Tester reports folder override
input string InOutputFolder    = "ProfitByHour"; // Output folder (MQL5\Files\...)

//--- WinAPI constants -----------------------------------------------------------
#define GENERIC_READ          0x80000000
#define OPEN_EXISTING         3
#define FILE_ATTRIBUTE_NORMAL 0x00000080
#define INVALID_HANDLE_VALUE (-1)
#define MAX_CHUNK_BYTES       67108864   // 64 MB read limit
#define MAX_DECODE_BYTES      268435456   // 256 MB single-decode limit
#define CP_UTF16LE            1200       // not predefined in MQL5
#define CP_ACP                0          // ANSI code page
#define CP_UTF8               65001
#define SEEK_SET              0
#define SEEK_END              2

#import "kernel32.dll"
long  CreateFileA(uchar &lpFileName[], long dwDesiredAccess, long dwShareMode,
                  long lpSecurityAttributes, long dwCreationDisposition,
                  long dwFlagsAndAttributes, long hTemplateFile);
int   ReadFile(long hFile, uchar &lpBuffer[], long nNumberOfBytesToRead,
               long &lpNumberOfBytesRead, long lpOverlapped);
int   CloseHandle(long hObject);
uint  GetFileSize(long hFile, long &lpFileSizeHigh);
uint  GetFileAttributesA(uchar &lpFileName[]);
int   FindFirstFileA(uchar &lpFileName[], uchar &lpFindFileData[]);
int   FindNextFileA(long hFindFile, uchar &lpFindFileData[]);
int   FindClose(long hFindFile);
long  SetFilePointer(long hFile, long lDistanceToMove, long &lpDistanceToMoveHigh, long dwMoveMethod);
#import
#import "msvcrt.dll"
long  fopen(uchar &lpFileName[], uchar &lpMode[]);
int   fread(uchar &lpBuffer[], long size, long count, long stream);
int   fclose(long stream);
int   fseek(long stream, long offset, int origin);
long  ftell(long stream);
#import

//--- globals -------------------------------------------------------------------
string gDataPath      = "";
string gReportsFolder = "";
string gLogFolder     = "";
int    gLineOffset    = 0;   // journal lines already consumed (display only)
long   gByteOffset    = 0;   // journal byte position already consumed
int    gJournalEnc    = 0;   // 0=unknown, 1=UTF-16LE, 2=UTF-8, 3=ANSI
string gLastDate      = "";
bool   gDllOk         = false;
bool   gOnlyLast      = false;   // replay: analyze only the most recent backtest

//--- deal structure (report XML path) -------------------------------------------
struct DealInfo
  {
   string            entry;
   string            symbol;
   string            type;
   datetime          time;
   int               hour;
   double            volume;
   double            profit;
   double            swap;
   double            commission;
  };

//--- open position lot (report XML path, FIFO) ----------------------------------
struct OpenLot
  {
   string            symbol;
   string            dir;
   double            volume;
   int               hour;
  };

//--- deal parsed from the tester journal ----------------------------------------
struct LogDeal
  {
   int               id;
   string            dir;
   string            symbol;
   double            volume;
   double            price;
   datetime          time;
   int               hour;
   int               closesPos;   // position id being closed, -1 = opening deal
  };

//--- position built from the tester journal -------------------------------------
struct LogPos
  {
   int               id;
   string            symbol;
   string            dir;
   double            entry;
   datetime          entryTime;
   int               hour;
   double            vol;
  };

//--- one tester session being parsed --------------------------------------------
struct LogSession
  {
   bool              active;
   string            key;
   string            expert;
   string            symbol;
   string            period;
   string            fromD;
   string            toD;
   string            model;
   string            wallStart;
   LogDeal           deals[];
   int               lastOrderClosesPos;
   double            finalBalance;
   bool              hasFinal;
   bool              completed;
  };

LogSession gSess;

//--- report data passed to the XLSX writer --------------------------------------
struct ReportData
  {
   string            expert;
   string            symbol;
   string            period;
   string            model;
   string            fromD;
   string            toD;
   string            extraLabel;
   string            extraValue;
   double            hourProfit[24];
   int               hourDeals[24];
   double            totalNet;
   double            grossP;
   double            grossL;
   double            extraNum;
   int               totalDeals;
   int               closedCnt;
  };

//+------------------------------------------------------------------+
//| Service start                                                      |
//+------------------------------------------------------------------+
void OnStart()
  {
   Print("ProfitByHourReportService v1.23 started (poll ", InPollIntervalSec, " s).");
   gDllOk = MQLInfoInteger(MQL_DLLS_ALLOWED);
   if(!gDllOk)
      Print("WARNING: 'Allow DLL imports' is disabled (Tools -> Options -> Expert Advisors).",
            " The service needs it to read the tester journal.");
   gDataPath = TerminalInfoString(TERMINAL_DATA_PATH);
   gReportsFolder = (InReportsFolder == "") ? gDataPath + "\\Tester\\reports" : InReportsFolder;
   gLogFolder = gDataPath + "\\Tester\\logs";
   Print("Reports folder : ", gReportsFolder);
   Print("Tester journal : ", gLogFolder);

//--- start position inside today's journal (line based)
   MqlDateTime mdt0;
   TimeToStruct(TimeLocal(), mdt0);
   string today = StringFormat("%04d%02d%02d", mdt0.year, mdt0.mon, mdt0.day);
   gLastDate = today;
   string logPath = gLogFolder + "\\" + today + ".log";
   if(InProcessExisting)
     {
      gLineOffset = 0;
      gByteOffset = 0;
     }
   else
     {
      // Tail the journal from its current end - never decode the whole
      // (possibly multi-GB) file at startup.
      gLineOffset = 0;
      gByteOffset = FileSizeBytes(logPath);
      if(gByteOffset < 0)
         gByteOffset = 0;
     }
   gJournalEnc = DetectJournalEncoding(logPath);
   if(true)   // legacy dead code guard (old whole-file startup read is unused)
      gLineOffset = 0;
   else
     {
      uchar buf[];
      int  n = 0;
      if(false)   // legacy whole-file startup read removed
        {
         string w = DecodeTextBytes(buf, n);
         gLineOffset = CountNewlines(w);
        }
     }
   if(InProcessExisting && InVerbose)
      Print("Processing existing tester journal content (offset 0).");
   Print("Journal start line = ", gLineOffset, " (InProcessExisting=", InProcessExisting, ").");

//--- WinAPI self-test (diagnostics)
   uint wa = GetAttrFile(logPath);
   Print("PROBE: journal attributes = ", wa, "  (32/128 = OK, 4294967295 = failed)");
   long hw = OpenFileRead("C:\\Windows\\win.ini");
   if(hw == INVALID_HANDLE_VALUE)
      Print("PROBE: CreateFileA(C:\\Windows\\win.ini) FAILED (mql_err=", GetLastError(), ")");
   else
     {
      Print("PROBE: CreateFileA(C:\\Windows\\win.ini) OK");
      CloseHandle(hw);
     }
   uchar pr[];
   int   prn = 0;
   if(ReadFileBytes("C:\\Windows\\win.ini", pr, prn))
      Print("PROBE: fopen/fread(C:\\Windows\\win.ini) OK (", prn, " bytes)");
   else
      Print("PROBE: fopen/fread(C:\\Windows\\win.ini) FAILED");
   uchar pat2[];
   ToAnsi("C:\\Windows\\*.ini", pat2);
   uchar fd2[600];
   int hf = FindFirstFileA(pat2, fd2);
   if(hf == INVALID_HANDLE_VALUE)
      Print("PROBE: FindFirstFileA(C:\\Windows\\*.ini) FAILED");
   else
     {
      Print("PROBE: FindFirstFileA OK, first = ", GetFindFileNameA(fd2));
      FindClose(hf);
     }

//--- analyze existing backtests (only the most recent one)
   if(InProcessExisting)
      gLineOffset = 0;
   else
     {
      // Tail the journal from its current end - never decode the whole
      // (possibly multi-GB) file at startup.
      gLineOffset = 0;
      gByteOffset = FileSizeBytes(logPath);
      if(gByteOffset < 0)
         gByteOffset = 0;
     }
   gJournalEnc = DetectJournalEncoding(logPath);
   if(InProcessExisting)
      ReplayExistingJournals();

//--- main loop (proven pattern for services: no timer)
   datetime lastBeat = 0;
   while(!IsStopped())
     {
      ScanTesterLog();
      ScanReportFiles();
      int ms = InPollIntervalSec * 1000;
      if(ms < 200)
         ms = 200;
      if(InVerbose && TimeCurrent() - lastBeat >= 60)
        {
         lastBeat = TimeCurrent();
         Print("Alive: journal lines ", gLineOffset, " - waiting for a backtest...");
        }
      Sleep(ms);
     }
   Print("ProfitByHourReportService stopped.");
  }

//+------------------------------------------------------------------+
//| Tail the tester journal and parse completed sessions              |
//+------------------------------------------------------------------+
void ScanTesterLog()
  {
   MqlDateTime mdt0;
   TimeToStruct(TimeLocal(), mdt0);
   string today = StringFormat("%04d%02d%02d", mdt0.year, mdt0.mon, mdt0.day);
   if(gLastDate != today)
     {
      gLastDate = today;
      gLineOffset = 0;
      gByteOffset = 0;
      gJournalEnc = 0;
     }
   string logPath = gLogFolder + "\\" + today + ".log";
   long fsz = FileSizeBytes(logPath);
   if(fsz < 0)
     {
      if(InVerbose)
         Print("ScanTesterLog: cannot stat journal ", logPath);
      return;
     }
   if(fsz < gByteOffset)                       // journal was truncated/rotated
     {
      gByteOffset = 0;
      gLineOffset = 0;
      gJournalEnc = 0;
     }
   if(fsz <= gByteOffset)
      return;                                  // nothing new since the last poll
   long want = fsz - gByteOffset;
   if(want > MAX_CHUNK_BYTES)
      want = MAX_CHUNK_BYTES;                  // read at most 64 MB per poll
   uchar buf[];
   int   n = 0;
   if(!ReadFileBytesFrom(logPath, gByteOffset, (int)want, buf, n))
     {
      if(InVerbose)
         Print("ScanTesterLog: cannot read journal ", logPath);
      return;
     }
   string text = DecodeJournalChunk(buf, n, (gByteOffset == 0));
   int consumed = BytesThroughLastNewline(buf, n);
   gByteOffset += consumed;                    // rewind a trailing partial line
   gLineOffset += CountNewlines(text);
   ParseCompleteLines(text);
  }

void ScanTesterLog_OLD()
  {
   MqlDateTime mdt0;
   TimeToStruct(TimeLocal(), mdt0);
   string today = StringFormat("%04d%02d%02d", mdt0.year, mdt0.mon, mdt0.day);
   if(gLastDate != today)
     {
      gLastDate = today;
      gLineOffset = 0;
     }
   string logPath = gLogFolder + "\\" + today + ".log";
   uchar buf[];
   int   n = 0;
   if(!ReadFileBytes(logPath, buf, n))
     {
      if(InVerbose)
         Print("ScanTesterLog: cannot read journal ", logPath);
      return;
     }
   string whole = DecodeTextBytes(buf, n);
   int total = CountNewlines(whole);
   if(total <= gLineOffset)
      return;
   int start = LineStartAfter(whole, gLineOffset);
   string tail = StringSubstr(whole, start);
   ParseCompleteLines(tail);
   gLineOffset = total;
  }

//+------------------------------------------------------------------+
//| Process complete lines (the trailing partial line is deferred)    |
//+------------------------------------------------------------------+
void ParseCompleteLines(string tail)
  {
   int len = StringLen(tail);
   int start = 0;
   for(int i = 0; i < len; i++)
     {
      if(StringGetCharacter(tail, i) == '\n')
        {
         string line = StringSubstr(tail, start, i - start);
         start = i + 1;
         int r = StringFind(line, "\r");
         if(r >= 0)
            line = StringSubstr(line, 0, r);
         ProcessLogLine(line);
        }
     }
  }

//+------------------------------------------------------------------+
//| Count newline characters in a string                              |
//+------------------------------------------------------------------+
int CountNewlines(string s)
  {
   int c = 0;
   int len = StringLen(s);
   for(int i = 0; i < len; i++)
      if(StringGetCharacter(s, i) == '\n')
         c++;
   return c;
  }

//+------------------------------------------------------------------+
//| Char index just after the (skipNewlines)-th newline               |
//+------------------------------------------------------------------+
int LineStartAfter(string s, int skipNewlines)
  {
   int len = StringLen(s);
   int found = 0;
   for(int i = 0; i < len; i++)
     {
      if(StringGetCharacter(s, i) == '\n')
        {
         found++;
         if(found == skipNewlines)
            return i + 1;
        }
     }
   return 0;
  }

//+------------------------------------------------------------------+
//| Route one journal line                                             |
//+------------------------------------------------------------------+
void ProcessLogLine(string line)
  {
   string parts[];
   int n = StringSplit(line, '\t', parts);
   if(n < 5)
      return;
   string wall = parts[2];
   string msg  = parts[4];

//--- new test session?
   if(StringFind(msg, "started with inputs") >= 0 && StringFind(msg, "testing of ") >= 0)
     {
      if(gSess.active && !gOnlyLast)
         FinalizeSession();
      BeginSession(msg, wall);
      return;
     }
   if(!gSess.active)
      return;

//--- completion markers
   if(StringFind(msg, "Test passed") >= 0 || StringFind(msg, "stopped testing") >= 0 ||
      StringFind(msg, "testing stopped") >= 0)
     {
      FinalizeSession();
      return;
     }
//--- final balance
   if(StringFind(msg, "final balance ") >= 0)
     {
      gSess.hasFinal = true;
      gSess.finalBalance = ParseFinalBalance(msg);
      return;
     }
//--- model info
   if(StringFind(msg, "ticks generating") >= 0)
     {
      int mt = StringFind(msg, " ticks generating");
      if(mt > 0)
        {
         string mm = StringSubstr(msg, 0, mt);
         int col = StringFind(mm, ": ");
         if(col >= 0)
            mm = StringSubstr(mm, col + 2);
         gSess.model = mm;
        }
      return;
     }
//--- deal / order lines start with the test time "YYYY.MM.DD HH:MM:SS"
   if(StringLen(msg) >= 19 && StringGetCharacter(msg, 4) == '.' && StringGetCharacter(msg, 19) == ' ')
     {
      string testtime = StringSubstr(msg, 0, 19);
      string rest = StringSubstr(msg, 19);
      // trim leading spaces
      while(StringLen(rest) > 0 && StringGetCharacter(rest, 0) == ' ')
         rest = StringSubstr(rest, 1);
      if(StringFind(rest, "deal #") == 0)
         ParseDealLine(testtime, rest);
      else
         if(StringFind(rest, "market ") == 0 || StringFind(rest, "instant ") == 0)
           {
            // order line: "market buy 0.03 XAUUSD, close #2 (...)" or "market sell 0.03 XAUUSD sl:..."
            int cp = StringFind(rest, ", close #");
            if(cp >= 0)
               gSess.lastOrderClosesPos = ToIntAfter(rest, cp + 9);
            else
               gSess.lastOrderClosesPos = -1;
           }
     }
  }

//+------------------------------------------------------------------+
//| Begin a new session from its header line                          |
//+------------------------------------------------------------------+
void BeginSession(string msg, string wall)
  {
   gSess.active = true;
   ArrayResize(gSess.deals, 0);
   gSess.lastOrderClosesPos = -1;
   gSess.hasFinal = false;
   gSess.completed = false;
   gSess.finalBalance = 0.0;
   gSess.model = "";
   gSess.expert = "";
   gSess.symbol = "";
   gSess.period = "";
   gSess.fromD = "";
   gSess.toD = "";
   gSess.wallStart = wall;

//--- "XAUUSD_500PIPS,M1: testing of Experts\VBSM_EA_v3.ex5 from ... to ... started"
   int p1 = StringFind(msg, ":");
   if(p1 > 0)
     {
      string symPer = StringSubstr(msg, 0, p1);
      int comma = StringFind(symPer, ",");
      if(comma > 0)
        {
         gSess.symbol = StringSubstr(symPer, 0, comma);
         gSess.period = StringSubstr(symPer, comma + 1);
        }
     }
   int p2 = StringFind(msg, "testing of ");
   int p3 = StringFind(msg, ".ex5", p2);
   if(p2 >= 0 && p3 > p2)
     {
      string path = StringSubstr(msg, p2 + 11, p3 - p2 - 11);   // "Experts\VBSM_EA_v3"
      // expert = part after the last backslash
      int bs = -1;
      for(int i = StringLen(path) - 1; i >= 0; i--)
         if(StringGetCharacter(path, i) == '\\')
           {
            bs = i;
            break;
           }
      gSess.expert = (bs >= 0) ? StringSubstr(path, bs + 1) : path;
     }
   int pf = StringFind(msg, " from ", p3);
   int pt = StringFind(msg, " to ", pf);
   int ps = StringFind(msg, " started", pt);
   if(pf >= 0 && pt > pf && ps > pt)
     {
      gSess.fromD = StringSubstr(msg, pf + 6, pt - pf - 6);
      gSess.toD   = StringSubstr(msg, pt + 4, ps - pt - 4);
     }
   gSess.key = gSess.wallStart + "|" + gSess.symbol + "|" + gSess.period + "|"
               + gSess.expert + "|" + gSess.fromD + "|" + gSess.toD;
   if(InVerbose)
      Print("Backtest session detected: ", gSess.expert, " ", gSess.symbol, " ", gSess.period,
            " ", gSess.fromD, " -> ", gSess.toD);
  }

//+------------------------------------------------------------------+
//| Parse a deal line and store it in the current session             |
//+------------------------------------------------------------------+
void ParseDealLine(string testtime, string rest)
  {
// rest: "deal #2 sell 0.03 XAUUSD at 4522.16 done (based on order #2)"
   int p = StringFind(rest, "deal #");
   p += 6;
   int p2 = 0;
   LogDeal d;
   d.id = ToIntFrom(rest, p, p2);
   string tok = NextToken(rest, p2, p2);
   d.dir = tok;
   tok = NextToken(rest, p2, p2);
   d.volume = ToDbl(tok);
   tok = NextToken(rest, p2, p2);
   d.symbol = tok;
   int ap = StringFind(rest, " at ", p2);
   if(ap >= 0)
      d.price = ToDbl(StringSubstr(rest, ap + 4));
   d.time = StringToTime(testtime);
   MqlDateTime mdt;
   TimeToStruct(d.time, mdt);
   d.hour = mdt.hour;
   d.closesPos = gSess.lastOrderClosesPos;
   gSess.lastOrderClosesPos = -1;
   int n = ArraySize(gSess.deals);
   ArrayResize(gSess.deals, n + 1);
   gSess.deals[n] = d;
  }

//+------------------------------------------------------------------+
//| Session finished: build the XLSX (once) and reset                 |
//+------------------------------------------------------------------+
void FinalizeSession()
  {
   if(!gSess.active)
      return;
// (in replay mode, defer until we know this is the most recent session)
   if(gOnlyLast)
     {
      gSess.completed = true;   // defer: only the most recent session gets a report
      return;
     }
   if(InVerbose)
      Print("Backtest session finished: ", gSess.expert, " ", gSess.symbol, " ", gSess.period,
            " (deals: ", IntegerToString(ArraySize(gSess.deals)), ")");
   if(!IsProcessedKey(gSess.key))
     {
      BuildLogSessionXlsx();
      MarkProcessedKey(gSess.key);
     }
   gSess.active = false;
   ArrayResize(gSess.deals, 0);
  }

//+------------------------------------------------------------------+
//| Compute the by-hour report from the journal deals and write XLSX  |
//+------------------------------------------------------------------+
void BuildLogSessionXlsx()
  {
   double hourProfit[24];
   int    hourDeals[24];
   int    hourPos[24];
   ArrayInitialize(hourProfit, 0.0);
   ArrayInitialize(hourDeals, 0);
   ArrayInitialize(hourPos, 0);

   LogPos pos[];
   double totalNet = 0.0, grossP = 0.0, grossL = 0.0;
   int    closedCnt = 0, openCnt = 0;

   int nd = ArraySize(gSess.deals);
   for(int i = 0; i < nd; i++)
     {
      LogDeal d = gSess.deals[i];
      if(d.closesPos < 0)
        {
         // opening deal
         int n = ArraySize(pos);
         ArrayResize(pos, n + 1);
         pos[n].id = d.id;
         pos[n].symbol = d.symbol;
         pos[n].dir = d.dir;
         pos[n].entry = d.price;
         pos[n].entryTime = d.time;
         pos[n].hour = d.hour;
         pos[n].vol = d.volume;
        }
      else
        {
         // closing deal -> find the position
         int j = -1;
         for(int k = 0; k < ArraySize(pos); k++)
            if(pos[k].id == d.closesPos && pos[k].symbol == d.symbol && pos[k].vol > 0.0)
              {
               j = k;
               break;
              }
         if(j < 0)
            continue;
         double m = MathMin(d.volume, pos[j].vol);
         double diff = (pos[j].dir == "buy") ? (d.price - pos[j].entry) : (pos[j].entry - d.price);
         double pnl = CalcProfit(pos[j].symbol, diff, m);
         hourProfit[pos[j].hour] += pnl;
         hourDeals[pos[j].hour]++;
         hourPos[pos[j].hour]++;
         totalNet += pnl;
         if(pnl >= 0)
            grossP += pnl;
         else
            grossL += pnl;
         closedCnt++;
         pos[j].vol -= m;
        }
     }
   for(int i = 0; i < ArraySize(pos); i++)
      if(pos[i].vol > 0.0)
         openCnt++;

   ReportData r;
   r.expert = gSess.expert;
   r.symbol = gSess.symbol;
   r.period = gSess.period;
   r.model  = gSess.model;
   r.fromD  = gSess.fromD;
   r.toD    = gSess.toD;
   r.extraLabel = "";
   r.extraValue = "";
   r.extraNum   = 0.0;
   ArrayCopy(r.hourProfit, hourProfit);
   ArrayCopy(r.hourDeals, hourDeals);
   r.totalNet = totalNet;
   r.grossP   = grossP;
   r.grossL   = grossL;
   r.totalDeals = nd;
   r.closedCnt  = closedCnt;

   string base = SafeName(gSess.expert) + "_" + SafeName(gSess.symbol) + "_"
                 + SafeName(gSess.period) + "_" + SafeName(gSess.fromD) + "_"
                 + SafeName(gSess.toD) + "_" + SafeName(gSess.wallStart);
   WriteReportXlsx(base, r);
  }

//+------------------------------------------------------------------+
//| P/L for a price difference and volume on a symbol                 |
//+------------------------------------------------------------------+
double CalcProfit(string symbol, double diff, double volume)
  {
   double ts = SymbolInfoDouble(symbol, SYMBOL_TRADE_TICK_SIZE);
   double tv = SymbolInfoDouble(symbol, SYMBOL_TRADE_TICK_VALUE);
   if(ts > 0.0 && tv > 0.0)
      return MathRound(diff / ts) * tv * volume;
   double cs = SymbolInfoDouble(symbol, SYMBOL_TRADE_CONTRACT_SIZE);
   if(cs > 0.0)
      return diff * volume * cs;
   if(InVerbose)
      Print("WARNING: symbol specs unavailable for ", symbol, ", using raw price diff.");
   return diff * volume;
  }

//+------------------------------------------------------------------+
//| Scan the Tester\reports folder for saved report XMLs (exact path) |
//+------------------------------------------------------------------+
void ScanReportFiles()
  {
   string pattern = gReportsFolder + "\\*.xml";
   uchar pat[];
   ToAnsi(pattern, pat);
   uchar fd[600];                       // WIN32_FIND_DATAA
   int hFind = (gDllOk) ? FindFirstFileA(pat, fd) : INVALID_HANDLE_VALUE;
   if(hFind == INVALID_HANDLE_VALUE)
      return;                           // no reports saved yet
   do
     {
      string fname = GetFindFileNameA(fd);
      if(fname != "" && StringFind(fname, ".xml") >= 0)
        {
         string full = gReportsFolder + "\\" + fname;
         ProcessReportFile(full, fname);
        }
     }
   while(FindNextFileA(hFind, fd) != 0);
   FindClose(hFind);
  }

//+------------------------------------------------------------------+
//| Process one saved report file (exact profit/swap/commission)      |
//+------------------------------------------------------------------+
void ProcessReportFile(string fullPath, string fname)
  {
   if(IsProcessed(fname))
      return;
   string xml = ReadTextFile(fullPath);
   if(xml == "")
      return;
   if(StringFind(xml, "<report>") < 0 || StringFind(xml, "</report>") < 0)
      return;
   if(StringFind(xml, "<deal_list") < 0)
      return;
   if(StringFind(xml, "<deal_list>") >= 0 && StringFind(xml, "</deal_list>") < 0)
      return;                            // still being written
   if(BuildReportXlsx(xml, fname))
     {
      MarkProcessed(fname);
      if(InVerbose)
         Print("Processed saved report: ", fname);
     }
  }

//+------------------------------------------------------------------+
//| Parse a saved tester report XML and write the by-hour XLSX        |
//+------------------------------------------------------------------+
bool BuildReportXlsx(string xml, string srcFile)
  {
   string expert="", symbol="", period="", model="", fromD="", toD="", deposit="";
   int gp = StringFind(xml, "<general>");
   if(gp >= 0)
     {
      int ge = StringFind(xml, "</general>", gp);
      if(ge > gp)
        {
         string g = StringSubstr(xml, gp + 9, ge - gp - 9);
         expert  = GetTag(g, "expert");
         symbol  = GetTag(g, "symbol");
         period  = GetTag(g, "period");
         model   = GetTag(g, "model");
         fromD   = GetTag(g, "from");
         toD     = GetTag(g, "to");
         deposit = GetTag(g, "initial_deposit");
        }
     }

   string dealsXml = "";
   int dp = StringFind(xml, "<deal_list>");
   if(dp >= 0)
     {
      int de = StringFind(xml, "</deal_list>", dp);
      if(de > dp)
         dealsXml = StringSubstr(xml, dp + 11, de - dp - 11);
     }

   int nDeals = 0;
     {
      int p2 = 0;
      while((p2 = StringFind(dealsXml, "<deal ", p2)) >= 0)
        {
         nDeals++;
         p2 += 6;
        }
     }

   DealInfo deals[];
   ArrayResize(deals, nDeals);
   int idx = 0;
   int pos = 0;
   while(true)
     {
      int ds = StringFind(dealsXml, "<deal ", pos);
      if(ds < 0)
         break;
      int dse = StringFind(dealsXml, "/>", ds);
      int dEnd = -1;
      if(dse >= 0)
         dEnd = dse;
      else
        {
         int dse2 = StringFind(dealsXml, ">", ds);
         if(dse2 < 0)
            break;
         dEnd = dse2;
        }
      string line = StringSubstr(dealsXml, ds, dEnd - ds);
      pos = dEnd + 2;

      DealInfo d;
      d.entry      = GetAttr(line, "entry");
      d.symbol     = GetAttr(line, "symbol");
      d.type       = GetAttr(line, "type");
      string tstr  = GetAttr(line, "time");
      d.time       = StringToTime(tstr);
      MqlDateTime mdt;
      TimeToStruct(d.time, mdt);
      d.hour       = mdt.hour;
      d.volume     = ToDbl(GetAttr(line, "volume"));
      d.profit     = ToDbl(GetAttr(line, "profit"));
      d.swap       = ToDbl(GetAttr(line, "swap"));
      d.commission = ToDbl(GetAttr(line, "commission"));
      deals[idx++] = d;
     }

   double hourProfit[24];
   int    hourDeals[24];
   int    hourPos[24];
   ArrayInitialize(hourProfit, 0.0);
   ArrayInitialize(hourDeals, 0);
   ArrayInitialize(hourPos, 0);

   OpenLot open[];
   double totalNet = 0.0, grossP = 0.0, grossL = 0.0, floating = 0.0;
   int    closedCnt = 0;

   for(int i = 0; i < nDeals; i++)
     {
      DealInfo d = deals[i];
      string e = d.entry;
      if(e == "in")
         AddLot(open, d);
      else
         if(e == "inout")
           {
            CloseVolume(open, d, hourProfit, hourDeals, hourPos, totalNet, grossP, grossL, closedCnt);
            AddLot(open, d);
           }
         else
            if(e == "out" || e == "out_by")
               CloseVolume(open, d, hourProfit, hourDeals, hourPos, totalNet, grossP, grossL, closedCnt);
     }

   int op = StringFind(xml, "<open_positions>");
   if(op >= 0)
     {
      int oe = StringFind(xml, "</open_positions>", op);
      if(oe > op)
        {
         string ops = StringSubstr(xml, op + 15, oe - op - 15);
         int p3 = 0;
         while(true)
           {
            int ps = StringFind(ops, "<position ", p3);
            if(ps < 0)
               break;
            int pe2 = StringFind(ops, "/>", ps);
            int pend = (pe2 >= 0) ? pe2 : StringFind(ops, ">", ps);
            if(pend < 0)
               break;
            string pl = StringSubstr(ops, ps, pend - ps);
            p3 = pend + 2;
            double pnet = ToDbl(GetAttr(pl, "profit")) + ToDbl(GetAttr(pl, "swap"))
                          + ToDbl(GetAttr(pl, "commission"));
            floating += pnet;
            totalNet += pnet;
            if(pnet >= 0)
               grossP += pnet;
            else
               grossL += pnet;
           }
        }
     }

   ReportData r;
   r.expert = expert;
   r.symbol = symbol;
   r.period = period;
   r.model  = model;
   r.fromD  = fromD;
   r.toD    = toD;
   r.extraLabel = "Initial Deposit";
   r.extraValue = deposit;
   r.extraNum   = floating;
   ArrayCopy(r.hourProfit, hourProfit);
   ArrayCopy(r.hourDeals, hourDeals);
   r.totalNet = totalNet;
   r.grossP   = grossP;
   r.grossL   = grossL;
   r.totalDeals = nDeals;
   r.closedCnt  = closedCnt;

   string base = SafeName(StringSubstr(srcFile, 0, StringLen(srcFile) - 4));
   return WriteReportXlsx(base + "_profit_by_hour", r);
  }

//+------------------------------------------------------------------+
//| Match a closing deal against open lots (FIFO) and attribute       |
//+------------------------------------------------------------------+
void CloseVolume(OpenLot &openArr[], DealInfo &d, double &hp[], int &hd[], int &hpos[],
                 double &totalNet, double &grossP, double &grossL, int &closedCnt)
  {
   double net = d.profit + d.swap + d.commission;
   totalNet += net;
   if(net >= 0)
      grossP += net;
   else
      grossL += net;
   if(d.volume <= 0.0)
      return;
   double need = d.volume;
   string wantDir = (d.type == "buy") ? "sell" : "buy";
   int j = 0;
   while(j < ArraySize(openArr) && need > 0.0000001)
     {
      if(openArr[j].symbol == d.symbol && openArr[j].dir == wantDir && openArr[j].volume > 0.0)
        {
         double m = MathMin(need, openArr[j].volume);
         double share = net * m / d.volume;
         hp[openArr[j].hour] += share;
         hd[openArr[j].hour]++;
         hpos[openArr[j].hour]++;
         closedCnt++;
         openArr[j].volume -= m;
         need -= m;
         if(openArr[j].volume < 0.0000001)
           {
            int n = ArraySize(openArr);
            for(int k = j; k < n - 1; k++)
               openArr[k] = openArr[k + 1];
            ArrayResize(openArr, n - 1);
            continue;
           }
        }
      j++;
     }
  }

//+------------------------------------------------------------------+
//| Add an opened position lot to the FIFO queue                      |
//+------------------------------------------------------------------+
void AddLot(OpenLot &openArr[], DealInfo &d)
  {
   if(d.volume <= 0.0)
      return;
   int n = ArraySize(openArr);
   ArrayResize(openArr, n + 1);
   openArr[n].symbol = d.symbol;
   openArr[n].dir    = d.type;
   openArr[n].volume = d.volume;
   openArr[n].hour   = d.hour;
  }

//+------------------------------------------------------------------+
//| Build the chart XML (vertical bars: hours x, |profit| y)          |
//| Profit bars are blue, loss bars are RED; all bars point upward.   |
//+------------------------------------------------------------------+
string BuildChartXml(ReportData &r)
  {
   string x = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\r\n";
   x += "<c:chartSpace xmlns:c=\"http://schemas.openxmlformats.org/drawingml/2006/chart\" "
        "xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\" "
        "xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\">";
   x += "<c:chart><c:plotArea><c:layout/>";
   x += "<c:barChart><c:barDir val=\"col\"/><c:grouping val=\"clustered\"/><c:varyColors val=\"0\"/>";
   x += "<c:ser><c:idx val=\"0\"/><c:order val=\"0\"/>";
   x += "<c:tx><c:strRef><c:f>Report!$E$10</c:f><c:strCache><c:ptCount val=\"1\"/>"
        "<c:pt idx=\"0\"><c:v>Loss</c:v></c:pt></c:strCache></c:strRef></c:tx>";
   x += "<c:spPr><a:solidFill><a:srgbClr val=\"C00000\"/></a:solidFill>"
        "<a:ln w=\"9525\"><a:solidFill><a:srgbClr val=\"8B0000\"/></a:solidFill></a:ln></c:spPr>";
   x += "<c:cat><c:numRef><c:f>Report!$A$11:$A$34</c:f><c:numCache><c:formatCode>General</c:formatCode>"
        "<c:ptCount val=\"24\"/>";
   for(int h = 0; h < 24; h++)
      x += "<c:pt idx=\"" + IntegerToString(h) + "\"><c:v>" + IntegerToString(h) + "</c:v></c:pt>";
   x += "</c:numCache></c:numRef></c:cat>";
   x += "<c:val><c:numRef><c:f>Report!$D$11:$D$34</c:f><c:numCache><c:formatCode>0.00</c:formatCode>"
        "<c:ptCount val=\"24\"/>";
   for(int h = 0; h < 24; h++)
      x += "<c:pt idx=\"" + IntegerToString(h) + "\"><c:v>" + NumStr(MathAbs(r.hourProfit[h])) + "</c:v></c:pt>";
   x += "</c:numCache></c:numRef></c:val>";
   x += "</c:ser>";















   x += "<c:ser><c:idx val=\"1\"/><c:order val=\"1\"/>";
   x += "<c:tx><c:strRef><c:f>Report!$C$10</c:f><c:strCache><c:ptCount val=\"1\"/>"
        "<c:pt idx=\"0\"><c:v>Profit</c:v></c:pt></c:strCache></c:strRef></c:tx>";
   x += "<c:spPr><a:solidFill><a:srgbClr val=\"4472C4\"/></a:solidFill>"
        "<a:ln w=\"9525\"><a:solidFill><a:srgbClr val=\"2F528F\"/></a:solidFill></a:ln></c:spPr>";
   for(int h = 0; h < 24; h++)
     {
      bool neg = (r.hourProfit[h] < 0.0);
      string fill = neg ? "C00000" : "4472C4";
      string brd  = neg ? "8B0000" : "2F528F";
      x += "<c:dPt><c:idx val=\"" + IntegerToString(h) + "\"/><c:bubble3D val=\"0\"/>"
           "<c:spPr><a:solidFill><a:srgbClr val=\"" + fill + "\"/></a:solidFill>"
           "<a:ln w=\"9525\"><a:solidFill><a:srgbClr val=\"" + brd + "\"/></a:solidFill></a:ln></c:spPr></c:dPt>";
     }
   x += "<c:cat><c:numRef><c:f>Report!$A$11:$A$34</c:f><c:numCache><c:formatCode>General</c:formatCode>"
        "<c:ptCount val=\"24\"/>";
   for(int h = 0; h < 24; h++)
      x += "<c:pt idx=\"" + IntegerToString(h) + "\"><c:v>" + IntegerToString(h) + "</c:v></c:pt>";
   x += "</c:numCache></c:numRef></c:cat>";
   x += "<c:val><c:numRef><c:f>Report!$D$11:$D$34</c:f><c:numCache><c:formatCode>0.00</c:formatCode>"
        "<c:ptCount val=\"24\"/>";
   for(int h = 0; h < 24; h++)
      x += "<c:pt idx=\"" + IntegerToString(h) + "\"><c:v>" + NumStr(MathAbs(r.hourProfit[h])) + "</c:v></c:pt>";
   x += "</c:numCache></c:numRef></c:val>";
   x += "</c:ser>";
   x += "<c:gapWidth val=\"50\"/><c:overlap val=\"100\"/>";
   x += "<c:axId val=\"1111111111\"/><c:axId val=\"2222222222\"/>";
   x += "</c:barChart>";
   x += "<c:catAx><c:axId val=\"1111111111\"/><c:scaling><c:orientation val=\"minMax\"/></c:scaling>"
        "<c:delete val=\"0\"/><c:axPos val=\"b\"/>"
        "<c:title><c:tx><c:rich><a:bodyPr/><a:lstStyle/><a:p><a:r><a:t>Hour</a:t></a:r></a:p></c:rich></c:tx></c:title>"
        "<c:numFmt formatCode=\"General\" sourceLinked=\"1\"/>"
        "<c:majorTickMark val=\"out\"/><c:minorTickMark val=\"none\"/><c:tickLblPos val=\"nextTo\"/>"
        "<c:crossAx val=\"2222222222\"/><c:crosses val=\"autoZero\"/><c:auto val=\"1\"/>"
        "<c:lblAlgn val=\"ctr\"/><c:lblOffset val=\"100\"/><c:noMultiLvlLbl val=\"0\"/></c:catAx>";
   x += "<c:valAx><c:axId val=\"2222222222\"/><c:scaling><c:orientation val=\"minMax\"/></c:scaling>"
        "<c:delete val=\"0\"/><c:axPos val=\"l\"/>"
        "<c:title><c:tx><c:rich><a:bodyPr/><a:lstStyle/><a:p><a:r><a:t>Profit</a:t></a:r></a:p></c:rich></c:tx></c:title>"
        "<c:numFmt formatCode=\"General\" sourceLinked=\"1\"/>"
        "<c:majorTickMark val=\"out\"/><c:minorTickMark val=\"none\"/><c:tickLblPos val=\"nextTo\"/>"
        "<c:crossAx val=\"1111111111\"/><c:crosses val=\"autoZero\"/><c:crossBetween val=\"between\"/></c:valAx>";
   x += "</c:plotArea>";
   x += "<c:legend><c:legendPos val=\"r\"/><c:overlay val=\"0\"/></c:legend>";
   x += "<c:plotVisOnly val=\"1\"/><c:dispBlanksAs val=\"gap\"/>";
   x += "</c:chart><c:spPr><a:noFill/></c:spPr></c:chartSpace>";
   return x;
  }

//+------------------------------------------------------------------+
//| Build the worksheet XML from the report data                      |
//+------------------------------------------------------------------+
string BuildSheetXml(ReportData &r)
  {
   string x = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\r\n";
   x += "<worksheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\" "
        "xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\"><sheetData>";
   x += RowText(1, 1, "Profit by Deal Entry in Hour");
   x += RowPair(2, "Expert", r.expert);
   x += RowPair(3, "Symbol", r.symbol);
   x += RowPair(4, "Period", r.period);
   x += RowPair(5, "Model", r.model);
   x += RowPair(6, "From", r.fromD);
   x += RowPair(7, "To", r.toD);
   x += RowPair(8, "Generated", TimeToString(TimeLocal(), TIME_DATE | TIME_SECONDS));
   if(r.extraLabel != "")
      x += RowPair(9, r.extraLabel, r.extraValue);
   x += "<row r=\"10\">"
        "<c r=\"A10\" t=\"inlineStr\"><is><t>Hour</t></is></c>"
        "<c r=\"B10\" t=\"inlineStr\"><is><t>Deals</t></is></c>"
        "<c r=\"C10\" t=\"inlineStr\"><is><t>Profit</t></is></c>"
        "<c r=\"D10\" s=\"2\" t=\"inlineStr\"><is><t>Abs Profit</t></is></c>"
        "<c r=\"E10\" s=\"2\" t=\"inlineStr\"><is><t>Loss</t></is></c></row>";

   for(int h = 0; h < 24; h++)
     {
      int row = 11 + h;
      string rr = IntegerToString(row);
      x += "<row r=\"" + rr + "\">";
      x += "<c r=\"A" + rr + "\"><v>" + IntegerToString(h) + "</v></c>";
      x += "<c r=\"B" + rr + "\"><v>" + IntegerToString(r.hourDeals[h]) + "</v></c>";
      x += "<c r=\"C" + rr + "\"><v>" + NumStr(r.hourProfit[h]) + "</v></c>";
      x += "<c r=\"D" + rr + "\" s=\"1\"><v>" + NumStr(MathAbs(r.hourProfit[h])) + "</v></c>";

      x += "</row>";
     }
   x += RowPairNum(35, "Total Net Profit", NumStr(r.totalNet));
   x += RowPairNum(36, "Gross Profit", NumStr(r.grossP));
   x += RowPairNum(37, "Gross Loss", NumStr(r.grossL));
   if(r.extraLabel != "")
      x += RowPairNum(38, r.extraLabel + " (open positions)", NumStr(r.extraNum));
   x += RowPairNum(39, "Closed Positions", IntegerToString(r.closedCnt));
   x += RowPairNum(40, "Total Deals", IntegerToString(r.totalDeals));
   x += "</sheetData><drawing r:id=\"rId1\"/></worksheet>";
   return x;
  }

//+------------------------------------------------------------------+
//| One row with a single text cell                                    |
//+------------------------------------------------------------------+
string RowText(int row, int col, string text)
  {
   string c = ColName(col);
   string rr = IntegerToString(row);
   return "<row r=\"" + rr + "\"><c r=\"" + c + rr + "\" t=\"inlineStr\"><is><t>"
          + XmlEscape(text) + "</t></is></c></row>";
  }

//+------------------------------------------------------------------+
//| One row: label (text) + value (text)                              |
//+------------------------------------------------------------------+
string RowPair(int row, string label, string value)
  {
   string rr = IntegerToString(row);
   string a = "<c r=\"A" + rr + "\" t=\"inlineStr\"><is><t>" + XmlEscape(label) + "</t></is></c>";
   string b = "<c r=\"B" + rr + "\" t=\"inlineStr\"><is><t>" + XmlEscape(value) + "</t></is></c>";
   return "<row r=\"" + rr + "\">" + a + b + "</row>";
  }

//+------------------------------------------------------------------+
//| One row: label (text) + value (number)                            |
//+------------------------------------------------------------------+
string RowPairNum(int row, string label, string numStr)
  {
   string rr = IntegerToString(row);
   string a = "<c r=\"A" + rr + "\" t=\"inlineStr\"><is><t>" + XmlEscape(label) + "</t></is></c>";
   string b = "<c r=\"B" + rr + "\"><v>" + numStr + "</v></c>";
   return "<row r=\"" + rr + "\">" + a + b + "</row>";
  }

//+------------------------------------------------------------------+
//| Excel column name for a 1-based column index                      |
//+------------------------------------------------------------------+
string ColName(int col)
  {
   string s = "";
   while(col > 0)
     {
      int m = (col - 1) % 26;
      s = CharToString((uchar)('A' + m)) + s;
      col = (col - 1) / 26;
     }
   return s;
  }

//+------------------------------------------------------------------+
//| Number as a decimal string (always with a dot)                    |
//+------------------------------------------------------------------+
string NumStr(double v)
  {
   string s = StringFormat("%.2f", v);
   StringReplace(s, ",", ".");
   return s;
  }

//--- CRC32 (ZIP) ----------------------------------------------------------------
uint gCrcTable[256];
bool gCrcInit = false;

//+------------------------------------------------------------------+
//|                                                                  |
//+------------------------------------------------------------------+
void InitCrcTable()
  {
   for(uint i = 0; i < 256; i++)
     {
      uint c = i;
      for(int k = 0; k < 8; k++)
         c = ((c & 1) != 0) ? (0xEDB88320 ^ (c >> 1)) : (c >> 1);
      gCrcTable[i] = c;
     }
   gCrcInit = true;
  }

//+------------------------------------------------------------------+
//|                                                                  |
//+------------------------------------------------------------------+
uint Crc32Bytes(uchar &b[], int n)
  {
   if(!gCrcInit)
      InitCrcTable();
   uint c = 0xFFFFFFFF;
   for(int i = 0; i < n; i++)
      c = gCrcTable[(c ^ (uint)b[i]) & 0xFF] ^ (c >> 8);
   return c ^ 0xFFFFFFFF;
  }

//+------------------------------------------------------------------+
//| DOS date/time for ZIP headers (local time)                        |
//+------------------------------------------------------------------+
void DosDateTime(datetime t, ushort &dosTime, ushort &dosDate)
  {
   MqlDateTime m;
   TimeToStruct(t, m);
   dosTime = (ushort)((m.hour << 11) | (m.min << 5) | (m.sec / 2));
   dosDate = (ushort)(((m.year - 1980) << 9) | (m.mon << 5) | m.day);
  }

//+------------------------------------------------------------------+
//| Append a 16-bit little-endian value to a byte array               |
//+------------------------------------------------------------------+
void AppendLE16(uchar &dst[], ushort v)
  {
   int n = ArraySize(dst);
   ArrayResize(dst, n + 2);
   dst[n] = (uchar)(v & 0xFF);
   dst[n + 1] = (uchar)((v >> 8) & 0xFF);
  }

//+------------------------------------------------------------------+
//| Append a 32-bit little-endian value to a byte array               |
//+------------------------------------------------------------------+
void AppendLE32(uchar &dst[], uint v)
  {
   int n = ArraySize(dst);
   ArrayResize(dst, n + 4);
   dst[n] = (uchar)(v & 0xFF);
   dst[n + 1] = (uchar)((v >> 8) & 0xFF);
   dst[n + 2] = (uchar)((v >> 16) & 0xFF);
   dst[n + 3] = (uchar)((v >> 24) & 0xFF);
  }

//+------------------------------------------------------------------+
//| Assemble a minimal .xlsx (ZIP with STORE entries) and write it    |
//+------------------------------------------------------------------+
bool WriteXlsxFile(string fullPath, string sheetXml, string chartXml)
  {
   string names[11] = { "[Content_Types].xml", "_rels/.rels", "xl/workbook.xml",
                        "xl/_rels/workbook.xml.rels", "xl/worksheets/sheet1.xml", "xl/styles.xml",
                        "xl/worksheets/_rels/sheet1.xml.rels", "xl/drawings/drawing1.xml",
                        "xl/drawings/_rels/drawing1.xml.rels", "xl/charts/chart1.xml",
                        "xl/charts/_rels/chart1.xml.rels"
                      };
   string contents[11];
   contents[0] = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\r\n"
                 "<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\">"
                 "<Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/>"
                 "<Default Extension=\"xml\" ContentType=\"application/xml\"/>"
                 "<Override PartName=\"/xl/workbook.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml\"/>"
                 "<Override PartName=\"/xl/worksheets/sheet1.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml\"/>"
                 "<Override PartName=\"/xl/styles.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml\"/>"
                 "<Override PartName=\"/xl/drawings/drawing1.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.drawing+xml\"/>"
                 "<Override PartName=\"/xl/charts/chart1.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.drawingml.chart+xml\"/>"
                 "</Types>";
   contents[1] = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\r\n"
                 "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">"
                 "<Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument\" Target=\"xl/workbook.xml\"/>"
                 "</Relationships>";
   contents[2] = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\r\n"
                 "<workbook xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\" "
                 "xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\">"
                 "<sheets><sheet name=\"Report\" sheetId=\"1\" r:id=\"rId1\"/></sheets></workbook>";
   contents[3] = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\r\n"
                 "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">"
                 "<Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet\" Target=\"worksheets/sheet1.xml\"/>"
                 "<Relationship Id=\"rId2\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles\" Target=\"styles.xml\"/>"
                 "</Relationships>";
   contents[4] = sheetXml;
   contents[5] = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\r\n"
                 "<styleSheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\">"
                 "<numFmts count=\"1\"><numFmt numFmtId=\"164\" formatCode=\";;;\"/></numFmts>"
                 "<fonts count=\"2\"><font><sz val=\"11\"/><name val=\"Calibri\"/></font><font><sz val=\"11\"/><color rgb=\"FFFFFFFF\"/><name val=\"Calibri\"/></font></fonts>"
                 "<fills count=\"1\"><fill><patternFill patternType=\"none\"/></fill></fills>"
                 "<borders count=\"1\"><border/></borders>"
                 "<cellStyleXfs count=\"1\"><xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\"/></cellStyleXfs>"
                 "<cellXfs count=\"3\"><xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\" xfId=\"0\"/><xf numFmtId=\"164\" fontId=\"0\" fillId=\"0\" borderId=\"0\" xfId=\"0\" applyNumberFormat=\"1\"/><xf numFmtId=\"0\" fontId=\"1\" fillId=\"0\" borderId=\"0\" xfId=\"0\" applyFont=\"1\"/></cellXfs>"
                 "</styleSheet>";
   contents[6] = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\r\n"
                 "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">"
                 "<Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing\" Target=\"../drawings/drawing1.xml\"/>"
                 "</Relationships>";
   contents[7] = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\r\n"
                 "<xdr:wsDr xmlns:xdr=\"http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing\" "
                 "xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\">"
                 "<xdr:twoCellAnchor>"
                 "<xdr:from><xdr:col>4</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>1</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:from>"
                 "<xdr:to><xdr:col>15</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>36</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:to>"
                 "<xdr:graphicFrame macro=\"\">"
                 "<xdr:nvGraphicFramePr><xdr:cNvPr id=\"2\" name=\"Chart 1\"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>"
                 "<xdr:xfrm><a:off x=\"0\" y=\"0\"/><a:ext cx=\"0\" cy=\"0\"/></xdr:xfrm>"
                 "<a:graphic><a:graphicData uri=\"http://schemas.openxmlformats.org/drawingml/2006/chart\">"
                 "<c:chart xmlns:c=\"http://schemas.openxmlformats.org/drawingml/2006/chart\" "
                 "xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\" r:id=\"rId1\"/>"
                 "</a:graphicData></a:graphic>"
                 "</xdr:graphicFrame><xdr:clientData/>"
                 "</xdr:twoCellAnchor></xdr:wsDr>";
   contents[8] = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\r\n"
                 "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">"
                 "<Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart\" Target=\"../charts/chart1.xml\"/>"
                 "</Relationships>";
   contents[9] = chartXml;
   contents[10] = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\r\n"
                  "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">"
                  "<Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet\" Target=\"../worksheets/sheet1.xml\"/>"
                  "</Relationships>";

   uchar zip[];
   uchar cd[];
   int nParts = 11;
   int offs[11];
   int sizes[11];
   uint crcs[11];
   ushort dtm = 0, dtd = 0;
   DosDateTime(TimeLocal(), dtm, dtd);

   for(int i = 0; i < nParts; i++)
     {
      uchar data[];
      StringToCharArray(contents[i], data, 0, StringLen(contents[i]), CP_UTF8);
      int sz = ArraySize(data);
      uint crc = Crc32Bytes(data, sz);
      offs[i] = ArraySize(zip);
      sizes[i] = sz;
      crcs[i] = crc;
      // local file header
      AppendLE32(zip, 0x04034b50);
      AppendLE16(zip, 20);          // version needed
      AppendLE16(zip, 0);           // flags
      AppendLE16(zip, 0);           // method = stored
      AppendLE16(zip, dtm);
      AppendLE16(zip, dtd);
      AppendLE32(zip, crc);
      AppendLE32(zip, (uint)sz);    // compressed size
      AppendLE32(zip, (uint)sz);    // uncompressed size
      AppendLE16(zip, (ushort)StringLen(names[i]));
      AppendLE16(zip, 0);           // extra field length
      uchar nm[];
      StringToCharArray(names[i], nm, 0, StringLen(names[i]), CP_UTF8);
      ArrayCopy(zip, nm, ArraySize(zip), 0, ArraySize(nm));
      ArrayCopy(zip, data, ArraySize(zip), 0, sz);
      // central directory entry
      AppendLE32(cd, 0x02014b50);
      AppendLE16(cd, 20);           // version made by
      AppendLE16(cd, 20);           // version needed
      AppendLE16(cd, 0);            // flags
      AppendLE16(cd, 0);            // method
      AppendLE16(cd, dtm);
      AppendLE16(cd, dtd);
      AppendLE32(cd, crc);
      AppendLE32(cd, (uint)sz);
      AppendLE32(cd, (uint)sz);
      AppendLE16(cd, (ushort)StringLen(names[i]));
      AppendLE16(cd, 0);            // extra length
      AppendLE16(cd, 0);            // comment length
      AppendLE16(cd, 0);            // disk number
      AppendLE16(cd, 0);            // internal attributes
      AppendLE32(cd, 0);            // external attributes
      AppendLE32(cd, (uint)offs[i]);
      ArrayCopy(cd, nm, ArraySize(cd), 0, ArraySize(nm));
     }
   int cdOffset = ArraySize(zip);
   ArrayCopy(zip, cd, ArraySize(zip), 0, ArraySize(cd));
// end of central directory record
   AppendLE32(zip, 0x06054b50);
   AppendLE16(zip, 0);              // disk number
   AppendLE16(zip, 0);              // disk with CD
   AppendLE16(zip, (ushort)nParts); // entries on this disk
   AppendLE16(zip, (ushort)nParts); // total entries
   AppendLE32(zip, (uint)ArraySize(cd));
   AppendLE32(zip, (uint)cdOffset);
   AppendLE16(zip, 0);              // comment length

   int h = FileOpen(fullPath, FILE_WRITE | FILE_READ | FILE_BIN);
   if(h == INVALID_HANDLE)
     {
      Print("Cannot create output file: ", fullPath, " (err=", GetLastError(), ")");
      return false;
     }
   FileWriteArray(h, zip);
   FileClose(h);
   return true;
  }

//+------------------------------------------------------------------+
//| Write an XLSX report to MQL5\Files\<InOutputFolder>               |
//+------------------------------------------------------------------+
bool WriteReportXlsx(string baseName, ReportData &r)
  {
   string sheet = BuildSheetXml(r);
   string chart = BuildChartXml(r);
   string outName = baseName + ".xlsx";
   FolderCreate(InOutputFolder);
   if(!WriteXlsxFile(InOutputFolder + "\\" + outName, sheet, chart))
      return false;
   Print("Saved: ", gDataPath + "\\MQL5\\Files\\" + InOutputFolder + "\\" + outName,
         "  (total net ", DoubleToString(r.totalNet, 2), ", closed ", IntegerToString(r.closedCnt), ")");
   return true;
  }

//+------------------------------------------------------------------+
//| Read a whole file (outside the sandbox) into bytes[]             |
//| 1st try: WinAPI CreateFileA + ReadFile                           |

//| 2nd try: msvcrt fopen + fread                                    |
//+------------------------------------------------------------------+
//+------------------------------------------------------------------+
//| Replay recent tester journals, analyzing only the most recent     |
//| backtest (used when InProcessExisting=true)                       |
//+------------------------------------------------------------------+
void ReplayExistingJournals()
  {
   int days = InReplayDays;
   if(days < 0)
      days = 0;
   if(days > 60)
      days = 60;
   Print("Replay: analyzing the most recent backtest (look-back = ", days, " day(s), 0 = all)...");

   string pattern = gLogFolder + "\\*.log";
   uchar pat[];
   ToAnsi(pattern, pat);
   uchar fd[600];
   int hFind = FindFirstFileA(pat, fd);
   if(hFind == INVALID_HANDLE_VALUE)
     {
      Print("Replay: no tester journals in ", gLogFolder);
      return;
     }



   int lookDays = (days > 0) ? days : 365;
   gOnlyLast = true;
   for(int d = lookDays; d >= 0 && !IsStopped(); d--)
     {
      string ds = TimeToString(TimeCurrent() - (datetime)(d * 86400), TIME_DATE);
      StringReplace(ds, ".", "");
      string cand = ds + ".log";
      uchar cpat[];
      ToAnsi(gLogFolder + "\\" + cand, cpat);
      uchar cfd[600];
      int hF = FindFirstFileA(cpat, cfd);
      if(hF == INVALID_HANDLE_VALUE)
         continue;
      FindClose(hF);
      if(InVerbose)
         Print("Replay: ", cand, " ...");
      StreamJournalLines(gLogFolder + "\\" + cand);
     }

















      gOnlyLast = false;

      if(gSess.active)
        {
         if(gSess.completed)
           {
            Print("Replay: most recent backtest finished -> writing report (", gSess.expert,
                  " ", gSess.symbol, " ", gSess.period, ", deals=",
                  IntegerToString(ArraySize(gSess.deals)), ")");
            FinalizeSession();
           }
         else
            Print("Replay: most recent backtest has not finished yet - will report when it completes.");
        }
      else
         Print("Replay: no backtest found within the look-back window.");

      // the live loop must not re-parse today's journal from scratch
      uchar buf[];
      int   n = 0;
      string todayPath = gLogFolder + "\\" + gLastDate + ".log";
      // whole-file read removed - the live loop tails from gByteOffset
         gByteOffset = FileSizeBytes(todayPath);
      if(gByteOffset < 0)
         gByteOffset = 0;
      gJournalEnc = DetectJournalEncoding(todayPath);
     }

//+------------------------------------------------------------------+
//| Stream a (possibly huge) journal file in chunks, feeding complete |
//| lines to ProcessLogLine() - no 100 MB whole-file limit            |
//+------------------------------------------------------------------+
//+------------------------------------------------------------------+
//| File size in bytes (WinAPI with msvcrt fallback), -1 on error    |
//+------------------------------------------------------------------+
long FileSizeBytes(string path)
  {
   uchar p[];
   ToAnsi(path, p);
   long h = CreateFileA(p, GENERIC_READ, 1 | 2,
                        0, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, 0);
   if(h != INVALID_HANDLE_VALUE)
     {
      long hi  = 0;
      uint lo  = GetFileSize(h, hi);
      CloseHandle(h);
      if(lo != 0xFFFFFFFF)
         return ((long)hi << 32) | (long)lo;
     }
   uchar mode[];
   ToAnsi("rb", mode);
   long f = fopen(p, mode);
   if(f == 0)
      return -1;
   fseek(f, 0, SEEK_END);
   long sz = ftell(f);
   fclose(f);
   return sz;
  }

//+------------------------------------------------------------------+
//| Read up to maxBytes starting at offset (WinAPI with fallback)    |
//+------------------------------------------------------------------+
bool ReadFileBytesFrom(string path, long offset, int maxBytes, uchar &bytes[], int &size)
  {
   uchar p[];
   ToAnsi(path, p);
   long h = CreateFileA(p, GENERIC_READ, 1 | 2,
                        0, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, 0);
   if(h != INVALID_HANDLE_VALUE)
     {
      long hi  = (long)((ulong)offset >> 32);
      long low = (long)(offset & 0xFFFFFFFF);
      long pos = SetFilePointer(h, low, hi, SEEK_SET);
      if(pos != INVALID_HANDLE_VALUE)
        {
         ArrayResize(bytes, maxBytes);
         long read = 0;
         int  ok = ReadFile(h, bytes, (long)maxBytes, read, 0);
         CloseHandle(h);
         if(ok != 0 && read > 0)
           {
            ArrayResize(bytes, (int)read);
            size = (int)read;
            return true;
           }
         return false;
        }
      CloseHandle(h);
     }
   uchar mode[];
   ToAnsi("rb", mode);
   long f = fopen(p, mode);
   if(f == 0)
      return false;
   fseek(f, offset, SEEK_SET);
   ArrayResize(bytes, maxBytes);
   int rd = fread(bytes, 1, maxBytes, f);
   fclose(f);
   if(rd > 0)
     {
      ArrayResize(bytes, rd);
      size = rd;
      return true;
     }
   return false;
  }

//+------------------------------------------------------------------+
//| Journal encoding from the BOM (0=unknown, 1=UTF-16LE, 2=UTF-8,   |
//| 3=ANSI)                                                          |
//+------------------------------------------------------------------+
int DetectJournalEncoding(string path)
  {
   uchar b[4];
   int   n = 0;
   if(!ReadFileBytesFrom(path, 0, 4, b, n))
      return 0;
   if(n >= 2 && b[0] == 0xFF && b[1] == 0xFE)
      return 1;
   if(n >= 3 && b[0] == 0xEF && b[1] == 0xBB && b[2] == 0xBF)
      return 2;
   return 3;
  }

//+------------------------------------------------------------------+
//| Bytes through the last complete line ending in the chunk         |
//+------------------------------------------------------------------+
int BytesThroughLastNewline(uchar &b[], int n)
  {
   if(gJournalEnc == 1)              // UTF-16LE: lines end with 0A 00
     {
      for(int i = n - 2; i >= 0; i--)
         if(b[i] == 0x0A && b[i + 1] == 0x00)
            return i + 2;
      return 0;
     }
   for(int i = n - 1; i >= 0; i--)   // UTF-8/ANSI: lines end with 0A
      if(b[i] == 0x0A)
         return i + 1;
   return 0;
  }

//+------------------------------------------------------------------+
//| Decode one journal chunk (encoding detected on the first chunk)  |
//+------------------------------------------------------------------+
string DecodeJournalChunk(uchar &b[], int n, bool firstChunk)
  {
   if(gJournalEnc == 0)
     {
      if(n >= 2 && b[0] == 0xFF && b[1] == 0xFE)
         gJournalEnc = 1;
      else
         if(n >= 3 && b[0] == 0xEF && b[1] == 0xBB && b[2] == 0xBF)
            gJournalEnc = 2;
         else
            gJournalEnc = 3;
     }
   if(gJournalEnc == 1)
      return DecodeUTF16LE(b, n);
   if(gJournalEnc == 2)
     {
      int skip = (firstChunk && n >= 3 && b[0] == 0xEF && b[1] == 0xBB && b[2] == 0xBF) ? 3 : 0;
      return CharArrayToString(b, skip, WHOLE_ARRAY, CP_UTF8);
     }
   return CharArrayToString(b, 0, WHOLE_ARRAY, CP_ACP);
  }

//+------------------------------------------------------------------+
//| Convert raw UTF-16 LE bytes to a UTF-8 MQL5 string               |
//+------------------------------------------------------------------+
string DecodeUTF16LE(uchar &b[], int n)
  {
   if(n <= 0)
      return "";
   int start = 0;
   if(n >= 2 && b[0] == 0xFF && b[1] == 0xFE)
      start = 2;                     // skip the BOM if present
   int m = n - start;
   if(m <= 0)
      return "";
   if(m > MAX_DECODE_BYTES)
     {
      Print("DecodeUTF16LE: input too large (", m, " bytes) - skipped.");
      return "";
     }
   uchar u8[];
   long cap64 = (long)m * 3 / 2 + 8; // UTF-8 output is at most 1.5x the UTF-16 input
   int  cap   = (int)cap64;
   if(ArrayResize(u8, cap) < cap)
     {
      Print("DecodeUTF16LE: cannot allocate ", cap, " bytes (input ", m, " bytes).");
      return "";
     }
   int w = 0;
   int i = start;
   while(i + 1 < n)
     {
      ushort c = (ushort)(b[i] | (b[i + 1] << 8));
      i += 2;
      uint cp = c;
      if(c >= 0xD800 && c <= 0xDBFF && i + 1 < n)
        {
         ushort c2 = (ushort)(b[i] | (b[i + 1] << 8));
         if(c2 >= 0xDC00 && c2 <= 0xDFFF)
           {
            cp = 0x10000 + ((uint)(c - 0xD800) << 10) + (c2 - 0xDC00);
            i += 2;
           }
        }
      if(cp < 0x80)
         u8[w++] = (uchar)cp;
      else
         if(cp < 0x800)
           {
            u8[w++] = (uchar)(0xC0 | (cp >> 6));
            u8[w++] = (uchar)(0x80 | (cp & 0x3F));
           }
         else
            if(cp < 0x10000)
              {
               u8[w++] = (uchar)(0xE0 | (cp >> 12));
               u8[w++] = (uchar)(0x80 | ((cp >> 6) & 0x3F));
               u8[w++] = (uchar)(0x80 | (cp & 0x3F));
              }
            else
              {
               u8[w++] = (uchar)(0xF0 | (cp >> 18));
               u8[w++] = (uchar)(0x80 | ((cp >> 12) & 0x3F));
               u8[w++] = (uchar)(0x80 | ((cp >> 6) & 0x3F));
               u8[w++] = (uchar)(0x80 | (cp & 0x3F));
              }
     }
   ArrayResize(u8, w);
   return CharArrayToString(u8, 0, WHOLE_ARRAY, CP_UTF8);
  }

   void StreamJournalLines(string path)
     {
      uchar p[];
      ToAnsi(path, p);
      long h = CreateFileA(p, GENERIC_READ, 1 | 2,
                           0, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, 0);
      if(h == INVALID_HANDLE_VALUE)
        {
         Print("Replay: cannot open ", path);
         return;
        }
      uchar buf[];
   int   n = 0;
   if(!ReadFileBytes(path, buf, n))
     {
      Print("Replay: cannot read ", path);
      return;
     }
   ParseCompleteLines(DecodeTextBytes(buf, n));
   return;
      int CHUNK = 0;   // (legacy dead code - never reached)
   uchar chunk[];
      ArrayResize(chunk, CHUNK);
      uchar carry[];
      ArrayResize(carry, 0);
      while(!IsStopped())
        {
         long rd = 0;
         if(ReadFile(h, chunk, CHUNK, rd, 0) == 0 || rd <= 0)
            break;
         int cc = ArraySize(carry);
         int cn = (int)rd;
         ArrayResize(carry, cc + cn);
         ArrayCopy(carry, chunk, cc, 0, cn);
         int lastNL = -1;
         int total  = ArraySize(carry);
         for(int i = total - 1; i >= 0; i--)
            if(carry[i] == '\n')
              {
               lastNL = i;
               break;
              }
         if(lastNL < 0)
            continue;                          // chunk is part of one long line
         string text = CharArrayToString(carry, 0, lastNL + 1, CP_ACP);
         ParseCompleteLines(text);
         int rn = total - lastNL - 1;          // trailing partial line
         if(rn > 0)
           {
            uchar rest[];
            ArrayResize(rest, rn);
            ArrayCopy(rest, carry, 0, lastNL + 1, rn);
            ArrayResize(carry, rn);
            ArrayCopy(carry, rest);
           }
         else
            ArrayResize(carry, 0);
        }
      CloseHandle(h);
      if(ArraySize(carry) > 0)                 // partial line at EOF
        {
         string text = CharArrayToString(carry, 0, WHOLE_ARRAY, CP_ACP);
         if(StringLen(text) > 0)
            ProcessLogLine(text);
        }
     }

//+------------------------------------------------------------------+
//|                                                                  |
//+------------------------------------------------------------------+
   bool ReadFileBytes(string path, uchar &bytes[], int &size)
     {
      //--- primary: WinAPI
      uchar p[];
      ToAnsi(path, p);
      long h = CreateFileA(p, GENERIC_READ, 1 | 2,
                           0, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, 0);
      if(h != INVALID_HANDLE_VALUE)
        {
         long  hi = 0;
         uint  fsz = GetFileSize(h, hi);
         if(fsz > MAX_DECODE_BYTES)
           {
            CloseHandle(h);
            if(InVerbose)
               Print("ReadFileBytes: file too large for a single read (", fsz, " bytes): ", path);
            return false;
           }
         if(fsz > 0)
         if(fsz > 0)
           {
            ArrayResize(bytes, (int)fsz);
            long read = 0;
            int  ok = ReadFile(h, bytes, (long)fsz, read, 0);
            CloseHandle(h);
            if(ok != 0 && read > 0)
              {
               size = (int)read;
               return true;
              }
           }
         else
            CloseHandle(h);
        }
      //--- fallback: msvcrt
      uchar mode[];
      ToAnsi("rb", mode);
      long f = fopen(p, mode);
      if(f == 0)
         return false;
      fseek(f, 0, SEEK_END);
      long fsz = ftell(f);
      if(fsz > MAX_DECODE_BYTES)
        {
         fclose(f);
         if(InVerbose)
            Print("ReadFileBytes: file too large for a single read (", fsz, " bytes): ", path);
         return false;
        }
      if(fsz > 0)
      fseek(f, 0, SEEK_SET);
      if(fsz > 0)
        {
         ArrayResize(bytes, (int)fsz);
         int rd = fread(bytes, 1, (int)fsz, f);
         fclose(f);
         if(rd > 0)
           {
            size = rd;
            return true;
           }
         return false;
        }

      fclose(f);
      return false;
     }

//+------------------------------------------------------------------+
//| Read a text file outside the sandbox                              |
//+------------------------------------------------------------------+
   string ReadTextFile(string path)
     {
      uchar buf[];
      int   n = 0;
      if(!ReadFileBytes(path, buf, n))
        {
         if(InVerbose)
            Print("Cannot read file: ", path);
         return "";
        }
      return DecodeTextBytes(buf, n);
     }

//+------------------------------------------------------------------+
//| Get file attributes (diagnostics)                                  |
//+------------------------------------------------------------------+
   uint GetAttrFile(string path)
     {
      uchar p[];
      ToAnsi(path, p);
      return GetFileAttributesA(p);
     }

//+------------------------------------------------------------------+
//| Open a file with WinAPI (used by the self-test probe)             |
//+------------------------------------------------------------------+
   long OpenFileRead(string path)
     {
      uchar p[];
      ToAnsi(path, p);
      long h = CreateFileA(p, GENERIC_READ, 1 | 2,
                           0, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, 0);
      if(h == INVALID_HANDLE_VALUE && InVerbose)
         Print("OpenFileRead failed: ", path, " (chars=", StringLen(path), ", mql_err=", GetLastError(), ")");
      return h;
     }

//+------------------------------------------------------------------+
//| Convert raw bytes to a MQL5 string (UTF-16 LE / UTF-8 / ANSI)     |
//+------------------------------------------------------------------+
   string DecodeTextBytes(uchar &b[], int n)
     {
      if(n <= 0)
         return "";
      if(n >= 3 && b[0] == 0xEF && b[1] == 0xBB && b[2] == 0xBF)
         return CharArrayToString(b, 3, WHOLE_ARRAY, CP_UTF8);
      if(n >= 2 && b[0] == 0xFF && b[1] == 0xFE)
         return DecodeUTF16LE(b, n);
      return CharArrayToString(b, 0, WHOLE_ARRAY, CP_ACP);
     }

   string DecodeTextBytes_OLD(uchar &b[], int n)
     {
      if(n >= 3 && b[0] == 0xEF && b[1] == 0xBB && b[2] == 0xBF)
         return CharArrayToString(b, 3, WHOLE_ARRAY, CP_UTF8);
      if(n >= 2 && b[0] == 0xFF && b[1] == 0xFE)
        {
         uchar u8[];
         int cap = n * 2 + 8;
         ArrayResize(u8, cap);
         int w = 0;
         int i = 2;
         while(i + 1 < n)
           {
            ushort c = (ushort)(b[i] | (b[i + 1] << 8));
            i += 2;
            uint cp = c;
            if(c >= 0xD800 && c <= 0xDBFF && i + 1 < n)
              {
               ushort c2 = (ushort)(b[i] | (b[i + 1] << 8));
               if(c2 >= 0xDC00 && c2 <= 0xDFFF)
                 {
                  cp = 0x10000 + ((uint)(c - 0xD800) << 10) + (c2 - 0xDC00);
                  i += 2;
                 }
              }
            if(cp < 0x80)
               u8[w++] = (uchar)cp;
            else
               if(cp < 0x800)
                 {
                  u8[w++] = (uchar)(0xC0 | (cp >> 6));
                  u8[w++] = (uchar)(0x80 | (cp & 0x3F));
                 }
               else
                  if(cp < 0x10000)
                    {
                     u8[w++] = (uchar)(0xE0 | (cp >> 12));
                     u8[w++] = (uchar)(0x80 | ((cp >> 6) & 0x3F));
                     u8[w++] = (uchar)(0x80 | (cp & 0x3F));
                    }
                  else
                    {
                     u8[w++] = (uchar)(0xF0 | (cp >> 18));
                     u8[w++] = (uchar)(0x80 | ((cp >> 12) & 0x3F));
                     u8[w++] = (uchar)(0x80 | ((cp >> 6) & 0x3F));
                     u8[w++] = (uchar)(0x80 | (cp & 0x3F));
                    }
           }
         ArrayResize(u8, w);
         return CharArrayToString(u8, 0, WHOLE_ARRAY, CP_UTF8);
        }
      return CharArrayToString(b, 0, WHOLE_ARRAY, CP_ACP);
     }

//+------------------------------------------------------------------+
//| Convert a MQL5 string to a null-terminated ANSI byte array        |
//+------------------------------------------------------------------+
   void ToAnsi(string s, uchar &arr[])
     {
      int n = StringToCharArray(s, arr, 0, StringLen(s), CP_ACP);
      ArrayResize(arr, n + 1);
      arr[n] = 0;
     }

//+------------------------------------------------------------------+
//| Extract the file name from a WIN32_FIND_DATAA buffer              |
//+------------------------------------------------------------------+
   string GetFindFileNameA(uchar &fd[])
     {
      string res = "";
      int off = 44;                         // cFileName offset (pack(4) layout)
      if(fd[off] == 0 && fd[off + 4] != 0)
         off = 48;                           // pack(8) layout variant
      for(int i = off; i < off + 260; i++)
        {
         if(fd[i] == 0)
            break;
         res += CharToString(fd[i]);
        }
      return res;
     }

//+------------------------------------------------------------------+
//| Integer parsed from s at position start; pos2 receives the end    |
//+------------------------------------------------------------------+
   int ToIntFrom(string s, int start, int &pos2)
     {
      int v = 0;
      int i = start;
      int n = StringLen(s);
      while(i < n && StringGetCharacter(s, i) >= '0' && StringGetCharacter(s, i) <= '9')
        {
         v = v * 10 + (StringGetCharacter(s, i) - '0');
         i++;
        }
      pos2 = i;
      return v;
     }

//+------------------------------------------------------------------+
//| Integer parsed after the given position in s                      |
//+------------------------------------------------------------------+
   int ToIntAfter(string s, int start)
     {
      int v = 0;
      int i = start;
      int n = StringLen(s);
      while(i < n && StringGetCharacter(s, i) >= '0' && StringGetCharacter(s, i) <= '9')
        {
         v = v * 10 + (StringGetCharacter(s, i) - '0');
         i++;
        }
      return v;
     }

//+------------------------------------------------------------------+
//| Next whitespace-delimited token after position start              |
//+------------------------------------------------------------------+
   string NextToken(string s, int start, int &pos2)
     {
      int i = start;
      int n = StringLen(s);
      while(i < n && StringGetCharacter(s, i) == ' ')
         i++;
      int b = i;
      while(i < n && StringGetCharacter(s, i) != ' ')
         i++;
      pos2 = i;
      return StringSubstr(s, b, i - b);
     }

//+------------------------------------------------------------------+
//| Value after "final balance " in a line                            |
//+------------------------------------------------------------------+
   double ParseFinalBalance(string msg)
     {
      int p = StringFind(msg, "final balance ");
      if(p < 0)
         return 0.0;
      return ToDbl(StringSubstr(msg, p + 14));
     }

//+------------------------------------------------------------------+
//| Get an XML attribute value                                         |
//+------------------------------------------------------------------+
   string GetAttr(string s, string name)
     {
      string pat = name + "=\"";
      int p = StringFind(s, pat);
      if(p < 0)
         return "";
      p += StringLen(pat);
      int q = StringFind(s, "\"", p);
      if(q < 0)
         return "";
      return StringSubstr(s, p, q - p);
     }

//+------------------------------------------------------------------+
//| Get the content of a simple XML tag                                |
//+------------------------------------------------------------------+
   string GetTag(string s, string name)
     {
      string o = "<" + name + ">";
      string c = "</" + name + ">";
      int p = StringFind(s, o);
      if(p < 0)
         return "";
      int q = StringFind(s, c, p);
      if(q < 0)
         return "";
      p += StringLen(o);
      return StringSubstr(s, p, q - p);
     }

//+------------------------------------------------------------------+
//| String to double (tolerant of locale comma)                       |
//+------------------------------------------------------------------+
   double ToDbl(string s)
     {
      if(s == "")
         return 0.0;
      StringReplace(s, ",", ".");
      return StringToDouble(s);
     }

//+------------------------------------------------------------------+
//| Escape a string for XML                                            |
//+------------------------------------------------------------------+
   string XmlEscape(string s)
     {
      StringReplace(s, "&", "&amp;");
      StringReplace(s, "<", "&lt;");
      StringReplace(s, ">", "&gt;");
      StringReplace(s, "\"", "&quot;");
      StringReplace(s, "'", "&apos;");
      return s;
     }

//+------------------------------------------------------------------+
//| Make a string safe for a file name                                |
//+------------------------------------------------------------------+
   string SafeName(string s)
     {
      StringReplace(s, ":", "_");
      StringReplace(s, ".", "_");
      StringReplace(s, " ", "_");
      StringReplace(s, "\\", "_");
      StringReplace(s, "/", "_");
      return s;
     }

//+------------------------------------------------------------------+
//| State: has this report file already been processed?               |
//+------------------------------------------------------------------+
   bool IsProcessed(string fname)
     {
      FolderCreate(InOutputFolder);
      int h = FileOpen(InOutputFolder + "\\processed.txt", FILE_READ | FILE_TXT | FILE_ANSI);
      if(h == INVALID_HANDLE)
         return false;
      bool found = false;
      while(!FileIsEnding(h))
        {
         string line = FileReadString(h);
         if(line == fname)
           {
            found = true;
            break;
           }
        }
      FileClose(h);
      return found;
     }

//+------------------------------------------------------------------+
//| State: remember a processed report file                            |
//+------------------------------------------------------------------+
   void MarkProcessed(string fname)
     {
      FolderCreate(InOutputFolder);
      int h = FileOpen(InOutputFolder + "\\processed.txt", FILE_WRITE | FILE_READ | FILE_TXT | FILE_ANSI);
      if(h == INVALID_HANDLE)
         return;
      FileSeek(h, 0, SEEK_END);
      FileWriteString(h, fname + "\r\n");
      FileClose(h);
     }

//+------------------------------------------------------------------+
//| State: has this tester session already been processed?            |
//+------------------------------------------------------------------+
   bool IsProcessedKey(string key)
     {
      FolderCreate(InOutputFolder);
      int h = FileOpen(InOutputFolder + "\\processed_sessions.txt", FILE_READ | FILE_TXT | FILE_ANSI);
      if(h == INVALID_HANDLE)
         return false;
      bool found = false;
      while(!FileIsEnding(h))
        {
         string line = FileReadString(h);
         if(line == key)
           {
            found = true;
            break;
           }
        }
      FileClose(h);
      return found;
     }

//+------------------------------------------------------------------+
//| State: remember a processed tester session                        |
//+------------------------------------------------------------------+
   void MarkProcessedKey(string key)
     {
      FolderCreate(InOutputFolder);
      int h = FileOpen(InOutputFolder + "\\processed_sessions.txt", FILE_WRITE | FILE_READ | FILE_TXT | FILE_ANSI);
      if(h == INVALID_HANDLE)
         return;
      FileSeek(h, 0, SEEK_END);
      FileWriteString(h, key + "\r\n");
      FileClose(h);
     }
//+------------------------------------------------------------------+
