| //+------------------------------------------------------------------+
|
| //| 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;
|
|
|
| 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());
|
| 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());
|
| //--- 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. |
|
| //+------------------------------------------------------------------+
|
| //+------------------------------------------------------------------+
|
| //| Publish the guard heartbeat and prove that the terminal kept it |
|
| //| |
|
| //| A write the terminal does not persist must never be read as a |
|
| //| protected instance, so the exact name and the exact stored value |
|
| //| are verified by read-back before the guard counts as established. |
|
| //+------------------------------------------------------------------+
|
| bool UQInstanceGuardPublish(const string name, const datetime stamp)
|
| {
|
| if(StringLen(name) <= 0 || StringLen(name) > UQ_GV_NAME_MAX_CHARS)
|
| return(false);
|
| if(!GlobalVariableSet(name, (double)stamp))
|
| return(false);
|
| if(!GlobalVariableCheck(name))
|
| return(false);
|
| return((datetime)GlobalVariableGet(name) == stamp);
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| 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;
|
|
|
| if(!g_risk.Init(rc))
|
| {
|
| PrintFormat("[UQ] INIT FAILED: risk controller: %s", g_risk.LastError());
|
| 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());
|
| return(INIT_PARAMETERS_INCORRECT);
|
| }
|
|
|
| PrintFormat("[UQ] %d symbol(s) managed | broker tag %s | %d rejected.",
|
| g_symbols.Count(), g_symbols.TagText(), g_symbols.Skipped());
|
|
|
| //--- duplicate instance guard. The name is bounded to the terminal limit
|
| //--- and the heartbeat is proven by read-back, because a global variable
|
| //--- the terminal silently declined to persist would otherwise leave the
|
| //--- guard blind and this instance unprotected against a duplicate.
|
| g_instanceGV = UQInstanceGuardName(g_risk.IdentityFingerprintV2());
|
| const string legacyGV = UQInstanceGuardLegacyName(g_risk.IdentityKey());
|
|
|
| //--- upgrade transition: a build published before this correction holds the
|
| //--- legacy guard and cannot recognise the V2 namespace. While its heartbeat
|
| //--- is fresh the V2 guard is deliberately NOT established, because the two
|
| //--- binaries would otherwise run side by side on one portfolio.
|
| if(InpPreventDuplicateInstances && StringLen(legacyGV) > 0 && GlobalVariableCheck(legacyGV))
|
| {
|
| const datetime legacyBeat = (datetime)GlobalVariableGet(legacyGV);
|
| if(legacyBeat > 0 && (long)(TimeLocal() - legacyBeat) < UQ_INSTANCE_TTL_SECONDS)
|
| {
|
| g_instanceConflict = true;
|
| PrintFormat("[UQ] WARNING: an instance started by a previous build is still active for portfolio '%s' (legacy guard '%s', heartbeat %s). The V2 instance guard is not established and new entries are blocked here. Stop every old instance before deploying this build.",
|
| InpPortfolioId, legacyGV, TimeToString(legacyBeat, TIME_DATE | TIME_SECONDS));
|
| }
|
| }
|
|
|
| if(!g_instanceConflict && InpPreventDuplicateInstances &&
|
| StringLen(g_instanceGV) > 0 && GlobalVariableCheck(g_instanceGV))
|
| {
|
| const datetime last = (datetime)GlobalVariableGet(g_instanceGV);
|
| if(last > 0 && (long)(TimeLocal() - last) < UQ_INSTANCE_TTL_SECONDS)
|
| {
|
| g_instanceConflict = true;
|
| PrintFormat("[UQ] WARNING: another instance of portfolio '%s' appears active (heartbeat %s). New entries are blocked here.",
|
| InpPortfolioId, TimeToString(last, TIME_DATE | TIME_SECONDS));
|
| }
|
| }
|
| if(!g_instanceConflict)
|
| {
|
| const datetime beat = TimeLocal();
|
| if(UQInstanceGuardPublish(g_instanceGV, beat))
|
| {
|
| g_instanceOwned = true;
|
| PrintFormat("[UQ] Instance guard '%s' active (%d chars, portfolio '%s').",
|
| g_instanceGV, StringLen(g_instanceGV), InpPortfolioId);
|
| }
|
| else
|
| {
|
| //--- fail closed: an unproven guard is never treated as protection
|
| g_instanceOwned = false;
|
| PrintFormat("[UQ] ERROR: the duplicate-instance guard '%s' (%d chars) could not be written and verified (err=%d).%s",
|
| g_instanceGV, StringLen(g_instanceGV), GetLastError(),
|
| (InpPreventDuplicateInstances
|
| ? " New entries are blocked because the guard cannot be proven."
|
| : " Duplicate protection is disabled, so the guard failure alone does not block entries."));
|
| if(InpPreventDuplicateInstances)
|
| g_instanceConflict = true;
|
| }
|
| }
|
|
|
| g_liq.Init((ulong)InpMagic, InpDeviationPoints, InpAllowWholeAccountLiquidation);
|
| EventSetTimer(2);
|
| UpdateStatus();
|
| return(INIT_SUCCEEDED);
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| Deinitialisation |
|
| //+------------------------------------------------------------------+
|
| void OnDeinit(const int reason)
|
| {
|
| EventKillTimer();
|
| g_symbols.Deinit();
|
|
|
| if(g_instanceOwned && StringLen(g_instanceGV) > 0)
|
| {
|
| GlobalVariableDel(g_instanceGV);
|
| 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 && StringLen(g_instanceGV) > 0)
|
| {
|
| if(!UQInstanceGuardPublish(g_instanceGV, TimeLocal()))
|
| {
|
| if(!g_instanceConflict)
|
| PrintFormat("[UQ] ERROR: the duplicate-instance guard heartbeat '%s' could not be written and verified (err=%d). New entries are blocked.",
|
| g_instanceGV, GetLastError());
|
| g_instanceConflict = true;
|
| }
|
| }
|
|
|
| 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);
|
| }
|
| }
|
| }
|
| //+------------------------------------------------------------------+
|