//+------------------------------------------------------------------+
//|  PipAlgo Copier — SLAVE EA (MT5)  v1.20                          |
//|  Polls the PHP copier server and executes trades on this account. |
//|  Supports: BUY/SELL/CLOSE/MODIFY/PENDING/REVERSE direction.       |
//|  © PipAlgo Developers | pipalgo.co.za                             |
//+------------------------------------------------------------------+
#property copyright "PipAlgo Developers"
#property link      "https://pipalgo.co.za"
#property version   "1.20"
#include <Trade\Trade.mqh>
#include <Trade\PositionInfo.mqh>

//── Input Parameters ──────────────────────────────────────────────────
input group  "=== Connection ==="
input string CopyToken    = "";                           // Copy Token from dashboard
input string ServerBase   = "https://copier.pipalgo.co.za"; // Server URL (no trailing slash)
input int    PollSeconds  = 3;                            // Polling interval in seconds

input group  "=== Execution ==="
input int    Slippage     = 10;                           // Max slippage in points
input bool   UseComment   = true;                         // Copy master trade comment
input long   MagicNumber  = 99002;                        // EA magic number

input group  "=== Display ==="
input bool   ShowPanel    = true;                         // Show on-chart status panel
input int    PanelX       = 10;                           // Panel X position
input int    PanelY       = 20;                           // Panel Y position
input bool   DebugLog     = false;                        // Extended Experts tab logging

//── Globals ───────────────────────────────────────────────────────────
CTrade        Trade;
CPositionInfo PosInfo;

struct TkMap5 { ulong masterTk; ulong slaveTk; string symbol; };
TkMap5 Maps[];
int    MapCount = 0;

//── Runtime state ─────────────────────────────────────────────────────
bool   g_Active        = false;
string g_StatusMsg     = "Connecting…";
string g_AccountKind   = "";
string g_LotMode       = "multiplier";
double g_LotValue      = 1.0;
double g_MaxLot        = 0;
string g_CopyDir       = "same";
string g_MasterLabel   = "";
string g_SubExpiry     = "";
int    g_DaysLeft      = -1;

int    g_PollCount     = 0;
int    g_ExecCount     = 0;
int    g_FailCount     = 0;
string g_LastAction    = "—";
string g_LastSymbol    = "";
datetime g_LastExecTime = 0;

//+------------------------------------------------------------------+
int OnInit()
{
    if (CopyToken == "") {
        Alert("PipAlgo Slave MT5: CopyToken is empty!\nPaste your Copy Token from the dashboard.");
        return INIT_FAILED;
    }

    Trade.SetExpertMagicNumber(MagicNumber);
    Trade.SetDeviationInPoints(Slippage);
    Trade.SetTypeFilling(ORDER_FILLING_IOC);   // fallback; broker-specific

    // ── Call status API on init to validate token + fetch settings ──
    string statusUrl = ServerBase + "/api/ea_status.php"
                     + "?token="      + CopyToken
                     + "&platform=mt5"
                     + "&ea_version=1.20";

    char   dummy[], result[];
    string reqH = "", resH = "";
    int    code = WebRequest("GET", statusUrl, reqH, 5000, dummy, result, resH);

    if (code == -1) {
        int err = GetLastError();
        string errTxt = "WebRequest failed (err " + IntegerToString(err) + ")."
                      + " Add URL to: Tools→Options→Expert Advisors→Allow WebRequest";
        Alert("PipAlgo Slave MT5: " + errTxt);
        Print("[PipAlgo Slave5] ", errTxt);
        return INIT_FAILED;
    }

    string resp = CharArrayToString(result);
    if (DebugLog) Print("[PipAlgo Slave5] Init status: ", resp);

    if (StringFind(resp, "\"ok\":true") == -1) {
        string msg = ParseStr(resp, "msg");
        Alert("PipAlgo Slave MT5: " + (msg != "" ? msg : "Server rejected token."));
        return INIT_FAILED;
    }

    // Load server-side settings into globals
    g_AccountKind  = ParseStr(resp, "account_kind");
    g_LotMode      = ParseStr(resp, "lot_mode");
    g_LotValue     = ParseNum(resp, "lot_value");
    g_MaxLot       = ParseNum(resp, "max_lot");
    g_CopyDir      = ParseStr(resp, "copy_direction");
    g_MasterLabel  = ParseStr(resp, "master_label");
    g_SubExpiry    = ParseStr(resp, "subscription_expires");
    g_DaysLeft     = (int)ParseInt(resp,  "days_remaining");
    g_StatusMsg    = ParseStr(resp, "message");
    string accStatus = ParseStr(resp, "status");

    if (accStatus != "active" && accStatus != "paused") {
        Alert("PipAlgo Slave MT5: " + g_StatusMsg);
        // Still start but panel will show warning
    }

    g_Active = (accStatus == "active");

    if (ShowPanel) {
        DrawPanel();
        UpdatePanel();
    }

    EventSetTimer(PollSeconds);

    Print("[PipAlgo Slave5] Init OK | Account: ", g_AccountKind,
          " | Master: ", g_MasterLabel, " | Direction: ", g_CopyDir,
          " | LotMode: ", g_LotMode, " x", DoubleToString(g_LotValue, 2));

    return INIT_SUCCEEDED;
}

