Universal_Quant_EA/Include/UQ_Exec.mqh
Chiki Bum Chiki Bum 946d4b0e9d fix: enforce account scope and daily monitoring continuity
Schema 7 adds a persisted monitoring heartbeat, the last recorded gap and a gap hold, with deterministic migration from schema 6 that keeps every lock, cycle identity and reservation.

D0 is no longer invented on startup or on a rollover: a trading day is adopted only when its boundary was observed while the EA was executing, or when the equity path across it is provably constant (no position, no pending order, no account deal). Otherwise the previous D0 is preserved, a monitoring-gap hold blocks every new entry and the exact gap is reported; the hold survives restart and clears only at an observed day boundary or an explicit owner cycle. Static, target and daily thresholds keep their priority, so a breach is still locked and liquidated while a day is unverified.

Account scope is now counted and disclosed for owned and unmanaged positions and pending orders; unmanaged pending orders block entries under the external-exposure guard, and the EA states that the account-wide limits cannot be guaranteed while whole-account liquidation stays disabled.
2026-10-30 11:20:47 +07:00
1258 lines
44 KiB
MQL5
//+------------------------------------------------------------------+
//| 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
//+------------------------------------------------------------------+