| //+------------------------------------------------------------------+
|
| //| 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
|
| //+------------------------------------------------------------------+
|