void OnDeinit(const int reason)
{
    EventKillTimer();
    ObjectsDeleteAll(0, "PAS5_");
    Comment("");
}

void OnTick()  { /* timer-driven, tick unused */ }

void OnTimer()
{
    PollAndExecute();
}

//── Poll server ────────────────────────────────────────────────────────
void PollAndExecute()
{
    g_PollCount++;

    string url = ServerBase + "/api/slave_poll.php"
               + "?token="      + CopyToken
               + "&platform=mt5"
               + "&ea_version=1.20";

    uchar  dummy[], result[];
    string reqH = "", resH = "";
    int    code = WebRequest("GET", url, reqH, 5000, dummy, result, resH);

    if (code == -1) {
        g_Active     = false;
        g_StatusMsg  = "Poll failed (err " + IntegerToString(GetLastError()) + ")";
        if (DebugLog) Print("[PipAlgo Slave5] ", g_StatusMsg);
        if (ShowPanel) UpdatePanel();
        return;
    }

    string resp = CharArrayToString(result);
    if (DebugLog && g_PollCount % 20 == 0)   // throttle debug output
        Print("[PipAlgo Slave5] Poll #", g_PollCount, ": ", StringSubstr(resp, 0, 180));

    if (StringFind(resp, "\"ok\":true") == -1) {
        string msg  = ParseStr(resp, "msg");
        g_Active    = false;
        g_StatusMsg = msg != "" ? msg : "Server error";
        if (ShowPanel) UpdatePanel();
        return;
    }

    g_Active    = true;
    g_StatusMsg = "Active";

    // ── Extract trades array ───────────────────────────────────────
    int pos      = StringFind(resp, "\"trades\":[");
    int arrStart = StringFind(resp, "[", pos);
    int arrEnd   = BracketEnd(resp, arrStart, '[', ']');
    if (arrStart < 0 || arrEnd < 0) { if (ShowPanel) UpdatePanel(); return; }

    string ts = StringSubstr(resp, arrStart + 1, arrEnd - arrStart - 1);
    if (StringLen(StringTrimLeft(StringTrimRight(ts))) == 0) {
        if (ShowPanel) UpdatePanel();
        return;
    }

    int from = 0;
    while (true) {
        int s = StringFind(ts, "{", from);
        if (s < 0) break;
        int e = BracketEnd(ts, s, '{', '}');
        if (e < 0) break;
        ProcessTrade(StringSubstr(ts, s, e - s + 1));
        from = e + 1;
    }

    if (ShowPanel) UpdatePanel();
}

