Universal_Quant_EA/Universal_Quant_EA.mq5
955 lines
40 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;
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: name of the terminal global variable |
//| that marks one logical identity as active |
//| |
//| The terminal silently drops a global variable whose name exceeds |
//| UQ_GV_NAME_MAX_CHARS characters while GlobalVariableSet() still |
//| reports success, so an over-long name leaves the guard blind and |
//| a duplicate instance is never detected. |
//| |
//| Every identity whose name already fits keeps byte for byte the |
//| name it always had, so an existing guard variable stays |
//| recognisable and the established namespace is not renamed. |
//| |
//| An over-long identity is folded into a bounded deterministic name |
//| built from the identity hash (UQHashString), which covers the |
//| complete identity key. The short form is the sanitised key, which |
//| always keeps its '_' separators and lowercase letters, while the |
//| folded form is uppercase hexadecimal, so a short name can never |
//| be mistaken for a folded one. A hash collision would still fail |
//| closed through the heartbeat read-back in UQInstanceGuardPublish. |
//+------------------------------------------------------------------+
string UQInstanceGuardName(const string identityKey)
{
const string name = UQ_GV_INSTANCE_PREFIX + identityKey;
//--- a name that already fits is returned unchanged, so no existing
//--- instance guard is renamed by this correction
if(StringLen(name) <= UQ_GV_NAME_MAX_CHARS)
return(name);
//--- the folded form is bounded by construction and deterministic: the
//--- whole identity key is hashed, nothing is truncated from the key
const int budget = UQ_GV_NAME_MAX_CHARS - StringLen(UQ_GV_INSTANCE_PREFIX);
return(UQ_GV_INSTANCE_PREFIX + StringSubstr(UQHashString(identityKey), 0, budget));
}
//+------------------------------------------------------------------+
//| 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.IdentityKey());
if(InpPreventDuplicateInstances && 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);
}
}
}
//+------------------------------------------------------------------+