Universal_Quant_EA/Universal_Quant_EA.mq5
UQ Review UQ Review 6408907b92 Fix duplicate-instance ownership integrity (V2 guard + persistence gate)
Defect A - shared guard deletion and first-creation race:

- The shared V2 guard is now a persistent coordination object. It is never
  deleted after creation; release and rollback are both a compare-and-swap
  back to the exact unclaimed sentinel (0.0), so a contender that acquires
  the guard between an owner's CAS and its cleanup keeps its claim.
- First creation is fenced by ballots: the contender announces its own
  ballot, scans for live foreign ballots, re-reads the guard AFTER the scan
  (a stale observation of absence is never used), verifies the sentinel it
  wrote, claims it by CAS and re-scans afterwards. A rival found at any
  point fails the claim closed and the guard is given back with a CAS to
  the sentinel - never deleted, never overwritten.
- The owner's ballot stays visible for as long as it owns the guard and is
  renewed by the heartbeat; only a contender's own ballot is ever removed,
  and only after it no longer owns the guard.
- UQ_GUARD_UNCLAIMED is a distinct protocol state, accepted for the V2
  guard namespace only. The legacy guard namespace keeps zero as corrupt
  evidence, and negative, fractional, non-finite and future values stay
  invalid everywhere.
- Ownership loss is latched for the lifetime of the guard object.

Defect B - ownership loss must stop persistence:

- CUQInstanceGuard implements CUQOwnershipVerifier: a read-only proof that
  the guard still carries this instance's value and is still inside the TTL.
- CUQRisk binds that verifier after the guard is acquired and before Init(),
  and refuses to initialize without one.
- A persistence boundary inside CUQRisk gates the primary write, the backup
  write, the staging write, legacy migration, V2 marker creation, marker
  restoration, marker removal and reservation reconciliation. Any failure
  latches the loss: the controller is unhealthy, entries are blocked, and
  no retry or reacquisition is possible in that instance.
- UQStateSave() takes the verifier and fences its three file mutations
  individually, so a loss proven before the primary commit cannot write it.

EA event ordering:

- OnTick() and OnTimer() verify ownership before risk evaluation, lock
  servicing, trailing and symbol processing.
- OnTradeTransaction() does not invoke persistence-capable reservation
  reconciliation after the loss.
- OnDeinit() releases only a guard this instance provably still holds.
- Heartbeats are throttled; every event performs the read-only proof, so a
  two second timer is not the only discovery path.

Input semantics:

- InpPreventDuplicateInstances is deprecated and reserved. Exclusive
  persistence ownership is unconditional, and an ownership loss always
  blocks entries, so the input has no effect. It is kept so existing .set
  files keep loading, and a runtime note reports that it is ignored.