//── Parse and execute one trade JSON object ───────────────────────────
void ProcessTrade(string obj)
{
    long   slaveId   = ParseInt(obj,  "slave_trade_id");
    ulong  masterTk  = (ulong)ParseInt(obj, "ticket");
    string symbol    = ParseStr(obj,  "symbol");
    string ttype     = ParseStr(obj,  "trade_type");
    double lot       = ParseNum(obj,  "copied_lot");
    double openPrice = ParseNum(obj,  "open_price");
    double sl        = ParseNum(obj,  "stop_loss");
    double tp        = ParseNum(obj,  "take_profit");
    string cmt       = UseComment ? ParseStr(obj, "comment") : "PipAlgo";

    // Apply server-side settings loaded at Init
    // (lot is already calculated by server, but we re-apply max_lot cap)
    if (g_MaxLot > 0 && lot > g_MaxLot) lot = g_MaxLot;

    // Apply copy direction reversal
    if (g_CopyDir == "reverse") ttype = ReverseType(ttype);

    if (DebugLog) Print("[PipAlgo Slave5] Trade: ", ttype, " ", symbol,
                        " lot=", lot, " sl=", sl, " tp=", tp);

    ulong  execTk  = 0;
    string status  = "failed";
    string errMsg  = "";

    // ── Market BUY ───────────────────────────────────────────────
    if (ttype == "BUY") {
        if (!SymbolSelect(symbol, true)) { errMsg = "Symbol not found: "+symbol; goto DONE; }
        double price = SymbolInfoDouble(symbol, SYMBOL_ASK);
        if (Trade.Buy(NormLot(symbol, lot), symbol, price,
                      NormPrice(symbol, sl), NormPrice(symbol, tp), cmt)) {
            execTk = Trade.ResultOrder();
            status = "executed";
            AddMap(masterTk, execTk, symbol);
        } else {
            errMsg = "Buy " + IntegerToString(Trade.ResultRetcode()) + " " + Trade.ResultComment();
        }
    }

    // ── Market SELL ──────────────────────────────────────────────
    else if (ttype == "SELL") {
        if (!SymbolSelect(symbol, true)) { errMsg = "Symbol not found: "+symbol; goto DONE; }
        double price = SymbolInfoDouble(symbol, SYMBOL_BID);
        if (Trade.Sell(NormLot(symbol, lot), symbol, price,
                       NormPrice(symbol, sl), NormPrice(symbol, tp), cmt)) {
            execTk = Trade.ResultOrder();
            status = "executed";
            AddMap(masterTk, execTk, symbol);
        } else {
            errMsg = "Sell " + IntegerToString(Trade.ResultRetcode()) + " " + Trade.ResultComment();
        }
    }

    // ── Pending orders ───────────────────────────────────────────
    else if (ttype == "BUY_LIMIT") {
        if (Trade.BuyLimit(NormLot(symbol,lot), NormPrice(symbol,openPrice), symbol,
                           NormPrice(symbol,sl), NormPrice(symbol,tp), ORDER_TIME_GTC, 0, cmt)) {
            execTk = Trade.ResultOrder(); status = "executed"; AddMap(masterTk, execTk, symbol);
        } else errMsg = "BuyLimit " + IntegerToString(Trade.ResultRetcode());
    }
    else if (ttype == "SELL_LIMIT") {
        if (Trade.SellLimit(NormLot(symbol,lot), NormPrice(symbol,openPrice), symbol,
                            NormPrice(symbol,sl), NormPrice(symbol,tp), ORDER_TIME_GTC, 0, cmt)) {
            execTk = Trade.ResultOrder(); status = "executed"; AddMap(masterTk, execTk, symbol);
        } else errMsg = "SellLimit " + IntegerToString(Trade.ResultRetcode());
    }
    else if (ttype == "BUY_STOP") {
        if (Trade.BuyStop(NormLot(symbol,lot), NormPrice(symbol,openPrice), symbol,
                          NormPrice(symbol,sl), NormPrice(symbol,tp), ORDER_TIME_GTC, 0, cmt)) {
            execTk = Trade.ResultOrder(); status = "executed"; AddMap(masterTk, execTk, symbol);
        } else errMsg = "BuyStop " + IntegerToString(Trade.ResultRetcode());
    }
    else if (ttype == "SELL_STOP") {
        if (Trade.SellStop(NormLot(symbol,lot), NormPrice(symbol,openPrice), symbol,
                           NormPrice(symbol,sl), NormPrice(symbol,tp), ORDER_TIME_GTC, 0, cmt)) {
            execTk = Trade.ResultOrder(); status = "executed"; AddMap(masterTk, execTk, symbol);
        } else errMsg = "SellStop " + IntegerToString(Trade.ResultRetcode());
    }

    // ── Close position ───────────────────────────────────────────
    else if (ttype == "CLOSE") {
        ulong slaveTk = FindSlave(masterTk);
        if (slaveTk > 0) {
            if (PositionSelectByTicket(slaveTk)) {
                if (Trade.PositionClose(slaveTk, Slippage)) {
                    status = "executed";
                    RemoveMap(slaveTk);
                } else {
                    errMsg = "Close " + IntegerToString(Trade.ResultRetcode());
                }
            } else {
                // Fallback: search open positions by magic+symbol
                ulong fallback = FindPosByMagicSymbol(symbol);
                if (fallback > 0) {
                    if (Trade.PositionClose(fallback, Slippage)) { status="executed"; RemoveMap(slaveTk); }
                    else errMsg = "FallbackClose " + IntegerToString(Trade.ResultRetcode());
                } else {
                    status  = "skipped";
                    errMsg  = "No slave position for master#" + IntegerToString((long)masterTk);
                }
            }
        } else {
            // No map entry — try by symbol+magic
            ulong fb = FindPosByMagicSymbol(symbol);
            if (fb > 0) {
                if (Trade.PositionClose(fb, Slippage)) status = "executed";
                else errMsg = "NoMapClose " + IntegerToString(Trade.ResultRetcode());
            } else {
                status  = "skipped";
                errMsg  = "No map for master#" + IntegerToString((long)masterTk);
            }
        }
    }

    // ── Modify SL/TP ─────────────────────────────────────────────
    else if (ttype == "MODIFY") {
        ulong slaveTk = FindSlave(masterTk);
        if (slaveTk > 0 && PositionSelectByTicket(slaveTk)) {
            if (Trade.PositionModify(slaveTk, NormPrice(symbol,sl), NormPrice(symbol,tp)))
                status = "executed";
            else
                errMsg = "Modify " + IntegerToString(Trade.ResultRetcode());
        } else {
            status  = "skipped";
            errMsg  = "No position for MODIFY #" + IntegerToString((long)masterTk);
        }
    }

    // ── Delete pending order ──────────────────────────────────────
    else if (ttype == "DELETE") {
        ulong slaveTk = FindSlave(masterTk);
        if (slaveTk > 0) {
            if (Trade.OrderDelete(slaveTk)) { status="executed"; RemoveMap(slaveTk); }
            else errMsg = "Delete " + IntegerToString(Trade.ResultRetcode());
        } else {
            status  = "skipped";
            errMsg  = "No pending to delete for #" + IntegerToString((long)masterTk);
        }
    }

    else {
        status  = "skipped";
        errMsg  = "Unhandled type: " + ttype;
    }

    DONE:
    if (status == "executed") {
        g_ExecCount++;
        g_LastAction   = ttype;
        g_LastSymbol   = symbol;
        g_LastExecTime = TimeCurrent();
    } else if (status == "failed") {
        g_FailCount++;
    }

    if (DebugLog || status == "failed")
        Print("[PipAlgo Slave5] ", ttype, " → ", status,
              " tk=", (long)execTk, errMsg!=""?" | "+errMsg:"");

    Ack((int)slaveId, (long)execTk, status, errMsg);
}

