Universal_Quant_EA/Include/UQ_Exec.mqh
1258 lignes
44 Kio
MQL5
BrutLien permanentVue normaleHistorique
//+------------------------------------------------------------------+
//| 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
//+------------------------------------------------------------------+