Universal_Quant_EA/Universal_Quant_EA.mq5
931 lines
39 KiB
MQL5
//+------------------------------------------------------------------+
//| 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)
input bool InpPreventDuplicateInstances = true; // Block entries when a duplicate instance is active
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 = "";
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());
Universal_Quant_EA
fix: recover the latest verified persistent state The backup was built from the previous primary before the new primary was installed, so it could lag the newest successful update, and a missing primary was still treated as a candidate fresh installation. Neither an interrupted commit nor a stale mirror was detectable. B1 state schema 6 (defines UQ_STATE_FORMAT=6, UQ_STATE_IDENTITY_FORMAT=5) adds a monotonic stateRevision plus recoveryHold/recoveryHoldTime. Formats 1..5 keep their own field block, so every published checksum still verifies over exactly the block that produced it, and a migration sets generation 1 without inventing a lock event. B2 UQStateSave is write-ahead and generation-aware: the next generation is derived from what is proven on disk (primary and backup), the staged file is written and read back, the same verified bytes go to the backup and are read back, the primary is replaced and read back, and primary and backup are finally reconciled on generation and content. A newer verified state is never overwritten by an older instance; a failure at any step returns false, which blocks every new entry. B3 LoadPersistentState orders primary and backup by generation: newer primary wins, equal generations must agree on content (otherwise fail closed), and a backup exactly one generation ahead is the interrupted-commit window and is adopted. Anything else is refused. B4 a backup is evidence of a previous initialization, so a missing primary is never a fresh installation and never creates a replacement B0; a verified backup is restored only under explicit owner authorization. B5 a recovered, reordered or superseded state always starts behind a persisted recovery hold (UQ_BLOCK_RECOVERY) that survives restart and only an explicit owner-authorized recovery risk cycle (UQ_RESET_RECOVERY, fresh nonce, explicit B0, flat scope) can clear. A recovery never clears a permanent lock the recovered state carries, and a cycle without a prior permanent lock records no prior-cycle reference instead of an inconsistent one. B6 the initialization marker is written and then verified; a marker that cannot be persisted or verified fails the load closed. B7 the status panel reports the persistence generation and an active recovery hold.
2026-10-30 11:09:43 +07:00
s += StringFormat("State : %s | persistence %s | generation %I64d\n",
(g_risk.StateFromBackup() ? "RESTORED-FROM-BACKUP" : (g_risk.StateMigrated() ? "MIGRATED" : "VERIFIED")),
Universal Quant EA
fix: recover the latest verified persistent state The backup was built from the previous primary before the new primary was installed, so it could lag the newest successful update, and a missing primary was still treated as a candidate fresh installation. Neither an interrupted commit nor a stale mirror was detectable. B1 state schema 6 (defines UQ_STATE_FORMAT=6, UQ_STATE_IDENTITY_FORMAT=5) adds a monotonic stateRevision plus recoveryHold/recoveryHoldTime. Formats 1..5 keep their own field block, so every published checksum still verifies over exactly the block that produced it, and a migration sets generation 1 without inventing a lock event. B2 UQStateSave is write-ahead and generation-aware: the next generation is derived from what is proven on disk (primary and backup), the staged file is written and read back, the same verified bytes go to the backup and are read back, the primary is replaced and read back, and primary and backup are finally reconciled on generation and content. A newer verified state is never overwritten by an older instance; a failure at any step returns false, which blocks every new entry. B3 LoadPersistentState orders primary and backup by generation: newer primary wins, equal generations must agree on content (otherwise fail closed), and a backup exactly one generation ahead is the interrupted-commit window and is adopted. Anything else is refused. B4 a backup is evidence of a previous initialization, so a missing primary is never a fresh installation and never creates a replacement B0; a verified backup is restored only under explicit owner authorization. B5 a recovered, reordered or superseded state always starts behind a persisted recovery hold (UQ_BLOCK_RECOVERY) that survives restart and only an explicit owner-authorized recovery risk cycle (UQ_RESET_RECOVERY, fresh nonce, explicit B0, flat scope) can clear. A recovery never clears a permanent lock the recovered state carries, and a cycle without a prior permanent lock records no prior-cycle reference instead of an inconsistent one. B6 the initialization marker is written and then verified; a marker that cannot be persisted or verified fails the load closed. B7 the status panel reports the persistence generation and an active recovery hold.
2026-10-30 11:09:43 +07:00
(g_risk.Healthy() ? "OK" : "FAILED"), g_risk.StateRevision());
//--- 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. |
//+------------------------------------------------------------------+
//+------------------------------------------------------------------+
//| 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(). InpPreventDuplicateInstances only decides whether a guard
//--- that is lost later also blocks entries; it cannot disable ownership.
//---
//--- 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);
//--- 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)
{
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)
{
g_risk.Evaluate();
ServiceRiskLocks();
//--- the heartbeat is the evidence a duplicate instance relies on, so a
//--- heartbeat that cannot be written and verified closes the entry gate
//--- instead of leaving this instance silently unprotected
if(g_instanceOwned)
{
//--- the heartbeat is a compare-and-swap against the value this instance
//--- holds, so it fails as soon as another instance owns the guard. A
//--- guard that can no longer be proven closes the entry gate here.
if(!g_instanceGuard.Heartbeat(TimeLocal()))
{
if(!g_instanceConflict)
PrintFormat("[UQ] ERROR: the duplicate-instance guard '%s' is no longer owned by this instance (%s). New entries are blocked.",
g_instanceGV, g_instanceGuard.Detail());
g_instanceConflict = true;
g_instanceOwned = false;
}
}
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
if(g_risk.ReserveState() != UQ_RESERVE_NONE)
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)
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);
}
}
}
//+------------------------------------------------------------------+