//── Acknowledge execution ─────────────────────────────────────────────
void Ack(int slaveId, long ticket, string status, string errMsg)
{
    string json = StringFormat(
        "{\"token\":\"%s\",\"slave_trade_id\":%d,\"ticket\":%I64d,"
        "\"status\":\"%s\",\"error_msg\":\"%s\"}",
        CopyToken, slaveId, ticket, status, EscJson(errMsg)
    );
    uchar  post[], result[];
    string reqH = "Content-Type: application/json", resH = "";
    StringToCharArray(json, post, 0, StringLen(json));
    WebRequest("POST", ServerBase + "/api/slave_ack.php", reqH, 5000, post, result, resH);
}

//── On-chart panel ────────────────────────────────────────────────────
void DrawPanel()
{
    color bg   = C'18,24,36';
    color fg   = clrWhite;
    color dim  = C'100,120,150';
    int   x    = PanelX, y = PanelY, w = 240, h = 200;

    ObjRect("PAS5_BG", x, y, w, h, bg);

    // Header
    ObjLabel("PAS5_LOGO", x+10, y+6,  "PipAlgo Slave MT5", fg,  9, "Arial Bold");
    ObjLabel("PAS5_VER",  x+w-44,y+8, "v1.20", dim, 7, "Arial");
    ObjLabel("PAS5_SEP0", x+10, y+22, "──────────────────────", dim, 7, "Courier New");

    // Account line
    string acctxt = g_AccountKind != "" ? StringToUpper(g_AccountKind) + " ACCOUNT" : "—";
    ObjLabel("PAS5_AK",  x+10, y+30, "Account",   dim, 8, "Arial");
    ObjLabel("PAS5_AV",  x+95, y+30, acctxt,      g_AccountKind=="real"?C'255,180,0':C'33,150,243', 8, "Arial Bold");

    ObjLabel("PAS5_SK",  x+10, y+46, "Signal",    dim, 8, "Arial");
    ObjLabel("PAS5_SV",  x+95, y+46, "—",         fg,  8, "Arial Bold");

    ObjLabel("PAS5_PK",  x+10, y+62, "Polls",     dim, 8, "Arial");
    ObjLabel("PAS5_PV",  x+95, y+62, "0",         fg,  8, "Arial Bold");

    ObjLabel("PAS5_EK",  x+10, y+78, "Executed",  dim, 8, "Arial");
    ObjLabel("PAS5_EV",  x+95, y+78, "0",         C'76,175,80', 8, "Arial Bold");

    ObjLabel("PAS5_FK",  x+10, y+94, "Errors",    dim, 8, "Arial");
    ObjLabel("PAS5_FV",  x+95, y+94, "0",         fg,  8, "Arial Bold");

    ObjLabel("PAS5_LK",  x+10,y+110, "Last Trade",dim, 8, "Arial");
    ObjLabel("PAS5_LV",  x+95,y+110, "—",         fg,  8, "Arial");

    ObjLabel("PAS5_TK",  x+10,y+126, "Exec Time", dim, 8, "Arial");
    ObjLabel("PAS5_TV",  x+95,y+126, "—",         fg,  8, "Arial");

    // Subscription line (real accounts)
    ObjLabel("PAS5_XK",  x+10,y+142, "Sub Expiry",dim, 8, "Arial");
    ObjLabel("PAS5_XV",  x+95,y+142, "—",         fg,  8, "Arial");

    ObjLabel("PAS5_SEP1",x+10,y+158,"──────────────────────", dim, 7, "Courier New");
    ObjLabel("PAS5_MSG", x+10,y+166, "Connecting…", C'255,180,0', 7, "Arial");
    ObjLabel("PAS5_TKN", x+10,y+180, "Token: " + StringSubstr(CopyToken,0,16)+"…", dim, 7, "Courier New");

    ChartRedraw();
}

