//+------------------------------------------------------------------+ //| UQ_Exec.mqh | //| Universal Quant EA - sizing, order validation and position I/O | //| | //| Position ownership is decided by Magic Number only. Anything | //| with a different magic is treated as external and is never | //| modified or closed unless whole-account liquidation was | //| explicitly enabled by the owner. | //+------------------------------------------------------------------+ #ifndef UQ_EXEC_MQH #define UQ_EXEC_MQH #include "UQ_Defs.mqh" #include "UQ_Symbols.mqh" #include "UQ_Risk.mqh" //+------------------------------------------------------------------+ //| Execution configuration | //+------------------------------------------------------------------+ struct UQExecConfig { ulong magic; ulong deviation; string portfolioTag; double riskPct; double maxAggRiskPct; double riskBufferPct; // % of the risk budget withheld for costs and slippage double slAtrMultiplier; double maxSpreadPoints; }; //--- The aggregate exposure is calculated in exactly one place, //--- UQExposureFromPools() in UQ_Risk.mqh, so positions, pending orders and //--- unreconciled reservations can never be counted by two different formulas. //+------------------------------------------------------------------+ //| Volume derived from monetary risk and the entry-to-stop distance | //| Never rounded upward beyond the permitted risk budget. | //+------------------------------------------------------------------+ bool UQVolumeForRisk(const string symbol, const ENUM_ORDER_TYPE type, const double entry, const double stop, const double riskMoney, double &lotsOut, string &err) { lotsOut = 0.0; err = ""; if(!(riskMoney > 0.0)) { err = "risk budget is not positive"; return(false); } const double vstep = SymbolInfoDouble(symbol, SYMBOL_VOLUME_STEP); const double vmin = SymbolInfoDouble(symbol, SYMBOL_VOLUME_MIN); const double vmax = SymbolInfoDouble(symbol, SYMBOL_VOLUME_MAX); if(!(vstep > 0.0) || !(vmin > 0.0) || !(vmax > 0.0)) { err = "invalid broker volume constraints"; return(false); } double p1 = 0.0; if(!OrderCalcProfit(type, symbol, 1.0, entry, stop, p1)) { err = StringFormat("OrderCalcProfit failed for one lot (%d)", GetLastError()); return(false); } //--- a stop-loss distance must produce an actual adverse loss: a zero or //--- positive result cannot describe risk, and MathAbs() must never turn it //--- into a plausible positive estimate if(!MathIsValidNumber(p1) || !(p1 < 0.0)) { err = StringFormat("the per-lot loss estimate is not an adverse loss (%s)", DoubleToString(p1, 2)); return(false); } const double lossPerLot = -p1; const double raw = riskMoney / lossPerLot; const int vdigits = UQVolumeDigits(vstep); double lots = NormalizeDouble(MathFloor(raw / vstep + 1e-9) * vstep, vdigits); if(lots > vmax) lots = NormalizeDouble(MathFloor(vmax / vstep) * vstep, vdigits); //--- bounded downward correction: the realised risk must never exceed the budget for(int attempt = 0; attempt < 8 && lots >= vmin; attempt++) { double p = 0.0; if(!OrderCalcProfit(type, symbol, lots, entry, stop, p)) { err = StringFormat("OrderCalcProfit failed for %s lots (%d)", DoubleToString(lots, vdigits), GetLastError()); return(false); } if(!MathIsValidNumber(p) || !(p < 0.0)) { err = StringFormat("the loss estimate for %s lots is not an adverse loss (%s)", DoubleToString(lots, vdigits), DoubleToString(p, 2)); return(false); } if(-p <= riskMoney + 1e-9) break; lots = NormalizeDouble(lots - vstep, vdigits); } if(lots < vmin) { err = StringFormat("minimum lot %s exceeds the permitted risk budget (raw %s lots)", DoubleToString(vmin, vdigits), DoubleToString(raw, 4)); return(false); } if(lots > vmax) { err = "volume exceeds the broker maximum"; return(false); } lotsOut = lots; return(true); } //+------------------------------------------------------------------+ //| Market order submission with protective stop attached. | //+------------------------------------------------------------------+ bool UQSendMarket(const string symbol, const int direction, const double lots, const double slPrice, const ulong magic, const ulong deviation, const string comment, ulong &orderTicket, ulong &dealTicket, ENUM_UQ_SUBMIT_RESULT &outcome, string &err) { orderTicket = 0; dealTicket = 0; err = ""; //--- every path below either proves that nothing was submitted or knows how //--- far the request went. The caller releases reserved risk on exactly two //--- of these outcomes. outcome = UQ_SUBMIT_NONE_LOCAL; MqlTick tick; if(!SymbolInfoTick(symbol, tick)) { err = "quote is unavailable"; return(false); } const ENUM_ORDER_TYPE type = (direction > 0) ? ORDER_TYPE_BUY : ORDER_TYPE_SELL; const double price = (direction > 0) ? tick.ask : tick.bid; if(price <= 0.0) { err = "bid/ask is invalid"; return(false); } MqlTradeRequest req; MqlTradeResult res; ZeroMemory(req); ZeroMemory(res); req.action = TRADE_ACTION_DEAL; req.symbol = symbol; req.volume = lots; req.type = type; req.price = UQNormalizePrice(symbol, price); req.sl = (slPrice > 0.0) ? UQNormalizePrice(symbol, slPrice) : 0.0; req.tp = 0.0; req.deviation = deviation; req.magic = magic; req.comment = comment; req.type_filling = UQPickFillingMode(symbol); //--- a failed call is classified before any conclusion is drawn about the //--- risk: a lost answer is not a rejection, so the reservation must stay if(!OrderSend(req, res)) { outcome = UQSubmitResultFromRetcode(res.retcode); err = StringFormat("OrderSend failed (retcode=%d, last error=%d), submission %s", res.retcode, GetLastError(), UQSubmitResultText(outcome)); return(false); } if(res.retcode != TRADE_RETCODE_DONE && res.retcode != TRADE_RETCODE_PLACED && res.retcode != TRADE_RETCODE_DONE_PARTIAL) { //--- the call returned but the server did not accept the request: only //--- a retcode that proves non-execution may release the reserved risk outcome = UQSubmitResultFromRetcode(res.retcode); err = StringFormat("order rejected (retcode=%d, comment='%s'), submission %s", res.retcode, res.comment, UQSubmitResultText(outcome)); return(false); } orderTicket = (ulong)res.order; dealTicket = (ulong)res.deal; outcome = UQ_SUBMIT_ACCEPTED; return(true); } //+------------------------------------------------------------------+ //| Open one signal position with full pre-trade validation. | //| | //| Order of operations, fail closed at every step: | //| 1. quote, spread and normalised stop (tick-size aligned); | //| 2. monetary budget from equity, minus the configured cost | //| buffer; | //| 3. aggregate exposure (positions + pending + reservation) and | //| the remaining aggregate budget; | //| 4. volume from the normalised stop, margin, OrderCheck; | //| 5. the actual risk is reserved and persisted BEFORE the request | //| leaves the terminal; | //| 6. the server answer is verified: a rejected order releases the | //| reservation, an accepted one keeps it until the resulting | //| position or pending order is visible. | //+------------------------------------------------------------------+ bool UQOpenSignalPosition(const string symbol, const int direction, const double atr, UQExecConfig &cfg, CUQRisk &risk, double &lotsOut, string &err) { lotsOut = 0.0; err = ""; if(direction == 0) { err = "no signal direction"; return(false); } if(!(atr > 0.0)) { err = "ATR is not usable"; return(false); } MqlTick tick; if(!SymbolInfoTick(symbol, tick)) { err = "quote is unavailable"; return(false); } if(tick.bid <= 0.0 || tick.ask <= 0.0) { err = "bid/ask is invalid"; return(false); } const double point = SymbolInfoDouble(symbol, SYMBOL_POINT); if(!(point > 0.0)) { err = "point size is invalid"; return(false); } const double spreadPoints = (tick.ask - tick.bid) / point; if(cfg.maxSpreadPoints > 0.0 && spreadPoints > cfg.maxSpreadPoints) { err = StringFormat("spread %s points exceeds limit %s", DoubleToString(spreadPoints, 1), DoubleToString(cfg.maxSpreadPoints, 1)); return(false); } const ENUM_ORDER_TYPE type = (direction > 0) ? ORDER_TYPE_BUY : ORDER_TYPE_SELL; const double entry = UQNormalizePrice(symbol, (direction > 0) ? tick.ask : tick.bid); const int sdig = (int)SymbolInfoInteger(symbol, SYMBOL_DIGITS); const double dist = atr * cfg.slAtrMultiplier; //--- the stop is aligned to the broker tick size first: the volume, the //--- monetary risk and the aggregate check all use the exact price that is //--- submitted, never a pre-normalisation estimate const double stop = UQNormalizePrice(symbol, (direction > 0) ? (entry - dist) : (entry + dist)); if(!(dist > 0.0) || stop <= 0.0) { err = "invalid stop distance"; return(false); } if(direction > 0 && !(stop < entry)) { err = "the normalised stop is not below the entry price"; return(false); } if(direction < 0 && !(stop > entry)) { err = "the normalised stop is not above the entry price"; return(false); } //--- broker minimum stop distance const long stopsLevel = (long)SymbolInfoInteger(symbol, SYMBOL_TRADE_STOPS_LEVEL); const double minDist = (double)stopsLevel * point; if(minDist > 0.0) { if(direction > 0 && (entry - stop) < minDist) { err = StringFormat("stop distance %s below broker minimum %s", DoubleToString(entry - stop, sdig), DoubleToString(minDist, sdig)); return(false); } if(direction < 0 && (stop - entry) < minDist) { err = StringFormat("stop distance %s below broker minimum %s", DoubleToString(stop - entry, sdig), DoubleToString(minDist, sdig)); return(false); } } //--- risk budget const double equity = AccountInfoDouble(ACCOUNT_EQUITY); if(!(equity > 0.0)) { err = "account equity is unavailable"; return(false); } double budget = equity * cfg.riskPct / 100.0; //--- optional withholding for transaction costs and slippage. It only ever //--- reduces the budget: at zero the estimate covers the stop-loss distance //--- alone and no all-in cost claim is made. if(cfg.riskBufferPct > 0.0) budget *= (1.0 - MathMin(cfg.riskBufferPct, 90.0) / 100.0); //--- aggregate exposure: positions, pending orders and reserved money UQExposure exp; risk.Exposure(cfg.magic, exp); if(!exp.bounded) { err = StringFormat("the aggregate EA exposure cannot be bounded (%s); new entries are blocked", exp.detail); return(false); } const double occupied = exp.Total(); const double aggLimit = equity * cfg.maxAggRiskPct / 100.0; const double aggRemaining = aggLimit - occupied; if(aggRemaining <= 0.0) { err = StringFormat("aggregate exposure %s already reached the limit %s (positions %s, pending %s, reserved %s)", DoubleToString(occupied, 2), DoubleToString(aggLimit, 2), DoubleToString(exp.positions, 2), DoubleToString(exp.pending, 2), DoubleToString(exp.reserved, 2)); return(false); } if(budget > aggRemaining) budget = aggRemaining; if(budget <= 0.0) { err = "risk budget is exhausted"; return(false); } //--- volume double lots = 0.0; if(!UQVolumeForRisk(symbol, type, entry, stop, budget, lots, err)) return(false); //--- margin double margin = 0.0; if(!OrderCalcMargin(type, symbol, lots, entry, margin)) { err = StringFormat("OrderCalcMargin failed (%d)", GetLastError()); return(false); } const double freeMargin = AccountInfoDouble(ACCOUNT_MARGIN_FREE); if(margin > freeMargin) { err = StringFormat("insufficient free margin (required %s, free %s)", DoubleToString(margin, 2), DoubleToString(freeMargin, 2)); return(false); } //--- the risk that the submitted volume actually carries double actualRisk = 0.0; if(!OrderCalcProfit(type, symbol, lots, entry, stop, actualRisk)) { err = StringFormat("OrderCalcProfit failed for the final volume (%d)", GetLastError()); return(false); } //--- the risk that is going to be reserved must be a verified adverse loss: //--- a zero or positive result means the monetary risk cannot be measured, //--- so the entry is refused instead of reserving a fabricated amount if(!MathIsValidNumber(actualRisk) || !(actualRisk < 0.0)) { err = StringFormat("the monetary risk to the stop cannot be measured (result %s); the entry is refused", DoubleToString(actualRisk, 2)); return(false); } actualRisk = -actualRisk; if(actualRisk > budget + 1e-9) { err = StringFormat("the normalised stop makes the risk %s exceed the budget %s", DoubleToString(actualRisk, 2), DoubleToString(budget, 2)); return(false); } if(actualRisk > aggRemaining + 1e-9) { err = StringFormat("the normalised stop makes the risk %s exceed the remaining aggregate budget %s", DoubleToString(actualRisk, 2), DoubleToString(aggRemaining, 2)); return(false); } //--- broker pre-trade validation of the exact request MqlTradeRequest req; MqlTradeCheckResult chk; ZeroMemory(req); ZeroMemory(chk); req.action = TRADE_ACTION_DEAL; req.symbol = symbol; req.volume = lots; req.type = type; req.price = entry; req.sl = stop; req.tp = 0.0; req.deviation = cfg.deviation; req.magic = cfg.magic; req.comment = cfg.portfolioTag; req.type_filling = UQPickFillingMode(symbol); if(!OrderCheck(req, chk)) { err = StringFormat("OrderCheck failed (retcode=%d, comment='%s', margin=%s, free=%s, last error=%d)", chk.retcode, chk.comment, DoubleToString(chk.margin, 2), DoubleToString(chk.margin_free, 2), GetLastError()); return(false); } if(chk.retcode != TRADE_RETCODE_DONE && chk.retcode != 0) { err = StringFormat("OrderCheck rejected the request (retcode=%d, comment='%s')", chk.retcode, chk.comment); return(false); } //--- the risk is reserved and persisted together with the identity of the //--- request, so a restart or a lost answer cannot lose it if(!risk.ReserveEntryRisk(cfg.magic, symbol, direction, lots, entry, stop, actualRisk, err)) return(false); //--- submit ulong orderTicket = 0; ulong dealTicket = 0; ENUM_UQ_SUBMIT_RESULT outcome = UQ_SUBMIT_UNCERTAIN; if(!UQSendMarket(symbol, direction, lots, stop, cfg.magic, cfg.deviation, cfg.portfolioTag, orderTicket, dealTicket, outcome, err)) { //--- only "nothing was submitted" and a proven broker rejection allow //--- the reserved risk to be withdrawn. Any other result means the //--- request may be live at the broker, so it stays reserved. if(outcome == UQ_SUBMIT_NONE_LOCAL || outcome == UQ_SUBMIT_REJECTED) risk.ReleaseReservation(UQSubmitResultText(outcome)); else { risk.MarkReservationUncertain(err); err = err + "; the reserved risk stays active and new entries are blocked"; } return(false); } //--- the accepted order keeps its reservation until the broker state shows //--- the position or the pending order it produced. Reconciling here means //--- the remaining symbols of this processing interval are already measured //--- against the verified exposure instead of the reservation. risk.NoteSubmittedTickets(orderTicket, dealTicket); risk.ReconcileReservationDepth(true); lotsOut = lots; PrintFormat("[UQ] OPEN %s %s lots=%s entry=%s sl=%s riskBudget=%s order=%s deal=%s", symbol, (direction > 0 ? "BUY" : "SELL"), DoubleToString(lots, UQVolumeDigits(SymbolInfoDouble(symbol, SYMBOL_VOLUME_STEP))), DoubleToString(entry, sdig), DoubleToString(stop, sdig), DoubleToString(budget, 2), IntegerToString((long)orderTicket), IntegerToString((long)dealTicket)); return(true); } //+------------------------------------------------------------------+ //| Close one position by ticket | //+------------------------------------------------------------------+ bool UQClosePosition(const ulong ticket, const ulong deviation, string &err) { err = ""; if(!PositionSelectByTicket(ticket)) { err = StringFormat("position %s is not selectable", IntegerToString((long)ticket)); return(false); } const string symbol = PositionGetString(POSITION_SYMBOL); const double volume = PositionGetDouble(POSITION_VOLUME); const long ptype = PositionGetInteger(POSITION_TYPE); const ulong magic = (ulong)PositionGetInteger(POSITION_MAGIC); if(volume <= 0.0) { err = "position volume is invalid"; return(false); } MqlTick tick; if(!SymbolInfoTick(symbol, tick)) { err = "quote is unavailable"; return(false); } MqlTradeRequest req; MqlTradeResult res; ZeroMemory(req); ZeroMemory(res); req.action = TRADE_ACTION_DEAL; req.position = ticket; req.symbol = symbol; req.volume = volume; req.type = (ptype == POSITION_TYPE_BUY) ? ORDER_TYPE_SELL : ORDER_TYPE_BUY; req.price = UQNormalizePrice(symbol, (ptype == POSITION_TYPE_BUY) ? tick.bid : tick.ask); req.deviation = deviation; req.magic = magic; req.comment = "UQ close"; req.type_filling = UQPickFillingMode(symbol); if(!OrderSend(req, res)) { err = StringFormat("close OrderSend failed (retcode=%d, last error=%d)", res.retcode, GetLastError()); return(false); } if(res.retcode != TRADE_RETCODE_DONE && res.retcode != TRADE_RETCODE_PLACED && res.retcode != TRADE_RETCODE_DONE_PARTIAL) { err = StringFormat("close rejected (retcode=%d)", res.retcode); return(false); } return(true); } //+------------------------------------------------------------------+ //| Scope inventory: pending orders | //+------------------------------------------------------------------+ struct UQPendingRef { ulong ticket; string symbol; ENUM_ORDER_TYPE type; double volume; double price; }; //+------------------------------------------------------------------+ //| Scope inventory: open positions | //+------------------------------------------------------------------+ struct UQPositionRef { ulong ticket; string symbol; long type; double volume; ulong magic; }; //+------------------------------------------------------------------+ //| Collect every pending order inside the authorized scope. | //| wholeAccount=false restricts the scope to this EA's magic only, | //| so unrelated orders are never touched. | //+------------------------------------------------------------------+ int UQCollectPending(const ulong magic, const bool wholeAccount, UQPendingRef &list[]) { ArrayResize(list, 0); int count = 0; const int total = OrdersTotal(); for(int i = 0; i < total; i++) { const ulong ticket = OrderGetTicket(i); if(ticket == 0) continue; if(!wholeAccount && (ulong)OrderGetInteger(ORDER_MAGIC) != magic) continue; ArrayResize(list, count + 1); list[count].ticket = ticket; list[count].symbol = OrderGetString(ORDER_SYMBOL); list[count].type = (ENUM_ORDER_TYPE)OrderGetInteger(ORDER_TYPE); list[count].volume = OrderGetDouble(ORDER_VOLUME_CURRENT); list[count].price = OrderGetDouble(ORDER_PRICE_OPEN); count++; } return(count); } //+------------------------------------------------------------------+ //| Collect every open position inside the authorized scope. | //+------------------------------------------------------------------+ int UQCollectPositions(const ulong magic, const bool wholeAccount, UQPositionRef &list[]) { ArrayResize(list, 0); int count = 0; const int total = PositionsTotal(); for(int i = 0; i < total; i++) { const ulong ticket = PositionGetTicket(i); if(ticket == 0) continue; if(!wholeAccount && (ulong)PositionGetInteger(POSITION_MAGIC) != magic) continue; ArrayResize(list, count + 1); list[count].ticket = ticket; list[count].symbol = PositionGetString(POSITION_SYMBOL); list[count].type = PositionGetInteger(POSITION_TYPE); list[count].volume = PositionGetDouble(POSITION_VOLUME); list[count].magic = (ulong)PositionGetInteger(POSITION_MAGIC); count++; } return(count); } //+------------------------------------------------------------------+ //| Account scope of the exposure this EA actually controls | //| | //| The three limits are account-equity limits, but liquidation is | //| restricted to this EA's own magic scope unless whole-account | //| liquidation is explicitly authorized. Unmanaged positions and | //| pending orders are counted, disclosed and - while the guard is | //| enabled - they block every new entry. They are never modified. | //+------------------------------------------------------------------+ struct UQAccountScope { int ownPositions; int ownPending; int externalPositions; int externalPending; string externalDetail; // symbols of the unmanaged exposure, diagnostics only }; //+------------------------------------------------------------------+ //| Count owned and unmanaged positions and pending orders | //+------------------------------------------------------------------+ bool UQAccountScopeScan(const ulong magic, UQAccountScope &out) { out.ownPositions = 0; out.ownPending = 0; out.externalPositions = 0; out.externalPending = 0; out.externalDetail = ""; //--- without an ownership key no exposure can be attributed, so the scope //--- is unusable and the caller must fail closed if(magic == 0) return(false); string names = ""; int listed = 0; const int positions = PositionsTotal(); for(int i = 0; i < positions; i++) { const ulong ticket = PositionGetTicket(i); if(ticket == 0) continue; if((ulong)PositionGetInteger(POSITION_MAGIC) == magic) { out.ownPositions++; continue; } out.externalPositions++; const string sym = PositionGetString(POSITION_SYMBOL); if(StringLen(sym) > 0 && StringFind(names, sym) < 0 && listed < 5) { names += (StringLen(names) > 0 ? "," : "") + sym; listed++; } } const int orders = OrdersTotal(); for(int i = 0; i < orders; i++) { const ulong ticket = OrderGetTicket(i); if(ticket == 0) continue; if((ulong)OrderGetInteger(ORDER_MAGIC) == magic) { out.ownPending++; continue; } out.externalPending++; const string sym = OrderGetString(ORDER_SYMBOL); if(StringLen(sym) > 0 && StringFind(names, sym) < 0 && listed < 5) { names += (StringLen(names) > 0 ? "," : "") + sym; listed++; } } out.externalDetail = names; return(true); } //+------------------------------------------------------------------+ //| One-line disclosure of the liquidation scope that is enforced | //+------------------------------------------------------------------+ string UQScopeDisclosure(const UQAccountScope &scope, const bool blockExternal, const bool wholeAccount) { if(wholeAccount) return(StringFormat("WHOLE-ACCOUNT liquidation authorized: %d owned and %d unmanaged position(s), %d unmanaged order(s) are in scope.", scope.ownPositions, scope.externalPositions, scope.externalPending)); string s = StringFormat("EA-ONLY liquidation (magic scope): %d owned position(s), %d owned order(s).", scope.ownPositions, scope.ownPending); if(scope.externalPositions > 0 || scope.externalPending > 0) { s += StringFormat(" %d unmanaged position(s) and %d unmanaged order(s)%s remain outside control, so the account-wide 5%%/8%%/10%% limits cannot be guaranteed while they are open.", scope.externalPositions, scope.externalPending, (StringLen(scope.externalDetail) > 0 ? " (" + scope.externalDetail + ")" : "")); if(!blockExternal) s += " The external-exposure guard is disabled by input, so new entries are not blocked by them."; else s += " New entries are blocked by the external-exposure guard."; } return(s); } //+------------------------------------------------------------------+ //| Account scope of an owner reset operation | //| | //| A reset opens a new risk cycle, so it is only accepted when this | //| EA holds no exposure of any kind and the liquidation state is | //| verified. Unmanaged exposure outside the EA scope is rejected | //| when the EA is required to control the whole account. | //+------------------------------------------------------------------+ struct UQResetScope { bool ok; string detail; int ownPositions; int ownPending; int externalPositions; int externalPending; bool liquidationComplete; }; //+------------------------------------------------------------------+ //| Verify the account scope before an owner reset is accepted | //+------------------------------------------------------------------+ bool UQCheckResetScope(const ulong magic, const bool blockExternal, const bool liquidationComplete, UQResetScope &out) { out.ok = false; out.detail = ""; out.ownPositions = 0; out.ownPending = 0; out.externalPositions = 0; out.externalPending = 0; out.liquidationComplete = liquidationComplete; if(liquidationComplete == false) { out.detail = "liquidation is not verified complete"; return(false); } if(!(bool)TerminalInfoInteger(TERMINAL_CONNECTED) && !(bool)MQLInfoInteger(MQL_TESTER)) { out.detail = "the terminal is not connected, so the account scope cannot be verified"; return(false); } const int positions = PositionsTotal(); for(int i = 0; i < positions; i++) { const ulong ticket = PositionGetTicket(i); if(ticket == 0) continue; if((ulong)PositionGetInteger(POSITION_MAGIC) == magic) out.ownPositions++; else out.externalPositions++; } const int orders = OrdersTotal(); for(int i = 0; i < orders; i++) { const ulong ticket = OrderGetTicket(i); if(ticket == 0) continue; if((ulong)OrderGetInteger(ORDER_MAGIC) == magic) out.ownPending++; else out.externalPending++; } if(out.ownPositions > 0) { out.detail = StringFormat("%d open EA position(s) must be closed and verified first", out.ownPositions); return(false); } if(out.ownPending > 0) { out.detail = StringFormat("%d EA pending order(s) must be cancelled and verified first", out.ownPending); return(false); } if(blockExternal && (out.externalPositions > 0 || out.externalPending > 0)) { out.detail = StringFormat("unmanaged external exposure is present (%d position(s), %d order(s))", out.externalPositions, out.externalPending); return(false); } out.ok = true; return(true); } //+------------------------------------------------------------------+ //| Authoritative re-query of one pending order ticket | //+------------------------------------------------------------------+ bool UQPendingExists(const ulong ticket) { return(OrderSelect(ticket)); } //+------------------------------------------------------------------+ //| Remaining volume of one position ticket, 0.0 when it is closed | //+------------------------------------------------------------------+ double UQPositionRemaining(const ulong ticket) { if(!PositionSelectByTicket(ticket)) return(0.0); return(PositionGetDouble(POSITION_VOLUME)); } //+------------------------------------------------------------------+ //| Cancel pending orders in scope and verify each removal. | //| A successful request is never treated as proof of deletion: | //| every targeted ticket is re-queried afterwards. | //+------------------------------------------------------------------+ int UQCancelPendingVerified(const ulong magic, const bool wholeAccount, int &requested, int &removed, string &detail) { requested = 0; removed = 0; detail = ""; UQPendingRef list[]; requested = UQCollectPending(magic, wholeAccount, list); for(int i = 0; i < requested; i++) { const ulong ticket = list[i].ticket; //--- the order may already be gone by the time it is processed if(!UQPendingExists(ticket)) { removed++; continue; } MqlTradeRequest req; MqlTradeResult res; ZeroMemory(req); ZeroMemory(res); req.action = TRADE_ACTION_REMOVE; req.order = ticket; const bool sent = OrderSend(req, res); const uint code = res.retcode; //--- verify the actual result instead of trusting the request if(UQPendingExists(ticket)) { detail += StringFormat("%s(retcode=%d%s) ", IntegerToString((long)ticket), (int)code, (sent ? "" : ",send-failed")); } else { removed++; } } if(requested > 0) detail += StringFormat("pendingRequested=%d pendingRemoved=%d ", requested, removed); return(removed); } //+------------------------------------------------------------------+ //| Close positions in scope and verify each closure. | //| A partially closed position is never counted as closed; its | //| remainder is retried here and, if still exposed, in the next | //| liquidation cycle. | //+------------------------------------------------------------------+ int UQClosePositionsVerified(const ulong magic, const bool wholeAccount, const ulong deviation, int &targeted, int &closed, int &partial, string &detail) { targeted = 0; closed = 0; partial = 0; detail = ""; UQPositionRef list[]; targeted = UQCollectPositions(magic, wholeAccount, list); for(int i = 0; i < targeted; i++) { const ulong ticket = list[i].ticket; double before = UQPositionRemaining(ticket); if(before <= 0.0) { closed++; continue; } string lastErr = ""; for(int attempt = 0; attempt < UQ_CLOSE_ATTEMPTS; attempt++) { if(!PositionSelectByTicket(ticket)) break; // already gone UQClosePosition(ticket, deviation, lastErr); const double after = UQPositionRemaining(ticket); if(after <= 0.0) break; // verified closed if(after < before) before = after; // partial fill: close the remainder } const double rest = UQPositionRemaining(ticket); if(rest <= 0.0) { closed++; continue; } const int vdigits = UQVolumeDigits(SymbolInfoDouble(list[i].symbol, SYMBOL_VOLUME_STEP)); if(rest < list[i].volume) { partial++; detail += StringFormat("%s(partial,remaining=%s) ", IntegerToString((long)ticket), DoubleToString(rest, vdigits)); } else { detail += StringFormat("%s(%s) ", IntegerToString((long)ticket), (StringLen(lastErr) > 0 ? lastErr : "position still open")); } } if(targeted > 0) detail += StringFormat("positionsTargeted=%d positionsClosed=%d positionsPartial=%d ", targeted, closed, partial); return(closed); } //+------------------------------------------------------------------+ //| Liquidation state machine | //| | //| Every cycle re-queries the broker pools. The machine reaches | //| LIQUIDATION_COMPLETE only when both pools are verified empty for | //| the authorized scope. Exhausting the bounded attempt budget | //| produces MANUAL_INTERVENTION while exposure remains - it never | //| produces a completion claim and never re-enables entries. | //+------------------------------------------------------------------+ class CUQLiquidator { private: ENUM_UQ_LIQ_STATE m_state; ulong m_magic; ulong m_deviation; bool m_wholeAccount; int m_cycles; datetime m_nextAttempt; bool m_flatVerified; string m_detail; bool RateLimited(void); void Schedule(const int seconds); bool VerifyFlat(string &detail); public: CUQLiquidator(void); ~CUQLiquidator(void); void Init(const ulong magic, const ulong deviation, const bool wholeAccount); void Arm(void); void Reset(void); void Service(void); ENUM_UQ_LIQ_STATE State(void) const { return(m_state); } string StateText(void) const { return(UQLiqStateText(m_state)); } bool Idle(void) const { return(m_state == UQ_LIQ_IDLE); } bool Complete(void) const { return(m_state == UQ_LIQ_COMPLETE); } bool Unresolved(void) const { return(m_state == UQ_LIQ_MANUAL_INTERVENTION); } bool FlatVerified(void) const { return(m_flatVerified); } int Cycles(void) const { return(m_cycles); } string Detail(void) const { return(m_detail); } }; //+------------------------------------------------------------------+ CUQLiquidator::CUQLiquidator(void) { m_state = UQ_LIQ_IDLE; m_magic = 0; m_deviation = 0; m_wholeAccount = false; m_cycles = 0; m_nextAttempt = 0; m_flatVerified = false; m_detail = ""; } //+------------------------------------------------------------------+ CUQLiquidator::~CUQLiquidator(void) { } //+------------------------------------------------------------------+ void CUQLiquidator::Init(const ulong magic, const ulong deviation, const bool wholeAccount) { m_magic = magic; m_deviation = deviation; m_wholeAccount = wholeAccount; Reset(); } //+------------------------------------------------------------------+ //| Arm the machine for a new lock event. Nothing is declared done. | //+------------------------------------------------------------------+ void CUQLiquidator::Arm(void) { m_state = UQ_LIQ_CANCEL_PENDING; m_cycles = 0; m_nextAttempt = 0; m_flatVerified = false; m_detail = ""; } //+------------------------------------------------------------------+ void CUQLiquidator::Reset(void) { m_state = UQ_LIQ_IDLE; m_cycles = 0; m_nextAttempt = 0; m_flatVerified = false; m_detail = ""; } //+------------------------------------------------------------------+ bool CUQLiquidator::RateLimited(void) { if(m_nextAttempt <= 0) return(false); const datetime now = UQServerNow(); if(now <= 0) return(false); return(now < m_nextAttempt); } //+------------------------------------------------------------------+ void CUQLiquidator::Schedule(const int seconds) { const datetime now = UQServerNow(); m_nextAttempt = (now > 0) ? (datetime)((long)now + seconds) : 0; } //+------------------------------------------------------------------+ //| Authoritative flat verification for the authorized scope. | //| An unverifiable account state is reported as unresolved. | //+------------------------------------------------------------------+ bool CUQLiquidator::VerifyFlat(string &detail) { if(!(bool)TerminalInfoInteger(TERMINAL_CONNECTED) && !(bool)MQLInfoInteger(MQL_TESTER)) { detail += "terminal is not connected: account state is UNVERIFIED "; return(false); } UQPendingRef pend[]; const int np = UQCollectPending(m_magic, m_wholeAccount, pend); UQPositionRef pos[]; const int nq = UQCollectPositions(m_magic, m_wholeAccount, pos); if(np > 0) { detail += StringFormat("pendingRemaining=%d [", np); for(int i = 0; i < np; i++) detail += IntegerToString((long)pend[i].ticket) + " "; detail += "] "; } if(nq > 0) { detail += StringFormat("positionsRemaining=%d [", nq); for(int i = 0; i < nq; i++) { detail += StringFormat("%s(vol=%s) ", IntegerToString((long)pos[i].ticket), DoubleToString(pos[i].volume, UQVolumeDigits(SymbolInfoDouble(pos[i].symbol, SYMBOL_VOLUME_STEP)))); } detail += "] "; } return(np == 0 && nq == 0); } //+------------------------------------------------------------------+ //| One bounded liquidation cycle: cancel, close, verify. | //+------------------------------------------------------------------+ void CUQLiquidator::Service(void) { if(m_state == UQ_LIQ_IDLE || m_state == UQ_LIQ_COMPLETE) return; if(RateLimited()) return; m_detail = ""; m_state = UQ_LIQ_CANCEL_PENDING; int cancelReq = 0; int cancelOk = 0; string cancelDetail = ""; UQCancelPendingVerified(m_magic, m_wholeAccount, cancelReq, cancelOk, cancelDetail); m_detail += cancelDetail; m_state = UQ_LIQ_CLOSE_POSITIONS; int targeted = 0; int closed = 0; int partial = 0; string closeDetail = ""; UQClosePositionsVerified(m_magic, m_wholeAccount, m_deviation, targeted, closed, partial, closeDetail); m_detail += closeDetail; m_state = UQ_LIQ_VERIFY_FLAT; if(VerifyFlat(m_detail)) { m_state = UQ_LIQ_COMPLETE; m_flatVerified = true; m_nextAttempt = 0; PrintFormat("[UQ] LIQUIDATION COMPLETE: order and position pools verified empty for the authorized scope after %d cycle(s).", m_cycles + 1); return; } m_cycles++; m_flatVerified = false; if(m_cycles >= UQ_MAX_LIQ_ATTEMPTS) { m_state = UQ_LIQ_MANUAL_INTERVENTION; Schedule(UQ_LIQ_MANUAL_RETRY_SECONDS); PrintFormat("[UQ] ERROR: LIQUIDATION UNRESOLVED after %d cycles. Manual intervention required; the risk lock stays active, entries stay blocked and controlled retries continue: %s", m_cycles, m_detail); return; } m_state = UQ_LIQ_RETRY_REQUIRED; Schedule(UQ_LIQ_RETRY_SECONDS); PrintFormat("[UQ] LIQUIDATION RETRY REQUIRED (cycle %d/%d): %s", m_cycles, UQ_MAX_LIQ_ATTEMPTS, m_detail); } //+------------------------------------------------------------------+ //| ATR trailing stop. Only ever tightens protection. | //+------------------------------------------------------------------+ int UQApplyAtrTrailing(const ulong magic, CUQSymbols &symbols, const double atrMultiplier, const ulong deviation) { int modified = 0; if(!(atrMultiplier > 0.0)) return(0); const int total = PositionsTotal(); for(int i = total - 1; i >= 0; i--) { const ulong ticket = PositionGetTicket(i); if(ticket == 0) continue; if((ulong)PositionGetInteger(POSITION_MAGIC) != magic) continue; const string symbol = PositionGetString(POSITION_SYMBOL); const int idx = symbols.FindByName(symbol); if(idx < 0) continue; const double atr = symbols.AtrValue(idx); if(!(atr > 0.0)) continue; const double point = SymbolInfoDouble(symbol, SYMBOL_POINT); const long stops = (long)SymbolInfoInteger(symbol, SYMBOL_TRADE_STOPS_LEVEL); const double minDist = (double)stops * point; const long ptype = PositionGetInteger(POSITION_TYPE); const double cur = PositionGetDouble(POSITION_PRICE_CURRENT); const double oldSl = PositionGetDouble(POSITION_SL); if(cur <= 0.0 || point <= 0.0) continue; const double trail = atr * atrMultiplier; double newSl = 0.0; if(ptype == POSITION_TYPE_BUY) { newSl = cur - trail; if(cur - newSl < minDist) newSl = cur - minDist; if(oldSl > 0.0 && newSl <= oldSl + point * 0.5) continue; if(newSl >= cur) continue; } else { newSl = cur + trail; if(newSl - cur < minDist) newSl = cur + minDist; if(oldSl > 0.0 && newSl >= oldSl - point * 0.5) continue; if(newSl <= cur) continue; } newSl = UQNormalizePrice(symbol, newSl); if(newSl <= 0.0) continue; MqlTradeRequest req; MqlTradeResult res; ZeroMemory(req); ZeroMemory(res); req.action = TRADE_ACTION_SLTP; req.position = ticket; req.symbol = symbol; req.sl = newSl; req.tp = PositionGetDouble(POSITION_TP); req.magic = magic; req.deviation = deviation; if(!OrderSend(req, res) || (res.retcode != TRADE_RETCODE_DONE && res.retcode != TRADE_RETCODE_PLACED)) { PrintFormat("[UQ] trailing stop update failed %s ticket=%s retcode=%d", symbol, IntegerToString((long)ticket), res.retcode); continue; } modified++; } return(modified); } #endif // UQ_EXEC_MQH //+------------------------------------------------------------------+