Preserved unchanged: V2 fingerprint and file locators, legacy identity hash
and key, legacy ambiguity refusal, risk limits and strategy logic.
2026-10-31 19:20:50 +07:00
//+------------------------------------------------------------------+
//| Universal_Quant_EA.mq5 |
//| |
//| Configurable quantitative EA for eligible broker-listed FX |
//| pairs and selectable MetaTrader 5 timeframes. |
//| |
//| Baseline model: volatility-normalised channel breakout on |
//| completed candles, EMA trend filter, ATR initial stop and ATR |
//| trailing stop. |
//| |
//| Centralised account risk control, evaluated in strict priority |
//| order before any signal: |
//| 1. static drawdown (default 8%) - permanent lock |
//| 2. equity target (default 10%) - permanent lock |
//| 3. daily loss (default 5%) - lock until next day |
//| 4. baseline / persistent-state validity |
//| 5. account, symbol and exposure permissions |
//| 6. signal generation and order submission |
//| |
//| Symbol resolution is fail-closed: only a unique eligible FX |
//| instrument is ever traded, and one centralised scheduler walks |
//| every managed symbol from both OnTick() and OnTimer(). |
//| |
//| No martingale, no grid recovery, no loss-recovery lot scaling |
//| and no removal of protective stops. Defaults are engineering |
//| parameters, not proven optimal values, and nothing here claims |
//| universal profitability. |
//| |
//| ACCOUNT SCOPE. The three limits are account-equity limits, but |
//| liquidation is restricted to this EA's own Magic Number 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. Strict |
//| account-wide enforcement therefore requires a dedicated account |
//| or an explicit authorization for whole-account liquidation; it |
//| is never enabled silently and is never claimed while unmanaged |
//| exposure remains open. |
//| |
//| MONITORING CONTINUITY. The limits can only be enforced while the |
//| EA is executing. A persisted heartbeat proves which broker- |
//| server trading day was observed; an unobserved day boundary is |
//| reconstructed only from a provably constant equity path, and |
//| otherwise preserves D0 and blocks entries. Equity thresholds are |
//| protective triggers, not guaranteed maximum realized losses: |
//| slippage, gaps, broker execution and outages can exceed them. |
//+------------------------------------------------------------------+
#property copyright "Universal_Quant_EA"
#property version "1.00"
#property description "Volatility-normalised channel-breakout EA for FX symbols on selectable timeframes."
#property description "Centralised risk control: daily loss 5%, static drawdown 8%, equity target 10%."
#property description "No martingale, no grid recovery, no removal of protective stops."
#property description "Liquidation is EA-scope by default; unmanaged exposure is disclosed and blocks entries."
#property description "A monitoring-gap hold blocks entries when a trading day could not be observed."
#include "Include/UQ_Defs.mqh"
#include "Include/UQ_State.mqh"
#include "Include/UQ_Risk.mqh"
#include "Include/UQ_Symbols.mqh"
#include "Include/UQ_Exec.mqh"
//+------------------------------------------------------------------+
//| Input parameters |
//+------------------------------------------------------------------+
input group "=== Operational control ==="
input bool InpEnableTrading = false; // Trading enabled
input ENUM_UQ_SYMBOL_MODE InpSymbolMode = UQ_SYM_EXPLICIT_LIST; // Symbol selection mode
input string InpSymbolList = "EURUSD,GBPUSD,USDJPY,AUDUSD,USDCAD"; // Symbol list (comma separated)
input bool InpRequireAllSymbols = true; // Fail init when a configured symbol cannot be resolved
input ENUM_TIMEFRAMES InpSignalTimeframe = PERIOD_H1; // Signal timeframe
//--- Ownership of positions and orders is decided by Magic Number only, so one
//--- Magic Number belongs to exactly one deployment: two portfolios sharing a
//--- Magic Number on one account would each manage the other's exposure. Order
//--- comments are diagnostics and never an ownership criterion.
input long InpMagic = 20261030; // Magic Number (unique per deployment)
input string InpPortfolioId = "UQ_PORTFOLIO_A"; // Portfolio identifier
input double InpInitialBalance = 0.0; // Initial balance B0 (required to create a risk cycle)
input bool InpAuthorizeFirstInit = false; // Authorize a new risk cycle when no state exists
input bool InpAllowBackupRestore = false; // Allow restore from verified state backup
input group "=== Account risk limits ==="
input double InpDailyLossPct = 5.0; // Daily maximum loss %
input double InpStaticDdPct = 8.0; // Static maximum drawdown %
input double InpTargetPct = 10.0; // Equity growth target %
input double InpRiskPerTradePct = 0.25; // Risk per trade %
input double InpMaxAggRiskPct = 1.0; // Maximum aggregate open risk %
input double InpRiskBufferPct = 0.0; // Risk buffer for costs and slippage %
input int InpMaxPositions = 5; // Maximum simultaneous EA positions
input group "=== Strategy ==="
input int InpChannelLookback = 20; // Channel lookback (bars)
input int InpEmaPeriod = 100; // EMA trend filter period
input int InpAtrPeriod = 14; // ATR period
input double InpSlAtrMultiplier = 2.0; // Initial stop loss (x ATR)
input double InpTrailAtrMultiplier = 3.0; // ATR trailing stop (x ATR)
input group "=== Execution filters ==="
input double InpMaxSpreadPoints = 30.0; // Maximum spread (points, 0 = disabled)
input ulong InpDeviationPoints = 20; // Maximum price deviation (points)
input group "=== External exposure handling ==="
input bool InpBlockOnExternalPositions = true; // Block new entries when external positions exist
input bool InpAllowWholeAccountLiquidation = false; // Allow whole-account liquidation (dangerous)
//--- deprecated and reserved: kept only so existing .set files keep loading.
//--- Exclusive persistence ownership is unconditional - it can never be
//--- disabled - and an ownership loss always blocks entries regardless of this
//--- value, so the input has no effect on either. See InstanceOwnershipHeld().
input bool InpPreventDuplicateInstances = true; // Deprecated, reserved (ownership is always exclusive)
input group "=== Owner reset of permanent locks ==="
//--- A reset never clears a lock in place: it opens a new risk cycle with the
//--- baseline supplied below. Every reset needs a unique nonce, the operation is
//--- persisted together with the consumed nonce, and the reset inputs must be
//--- cleared afterwards so a restart cannot repeat it.
input ENUM_UQ_RESET_REQUEST InpResetRequest = UQ_RESET_NONE; // Reset request
input string InpResetToken = ""; // Reset confirmation phrase
input string InpResetNonce = ""; // Reset nonce (unique, >= 12 chars)
input double InpResetBaseline = 0.0; // New cycle baseline B0 (reset only)
//+------------------------------------------------------------------+
//| Globals |
//+------------------------------------------------------------------+
CUQRisk g_risk;
CUQSymbols g_symbols;
//--- duplicate-instance ownership: the guard is acquired through a documented
//--- atomic compare-and-swap on the terminal global variable, never by
//--- "check for a value and then publish one"
CUQTerminalGuardStore g_guardStore;
CUQInstanceGuard g_instanceGuard;
bool g_instanceConflict = false;
bool g_instanceOwned = false;
string g_instanceGV = "";
//--- the heartbeat is throttled because a compare-and-swap over a terminal
//--- global variable is not free, while the read-only proof of ownership is
//--- cheap and therefore runs on every event, not only on the timer
#define UQ_HEARTBEAT_INTERVAL_MS 1000
uint g_lastHeartbeatTick = 0;
CUQLiquidator g_liq;
uint g_lastStatusTick = 0;
uint g_lastTrailTick = 0;
uint g_lastScheduleTick = 0;
uint g_schedulePasses = 0;
datetime g_lastScheduleTime = 0;
//+------------------------------------------------------------------+
//| Input validation. Invalid combinations are rejected, never |
//| silently replaced by unrelated values. |
//+------------------------------------------------------------------+
bool ValidateInputs(string &err)
{
err = "";
if(InpMagic <= 0)
{
err = "Magic Number must be positive";
return(false);
}
if(StringLen(InpPortfolioId) <= 0)
{
err = "Portfolio identifier must not be empty";
return(false);
}
if(StringLen(InpPortfolioId) > 24)
{
err = "Portfolio identifier must be at most 24 characters";
return(false);
}
if(InpInitialBalance < 0.0)
{
err = "Initial balance override must not be negative";
return(false);
}
if(InpAuthorizeFirstInit && !(InpInitialBalance > 0.0))
{
err = "Authorizing a new risk cycle requires an explicit positive initial balance B0";
return(false);
}
if(!(InpDailyLossPct > 0.0 && InpDailyLossPct < 100.0))
{
err = "Daily maximum loss percentage must be inside (0, 100)";
return(false);
}
if(!(InpStaticDdPct > 0.0 && InpStaticDdPct < 100.0))
{
err = "Static maximum drawdown percentage must be inside (0, 100)";
return(false);
}
if(!(InpTargetPct > 0.0 && InpTargetPct <= 1000.0))
{
err = "Equity growth target percentage must be inside (0, 1000]";
return(false);
}
if(!(InpRiskPerTradePct > 0.0 && InpRiskPerTradePct <= 100.0))
{
err = "Risk per trade percentage must be inside (0, 100]";
return(false);
}
if(!(InpMaxAggRiskPct > 0.0 && InpMaxAggRiskPct <= 100.0))
{
err = "Maximum aggregate open risk percentage must be inside (0, 100]";
return(false);
}
if(InpRiskPerTradePct > InpMaxAggRiskPct)
{
err = "Risk per trade must not exceed the maximum aggregate open risk";
return(false);
}
if(!(InpRiskBufferPct >= 0.0 && InpRiskBufferPct <= 50.0))
{
err = "Risk buffer must be inside [0, 50] percent";
return(false);
}
if(InpMaxPositions < 1)
{
err = "Maximum simultaneous positions must be at least 1";
return(false);
}
if(InpChannelLookback < 2)
{
err = "Channel lookback must be at least 2 bars";
return(false);
}
if(InpEmaPeriod < 2)
{
err = "EMA period must be at least 2 bars";
return(false);
}
if(InpAtrPeriod < 2)
{
err = "ATR period must be at least 2 bars";
return(false);
}
if(!(InpSlAtrMultiplier > 0.0))
{
err = "Initial stop-loss ATR multiplier must be positive";
return(false);
}
if(!(InpTrailAtrMultiplier > 0.0))
{
err = "ATR trailing multiplier must be positive";
return(false);
}
if(InpMaxSpreadPoints < 0.0)
{
err = "Maximum spread filter must not be negative";
return(false);
}
if(InpDeviationPoints > 1000)
{
err = "Deviation must not exceed 1000 points";
return(false);
}
if(InpSymbolMode == UQ_SYM_EXPLICIT_LIST && StringLen(InpSymbolList) == 0)
{
err = "Explicit symbol list mode requires at least one symbol";
return(false);
}
if(InpResetRequest != UQ_RESET_NONE)
{
if(StringLen(InpResetToken) == 0)
{
err = "An owner reset request requires the confirmation phrase";
return(false);
}
if(!UQResetNonceValid(InpResetNonce))
{
err = StringFormat("An owner reset request requires a unique nonce of %d..%d printable characters without '=' or '|'",
UQ_RESET_NONCE_MIN_LEN, UQ_RESET_NONCE_MAX_LEN);
return(false);
}
if(!(InpResetBaseline > 0.0))
{
err = "An owner reset request requires the baseline B0 of the new risk cycle";
return(false);
}
}
return(true);
}
//+------------------------------------------------------------------+
//| Terminal, account and program trading permissions |
//+------------------------------------------------------------------+
bool TradingPermissionsOk(void)
{
if(!(bool)TerminalInfoInteger(TERMINAL_TRADE_ALLOWED))
return(false);
if(!(bool)MQLInfoInteger(MQL_TRADE_ALLOWED))
return(false);
if(!(bool)AccountInfoInteger(ACCOUNT_TRADE_ALLOWED))
return(false);
if(!(bool)AccountInfoInteger(ACCOUNT_TRADE_EXPERT))
return(false);
return(true);
}
//+------------------------------------------------------------------+
//| Netting accounts allow only one position per symbol |
//+------------------------------------------------------------------+
bool IsNettingAccount(void)
{
const long mode = (long)AccountInfoInteger(ACCOUNT_MARGIN_MODE);
return(mode != (long)ACCOUNT_MARGIN_MODE_RETAIL_HEDGING);
}
//+------------------------------------------------------------------+
//| Own positions, optionally restricted to one symbol |
//+------------------------------------------------------------------+
int CountOwnPositions(const string symbol)
{
int count = 0;
const int total = PositionsTotal();
for(int i = 0; i < total; i++)
{
const ulong ticket = PositionGetTicket(i);
if(ticket == 0)
continue;
if((ulong)PositionGetInteger(POSITION_MAGIC) != (ulong)InpMagic)
continue;
if(StringLen(symbol) > 0 && PositionGetString(POSITION_SYMBOL) != symbol)
continue;
count++;
}
return(count);
}
//+------------------------------------------------------------------+
//| Any position on a symbol, regardless of ownership |
//+------------------------------------------------------------------+
int CountPositionsOnSymbol(const string symbol)
{
int count = 0;
const int total = PositionsTotal();
for(int i = 0; i < total; i++)
{
const ulong ticket = PositionGetTicket(i);
if(ticket == 0)
continue;
if(PositionGetString(POSITION_SYMBOL) == symbol)
count++;
}
return(count);
}
//+------------------------------------------------------------------+
//| Detect account exposure that this EA does not own |
//| |
//| The three limits are account-equity limits, so unmanaged positions|
//| and pending orders are counted and disclosed. While the guard is |
//| enabled they block every new entry; they are never modified, and |
//| the EA never claims account-wide protection over them. |
//+------------------------------------------------------------------+
int g_lastExternalExposure = -1;
bool ExternalExposurePresent(void)
{
UQAccountScope scope;
if(!UQAccountScopeScan((ulong)InpMagic, scope))
return(true); // fail closed: the scope cannot be verified
const int unmanaged = scope.externalPositions + scope.externalPending;
if(unmanaged != g_lastExternalExposure)
{
g_lastExternalExposure = unmanaged;
PrintFormat("[UQ] SCOPE: %s",
UQScopeDisclosure(scope, InpBlockOnExternalPositions, InpAllowWholeAccountLiquidation));
}
return(InpBlockOnExternalPositions && unmanaged > 0);
}
//+------------------------------------------------------------------+
//| Drive liquidation from the active risk lock. The lock is the |
//| authority: new entries stay blocked for as long as it is active, |
//| independently of liquidation progress. The machine is a state |
//| machine that only reports completion after verifying both pools. |
//+------------------------------------------------------------------+
void ServiceRiskLocks(void)
{
if(!g_risk.LiquidationRequired())
{
if(!g_liq.Idle())
{
PrintFormat("[UQ] Risk lock cleared; liquidation management idle again (last state %s).",
g_liq.StateText());
g_liq.Reset();
}
return;
}
//--- a lock arms the machine, including after an EA restart
if(g_liq.Idle())
{
g_liq.Arm();
PrintFormat("[UQ] Liquidation armed by risk control (%s).", g_risk.BlockText());
}
g_liq.Service();
}
//+------------------------------------------------------------------+
//| Evaluate one new signal bar for one symbol |
//+------------------------------------------------------------------+
void ProcessSymbolBar(const int idx, const datetime barTime)
{
const string symbol = g_symbols.Name(idx);
if(StringLen(symbol) == 0)
return;
if(g_symbols.LastEntryBar(idx) == barTime)
return;
if(CountOwnPositions(symbol) > 0)
return; // one EA position per symbol
if(CountOwnPositions("") >= InpMaxPositions)
{
g_symbols.Note(idx, "maximum simultaneous EA positions reached", true);
return;
}
if(IsNettingAccount() && CountPositionsOnSymbol(symbol) > 0)
{
g_symbols.Note(idx, "netting account already holds a position on this symbol", true);
return;
}
int direction = 0;
double atr = 0.0;
string reason = "";
if(!g_symbols.EvaluateSignal(idx, direction, atr, reason))
{
g_symbols.Note(idx, "signal unavailable: " + reason, true);
return;
}
if(direction == 0)
return;
//--- the broker trade mode must allow this side of the market
string dirReason = "";
if(!UQSymbolAllowsDirection(symbol, direction, dirReason))
{
g_symbols.Note(idx, "signal direction is not permitted: " + dirReason, true);
return;
}
UQExecConfig ec;
ec.magic = (ulong)InpMagic;
ec.deviation = InpDeviationPoints;
ec.portfolioTag = InpPortfolioId;
ec.riskPct = InpRiskPerTradePct;
ec.maxAggRiskPct = InpMaxAggRiskPct;
ec.riskBufferPct = InpRiskBufferPct;
ec.slAtrMultiplier = InpSlAtrMultiplier;
ec.maxSpreadPoints = InpMaxSpreadPoints;
double lots = 0.0;
string err = "";
if(!UQOpenSignalPosition(symbol, direction, atr, ec, g_risk, lots, err))
{
PrintFormat("[UQ] SKIP %s %s: %s", symbol, (direction > 0 ? "BUY" : "SELL"), err);
return;
}
g_symbols.SetLastEntryBar(idx, barTime);
PrintFormat("[UQ] SIGNAL %s %s bar=%s | %s",
symbol, (direction > 0 ? "BUY" : "SELL"),
TimeToString(barTime, TIME_DATE | TIME_MINUTES), reason);
}
//+------------------------------------------------------------------+
//| Centralised multi-symbol scheduler |
//| |
//| Every managed symbol is examined for its own completed signal |
//| bar, so a multi-symbol portfolio never depends on ticks arriving |
//| on the attached chart symbol alone. The same body serves |
//| OnTick() and OnTimer(), which keeps one risk order, one account |
//| risk check per submission and one duplicate-entry guard no |
//| matter which event source woke it up. |
//+------------------------------------------------------------------+
void ProcessManagedSymbols(void)
{
if(g_symbols.Count() <= 0)
return;
if(!InpEnableTrading || g_instanceConflict)
return;
if(g_risk.LiquidationRequired() || !g_risk.CanOpenNew())
return;
if(!TradingPermissionsOk())
return;
if(ExternalExposurePresent())
return;
g_lastScheduleTick = GetTickCount();
g_lastScheduleTime = UQServerNow();
g_schedulePasses++;
for(int i = 0; i < g_symbols.Count(); i++)
{
//--- the gate is re-checked for every symbol: a lock, an unresolved
//--- reservation or an exhausted budget stops the remaining symbols
if(!g_risk.CanOpenNew())
break;
datetime barTime = 0;
if(!g_symbols.IsNewBar(i, barTime))
continue;
//--- the risk state may have changed while an earlier symbol was
//--- submitted, so the account gate is never assumed to still hold
if(!g_risk.CanOpenNew())
break;
ProcessSymbolBar(i, barTime);
}
}
//+------------------------------------------------------------------+
//| Protective trailing, throttled to once per second |
//+------------------------------------------------------------------+
void ServiceTrailing(void)
{
const uint now = GetTickCount();
if((now - g_lastTrailTick) < 1000)
return;
g_lastTrailTick = now;
UQApplyAtrTrailing((ulong)InpMagic, g_symbols, InpTrailAtrMultiplier, InpDeviationPoints);
}
//+------------------------------------------------------------------+
//| Compact on-chart status display |
//+------------------------------------------------------------------+
void UpdateStatus(void)
{
UQExposure exp;
g_risk.Exposure((ulong)InpMagic, exp);
const double equity = g_risk.Equity();
const double riskPct = (equity > 0.0) ? (exp.Total() / equity) * 100.0 : 0.0;
const double posPct = (equity > 0.0) ? (exp.positions / equity) * 100.0 : 0.0;
const double resPct = (equity > 0.0) ? (exp.reserved / equity) * 100.0 : 0.0;
UQAccountScope scope;
const bool scopeKnown = UQAccountScopeScan((ulong)InpMagic, scope);
string scheduleText = "idle";
if(g_lastScheduleTime > 0)
scheduleText = TimeToString(g_lastScheduleTime, TIME_DATE | TIME_SECONDS);
string s = "";
s += StringFormat("%s v%s | %s | %s\n", UQ_COMMENT_PREFIX, UQ_VERSION,
g_risk.IdentityKey(), EnumToString(InpSignalTimeframe));
s += StringFormat("Lock state : %s\n", g_risk.BlockText());
s += StringFormat("Cycle : %s seq %d | %s\n", g_risk.CycleId(), g_risk.CycleSequence(),
(g_risk.ResetRejected() ? "RESET REJECTED" :
(g_risk.ResetApplied() ? "RESET APPLIED" : "reset idle")));
//--- recorded lock history of the closed cycle: kind 3 means both
//--- permanent locks were carried, and it is preserved across restarts
s += StringFormat("Prior locks : %s (kind %d)\n", g_risk.PriorLockText(), g_risk.PriorLockKind());
s += StringFormat("State : %s | persistence %s | generation %I64d\n",
(g_risk.StateFromBackup() ? "RESTORED-FROM-BACKUP" : (g_risk.StateMigrated() ? "MIGRATED" : "VERIFIED")),
(g_risk.Healthy() ? "OK" : "FAILED"), g_risk.StateRevision());
s += StringFormat("Ownership : %s\n",
(!g_instanceOwned || g_instanceConflict
? (g_risk.OwnershipLost() ? "LOST - persistence refused, entries blocked" : "NOT PROVEN - entries blocked")
: "HELD (exclusive)"));
//--- a recovery hold is shown explicitly: entries stay blocked until the
//--- owner authorizes a recovery risk cycle, even after a restart
if(g_risk.RecoveryHold())
s += StringFormat("Recovery : HOLD since %s | owner authorization required\n",
TimeToString(g_risk.RecoveryHoldTime(), TIME_DATE | TIME_SECONDS));
s += StringFormat("B0 %.2f | Equity %.2f | Target %.2f\n",
g_risk.Baseline(), equity, g_risk.TargetEquity());
s += StringFormat("D0 %.2f | Daily loss %.2f%% / %.2f%%%s\n",
g_risk.DailyStart(), g_risk.DailyLossPct(), g_risk.DailyLimitPct(),
(g_risk.DailyMonitored() ? "" : " UNVERIFIED DAY (monitoring gap)"));
s += StringFormat("Monitoring : %s | gap %d s | heartbeat %s\n",
(g_risk.MonitorGapHold() ? "GAP HOLD" : "continuous"),
g_risk.MonitorGapSeconds(),
(g_risk.LastMonitorTime() > 0
? TimeToString(g_risk.LastMonitorTime(), TIME_DATE | TIME_SECONDS)
: "none"));
if(g_risk.MonitorGapHold())
s += StringFormat("Gap hold : since %s | entries blocked until an observed day boundary\n",
TimeToString(g_risk.MonitorGapHoldTime(), TIME_DATE | TIME_SECONDS));
s += StringFormat("Static DD %.2f%% / %.2f%% | Floors %.2f / %.2f\n",
g_risk.StaticDdPct(), g_risk.StaticLimitPct(),
g_risk.DailyFloor(), g_risk.StaticFloor());
s += StringFormat("EA exposure %.2f%% / %.2f%%%s\n",
riskPct, InpMaxAggRiskPct,
(exp.bounded ? "" : " UNBOUNDED: " + UQExposureBrief(exp.detail)));
s += StringFormat(" positions %.2f%% (%d) | pending %d | reserved %.2f%% %s\n",
posPct, exp.positionCount, exp.pendingCount, resPct,
UQReserveStateText(g_risk.ReserveState()));
if(g_risk.ReserveState() != UQ_RESERVE_NONE)
s += StringFormat(" reservation id %s | order %s | deal %s | position %s\n",
g_risk.ReserveId(),
IntegerToString((long)g_risk.ReserveTicket()),
IntegerToString((long)g_risk.ReserveDeal()),
IntegerToString((long)g_risk.ReservePosition()));
s += StringFormat("EA positions %d / %d | External positions %d | External orders %d\n",
scope.ownPositions, InpMaxPositions,
scope.externalPositions, scope.externalPending);
if(!scopeKnown)
s += "Scope : UNVERIFIED, new entries are blocked\n";
else if(scope.externalPositions > 0 || scope.externalPending > 0)
s += StringFormat("Scope : %s\n",
UQScopeDisclosure(scope, InpBlockOnExternalPositions, InpAllowWholeAccountLiquidation));
s += StringFormat("Liquidation : %s | cycles %d\n", g_liq.StateText(), g_liq.Cycles());
s += StringFormat("Scheduler : %s | passes %d | idle %u ms\n",
scheduleText, (int)g_schedulePasses,
(uint)(g_lastScheduleTick > 0 ? (GetTickCount() - g_lastScheduleTick) : 0));
s += StringFormat("Trading %s | Symbols %d | tag %s | rejected %d",
(InpEnableTrading ? "ENABLED" : "DISABLED"), g_symbols.Count(),
g_symbols.TagText(), g_symbols.Skipped());
Comment(s);
}
//+------------------------------------------------------------------+
void UpdateStatusThrottled(void)
{
const uint now = GetTickCount();
if((now - g_lastStatusTick) < 1000)
return;
g_lastStatusTick = now;
UpdateStatus();
}
//+------------------------------------------------------------------+
//| Duplicate-instance guard names live in UQ_State.mqh together with |
//| the other persistence locators, so the V2 guard, the V2 marker and |
//| the V2 state files are derived from one fingerprint by one |
//| definition and one test path. |
//+------------------------------------------------------------------+
//+------------------------------------------------------------------+
//| Latch the loss of exclusive instance ownership |
//| |
//| The loss is permanent for this run: the entry gate closes, the |
//| risk controller is told to refuse every persistent mutation and |
//| nothing reacquires the guard inside this instance. |
//+------------------------------------------------------------------+
void InstanceOwnershipLost(const string why)
{
if(!g_instanceConflict)
{
PrintFormat("[UQ] ERROR: this instance no longer owns the duplicate-instance guard '%s' (%s). New entries are blocked and no persistent risk state is read, written, migrated or marked from this point on.",
g_instanceGV, why);
}
g_instanceConflict = true; // latched for the lifetime of this instance
g_instanceOwned = false;
g_risk.DeclareOwnershipLost(why);
}
//+------------------------------------------------------------------+
//| Proof of ownership before anything that may mutate risk state |
//| |
//| The heartbeat is throttled, the read-only verification is not: |
//| every persistence-capable event verifies the guard instead of |
//| trusting the two second timer. The first failure latches the loss. |
//+------------------------------------------------------------------+
bool InstanceOwnershipHeld(const bool allowHeartbeat)
{
if(!g_instanceOwned || g_instanceConflict)
return(false);
if(g_instanceGuard.Lost())
{
InstanceOwnershipLost(g_instanceGuard.Detail());
return(false);
}
if(allowHeartbeat)
{
const uint now = GetTickCount();
if(g_lastHeartbeatTick == 0 || (now - g_lastHeartbeatTick) >= UQ_HEARTBEAT_INTERVAL_MS)
{
g_lastHeartbeatTick = now;
//--- a compare-and-swap against the value this instance holds, so it
//--- fails as soon as another instance owns the guard
if(!g_instanceGuard.Heartbeat(TimeLocal()))
{
InstanceOwnershipLost(g_instanceGuard.Detail());
return(false);
}
}
}
string detail = "";
if(!g_instanceGuard.OwnershipVerified(detail))
{
InstanceOwnershipLost(detail);
return(false);
}
return(true);
}
//+------------------------------------------------------------------+
//| Initialisation |
//+------------------------------------------------------------------+
int OnInit(void)
{
string cfgErr = "";
if(!ValidateInputs(cfgErr))
{
PrintFormat("[UQ] INIT FAILED: %s", cfgErr);
return(INIT_PARAMETERS_INCORRECT);
}
PrintFormat("[UQ] Universal_Quant_EA v%s starting | trading=%s | timeframe=%s | symbols=%s",
UQ_VERSION, (InpEnableTrading ? "ENABLED" : "DISABLED"),
EnumToString(InpSignalTimeframe),
(InpSymbolMode == UQ_SYM_CURRENT_CHART ? _Symbol : InpSymbolList));
//--- centralized risk controller
UQRiskConfig rc;
rc.magic = (ulong)InpMagic;
rc.portfolio = InpPortfolioId;
rc.balanceOverride = InpInitialBalance;
rc.dailyLimitPct = InpDailyLossPct;
rc.staticLimitPct = InpStaticDdPct;
rc.targetPct = InpTargetPct;
rc.resetRequest = InpResetRequest;
rc.resetToken = InpResetToken;
rc.resetBaseline = InpResetBaseline;
rc.resetNonce = InpResetNonce;
rc.allowFirstInit = InpAuthorizeFirstInit;
rc.allowBackupRestore = InpAllowBackupRestore;
//--- an owner reset is only accepted on a verified flat account scope
UQResetScope resetScope;
resetScope.ok = false;
resetScope.detail = "";
resetScope.ownPositions = 0;
resetScope.ownPending = 0;
resetScope.externalPositions = 0;
resetScope.externalPending = 0;
resetScope.liquidationComplete = (g_liq.Idle() || g_liq.Complete());
if(InpResetRequest != UQ_RESET_NONE)
{
UQCheckResetScope((ulong)InpMagic, InpBlockOnExternalPositions,
resetScope.liquidationComplete, resetScope);
if(!resetScope.ok)
PrintFormat("[UQ] RESET PRECONDITION NOT MET: %s", resetScope.detail);
}
rc.resetPreconditionsOk = resetScope.ok;
rc.resetPreconditionDetail = resetScope.detail;
//--- duplicate-instance ownership is established BEFORE any function that can
//--- read, migrate, create or save a persistent risk scope is called. The
//--- requirement is unconditional: the ownership record is what makes one
//--- persistent scope exclusive, so a duplicate deployment must never reach
//--- g_risk.Init(). Exclusive ownership is unconditional and cannot be
//--- disabled by any input; InpPreventDuplicateInstances is deprecated and
//--- reserved.
//--- Order enforced here:
//--- 1. account identity (server, login, portfolio)
//--- 2. legacy-instance ownership
//--- 3. V2-instance ownership, acquired atomically
//--- 4. g_risk.Init(), i.e. risk-state load / migration / creation / save
string ownershipDetail = "";
UQIdentity ownershipId;
string identityErr = "";
if(!UQBuildIdentity(InpPortfolioId, ownershipId, identityErr))
{
PrintFormat("[UQ] INIT FAILED: %s. No persistent risk state was read, migrated, created or saved.", identityErr);
return(INIT_FAILED);
}
const ENUM_UQ_OWNERSHIP ownership =
UQInstanceOwnershipAcquire(g_instanceGuard, &g_guardStore, ownershipId,
TimeLocal(), UQ_INSTANCE_TTL_SECONDS, ownershipDetail);
g_instanceGV = g_instanceGuard.Name();
g_instanceOwned = false;
g_instanceConflict = true;
if(ownership != UQ_OWNERSHIP_READY)
{
PrintFormat("[UQ] INIT FAILED: duplicate-instance ownership was not established (%s): %s",
UQOwnershipText(ownership), ownershipDetail);
PrintFormat("[UQ] Fail closed: no persistent risk state was read, migrated, created or saved, and no state marker was written.");
return(INIT_FAILED);
}
g_instanceOwned = true;
g_instanceConflict = false;
PrintFormat("[UQ] Instance guard '%s' acquired atomically (%d chars, portfolio '%s').",
g_instanceGV, StringLen(g_instanceGV), InpPortfolioId);
//--- the deprecated input is reported and never obeyed: exclusive ownership
//--- is unconditional, so its value can neither widen access to the risk
//--- scope nor keep persistence alive after an ownership loss
if(!InpPreventDuplicateInstances)
PrintFormat("[UQ] NOTE: InpPreventDuplicateInstances=false is deprecated and has no effect. Exclusive persistence ownership is mandatory and an ownership loss always blocks entries.");
//--- the risk controller receives the ownership verifier of the guard this
//--- instance holds BEFORE it may read, migrate, create or save anything
g_risk.BindOwnership(&g_instanceGuard);
//--- only now may the persistent risk scope be touched
if(!g_risk.Init(rc))
{
PrintFormat("[UQ] INIT FAILED: risk controller: %s. The instance guard is released.", g_risk.LastError());
g_instanceGuard.Release();
g_instanceOwned = false;
g_instanceConflict = true;
return(INIT_FAILED);
}
if(g_risk.ResetRejected())
PrintFormat("[UQ] RISK CYCLE UNCHANGED: the owner reset request was rejected; new entries stay blocked until it is corrected or removed.");
if(g_risk.ResetApplied())
PrintFormat("[UQ] OWNER RESET APPLIED: risk cycle %s is active. Remove the reset inputs now so a restart cannot repeat the request.",
g_risk.CycleId());
PrintFormat("[UQ] Ownership scope: magic=%s portfolio=%s. One Magic Number must belong to exactly one deployment.",
IntegerToString(InpMagic), InpPortfolioId);
//--- the enforced liquidation scope is disclosed at start: the limits are
//--- account-equity limits, but this EA only controls its own magic scope
//--- unless whole-account liquidation is explicitly authorized
{
UQAccountScope scope;
if(UQAccountScopeScan((ulong)InpMagic, scope))
PrintFormat("[UQ] Scope: %s", UQScopeDisclosure(scope, InpBlockOnExternalPositions, InpAllowWholeAccountLiquidation));
else
PrintFormat("[UQ] WARNING: the account scope cannot be verified; new entries are blocked until the broker state is readable.");
}
if(g_risk.ReserveState() != UQ_RESERVE_NONE)
PrintFormat("[UQ] WARNING: an entry reservation of %.2f %s survived a restart in state %s (id=%s, order=%s, deal=%s, position=%s). It keeps counting as exposure and new entries stay blocked until a broker transaction proves what the request produced.",
g_risk.ReservedRisk(), g_risk.ReserveSymbol(),
UQReserveStateText(g_risk.ReserveState()), g_risk.ReserveId(),
IntegerToString((long)g_risk.ReserveTicket()),
IntegerToString((long)g_risk.ReserveDeal()),
IntegerToString((long)g_risk.ReservePosition()));
//--- symbol registry and indicator handles
if(!g_symbols.Init(InpSymbolMode, InpSymbolList, InpSignalTimeframe,
InpChannelLookback, InpEmaPeriod, InpAtrPeriod,
InpRequireAllSymbols))
{
PrintFormat("[UQ] INIT FAILED: %s", g_symbols.LastError());
g_instanceGuard.Release();
g_instanceOwned = false;
return(INIT_PARAMETERS_INCORRECT);
}
PrintFormat("[UQ] %d symbol(s) managed | broker tag %s | %d rejected.",
g_symbols.Count(), g_symbols.TagText(), g_symbols.Skipped());
g_liq.Init((ulong)InpMagic, InpDeviationPoints, InpAllowWholeAccountLiquidation);
EventSetTimer(2);
UpdateStatus();
return(INIT_SUCCEEDED);
}
//+------------------------------------------------------------------+
//| Deinitialisation |
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
{
EventKillTimer();
g_symbols.Deinit();
//--- release only a guard this instance demonstrably still owns. The release
//--- is a compare-and-swap against the value this instance holds, so a claim
//--- a successor acquired is never deleted or overwritten here.
if(g_instanceOwned)
{
if(!g_instanceGuard.Release())
PrintFormat("[UQ] Instance guard '%s' was not released: %s",
g_instanceGV, g_instanceGuard.Detail());
g_instanceOwned = false;
}
Comment("");
PrintFormat("[UQ] Stopped (reason=%d). Final lock state: %s", reason, g_risk.BlockText());
}
//+------------------------------------------------------------------+
//| Main tick handler |
//+------------------------------------------------------------------+
void OnTick(void)
{
//--- ownership is proven before any persistence-capable path runs: risk
//--- evaluation, lock servicing, trailing management and symbol processing
//--- can all mutate persistent risk state
if(!InstanceOwnershipHeld(true))
{
UpdateStatusThrottled();
return;
}
g_risk.Evaluate();
//--- a risk-control event always outranks a trading signal
ServiceRiskLocks();
if(g_risk.LiquidationRequired())
{
UpdateStatusThrottled();
return;
}
if(!InpEnableTrading)
{
UpdateStatusThrottled();
return;
}
if(!TradingPermissionsOk())
{
UpdateStatusThrottled();
return;
}
//--- protective trailing runs even when the entry gate is closed
ServiceTrailing();
//--- the tick path only wakes the scheduler; all symbol processing,
//--- including the account risk gate, lives in one place
ProcessManagedSymbols();
UpdateStatusThrottled();
}
//+------------------------------------------------------------------+
//| Timer: risk monitoring, trailing, scheduling and status refresh |
//| |
//| A managed symbol that never ticks on the attached chart must |
//| still be processed, so the timer runs the same scheduled body as |
//| the tick handler. The duplicate-entry guard keeps the two paths |
//| from acting twice on one completed bar. |
//+------------------------------------------------------------------+
void OnTimer(void)
{
//--- the timer is this instance's heartbeat, so ownership is refreshed and
//--- verified BEFORE anything that may mutate persistent risk state runs
if(!InstanceOwnershipHeld(true))
{
UpdateStatus();
return;
}
g_risk.Evaluate();
ServiceRiskLocks();
if(InpEnableTrading && !g_risk.LiquidationRequired() && TradingPermissionsOk())
{
ServiceTrailing();
ProcessManagedSymbols();
}
UpdateStatus();
}
//+------------------------------------------------------------------+
//| Execution feedback |
//+------------------------------------------------------------------+
void OnTradeTransaction(const MqlTradeTransaction &trans,
const MqlTradeRequest &request,
const MqlTradeResult &result)
{
if(trans.type == TRADE_TRANSACTION_DEAL_ADD)
{
if(StringLen(trans.symbol) > 0)
PrintFormat("[UQ] Deal recorded: %s deal=%s order=%s position=%s",
trans.symbol, IntegerToString((long)trans.deal),
IntegerToString((long)trans.order),
IntegerToString((long)trans.position));
//--- a deal is exactly the evidence an outstanding reservation waits
//--- for, so it is reconciled at once instead of on the next poll - but
//--- never after this instance lost the guard, because reconciliation
//--- persists the risk state it reconciles
if(g_risk.ReserveState() != UQ_RESERVE_NONE && InstanceOwnershipHeld(true))
g_risk.ReconcileReservationDepth(true);
return;
}
//--- a pending order becoming active or disappearing is the other half of
//--- the evidence set: reconcile it as well, never assume it was rejected
if(trans.type == TRADE_TRANSACTION_ORDER_ADD ||
trans.type == TRADE_TRANSACTION_ORDER_UPDATE ||
trans.type == TRADE_TRANSACTION_ORDER_DELETE)
{
if(g_risk.ReserveState() != UQ_RESERVE_NONE && InstanceOwnershipHeld(true))
g_risk.ReconcileReservationDepth(true);
return;
}
if(trans.type == TRADE_TRANSACTION_REQUEST)
{
if(result.retcode != TRADE_RETCODE_DONE &&
result.retcode != TRADE_RETCODE_PLACED &&
result.retcode != 0)
{
PrintFormat("[UQ] Request rejected: retcode=%d comment='%s'",
result.retcode, result.comment);
}
}
}
//+------------------------------------------------------------------+