void UpdatePanel()
{
    color green  = C'76,175,80';
    color red    = C'239,83,80';
    color blue   = C'33,150,243';
    color yellow = C'255,180,0';
    color grey   = C'100,120,150';

    // Signal status
    if (g_Active) {
        ObjTxt("PAS5_SV", "● Active");
        ObjClr("PAS5_SV", green);
    } else {
        ObjTxt("PAS5_SV", "● " + (StringLen(g_StatusMsg)>18 ? StringSubstr(g_StatusMsg,0,18)+"…" : g_StatusMsg));
        ObjClr("PAS5_SV", red);
    }

    ObjTxt("PAS5_PV", IntegerToString(g_PollCount));
    ObjTxt("PAS5_EV", IntegerToString(g_ExecCount));
    ObjClr("PAS5_EV", g_ExecCount > 0 ? green : grey);
    ObjTxt("PAS5_FV", IntegerToString(g_FailCount));
    ObjClr("PAS5_FV", g_FailCount > 0 ? red : green);

    // Last trade
    string lastTrade = g_LastAction != "—" ? g_LastAction + (g_LastSymbol!="" ? " "+g_LastSymbol : "") : "—";
    ObjTxt("PAS5_LV", lastTrade);
    string lastTime  = g_LastExecTime > 0 ? TimeToString(g_LastExecTime, TIME_MINUTES|TIME_SECONDS) : "—";
    ObjTxt("PAS5_TV", lastTime);

    // Subscription
    if (g_AccountKind == "real" && g_SubExpiry != "") {
        string expiryDisp = StringSubstr(g_SubExpiry, 0, 10);
        if (g_DaysLeft >= 0) expiryDisp += " (" + IntegerToString(g_DaysLeft) + "d)";
        ObjTxt("PAS5_XV", expiryDisp);
        ObjClr("PAS5_XV", g_DaysLeft <= 3 ? red : (g_DaysLeft <= 7 ? yellow : green));
    } else if (g_AccountKind == "demo") {
        ObjTxt("PAS5_XV", "Free (Demo)");
        ObjClr("PAS5_XV", blue);
    } else {
        ObjTxt("PAS5_XV", "—");
    }

    // Status message
    ObjTxt("PAS5_MSG", StringLen(g_StatusMsg)>34 ? StringSubstr(g_StatusMsg,0,34)+"…" : g_StatusMsg);
    ObjClr("PAS5_MSG", g_Active ? green : yellow);

    ChartRedraw();
}

//── Object helpers ────────────────────────────────────────────────────
void ObjRect(string n,int x,int y,int w,int h,color bg)
{
    if (ObjectFind(0,n)<0) ObjectCreate(0,n,OBJ_RECTANGLE_LABEL,0,0,0);
    ObjectSetInteger(0,n,OBJPROP_XDISTANCE,x);  ObjectSetInteger(0,n,OBJPROP_YDISTANCE,y);
    ObjectSetInteger(0,n,OBJPROP_XSIZE,w);       ObjectSetInteger(0,n,OBJPROP_YSIZE,h);
    ObjectSetInteger(0,n,OBJPROP_BGCOLOR,bg);
    ObjectSetInteger(0,n,OBJPROP_BORDER_TYPE,BORDER_RAISED);
    ObjectSetInteger(0,n,OBJPROP_CORNER,CORNER_LEFT_UPPER);
    ObjectSetInteger(0,n,OBJPROP_BACK,false);
    ObjectSetInteger(0,n,OBJPROP_SELECTABLE,false);
}
void ObjLabel(string n,int x,int y,string txt,color clr,int sz,string font)
{
    if (ObjectFind(0,n)<0) ObjectCreate(0,n,OBJ_LABEL,0,0,0);
    ObjectSetInteger(0,n,OBJPROP_XDISTANCE,x);   ObjectSetInteger(0,n,OBJPROP_YDISTANCE,y);
    ObjectSetString(0,n,OBJPROP_TEXT,txt);        ObjectSetInteger(0,n,OBJPROP_COLOR,clr);
    ObjectSetInteger(0,n,OBJPROP_FONTSIZE,sz);    ObjectSetString(0,n,OBJPROP_FONT,font);
    ObjectSetInteger(0,n,OBJPROP_CORNER,CORNER_LEFT_UPPER);
    ObjectSetInteger(0,n,OBJPROP_SELECTABLE,false);
    ObjectSetInteger(0,n,OBJPROP_BACK,false);
}
void ObjTxt(string n,string txt)  { ObjectSetString(0,n,OBJPROP_TEXT,txt); }
void ObjClr(string n,color  clr)  { ObjectSetInteger(0,n,OBJPROP_COLOR,clr); }

//── Ticket map helpers ────────────────────────────────────────────────
void AddMap(ulong m, ulong s, string sym) {
    ArrayResize(Maps, MapCount+1);
    Maps[MapCount].masterTk = m;
    Maps[MapCount].slaveTk  = s;
    Maps[MapCount].symbol   = sym;
    MapCount++;
}
ulong FindSlave(ulong m) {
    for (int i=0; i<MapCount; i++) if (Maps[i].masterTk == m) return Maps[i].slaveTk;
    return 0;
}
void RemoveMap(ulong s) {
    for (int i=0; i<MapCount; i++) {
        if (Maps[i].slaveTk == s) {
            for (int j=i; j<MapCount-1; j++) Maps[j]=Maps[j+1];
            MapCount--; ArrayResize(Maps, MapCount); return;
        }
    }
}

// Fallback: find an open position on symbol with our magic
ulong FindPosByMagicSymbol(string sym) {
    for (int i=0; i<PositionsTotal(); i++) {
        ulong tk = PositionGetTicket(i);
        if (tk == 0) continue;
        if (PositionGetString(POSITION_SYMBOL) == sym &&
            PositionGetInteger(POSITION_MAGIC)  == MagicNumber)
            return tk;
    }
    return 0;
}

//── Normalisation helpers ─────────────────────────────────────────────
double NormLot(string sym, double lot) {
    double step = SymbolInfoDouble(sym, SYMBOL_VOLUME_STEP);
    double minL = SymbolInfoDouble(sym, SYMBOL_VOLUME_MIN);
    double maxL = SymbolInfoDouble(sym, SYMBOL_VOLUME_MAX);
    if (step > 0) lot = MathRound(lot / step) * step;
    return MathMax(minL, MathMin(maxL, lot));
}
double NormPrice(string sym, double price) {
    if (price == 0) return 0;
    int digits = (int)SymbolInfoInteger(sym, SYMBOL_DIGITS);
    return NormalizeDouble(price, digits);
}

//── Direction reversal ────────────────────────────────────────────────
string ReverseType(string t) {
    if (t == "BUY")        return "SELL";
    if (t == "SELL")       return "BUY";
    if (t == "BUY_LIMIT")  return "SELL_LIMIT";
    if (t == "SELL_LIMIT") return "BUY_LIMIT";
    if (t == "BUY_STOP")   return "SELL_STOP";
    if (t == "SELL_STOP")  return "BUY_STOP";
    return t;
}

//── Minimal JSON parser ───────────────────────────────────────────────
string ParseStr(string json, string key) {
    string s = "\""+key+"\":\"";
    int i = StringFind(json,s); if (i<0) return "";
    i += StringLen(s);
    int e = StringFind(json,"\"",i); if (e<0) return "";
    return StringSubstr(json,i,e-i);
}
double ParseNum(string json, string key) {
    string s = "\""+key+"\":";
    int i = StringFind(json,s); if (i<0) return 0;
    i += StringLen(s);
    // skip "null"
    if (StringSubstr(json,i,4) == "null") return 0;
    int e = i;
    while (e<StringLen(json)) {
        ushort c=StringGetCharacter(json,e);
        if (c==','||c=='}'||c==']'||c==' '||c=='\n'||c=='\r') break;
        e++;
    }
    return StringToDouble(StringSubstr(json,i,e-i));
}
long ParseInt(string json, string key) { return (long)ParseNum(json,key); }

string EscJson(string s) {
    StringReplace(s,"\\","\\\\"); StringReplace(s,"\"","\\\"");
    StringReplace(s,"\n","\\n");  StringReplace(s,"\r","\\r");
    return s;
}

// Find matching closing bracket
int BracketEnd(string s, int start, ushort open, ushort close) {
    int depth=0;
    for(int i=start; i<StringLen(s); i++){
        ushort c=StringGetCharacter(s,i);
        if(c==open) depth++;
        else if(c==close){ depth--; if(depth==0) return i; }
    }
    return -1;
}

string StringToUpper(string s) {
    string r = s;
    StringToUpper(r);
    return r;
}
//+------------------------------------------------------------------+
