//+------------------------------------------------------------------+ //| UQ_State.mqh | //| Universal Quant EA - identity and persistent risk state | //| | //| State is stored as an ASCII key=value block in the terminal | //| common folder. The file name and the embedded identity hash are | //| both derived from broker server + account login + portfolio id, | //| so one account can never silently inherit another's baselines. | //| | //| Lifecycle policy - fail closed: | //| * a state file that is absent, empty, truncated, corrupt, | //| version-unsupported, identity-mismatched or schema-invalid | //| is NEVER treated as a fresh installation; | //| * only an explicit owner-authorized first initialization with | //| a deliberately declared positive baseline may create a new | //| risk cycle; | //| * a redundant marker global variable records that the scope | //| has already been initialized, so deleting the state file | //| cannot silently restart a risk cycle; | //| * every serialized state carries a checksum over a canonical | //| fixed-order field block, verified on load and again after | //| every write; | //| * every state carries a strictly increasing generation that is | //| written ahead of the primary, read back and reconciled, so a | //| stale copy can never overwrite a newer verified state; | //| * the previous verified state is retained as a backup and is | //| only ever used under an explicit owner authorization, and any | //| recovered state always starts behind a recovery hold. | //| | //| No software can prove that a previous installation never | //| existed if every evidence source was deliberately erased. In | //| that case the owner must authorize a new risk cycle explicitly. | //+------------------------------------------------------------------+ #ifndef UQ_STATE_MQH #define UQ_STATE_MQH #include "UQ_Defs.mqh" //--- The shared ".tmp" suffix is superseded by the operation-scoped locator //--- defined below. It is kept only so a temporary file left behind by a //--- previously published build stays identifiable; this build never writes it. #define UQ_STATE_TEMP_EXT ".tmp" //--- Operation-scoped temporary state locator. A save must never use a //--- temporary file that another instance or another save attempt could also //--- use, so the name carries the identity fingerprint plus a fixed-width //--- operation tag. 20 tag characters put the whole name at 104 characters: //--- MT5 resolves the file inside the terminal sandbox and only the overall //--- path is bounded (MAX_PATH, 260), so the name stays far inside the limit. #define UQ_STATE_TEMP_MARK ".tmp_" #define UQ_STATE_TEMP_INSTANCE_CHARS 12 #define UQ_STATE_TEMP_OP_CHARS 8 #define UQ_STATE_TEMP_TAG_CHARS (UQ_STATE_TEMP_INSTANCE_CHARS + UQ_STATE_TEMP_OP_CHARS) #define UQ_STATE_BACKUP_EXT ".bak" #define UQ_STATE_CHECKSUM_KEY "checksum" #define UQ_STATE_CHECKSUM_LENGTH 16 #define UQ_STATE_RETRY_SECONDS 5 #define UQ_STATE_MAX_CLOCK_SKEW 86400 #define UQ_STATE_MAX_RESET_SIG 64 //--- A terminal global variable whose name is longer than this is silently //--- dropped by the terminal while GlobalVariableSet() still reports success. //--- The loss is only observable through a read-back verification, so every //--- derived name must stay inside the limit instead of relying on the write //--- to fail. #define UQ_GV_NAME_MAX_CHARS 63 //+------------------------------------------------------------------+ //| Ownership verification boundary | //| | //| The risk controller and the state writer ask this interface | //| before every persistent mutation, so the check sits at the | //| side-effect boundary and not only in an event handler. The default | //| answer is "not verified": a caller that was never bound to a live | //| guard refuses to write. The concrete implementation is the | //| duplicate-instance guard defined further below. | //+------------------------------------------------------------------+ class CUQOwnershipVerifier { public: virtual bool OwnershipVerified(string &detail) { detail = "no instance-ownership verifier is bound"; return(false); } virtual string OwnershipScope(void) { return(""); } virtual bool OwnershipLost(void) { return(true); } }; //+------------------------------------------------------------------+ //| Fail-closed ownership fence for one file mutation | //| | //| Called immediately before the staging write, the backup write and | //| the primary commit of a state save, so a controller that lost the | //| guard cannot mutate any of them. | //+------------------------------------------------------------------+ bool UQOwnershipSaveGate(CUQOwnershipVerifier *owner, const string operation) { if(owner == NULL) { PrintFormat("[UQ] ERROR: no instance-ownership verifier is bound, so the %s is never written.", operation); return(false); } string detail = ""; if(!owner.OwnershipVerified(detail)) { PrintFormat("[UQ] ERROR: the %s is not written because this instance does not own the persistent risk scope (%s).", operation, detail); return(false); } return(true); } //+------------------------------------------------------------------+ //| Identity of the persistence scope | //+------------------------------------------------------------------+ struct UQIdentity { string server; // broker server name (never written to disk) long login; // account login (never written to disk) string portfolio; // EA portfolio identifier string key; // file-system safe scope key string hash; // integrity hash of the scope triple (LEGACY) string fingerprintV2; // SHA-256 of the canonical, unsanitised identity tuple //--- true when the LEGACY serialisation of this tuple cannot be decoded back //--- to its components: 'server + "|" + login + "|" + portfolio' is then //--- shared with a different tuple, so a legacy state found under the legacy //--- locators can never be proven to belong to this identity and is never //--- migrated bool legacyAmbiguous; }; //+------------------------------------------------------------------+ //| Persisted risk state | //+------------------------------------------------------------------+ struct UQRiskState { int format; string identity; // integrity hash of the owning scope (LEGACY) string identityV2; // full V2 fingerprint of the owning scope (schema 8) double baselineBalance; // B0 - fixed initial balance baseline bool baselineValid; datetime baselineTime; double dailyStartEquity; // D0 - starting equity of the trading day bool dailyValid; datetime dailyDate; // broker-server calendar day of D0 datetime dailyStartTime; bool dailyLock; datetime dailyLockTime; bool staticLock; datetime staticLockTime; bool targetLock; datetime targetLockTime; string resetSignature; // legacy pre-cycle reset signature, migrated and unused datetime createdAt; datetime updatedAt; int cycleSequence; // risk-cycle counter, 1 for the first cycle string cycleId; // unique identifier of the current risk cycle string resetNonceHistory; // consumed reset nonces, '|' separated double priorBaselineBalance; // B0 of the cycle closed by the most recent reset string priorCycleId; // identifier of that previous cycle int priorLockKind; // 0 none, 1 static-drawdown, 2 equity-target, 3 both datetime priorLockTime; // lock event that authorized the most recent reset //--- outstanding entry risk. A submitted entry is reserved before the //--- request leaves the terminal and released only after the broker state //--- proves where the order went, so a restart cannot forget it. ENUM_UQ_RESERVE_STATE reserveState; double reserveMoney; ulong reserveTicket; // order ticket returned by the server string reserveSymbol; datetime reserveTime; string reserveId; // identity of this request, unique per scope ulong reserveMagic; // Magic Number of the request string reserveScope; // portfolio scope the request belongs to int reserveDirection; // +1 buy, -1 sell, 0 when it cannot be proven double reserveVolume; // requested volume double reserveEntry; // expected entry price of the request double reserveStop; // submitted protective stop ulong reserveDeal; // deal ticket reported by the server ulong reservePosition; // position identifier reported for the deal //--- persistence generation. Every authoritative state carries a strictly //--- increasing revision, so an interrupted commit is always detectable and //--- a stale copy can never overwrite a newer verified state. long stateRevision; bool recoveryHold; // entries blocked until the owner authorizes recovery datetime recoveryHoldTime; // when the recovery hold was recorded //--- monitoring continuity. The heartbeat records the last verified //--- execution moment, so a restart can distinguish a trading day that was //--- observed from its boundary from one whose equity path was never seen. datetime monitorTime; // last verified monitoring heartbeat (server time) int monitorGapSeconds; // most recent unobserved interval, 0 = none bool gapHold; // entries blocked until an observed day boundary datetime gapHoldTime; // when the monitoring-gap hold was recorded }; //+------------------------------------------------------------------+ //| Classification of one load attempt | //+------------------------------------------------------------------+ enum ENUM_UQ_STATE_LOAD { UQ_LOAD_ERROR = -1, // file exists but is unusable UQ_LOAD_ABSENT = 0, // no file at this location UQ_LOAD_VALID = 1, // current format, checksum verified UQ_LOAD_LEGACY = 2 // previous published format, schema verified }; //+------------------------------------------------------------------+ //| Reconciliation state of the redundant initialization marker | //+------------------------------------------------------------------+ enum ENUM_UQ_MARKER_STATE { UQ_MARKER_ABSENT = 0, // marker missing: no evidence either way UQ_MARKER_MATCH = 1, // marker agrees with the persisted state UQ_MARKER_MISMATCH = 2, // marker disagrees with the persisted state UQ_MARKER_INVALID = 3 // marker exists but holds no usable record }; //+------------------------------------------------------------------+ //| Canonical persisted field table. The order of this enumeration | //| defines the order of the canonical block that is hashed, so the | //| checksum is independent of the physical line order in the file. | //+------------------------------------------------------------------+ enum ENUM_UQ_STATE_FIELD { UQ_F_FORMAT = 0, UQ_F_IDENTITY, UQ_F_BASELINE_BALANCE, UQ_F_BASELINE_VALID, UQ_F_BASELINE_TIME, UQ_F_DAILY_EQUITY, UQ_F_DAILY_VALID, UQ_F_DAILY_DATE, UQ_F_DAILY_START_TIME, UQ_F_DAILY_LOCK, UQ_F_DAILY_LOCK_TIME, UQ_F_STATIC_LOCK, UQ_F_STATIC_LOCK_TIME, UQ_F_TARGET_LOCK, UQ_F_TARGET_LOCK_TIME, UQ_F_RESET_SIGNATURE, UQ_F_CREATED_AT, UQ_F_UPDATED_AT, UQ_F_CYCLE_SEQ, UQ_F_CYCLE_ID, UQ_F_RESET_NONCE_HISTORY, UQ_F_PRIOR_B0, UQ_F_PRIOR_CYCLE_ID, UQ_F_PRIOR_LOCK_KIND, UQ_F_PRIOR_LOCK_TIME, UQ_F_RESERVE_STATE, UQ_F_RESERVE_MONEY, UQ_F_RESERVE_TICKET, UQ_F_RESERVE_SYMBOL, UQ_F_RESERVE_TIME, UQ_F_RESERVE_ID, UQ_F_RESERVE_MAGIC, UQ_F_RESERVE_SCOPE, UQ_F_RESERVE_DIRECTION, UQ_F_RESERVE_VOLUME, UQ_F_RESERVE_ENTRY, UQ_F_RESERVE_STOP, UQ_F_RESERVE_DEAL, UQ_F_RESERVE_POSITION, UQ_F_STATE_REVISION, UQ_F_RECOVERY_HOLD, UQ_F_RECOVERY_TIME, UQ_F_MONITOR_TIME, UQ_F_MONITOR_GAP, UQ_F_GAP_HOLD, UQ_F_GAP_HOLD_TIME, //--- schema 8. The V2 identity is appended LAST on purpose: every earlier //--- schema hashes only the leading N fields of this table, so inserting a //--- field anywhere else would silently change what an old checksum covers //--- and no previously persisted state could be verified any more. UQ_F_IDENTITY_V2, UQ_F_COUNT }; //+------------------------------------------------------------------+ //+------------------------------------------------------------------+ //| Number of fields written by the schemas that predate the risk- | //| cycle schema. They lead the canonical table, so a legacy checksum | //| is recomputed over exactly the block that produced it. | //+------------------------------------------------------------------+ #define UQ_STATE_BASE_FIELDS 18 #define UQ_STATE_CYCLE_FIELDS 25 #define UQ_STATE_RESERVE_FIELDS 30 #define UQ_STATE_IDENTITY_FIELDS 39 #define UQ_STATE_GENERATION_FIELDS 42 #define UQ_STATE_MONITOR_FIELDS 46 #define UQ_STATE_V2_FIELDS 47 //+------------------------------------------------------------------+ //| Number of fields that a given schema serialises. A checksum is | //| only valid over the exact block that produced it, so the field | //| count is derived from the schema version and never from the | //| current build. | //+------------------------------------------------------------------+ int UQFieldCountForFormat(const int fmt) { if(fmt >= UQ_STATE_FORMAT) return((int)UQ_F_COUNT); if(fmt >= UQ_STATE_MONITOR_FORMAT) return((int)UQ_STATE_MONITOR_FIELDS); if(fmt >= UQ_STATE_GENERATION_FORMAT) return((int)UQ_STATE_GENERATION_FIELDS); if(fmt >= UQ_STATE_IDENTITY_FORMAT) return((int)UQ_STATE_IDENTITY_FIELDS); if(fmt == UQ_STATE_RESERVE_FORMAT) return((int)UQ_STATE_RESERVE_FIELDS); if(fmt == UQ_STATE_CYCLE_FORMAT) return((int)UQ_STATE_CYCLE_FIELDS); return((int)UQ_STATE_BASE_FIELDS); } //+------------------------------------------------------------------+ //| Reset a state structure to defaults | //+------------------------------------------------------------------+ void UQStateReset(UQRiskState &st) { st.format = UQ_STATE_FORMAT; st.identity = ""; st.identityV2 = ""; st.baselineBalance = 0.0; st.baselineValid = false; st.baselineTime = 0; st.dailyStartEquity = 0.0; st.dailyValid = false; st.dailyDate = 0; st.dailyStartTime = 0; st.dailyLock = false; st.dailyLockTime = 0; st.staticLock = false; st.staticLockTime = 0; st.targetLock = false; st.targetLockTime = 0; st.resetSignature = ""; st.createdAt = 0; st.updatedAt = 0; st.cycleSequence = 0; st.cycleId = ""; st.resetNonceHistory = ""; st.priorBaselineBalance = 0.0; st.priorCycleId = ""; st.priorLockKind = 0; st.priorLockTime = 0; st.reserveState = UQ_RESERVE_NONE; st.reserveMoney = 0.0; st.reserveTicket = 0; st.reserveSymbol = ""; st.reserveTime = 0; st.reserveId = ""; st.reserveMagic = 0; st.reserveScope = ""; st.reserveDirection = 0; st.reserveVolume = 0.0; st.reserveEntry = 0.0; st.reserveStop = 0.0; st.reserveDeal = 0; st.reservePosition = 0; st.stateRevision = 0; st.recoveryHold = false; st.recoveryHoldTime = 0; st.monitorTime = 0; st.monitorGapSeconds = 0; st.gapHold = false; st.gapHoldTime = 0; } //+------------------------------------------------------------------+ //| Canonical key of a persisted field | //+------------------------------------------------------------------+ string UQFieldKey(const int idx) { switch(idx) { case UQ_F_FORMAT: return("format"); case UQ_F_IDENTITY: return("identity"); case UQ_F_BASELINE_BALANCE: return("baselineBalance"); case UQ_F_BASELINE_VALID: return("baselineValid"); case UQ_F_BASELINE_TIME: return("baselineTime"); case UQ_F_DAILY_EQUITY: return("dailyStartEquity"); case UQ_F_DAILY_VALID: return("dailyValid"); case UQ_F_DAILY_DATE: return("dailyDate"); case UQ_F_DAILY_START_TIME: return("dailyStartTime"); case UQ_F_DAILY_LOCK: return("dailyLock"); case UQ_F_DAILY_LOCK_TIME: return("dailyLockTime"); case UQ_F_STATIC_LOCK: return("staticLock"); case UQ_F_STATIC_LOCK_TIME: return("staticLockTime"); case UQ_F_TARGET_LOCK: return("targetLock"); case UQ_F_TARGET_LOCK_TIME: return("targetLockTime"); case UQ_F_RESET_SIGNATURE: return("resetSignature"); case UQ_F_CREATED_AT: return("createdAt"); case UQ_F_UPDATED_AT: return("updatedAt"); case UQ_F_CYCLE_SEQ: return("cycleSequence"); case UQ_F_CYCLE_ID: return("cycleId"); case UQ_F_RESET_NONCE_HISTORY: return("resetNonceHistory"); case UQ_F_PRIOR_B0: return("priorBaselineBalance"); case UQ_F_PRIOR_CYCLE_ID: return("priorCycleId"); case UQ_F_PRIOR_LOCK_KIND: return("priorLockKind"); case UQ_F_PRIOR_LOCK_TIME: return("priorLockTime"); case UQ_F_RESERVE_STATE: return("reserveState"); case UQ_F_RESERVE_MONEY: return("reserveMoney"); case UQ_F_RESERVE_TICKET: return("reserveTicket"); case UQ_F_RESERVE_SYMBOL: return("reserveSymbol"); case UQ_F_RESERVE_TIME: return("reserveTime"); case UQ_F_RESERVE_ID: return("reserveId"); case UQ_F_RESERVE_MAGIC: return("reserveMagic"); case UQ_F_RESERVE_SCOPE: return("reserveScope"); case UQ_F_RESERVE_DIRECTION: return("reserveDirection"); case UQ_F_RESERVE_VOLUME: return("reserveVolume"); case UQ_F_RESERVE_ENTRY: return("reserveEntry"); case UQ_F_RESERVE_STOP: return("reserveStop"); case UQ_F_RESERVE_DEAL: return("reserveDeal"); case UQ_F_RESERVE_POSITION: return("reservePosition"); case UQ_F_STATE_REVISION: return("stateRevision"); case UQ_F_RECOVERY_HOLD: return("recoveryHold"); case UQ_F_RECOVERY_TIME: return("recoveryHoldTime"); case UQ_F_MONITOR_TIME: return("monitorTime"); case UQ_F_MONITOR_GAP: return("monitorGapSeconds"); case UQ_F_GAP_HOLD: return("gapHold"); case UQ_F_GAP_HOLD_TIME: return("gapHoldTime"); case UQ_F_IDENTITY_V2: return("identityV2"); } return(""); } //+------------------------------------------------------------------+ //| Index of a persisted field, or -1 when unknown | //+------------------------------------------------------------------+ int UQFieldIndex(const string key) { for(int i = 0; i < (int)UQ_F_COUNT; i++) if(UQFieldKey(i) == key) return(i); return(-1); } //+------------------------------------------------------------------+ //| Canonical serialised value of one persisted field | //+------------------------------------------------------------------+ string UQStateFieldValue(UQRiskState &st, const int idx) { switch(idx) { case UQ_F_FORMAT: return(IntegerToString(st.format)); case UQ_F_IDENTITY: return(st.identity); case UQ_F_BASELINE_BALANCE: return(DoubleToString(st.baselineBalance, 8)); case UQ_F_BASELINE_VALID: return(st.baselineValid ? "1" : "0"); case UQ_F_BASELINE_TIME: return(IntegerToString((long)st.baselineTime)); case UQ_F_DAILY_EQUITY: return(DoubleToString(st.dailyStartEquity, 8)); case UQ_F_DAILY_VALID: return(st.dailyValid ? "1" : "0"); case UQ_F_DAILY_DATE: return(IntegerToString((long)st.dailyDate)); case UQ_F_DAILY_START_TIME: return(IntegerToString((long)st.dailyStartTime)); case UQ_F_DAILY_LOCK: return(st.dailyLock ? "1" : "0"); case UQ_F_DAILY_LOCK_TIME: return(IntegerToString((long)st.dailyLockTime)); case UQ_F_STATIC_LOCK: return(st.staticLock ? "1" : "0"); case UQ_F_STATIC_LOCK_TIME: return(IntegerToString((long)st.staticLockTime)); case UQ_F_TARGET_LOCK: return(st.targetLock ? "1" : "0"); case UQ_F_TARGET_LOCK_TIME: return(IntegerToString((long)st.targetLockTime)); case UQ_F_RESET_SIGNATURE: return(st.resetSignature); case UQ_F_CREATED_AT: return(IntegerToString((long)st.createdAt)); case UQ_F_UPDATED_AT: return(IntegerToString((long)st.updatedAt)); case UQ_F_CYCLE_SEQ: return(IntegerToString((long)st.cycleSequence)); case UQ_F_CYCLE_ID: return(st.cycleId); case UQ_F_RESET_NONCE_HISTORY: return(st.resetNonceHistory); case UQ_F_PRIOR_B0: return(DoubleToString(st.priorBaselineBalance, 8)); case UQ_F_PRIOR_CYCLE_ID: return(st.priorCycleId); case UQ_F_PRIOR_LOCK_KIND: return(IntegerToString((long)st.priorLockKind)); case UQ_F_PRIOR_LOCK_TIME: return(IntegerToString((long)st.priorLockTime)); case UQ_F_RESERVE_STATE: return(IntegerToString((long)st.reserveState)); case UQ_F_RESERVE_MONEY: return(DoubleToString(st.reserveMoney, 8)); case UQ_F_RESERVE_TICKET: return(IntegerToString((long)st.reserveTicket)); case UQ_F_RESERVE_SYMBOL: return(st.reserveSymbol); case UQ_F_RESERVE_TIME: return(IntegerToString((long)st.reserveTime)); case UQ_F_RESERVE_ID: return(st.reserveId); case UQ_F_RESERVE_MAGIC: return(IntegerToString((long)st.reserveMagic)); case UQ_F_RESERVE_SCOPE: return(st.reserveScope); case UQ_F_RESERVE_DIRECTION: return(IntegerToString((long)st.reserveDirection)); case UQ_F_RESERVE_VOLUME: return(DoubleToString(st.reserveVolume, 8)); case UQ_F_RESERVE_ENTRY: return(DoubleToString(st.reserveEntry, 8)); case UQ_F_RESERVE_STOP: return(DoubleToString(st.reserveStop, 8)); case UQ_F_RESERVE_DEAL: return(IntegerToString((long)st.reserveDeal)); case UQ_F_RESERVE_POSITION: return(IntegerToString((long)st.reservePosition)); case UQ_F_STATE_REVISION: return(IntegerToString(st.stateRevision)); case UQ_F_RECOVERY_HOLD: return(st.recoveryHold ? "1" : "0"); case UQ_F_RECOVERY_TIME: return(IntegerToString((long)st.recoveryHoldTime)); case UQ_F_MONITOR_TIME: return(IntegerToString((long)st.monitorTime)); case UQ_F_MONITOR_GAP: return(IntegerToString((long)st.monitorGapSeconds)); case UQ_F_GAP_HOLD: return(st.gapHold ? "1" : "0"); case UQ_F_GAP_HOLD_TIME: return(IntegerToString((long)st.gapHoldTime)); case UQ_F_IDENTITY_V2: return(st.identityV2); } return(""); } //+------------------------------------------------------------------+ //| Canonical block that is covered by the integrity checksum | //+------------------------------------------------------------------+ string UQStateCanonicalBody(UQRiskState &st) { //--- every published schema is hashed over its own field set only, so an //--- older checksum still verifies and can be migrated deterministically const int fields = UQFieldCountForFormat(st.format); string body = ""; for(int i = 0; i < fields; i++) body += UQFieldKey(i) + "=" + UQStateFieldValue(st, i) + "\r\n"; return(body); } //+------------------------------------------------------------------+ //| Strict integer parser: rejects empty, blank and non-numeric text | //+------------------------------------------------------------------+ bool UQParseLong(const string v, long &out) { out = 0; const int n = StringLen(v); if(n <= 0 || n > 20) return(false); int start = 0; const ushort first = StringGetCharacter(v, 0); if(first == '-' || first == '+') start = 1; if(start >= n) return(false); int digits = 0; for(int i = start; i < n; i++) { const ushort c = StringGetCharacter(v, i); if(c < '0' || c > '9') return(false); digits++; } if(digits == 0) return(false); out = StringToInteger(v); return(true); } //+------------------------------------------------------------------+ //| Strict double parser: one optional leading sign, one decimal | //| point, at least one digit and no trailing garbage | //+------------------------------------------------------------------+ bool UQParseDoubleStrict(const string v, double &out) { out = 0.0; const int n = StringLen(v); if(n <= 0 || n > 40) return(false); int start = 0; const ushort first = StringGetCharacter(v, 0); if(first == '-' || first == '+') start = 1; if(start >= n) return(false); int digits = 0; int points = 0; for(int i = start; i < n; i++) { const ushort c = StringGetCharacter(v, i); if(c == '.') { points++; if(points > 1) return(false); continue; } if(c < '0' || c > '9') return(false); digits++; } if(digits == 0) return(false); out = StringToDouble(v); return(MathIsValidNumber(out)); } //+------------------------------------------------------------------+ //| Strict boolean parser: only the literals 0 and 1 are accepted | //+------------------------------------------------------------------+ bool UQParseBoolStrict(const string v, bool &out) { out = false; if(v == "0") return(true); if(v == "1") { out = true; return(true); } return(false); } //+------------------------------------------------------------------+ //| Parse a state block into a structure. Every required field must | //| be present exactly once; unknown fields are rejected. | //+------------------------------------------------------------------+ bool UQStateParseBody(const string body, UQRiskState &st, bool &hasChecksum, string &checksumStored, string &err) { hasChecksum = false; checksumStored = ""; err = ""; UQStateReset(st); char seen[]; ArrayResize(seen, (int)UQ_F_COUNT); ArrayInitialize(seen, (char)0); string lines[]; const int n = StringSplit(body, '\n', lines); for(int i = 0; i < n; i++) { string line = lines[i]; StringTrimRight(line); StringTrimLeft(line); if(StringLen(line) == 0) continue; const int eq = StringFind(line, "="); if(eq <= 0) { err = StringFormat("malformed line %d", i + 1); return(false); } const string key = StringSubstr(line, 0, eq); const string val = StringSubstr(line, eq + 1); if(key == UQ_STATE_CHECKSUM_KEY) { if(hasChecksum) { err = "duplicate checksum field"; return(false); } if(StringLen(val) != UQ_STATE_CHECKSUM_LENGTH) { err = "malformed checksum field"; return(false); } hasChecksum = true; checksumStored = val; continue; } const int idx = UQFieldIndex(key); if(idx < 0) { err = StringFormat("unknown field '%s'", key); return(false); } if(seen[idx] != 0) { err = StringFormat("duplicate field '%s'", key); return(false); } seen[idx] = (char)1; long iv = 0; double dv = 0.0; bool bv = false; switch(idx) { case UQ_F_FORMAT: if(!UQParseLong(val, iv)) { err = "field 'format' is not an integer"; return(false); } st.format = (int)iv; break; case UQ_F_IDENTITY: st.identity = val; break; case UQ_F_BASELINE_BALANCE: if(!UQParseDoubleStrict(val, dv)) { err = "field 'baselineBalance' is not numeric"; return(false); } st.baselineBalance = dv; break; case UQ_F_BASELINE_VALID: if(!UQParseBoolStrict(val, bv)) { err = "field 'baselineValid' is not a 0/1 flag"; return(false); } st.baselineValid = bv; break; case UQ_F_BASELINE_TIME: if(!UQParseLong(val, iv)) { err = "field 'baselineTime' is not an integer"; return(false); } st.baselineTime = (datetime)iv; break; case UQ_F_DAILY_EQUITY: if(!UQParseDoubleStrict(val, dv)) { err = "field 'dailyStartEquity' is not numeric"; return(false); } st.dailyStartEquity = dv; break; case UQ_F_DAILY_VALID: if(!UQParseBoolStrict(val, bv)) { err = "field 'dailyValid' is not a 0/1 flag"; return(false); } st.dailyValid = bv; break; case UQ_F_DAILY_DATE: if(!UQParseLong(val, iv)) { err = "field 'dailyDate' is not an integer"; return(false); } st.dailyDate = (datetime)iv; break; case UQ_F_DAILY_START_TIME: if(!UQParseLong(val, iv)) { err = "field 'dailyStartTime' is not an integer"; return(false); } st.dailyStartTime = (datetime)iv; break; case UQ_F_DAILY_LOCK: if(!UQParseBoolStrict(val, bv)) { err = "field 'dailyLock' is not a 0/1 flag"; return(false); } st.dailyLock = bv; break; case UQ_F_DAILY_LOCK_TIME: if(!UQParseLong(val, iv)) { err = "field 'dailyLockTime' is not an integer"; return(false); } st.dailyLockTime = (datetime)iv; break; case UQ_F_STATIC_LOCK: if(!UQParseBoolStrict(val, bv)) { err = "field 'staticLock' is not a 0/1 flag"; return(false); } st.staticLock = bv; break; case UQ_F_STATIC_LOCK_TIME: if(!UQParseLong(val, iv)) { err = "field 'staticLockTime' is not an integer"; return(false); } st.staticLockTime = (datetime)iv; break; case UQ_F_TARGET_LOCK: if(!UQParseBoolStrict(val, bv)) { err = "field 'targetLock' is not a 0/1 flag"; return(false); } st.targetLock = bv; break; case UQ_F_TARGET_LOCK_TIME: if(!UQParseLong(val, iv)) { err = "field 'targetLockTime' is not an integer"; return(false); } st.targetLockTime = (datetime)iv; break; case UQ_F_RESET_SIGNATURE: st.resetSignature = val; break; case UQ_F_CREATED_AT: if(!UQParseLong(val, iv)) { err = "field 'createdAt' is not an integer"; return(false); } st.createdAt = (datetime)iv; break; case UQ_F_UPDATED_AT: if(!UQParseLong(val, iv)) { err = "field 'updatedAt' is not an integer"; return(false); } st.updatedAt = (datetime)iv; break; case UQ_F_CYCLE_SEQ: if(!UQParseLong(val, iv)) { err = "field 'cycleSequence' is not an integer"; return(false); } st.cycleSequence = (int)iv; break; case UQ_F_CYCLE_ID: st.cycleId = val; break; case UQ_F_RESET_NONCE_HISTORY: st.resetNonceHistory = val; break; case UQ_F_PRIOR_B0: if(!UQParseDoubleStrict(val, dv)) { err = "field 'priorBaselineBalance' is not numeric"; return(false); } st.priorBaselineBalance = dv; break; case UQ_F_PRIOR_CYCLE_ID: st.priorCycleId = val; break; case UQ_F_PRIOR_LOCK_KIND: if(!UQParseLong(val, iv)) { err = "field 'priorLockKind' is not an integer"; return(false); } st.priorLockKind = (int)iv; break; case UQ_F_PRIOR_LOCK_TIME: if(!UQParseLong(val, iv)) { err = "field 'priorLockTime' is not an integer"; return(false); } st.priorLockTime = (datetime)iv; break; case UQ_F_RESERVE_STATE: if(!UQParseLong(val, iv)) { err = "field 'reserveState' is not an integer"; return(false); } st.reserveState = (ENUM_UQ_RESERVE_STATE)iv; break; case UQ_F_RESERVE_MONEY: if(!UQParseDoubleStrict(val, dv)) { err = "field 'reserveMoney' is not numeric"; return(false); } st.reserveMoney = dv; break; case UQ_F_RESERVE_TICKET: if(!UQParseLong(val, iv)) { err = "field 'reserveTicket' is not an integer"; return(false); } st.reserveTicket = (ulong)iv; break; case UQ_F_RESERVE_SYMBOL: st.reserveSymbol = val; break; case UQ_F_RESERVE_TIME: if(!UQParseLong(val, iv)) { err = "field 'reserveTime' is not an integer"; return(false); } st.reserveTime = (datetime)iv; break; case UQ_F_RESERVE_ID: st.reserveId = val; break; case UQ_F_RESERVE_MAGIC: if(!UQParseLong(val, iv) || iv < 0) { err = "field 'reserveMagic' is not an integer"; return(false); } st.reserveMagic = (ulong)iv; break; case UQ_F_RESERVE_SCOPE: st.reserveScope = val; break; case UQ_F_RESERVE_DIRECTION: if(!UQParseLong(val, iv)) { err = "field 'reserveDirection' is not an integer"; return(false); } st.reserveDirection = (int)iv; break; case UQ_F_RESERVE_VOLUME: if(!UQParseDoubleStrict(val, dv)) { err = "field 'reserveVolume' is not numeric"; return(false); } st.reserveVolume = dv; break; case UQ_F_RESERVE_ENTRY: if(!UQParseDoubleStrict(val, dv)) { err = "field 'reserveEntry' is not numeric"; return(false); } st.reserveEntry = dv; break; case UQ_F_RESERVE_STOP: if(!UQParseDoubleStrict(val, dv)) { err = "field 'reserveStop' is not numeric"; return(false); } st.reserveStop = dv; break; case UQ_F_RESERVE_DEAL: if(!UQParseLong(val, iv) || iv < 0) { err = "field 'reserveDeal' is not an integer"; return(false); } st.reserveDeal = (ulong)iv; break; case UQ_F_RESERVE_POSITION: if(!UQParseLong(val, iv) || iv < 0) { err = "field 'reservePosition' is not an integer"; return(false); } st.reservePosition = (ulong)iv; break; case UQ_F_STATE_REVISION: if(!UQParseLong(val, iv) || iv < 0) { err = "field 'stateRevision' is not an integer"; return(false); } st.stateRevision = (long)iv; break; case UQ_F_RECOVERY_HOLD: if(!UQParseBoolStrict(val, bv)) { err = "field 'recoveryHold' is not a 0/1 flag"; return(false); } st.recoveryHold = bv; break; case UQ_F_RECOVERY_TIME: if(!UQParseLong(val, iv) || iv < 0) { err = "field 'recoveryHoldTime' is not an integer"; return(false); } st.recoveryHoldTime = (datetime)iv; break; case UQ_F_MONITOR_TIME: if(!UQParseLong(val, iv) || iv < 0) { err = "field 'monitorTime' is not an integer"; return(false); } st.monitorTime = (datetime)iv; break; case UQ_F_MONITOR_GAP: if(!UQParseLong(val, iv) || iv < 0 || iv > (long)UQ_MONITOR_GAP_MAX_SECONDS) { err = "field 'monitorGapSeconds' is not a bounded integer"; return(false); } st.monitorGapSeconds = (int)iv; break; case UQ_F_GAP_HOLD: if(!UQParseBoolStrict(val, bv)) { err = "field 'gapHold' is not a 0/1 flag"; return(false); } st.gapHold = bv; break; case UQ_F_GAP_HOLD_TIME: if(!UQParseLong(val, iv) || iv < 0) { err = "field 'gapHoldTime' is not an integer"; return(false); } st.gapHoldTime = (datetime)iv; break; case UQ_F_IDENTITY_V2: //--- the complete 64 character fingerprint is required, so a //--- truncated or empty value can never pass as an identity if(!UQIdentityFingerprintValid(val)) { err = "field 'identityV2' is not a 64 character fingerprint"; return(false); } st.identityV2 = val; break; default: err = "unexpected field index"; return(false); } } //--- a schema published before the risk-cycle fields existed carries no //--- cycle block: only its own field set is mandatory const int required = UQFieldCountForFormat(st.format); for(int f = 0; f < required; f++) if(seen[f] == 0) { err = StringFormat("required field '%s' is missing", UQFieldKey(f)); return(false); } return(true); } //+------------------------------------------------------------------+ //| Schema and semantics validation of a parsed state | //+------------------------------------------------------------------+ bool UQStateValidate(UQIdentity &id, UQRiskState &st, string &err) { err = ""; if(st.format < UQ_STATE_LEGACY_FORMAT || st.format > UQ_STATE_FORMAT) { err = StringFormat("unsupported state format %d", st.format); return(false); } //--- the legacy identity field keeps its exact previous meaning and is //--- validated for every schema, including schema 8 if(st.identity != id.hash) { err = "state belongs to a different identity scope"; return(false); } if(st.format < UQ_STATE_FORMAT) { //--- a state written before schema 8 has no V2 identity, and its checksum //--- does not cover one: a value appearing there was never proven if(StringLen(st.identityV2) > 0) { err = "legacy state unexpectedly carries a V2 identity"; return(false); } } else { //--- schema 8 must carry the complete V2 fingerprint of exactly this //--- logical identity. An empty or missing value is never accepted. if(!UQIdentityFingerprintValid(st.identityV2)) { err = "V2 identity fingerprint is missing or malformed"; return(false); } if(!UQIdentityFingerprintValid(id.fingerprintV2)) { err = "the live V2 identity fingerprint is unavailable"; return(false); } if(st.identityV2 != id.fingerprintV2) { err = "state belongs to a different V2 identity scope"; return(false); } } if(st.createdAt <= 0) { err = "state creation timestamp is missing"; return(false); } if(st.updatedAt < st.createdAt) { err = "state update timestamp precedes the creation timestamp"; return(false); } const datetime now = UQServerNow(); if(now > 0 && st.updatedAt > now + (datetime)UQ_STATE_MAX_CLOCK_SKEW) { err = "state update timestamp lies implausibly far in the future"; return(false); } //--- fixed baseline if(!st.baselineValid) { err = "baseline validity flag is not set"; return(false); } if(!(st.baselineBalance > 0.0)) { err = "baseline B0 is not positive"; return(false); } if(st.baselineTime <= 0) { err = "baseline timestamp is missing"; return(false); } //--- daily baseline if(!st.dailyValid) { err = "daily validity flag is not set"; return(false); } if(!(st.dailyStartEquity > 0.0)) { err = "daily baseline D0 is not positive"; return(false); } if(st.dailyDate <= 0) { err = "broker-server trading date is missing"; return(false); } if(UQDayStart(st.dailyDate) != st.dailyDate) { err = "broker-server trading date is not day-aligned"; return(false); } if(st.dailyStartTime <= 0) { err = "daily baseline timestamp is missing"; return(false); } //--- lock consistency if(st.dailyLock && st.dailyLockTime <= 0) { err = "daily lock is active without a consistent lock event"; return(false); } if(!st.dailyLock && st.dailyLockTime != 0) { err = "daily lock time is set while the daily lock is inactive"; return(false); } if(st.staticLock && st.staticLockTime <= 0) { err = "static-drawdown lock is active without a consistent lock event"; return(false); } if(!st.staticLock && st.staticLockTime != 0) { err = "static-drawdown lock time is set while the lock is inactive"; return(false); } if(st.targetLock && st.targetLockTime <= 0) { err = "equity-target lock is active without a consistent lock event"; return(false); } if(!st.targetLock && st.targetLockTime != 0) { err = "equity-target lock time is set while the lock is inactive"; return(false); } if(st.staticLock && st.targetLock) { //--- the two permanent locks are independent: an equity target can be //--- reached before a drawdown (or the reverse), and neither may be lost //--- just because the other is also armed. The entry gate reports the //--- static-drawdown lock first, and liquidation is required either way. if(!(st.staticLockTime > 0 && st.targetLockTime > 0)) { err = "both permanent locks are active without consistent lock events"; return(false); } } //--- reset identifier must stay a single serialisable token if(StringLen(st.resetSignature) > UQ_STATE_MAX_RESET_SIG) { err = "reset-operation identifier is too long"; return(false); } if(StringFind(st.resetSignature, "=") >= 0 || StringFind(st.resetSignature, "\r") >= 0 || StringFind(st.resetSignature, "\n") >= 0) { err = "reset-operation identifier contains a forbidden character"; return(false); } //--- risk-cycle identity, only present from the cycle schema onwards if(st.format >= UQ_STATE_CYCLE_FORMAT) { if(st.cycleSequence < 1) { err = "risk-cycle sequence is not positive"; return(false); } if(StringLen(st.cycleId) <= 0 || StringLen(st.cycleId) > UQ_RESET_CYCLE_ID_MAX_LEN) { err = "risk-cycle identifier is missing or too long"; return(false); } if(StringFind(st.cycleId, "=") >= 0 || StringFind(st.cycleId, "|") >= 0 || StringFind(st.cycleId, "\r") >= 0 || StringFind(st.cycleId, "\n") >= 0) { err = "risk-cycle identifier contains a forbidden character"; return(false); } //--- consumed reset nonces if(StringLen(st.resetNonceHistory) > UQ_RESET_HISTORY_MAX_CHARS) { err = "consumed reset-nonce history is too long"; return(false); } if(StringLen(st.resetNonceHistory) > 0) { if(UQResetNonceHistoryCount(st.resetNonceHistory) > UQ_RESET_HISTORY_MAX) { err = "consumed reset-nonce history holds too many entries"; return(false); } string nonces[]; const int nn = StringSplit(st.resetNonceHistory, (ushort)'|', nonces); for(int k = 0; k < nn; k++) { if(StringLen(nonces[k]) == 0) continue; if(!UQResetNonceValid(nonces[k])) { err = "consumed reset-nonce history holds an invalid nonce"; return(false); } } } //--- reference to the cycle that the last reset closed. //--- The recorded kind is a bit field: 1 = static-drawdown, 2 = //--- equity-target, 3 = both. A cycle can legitimately end with both //--- permanent locks armed, so value 3 is accepted and preserved instead //--- of rejecting the whole state and losing the surviving lock. if(!UQPriorLockKindValid(st.priorLockKind)) { err = "recorded prior lock kind is not recognized"; return(false); } if(st.priorLockKind == UQ_PRIOR_LOCK_NONE) { if(st.priorLockTime != 0 || StringLen(st.priorCycleId) > 0 || st.priorBaselineBalance != 0.0) { err = "prior-cycle reference is recorded without a prior lock event"; return(false); } } else { if(st.priorLockTime <= 0) { err = "prior lock event timestamp is missing"; return(false); } if(StringLen(st.priorCycleId) <= 0 || StringLen(st.priorCycleId) > UQ_RESET_CYCLE_ID_MAX_LEN) { err = "prior cycle identifier is missing or too long"; return(false); } if(!(st.priorBaselineBalance > 0.0)) { err = "prior cycle baseline is not positive"; return(false); } //--- a permanent lock of a closed cycle can only exist because an //--- owner reset closed that cycle, and every applied reset consumes //--- exactly one nonce: the recorded history must prove it if(UQResetNonceHistoryCount(st.resetNonceHistory) < 1) { err = "recorded prior lock kind has no consumed reset nonce"; return(false); } if(st.priorCycleId == st.cycleId || st.priorLockTime > st.createdAt) { err = "prior-cycle reference is inconsistent with the current cycle"; return(false); } } if(st.cycleSequence == 1 && st.priorLockKind != UQ_PRIOR_LOCK_NONE) { err = "the first risk cycle cannot reference a previous cycle"; return(false); } } //--- entry-risk reservation, only present from the reservation schema onwards if(st.format >= UQ_STATE_RESERVE_FORMAT) { if(st.reserveState != UQ_RESERVE_NONE && st.reserveState != UQ_RESERVE_SENT && st.reserveState != UQ_RESERVE_UNRESOLVED) { err = "entry-risk reservation state is not recognized"; return(false); } if(st.reserveState == UQ_RESERVE_NONE) { if(st.reserveMoney != 0.0 || st.reserveTicket != 0 || StringLen(st.reserveSymbol) > 0 || st.reserveTime != 0) { err = "entry-risk reservation is recorded while its state is empty"; return(false); } } else { if(!(st.reserveMoney > 0.0)) { err = "reserved entry risk is not positive"; return(false); } if(st.reserveTime <= 0) { err = "reserved entry risk has no timestamp"; return(false); } if(StringLen(st.reserveSymbol) <= 0 || StringLen(st.reserveSymbol) > UQ_RESERVE_SYMBOL_MAX_LEN) { err = "reserved entry symbol is missing or too long"; return(false); } if(StringFind(st.reserveSymbol, "=") >= 0 || StringFind(st.reserveSymbol, "|") >= 0 || StringFind(st.reserveSymbol, "\r") >= 0 || StringFind(st.reserveSymbol, "\n") >= 0) { err = "reserved entry symbol contains a forbidden character"; return(false); } //--- a reservation is persisted before the order is submitted, so it //--- legitimately carries no ticket until the server has answered } } //--- reservation identity, only present from the identity schema onwards. //--- A reserved request that cannot be correlated with a broker record //--- must be recorded as UNRESOLVED; it may never look like a normal //--- submitted request that the reconciler could quietly release. if(st.format >= UQ_STATE_IDENTITY_FORMAT) { if(st.reserveState == UQ_RESERVE_NONE) { if(StringLen(st.reserveId) > 0 || st.reserveMagic != 0 || StringLen(st.reserveScope) > 0 || st.reserveDirection != 0 || st.reserveVolume != 0.0 || st.reserveEntry != 0.0 || st.reserveStop != 0.0 || st.reserveDeal != 0 || st.reservePosition != 0) { err = "reservation identity is recorded while its state is empty"; return(false); } } else { if(!UQReserveTextSafe(st.reserveId, UQ_RESERVE_ID_MAX_LEN)) { err = "reservation identifier is missing or malformed"; return(false); } if(StringLen(st.reserveScope) > UQ_RESERVE_SCOPE_MAX_LEN || (StringLen(st.reserveScope) > 0 && !UQReserveTextSafe(st.reserveScope, UQ_RESERVE_SCOPE_MAX_LEN))) { err = "reservation scope is malformed"; return(false); } if(st.reserveDirection != -1 && st.reserveDirection != 0 && st.reserveDirection != 1) { err = "reservation direction is not buy, sell or unknown"; return(false); } if(!MathIsValidNumber(st.reserveVolume) || st.reserveVolume < 0.0 || !MathIsValidNumber(st.reserveEntry) || st.reserveEntry < 0.0 || !MathIsValidNumber(st.reserveStop) || st.reserveStop < 0.0) { err = "reservation price or volume is not a finite non-negative number"; return(false); } //--- a request that is still expected to produce exposure must carry //--- the identity of that exposure, otherwise it is UNRESOLVED if(st.reserveState == UQ_RESERVE_SENT) { if(st.reserveDirection == 0 || !(st.reserveVolume > 0.0) || !(st.reserveEntry > 0.0) || !(st.reserveStop > 0.0)) { err = "a submitted reservation carries no correlatable identity"; return(false); } if(st.reserveDirection > 0 && !(st.reserveStop < st.reserveEntry)) { err = "a buy reservation carries a stop that is not below its entry"; return(false); } if(st.reserveDirection < 0 && !(st.reserveStop > st.reserveEntry)) { err = "a sell reservation carries a stop that is not above its entry"; return(false); } } } } //--- persistence generation, only present from the generation schema //--- onwards. A state without a positive generation can neither prove that //--- it is the newest one nor be ordered against its backup, so it is //--- refused instead of being trusted. if(st.format >= UQ_STATE_GENERATION_FORMAT) { if(st.stateRevision < 1) { err = "state generation revision is not positive"; return(false); } if(st.recoveryHold && st.recoveryHoldTime <= 0) { err = "recovery hold is active without a consistent hold event"; return(false); } if(!st.recoveryHold && st.recoveryHoldTime != 0) { err = "recovery hold time is set while no recovery hold is active"; return(false); } } //--- monitoring continuity, only present from the monitoring schema //--- onwards. A heartbeat in the future, a negative gap or a hold without //--- an event would make the daily-loss history unverifiable, so the state //--- is refused instead of being trusted. if(st.format >= UQ_STATE_MONITOR_FORMAT) { if(st.monitorTime > 0 && st.monitorTime > UQServerNow() + (datetime)UQ_STATE_MAX_CLOCK_SKEW) { err = "monitoring heartbeat lies implausibly far in the future"; return(false); } if(st.monitorGapSeconds < 0 || st.monitorGapSeconds > UQ_MONITOR_GAP_MAX_SECONDS) { err = "recorded monitoring gap is out of range"; return(false); } if(st.gapHold && st.gapHoldTime <= 0) { err = "monitoring-gap hold is active without a consistent hold event"; return(false); } if(!st.gapHold && st.gapHoldTime != 0) { err = "monitoring-gap hold time is set while no gap hold is active"; return(false); } } return(true); } //+------------------------------------------------------------------+ //| Persistence locators | //| | //| The V2 locators are derived from the full 256 bit fingerprint and | //| are the only locators a new state is ever written to. The legacy | //| locators keep the exact names every previously published build | //| used, because an existing installation still owns its state file | //| under that name and must be found (and validated) for migration. | //| | //| The two families are separate functions on purpose: no caller can | //| accidentally derive a V2 name from the lossy legacy key, and the | //| legacy names are never recomputed from the new fingerprint. | //+------------------------------------------------------------------+ string UQStateFileName(UQIdentity &id) { if(!UQIdentityFingerprintValid(id.fingerprintV2)) return(""); return(UQ_STATE_V2_FILE_PREFIX + id.fingerprintV2 + ".cfg"); } string UQStateBackupFileName(UQIdentity &id) { if(!UQIdentityFingerprintValid(id.fingerprintV2)) return(""); return(UQ_STATE_V2_FILE_PREFIX + id.fingerprintV2 + UQ_STATE_BACKUP_EXT); } //+------------------------------------------------------------------+ //| Fixed width hexadecimal rendering of the low bits of a value | //+------------------------------------------------------------------+ string UQStateFixedHex(ulong value, const int digits) { const string hex = "0123456789abcdef"; string out = ""; for(int i = 0; i < digits; i++) { out = StringSubstr(hex, (int)(value & 0x0F), 1) + out; value >>= 4; } return(out); } //+------------------------------------------------------------------+ //| Bounded tag that is unique to one save operation | //| | //| 12 hexadecimal characters of a terminal/process seed keep two | //| concurrent terminals apart, and an 8 hexadecimal operation | //| counter is strictly monotonic inside one process, so two save | //| attempts of the same scope can never derive the same temporary | //| name. Both parts are fixed width, so the name stays bounded | //| however many saves run and no temporary name can grow unbounded. | //+------------------------------------------------------------------+ string UQStateOperationTag(void) { static string instanceTag = ""; static ulong operationSeq = 0; //--- captured once per process: a save may outlive the chart or the //--- terminal session, and every later save of this process must still be //--- distinguishable from another terminal sharing the common folder if(StringLen(instanceTag) != UQ_STATE_TEMP_INSTANCE_CHARS) { const string seed = IntegerToString((long)ChartID()) + "|" + TerminalInfoString(TERMINAL_DATA_PATH) + "|" + IntegerToString((long)GetTickCount64()) + "|" + IntegerToString((long)GetMicrosecondCount()); instanceTag = StringSubstr(UQHashString(seed), 0, UQ_STATE_TEMP_INSTANCE_CHARS); } operationSeq++; return(instanceTag + UQStateFixedHex(operationSeq, UQ_STATE_TEMP_OP_CHARS)); } //+------------------------------------------------------------------+ //| Locator of the temporary file exactly one save operation owns | //| | //| Derived from the same V2 fingerprint as the primary and the | //| backup, plus the operation tag. The ".tmp_" separator can never | //| match the ".cfg" primary or the ".bak" backup, and the tag makes | //| the name private to a single save attempt, so one operation can | //| never observe, move or remove another operation's temporary | //| state. | //+------------------------------------------------------------------+ string UQStateTempFileName(UQIdentity &id, const string operationTag) { if(!UQIdentityFingerprintValid(id.fingerprintV2)) return(""); if(StringLen(operationTag) != UQ_STATE_TEMP_TAG_CHARS) return(""); return(UQ_STATE_V2_FILE_PREFIX + id.fingerprintV2 + UQ_STATE_TEMP_MARK + operationTag); } //+------------------------------------------------------------------+ //| Legacy locators, byte for byte identical to every previously | //| published build. They are read for migration and are otherwise | //| left untouched: a legacy file is never renamed, deleted or | //| overwritten by this build. | //+------------------------------------------------------------------+ string UQStateLegacyFileName(UQIdentity &id) { return("UQ_EA_STATE_" + id.key + ".cfg"); } string UQStateLegacyBackupFileName(UQIdentity &id) { return("UQ_EA_STATE_" + id.key + UQ_STATE_BACKUP_EXT); } //+------------------------------------------------------------------+ //| Build the persistence identity from explicit components | //| | //| Identical to UQBuildIdentity() for the live account; the split | //| exists so the identity of a synthetic tuple can be built and | //| verified without touching the account currently connected. | //+------------------------------------------------------------------+ bool UQBuildIdentityFromParts(const string server, const long login, const string portfolio, UQIdentity &id, string &err) { err = ""; id.server = server; id.login = login; id.portfolio = portfolio; id.key = ""; id.hash = ""; id.fingerprintV2 = ""; id.legacyAmbiguous = false; if(StringLen(id.server) <= 0) { err = "account server name is unavailable"; return(false); } if(id.login <= 0) { err = "account login is unavailable"; return(false); } if(StringLen(id.portfolio) <= 0) { err = "portfolio identifier is empty"; return(false); } //--- legacy identity: preserved byte for byte, still used for the legacy //--- locators, the legacy validation of persisted state and diagnostics id.key = UQSanitizeKey(id.server) + "_" + IntegerToString(id.login) + "_" + UQSanitizeKey(id.portfolio); id.hash = UQHashString(id.server + "|" + IntegerToString(id.login) + "|" + id.portfolio); //--- the legacy serialisation is 'server + "|" + login + "|" + portfolio'. //--- The login is canonical decimal digits, so the only way two different //--- tuples can produce the same legacy preimage - and therefore the same //--- legacy key and the same legacy hash - is a separator inside the server //--- or the portfolio. Such an identity can never prove that a legacy state //--- found under its legacy locator is its own, so the legacy evidence of an //--- ambiguous identity is refused instead of being migrated. id.legacyAmbiguous = (StringFind(id.server, "|", 0) >= 0 || StringFind(id.portfolio, "|", 0) >= 0); //--- V2 identity: built from the ORIGINAL components, so two identities whose //--- sanitised strings happen to match never share a locator. A failure here //--- fails closed: there is no fall back to the lossy legacy hash. string fpErr = ""; if(!UQComputeIdentityFingerprintV2(id.server, id.login, id.portfolio, id.fingerprintV2, fpErr)) { id.fingerprintV2 = ""; err = "identity fingerprint V2 could not be established: " + fpErr; return(false); } if(!UQIdentityFingerprintValid(id.fingerprintV2)) { id.fingerprintV2 = ""; err = "identity fingerprint V2 is malformed"; return(false); } return(true); } //+------------------------------------------------------------------+ //| Build the persistence identity from the live account | //+------------------------------------------------------------------+ bool UQBuildIdentity(const string portfolio, UQIdentity &id, string &err) { return(UQBuildIdentityFromParts(AccountInfoString(ACCOUNT_SERVER), (long)AccountInfoInteger(ACCOUNT_LOGIN), portfolio, id, err)); } //+------------------------------------------------------------------+ //| Risk-cycle identifier: sequence number plus a nonce/creation hash | //+------------------------------------------------------------------+ string UQBuildCycleId(const int sequence, const string seed) { return(StringFormat("C%04d-%s", sequence, StringSubstr(UQHashString(seed + "|" + IntegerToString(sequence)), 0, 8))); } //+------------------------------------------------------------------+ //| Upgrade a state written by an earlier published build to the | //| current risk-cycle schema. | //| | //| The upgrade is deterministic and never invents a new risk cycle: | //| the cycle the file already describes becomes cycle 1, no reset | //| nonce has been consumed yet and no prior cycle is referenced. | //| A pre-cycle reset signature cannot prove which nonce was used, so | //| it is dropped: a subsequent reset always needs a fresh nonce. | //+------------------------------------------------------------------+ void UQStateMigrateToMonitor(UQRiskState &st) { if(st.format >= UQ_STATE_MONITOR_FORMAT) return; //--- 6 -> 7: the monitoring fields are the only addition. The generation, //--- every lock, the cycle identity and an outstanding reservation are //--- carried over unchanged; no heartbeat is invented, so the first //--- monitored moment of the migrated state establishes it. if(st.format >= UQ_STATE_GENERATION_FORMAT) { st.monitorTime = 0; st.monitorGapSeconds = 0; st.gapHold = false; st.gapHoldTime = 0; st.format = UQ_STATE_MONITOR_FORMAT; return; } //--- the upgraded state becomes generation 1 of the current schema. An //--- upgrade is not an event that may clear a lock, so no recovery hold is //--- invented here, and the migration never discards a lock, a baseline, //--- a consumed nonce or an outstanding reservation. st.stateRevision = 1; st.recoveryHold = false; st.recoveryHoldTime = 0; if(st.format < UQ_STATE_CYCLE_FORMAT) { st.cycleSequence = 1; st.cycleId = UQBuildCycleId(1, st.identity + "|" + IntegerToString((long)st.createdAt)); st.resetNonceHistory = ""; st.priorBaselineBalance = 0.0; st.priorCycleId = ""; st.priorLockKind = 0; st.priorLockTime = 0; st.resetSignature = ""; } //--- an upgraded state must never lose an outstanding entry risk. A //--- reservation written by the previous schema keeps its money, symbol and //--- timestamp, but it carries no order, deal or position identity, so it //--- can no longer be correlated: it becomes UNRESOLVED instead of being //--- migrated to NONE, which would silently free the reserved risk if(st.format >= UQ_STATE_RESERVE_FORMAT && st.reserveState != UQ_RESERVE_NONE) { if(StringLen(st.reserveId) <= 0) st.reserveId = UQBuildReserveId("legacy", st.reserveSymbol, st.reserveTime, 0); st.reserveMagic = 0; st.reserveScope = ""; st.reserveDirection = 0; st.reserveVolume = 0.0; st.reserveEntry = 0.0; st.reserveStop = 0.0; st.reserveDeal = 0; st.reservePosition = 0; st.reserveState = UQ_RESERVE_UNRESOLVED; st.format = UQ_STATE_MONITOR_FORMAT; return; } //--- a schema that predates the reservation field set cannot carry any //--- entry risk at all st.reserveState = UQ_RESERVE_NONE; st.reserveMoney = 0.0; st.reserveTicket = 0; st.reserveSymbol = ""; st.reserveTime = 0; st.reserveId = ""; st.reserveMagic = 0; st.reserveScope = ""; st.reserveDirection = 0; st.reserveVolume = 0.0; st.reserveEntry = 0.0; st.reserveStop = 0.0; st.reserveDeal = 0; st.reservePosition = 0; st.format = UQ_STATE_MONITOR_FORMAT; } //+------------------------------------------------------------------+ //| Upgrade a state to the current schema. | //| | //| Every earlier schema is upgraded in order: the older builds first | //| reach schema 7 (monitoring continuity), then schema 8 adds the V2 | //| identity. A migration never invents a baseline, never clears a | //| lock, never rebuilds a reservation as zero risk and never creates | //| a new risk cycle. | //+------------------------------------------------------------------+ void UQStateMigrateToCurrent(UQRiskState &st) { if(st.format >= UQ_STATE_FORMAT) return; UQStateMigrateToMonitor(st); if(st.format < UQ_STATE_FORMAT) { //--- 7 -> 8. The V2 field is staged empty here: the only identity that //--- may ever enter it is the live fingerprint of the identity that just //--- passed the legacy identity validation, and UQStateSave() stamps //--- exactly that value, so no invented identity can reach a file. st.identityV2 = ""; st.format = UQ_STATE_FORMAT; } } //+------------------------------------------------------------------+ //| Load and fully validate one state file | //| UQ_LOAD_ERROR -> file exists but must not be trusted | //| UQ_LOAD_ABSENT -> no file at this location | //+------------------------------------------------------------------+ ENUM_UQ_STATE_LOAD UQStateLoadFile(const string fname, UQIdentity &id, UQRiskState &st, string &err) { err = ""; UQStateReset(st); if(!FileIsExist(fname, FILE_COMMON)) return(UQ_LOAD_ABSENT); const int h = FileOpen(fname, FILE_READ | FILE_BIN | FILE_COMMON); if(h == INVALID_HANDLE) { err = StringFormat("state file cannot be opened (err=%d)", GetLastError()); return(UQ_LOAD_ERROR); } const int size = (int)FileSize(h); if(size <= 0) { FileClose(h); err = "state file is empty"; return(UQ_LOAD_ERROR); } uchar buf[]; ArrayResize(buf, size); const int read = (int)FileReadArray(h, buf, 0, size); FileClose(h); if(read <= 0) { err = "state file could not be read"; return(UQ_LOAD_ERROR); } string body = CharArrayToString(buf, 0, read, CP_ACP); //--- strip trailing padding written by earlier builds int blen = StringLen(body); while(blen > 0) { const ushort c = StringGetCharacter(body, blen - 1); if(c == 0 || c == '\r' || c == '\n' || c == ' ' || c == '\t') { blen--; continue; } break; } body = StringSubstr(body, 0, blen); if(blen <= 0) { err = "state file contains no data"; return(UQ_LOAD_ERROR); } bool hasChecksum = false; string checksumStored = ""; if(!UQStateParseBody(body, st, hasChecksum, checksumStored, err)) return(UQ_LOAD_ERROR); if(st.format < UQ_STATE_LEGACY_FORMAT || st.format > UQ_STATE_FORMAT) { err = StringFormat("unsupported state format %d", st.format); return(UQ_LOAD_ERROR); } if(st.format >= UQ_STATE_PREV_FORMAT && !hasChecksum) { err = "integrity checksum is missing"; return(UQ_LOAD_ERROR); } if(st.format == UQ_STATE_LEGACY_FORMAT && hasChecksum) { err = "legacy state unexpectedly carries an integrity checksum"; return(UQ_LOAD_ERROR); } if(hasChecksum) { const string calc = UQHashString(UQStateCanonicalBody(st)); if(calc != checksumStored) { err = "integrity checksum mismatch"; return(UQ_LOAD_ERROR); } } if(!UQStateValidate(id, st, err)) return(UQ_LOAD_ERROR); //--- a verified state written by an earlier build is upgraded in memory; //--- the caller persists it in the current schema const int loadedFormat = st.format; if(loadedFormat < UQ_STATE_FORMAT) UQStateMigrateToCurrent(st); return((loadedFormat == UQ_STATE_FORMAT) ? UQ_LOAD_VALID : UQ_LOAD_LEGACY); } //+------------------------------------------------------------------+ //| Copy one common-folder file byte for byte | //+------------------------------------------------------------------+ bool UQStateCopyFile(const string src, const string dst) { const int hs = FileOpen(src, FILE_READ | FILE_BIN | FILE_COMMON); if(hs == INVALID_HANDLE) { PrintFormat("[UQ] ERROR: cannot open '%s' for copy (err=%d)", src, GetLastError()); return(false); } const int size = (int)FileSize(hs); if(size <= 0) { FileClose(hs); return(false); } uchar buf[]; ArrayResize(buf, size); const int read = (int)FileReadArray(hs, buf, 0, size); FileClose(hs); if(read <= 0) return(false); const int hd = FileOpen(dst, FILE_WRITE | FILE_BIN | FILE_COMMON); if(hd == INVALID_HANDLE) { PrintFormat("[UQ] ERROR: cannot create '%s' (err=%d)", dst, GetLastError()); return(false); } FileWriteArray(hd, buf, 0, read); FileClose(hd); return(true); } //+------------------------------------------------------------------+ //| Display text of a state load result | //+------------------------------------------------------------------+ string UQStateLoadText(const ENUM_UQ_STATE_LOAD r) { switch(r) { case UQ_LOAD_VALID: return("verified"); case UQ_LOAD_LEGACY: return("verified legacy schema"); case UQ_LOAD_ABSENT: return("absent"); case UQ_LOAD_ERROR: return("unusable"); } return("unknown"); } //+------------------------------------------------------------------+ //| Load one state file together with the generation it carries | //| | //| An absent or unusable file reports generation 0, so no caller can | //| mistake "no proven generation" for a generation to build upon. | //+------------------------------------------------------------------+ ENUM_UQ_STATE_LOAD UQStateLoadGenerational(const string fname, UQIdentity &id, UQRiskState &st, long &rev, string &err) { rev = 0; const ENUM_UQ_STATE_LOAD r = UQStateLoadFile(fname, id, st, err); if(r == UQ_LOAD_VALID || r == UQ_LOAD_LEGACY) rev = (st.stateRevision > 0 ? st.stateRevision : 0); return(r); } //+------------------------------------------------------------------+ //| Verify that one file holds exactly the generation that was | //| written: usable schema, expected revision and expected content. | //+------------------------------------------------------------------+ bool UQStateVerifyFile(const string fname, UQIdentity &id, const long expectRevision, const string expectCanonical, const string role) { UQRiskState check; string err = ""; const ENUM_UQ_STATE_LOAD r = UQStateLoadFile(fname, id, check, err); if(r != UQ_LOAD_VALID) { PrintFormat("[UQ] ERROR: the %s state file '%s' failed verification: %s", role, fname, (StringLen(err) > 0 ? err : UQStateLoadText(r))); return(false); } if(check.stateRevision != expectRevision) { PrintFormat("[UQ] ERROR: the %s state file '%s' carries generation %I64d instead of %I64d.", role, fname, check.stateRevision, expectRevision); return(false); } if(UQStateCanonicalBody(check) != expectCanonical) { PrintFormat("[UQ] ERROR: the %s state file '%s' does not describe the generation that was written.", role, fname); return(false); } return(true); } //+------------------------------------------------------------------+ //| Remove only the temporary file one save operation owns | //| | //| A cleanup is a persistence mutation, so it is fenced exactly | //| like a write. Only the uniquely named file of the operation | //| itself may be removed, and only while ownership of the scope is | //| proven at the moment of the removal. | //| | //| When ownership is lost the file is left in place as an orphan: | //| deleting it would be a mutation performed without ownership, and | //| a successor may already own a temporary file of its own. Because | //| the name is private to the operation that created it, an orphan | //| can never be confused with another operation file, and a later | //| legitimate owner may remove it only after proving that no live | //| save operation uses it. | //| | //| No shared, foreign or unidentifiable temporary file is removed | //| here, and no scope-wide sweep of temporary files is performed. | //+------------------------------------------------------------------+ void UQStateDiscardTemp(const string tempName, CUQOwnershipVerifier *owner, const string reason) { if(StringLen(tempName) <= 0) return; if(owner == NULL) { PrintFormat("[UQ] WARNING: the temporary state '%s' is left in place (%s): no instance-ownership verifier is bound.", tempName, reason); return; } string detail = ""; if(!owner.OwnershipVerified(detail)) { PrintFormat("[UQ] WARNING: the operation-owned temporary state '%s' is left in place as an orphan (%s): ownership of the scope is not proven (%s).", tempName, reason, detail); return; } if(!FileIsExist(tempName, FILE_COMMON)) return; if(!FileDelete(tempName, FILE_COMMON)) PrintFormat("[UQ] WARNING: the operation-owned temporary state '%s' could not be removed (%s, err=%d).", tempName, reason, GetLastError()); } //+------------------------------------------------------------------+ //| Timestamp policy for a persisted risk state | //| | //| ONE clock. Both timestamps written into a persisted risk state are| //| broker-server time from UQServerNow(). TimeCurrent() is the time | //| of the last quote and is only the documented fallback INSIDE | //| UQServerNow(); it is never used here directly. On a quiet market | //| no new quote arrives, so TimeCurrent() trails the broker clock and| //| a stamp taken from it produced updatedAt < createdAt: the state | //| then failed UQStateValidate() and a legitimate owner reset was | //| refused for a reason that had nothing to do with the reset. | //| | //| The resolved stamp preserves chronology and is never rewound: | //| * it is the broker-server time; | //| * it never precedes the creation timestamp of the same state; | //| * it never precedes the timestamp already persisted for the same| //| scope, so a server clock that steps backwards holds the | //| previous value instead of moving persisted time backwards. | //| | //| Holding and clamping both stay inside the plausibility window that| //| UQStateValidate() enforces (UQ_STATE_MAX_CLOCK_SKEW); a timestamp | //| outside that window is refused, because a state written with it | //| could never be loaded again. | //| | //| An unavailable clock is not a timestamp: if the broker-server time| //| is missing (UQServerNow() == 0) the stamp is refused and the caller| //| must fail closed instead of persisting a state whose time cannot be| //| trusted. Nothing here invents an offset, a second clock or a local| //| PC time. | //+------------------------------------------------------------------+ bool UQStateStampResolve(const datetime now, const datetime createdAt, const datetime persistedUpdatedAt, datetime &stamp, string &err) { err = ""; stamp = 0; if(now <= 0) { err = "broker-server time is unavailable"; return(false); } datetime candidate = now; //--- a saved state is never stamped older than its own creation if(createdAt > candidate) candidate = createdAt; //--- persisted time is held, never moved backwards if(persistedUpdatedAt > candidate) candidate = persistedUpdatedAt; //--- the value that is about to be persisted must stay inside the window //--- UQStateValidate() accepts, otherwise the file written here could not //--- be read back by this same build if(candidate > now + (datetime)UQ_STATE_MAX_CLOCK_SKEW) { err = StringFormat("the risk state timestamp %s lies implausibly far in the future (broker-server time %s)", TimeToString(candidate, TIME_DATE | TIME_SECONDS), TimeToString(now, TIME_DATE | TIME_SECONDS)); return(false); } stamp = candidate; return(true); } //+------------------------------------------------------------------+ //| Persist one state generation. | //| | //| The order is write-ahead, so no interruption can leave an entry- | //| permitted state behind an unverified file: | //| 1. the next generation is derived from what is proven on disk; | //| 2. the temporary file is written and read back; | //| 3. the same verified bytes are written to the backup and read | //| back, so the backup can never lag the latest update; | //| 4. the primary is replaced by the verified temporary file and | //| read back; | //| 5. primary and backup are reconciled on generation and content. | //| | //| A failure at any step returns false, which degrades the risk | //| controller to unhealthy and blocks every new entry. | //+------------------------------------------------------------------+ bool UQStateSave(UQIdentity &id, UQRiskState &st, CUQOwnershipVerifier *owner = NULL) { //--- a state may only be persisted together with the identity it belongs. //--- Without a valid V2 fingerprint no locator exists, so saving would write //--- a shared or empty name: the save fails closed instead. if(!UQIdentityFingerprintValid(id.fingerprintV2)) { PrintFormat("[UQ] ERROR: the V2 identity fingerprint is unavailable, so no risk state may be persisted for this scope."); return(false); } st.format = UQ_STATE_FORMAT; st.identity = id.hash; st.identityV2 = id.fingerprintV2; const string fname = UQStateFileName(id); const string bak = UQStateBackupFileName(id); //--- the temporary file belongs to THIS save operation alone: its name //--- carries a bounded operation tag, so no other instance and no other //--- save attempt can observe, move or remove it, and it can never equal //--- the primary (".cfg") or the backup (".bak") locator const string tmp = UQStateTempFileName(id, UQStateOperationTag()); if(StringLen(tmp) <= 0 || tmp == fname || tmp == bak) { PrintFormat("[UQ] ERROR: an operation-scoped temporary state locator could not be derived, so no state is written."); return(false); } //--- 1. the next generation is derived from the files proven on disk, so a //--- stale in-memory copy can never overwrite a newer verified state UQRiskState primaryOnDisk; UQRiskState backupOnDisk; string perr = ""; string berr = ""; long revPrimary = 0; long revBackup = 0; const ENUM_UQ_STATE_LOAD loadPrimary = UQStateLoadGenerational(fname, id, primaryOnDisk, revPrimary, perr); const ENUM_UQ_STATE_LOAD loadBackup = UQStateLoadGenerational(bak, id, backupOnDisk, revBackup, berr); if(loadPrimary == UQ_LOAD_ERROR) PrintFormat("[UQ] WARNING: the unusable primary state '%s' (%s) is replaced by a verified generation.", fname, perr); const long provenMax = (revPrimary > revBackup ? revPrimary : revBackup); if(provenMax > st.stateRevision) { PrintFormat("[UQ] ERROR: the persistent state holds generation %I64d while this instance holds generation %I64d. A newer verified state is never overwritten.", provenMax, st.stateRevision); return(false); } //--- the timestamp is resolved once, centrally, from the ONE broker-server //--- clock and from what is already proven on disk: a save can neither //--- precede the creation of its own cycle nor move persisted time //--- backwards, and an unavailable clock fails the save closed before any //--- file is staged (see UQStateStampResolve()). datetime persistedUpdatedAt = 0; if(loadPrimary == UQ_LOAD_VALID || loadPrimary == UQ_LOAD_LEGACY) persistedUpdatedAt = primaryOnDisk.updatedAt; if((loadBackup == UQ_LOAD_VALID || loadBackup == UQ_LOAD_LEGACY) && backupOnDisk.updatedAt > persistedUpdatedAt) persistedUpdatedAt = backupOnDisk.updatedAt; string stampErr = ""; if(!UQStateStampResolve(UQServerNow(), st.createdAt, persistedUpdatedAt, st.updatedAt, stampErr)) { PrintFormat("[UQ] ERROR: no risk state may be persisted: %s.", stampErr); return(false); } //--- a state created now is created at the moment it is first persisted if(st.createdAt <= 0) st.createdAt = st.updatedAt; st.stateRevision = ((st.stateRevision > provenMax) ? st.stateRevision : provenMax) + 1; //--- 2. serialise and stage const string canonical = UQStateCanonicalBody(st); const string body = canonical + UQ_STATE_CHECKSUM_KEY + "=" + UQHashString(canonical) + "\r\n"; const int len = StringLen(body); if(len <= 0) { PrintFormat("[UQ] ERROR: state serialisation produced no data"); return(false); } uchar buf[]; ArrayResize(buf, len); for(int i = 0; i < len; i++) buf[i] = (uchar)StringGetCharacter(body, i); //--- the temporary name is private to this operation, so no pre-existing //--- file has to be removed before the staging write if(!UQOwnershipSaveGate(owner, "staged risk state")) return(false); const int h = FileOpen(tmp, FILE_WRITE | FILE_BIN | FILE_COMMON); if(h == INVALID_HANDLE) { PrintFormat("[UQ] ERROR: cannot create state file '%s' (err=%d)", tmp, GetLastError()); return(false); } FileWriteArray(h, buf, 0, len); FileClose(h); //--- 3. the staged generation must verify before it is copied anywhere if(!UQStateVerifyFile(tmp, id, st.stateRevision, canonical, "staged")) { UQStateDiscardTemp(tmp, owner, "the staged state failed verification"); return(false); } //--- 4. the backup receives exactly the same verified generation. It is a //--- separate file mutation, so it is fenced separately: a loss proven //--- here leaves the backup exactly as it was. if(!UQOwnershipSaveGate(owner, "backup risk state")) { UQStateDiscardTemp(tmp, owner, "ownership was lost before the backup write"); return(false); } if(!UQStateCopyFile(tmp, bak)) { PrintFormat("[UQ] ERROR: the verified generation could not be written to the backup '%s'.", bak); UQStateDiscardTemp(tmp, owner, "the backup copy failed"); return(false); } if(!UQStateVerifyFile(bak, id, st.stateRevision, canonical, "backup")) { UQStateDiscardTemp(tmp, owner, "the backup state failed verification"); return(false); } //--- 5. commit the verified generation as the primary. The commit is the //--- authoritative mutation and is fenced on its own. if(!UQOwnershipSaveGate(owner, "primary risk state")) { UQStateDiscardTemp(tmp, owner, "ownership was lost before the primary commit"); return(false); } if(!FileMove(tmp, FILE_COMMON, fname, FILE_COMMON | FILE_REWRITE)) { PrintFormat("[UQ] ERROR: cannot commit state file '%s' (err=%d)", fname, GetLastError()); UQStateDiscardTemp(tmp, owner, "the primary commit failed"); return(false); } if(!UQStateVerifyFile(fname, id, st.stateRevision, canonical, "primary")) return(false); //--- 6. primary and backup must now describe the same generation UQRiskState checkPrimary; UQRiskState checkBackup; string cerr = ""; string xerr = ""; long revP = 0; long revB = 0; if(UQStateLoadGenerational(fname, id, checkPrimary, revP, cerr) != UQ_LOAD_VALID || UQStateLoadGenerational(bak, id, checkBackup, revB, xerr) != UQ_LOAD_VALID || revP != st.stateRevision || revB != st.stateRevision || UQStateCanonicalBody(checkPrimary) != UQStateCanonicalBody(checkBackup)) { PrintFormat("[UQ] ERROR: the persisted state could not be reconciled (primary generation %I64d, backup generation %I64d, expected %I64d).", revP, revB, st.stateRevision); return(false); } return(true); } //+------------------------------------------------------------------+ //| Redundant initialization marker | //| | //| A terminal global variable records that this identity scope has | //| already been initialized and the creation timestamp it was | //| initialized with. It is deliberately not stored inside the state | //| file, so deleting or truncating the file cannot fabricate a | //| fresh installation while the terminal data folder survives. | //+------------------------------------------------------------------+ //+------------------------------------------------------------------+ //| V2 initialization marker name | //| | //| The name is derived from the V2 fingerprint only. 58 hexadecimal | //| characters carry 232 of the 256 bits and keep the whole name at | //| exactly 63 characters, the terminal limit; the complete 256 bit | //| fingerprint is retained in the persisted state and in the file | //| locators. The "UQ2M_" namespace is disjoint from the legacy | //| "UQEA_STATE_MARKER_" namespace, so neither can be read as the | //| other. | //+------------------------------------------------------------------+ string UQStateMarkerName(UQIdentity &id) { const string shortFp = UQIdentityFingerprintShort(id.fingerprintV2); if(StringLen(shortFp) != UQ_IDV2_GV_CHARS) return(""); const string name = UQ_GV_STATE_MARKER_V2_PREFIX + shortFp; if(StringLen(name) > UQ_GV_NAME_MAX_CHARS) return(""); return(name); } //+------------------------------------------------------------------+ //| Legacy initialization marker name | //| | //| Byte for byte the name every previously published build used. It | //| is only ever read (to detect an already initialized legacy scope) | //| and is never written, renamed or removed by this build. | //+------------------------------------------------------------------+ string UQStateLegacyMarkerName(UQIdentity &id) { const string name = UQ_GV_STATE_MARKER_PREFIX + id.key; //--- every scope whose name already fits keeps exactly the name it always //--- had, so an existing marker stays readable and its scope is never //--- mistaken for a fresh installation if(StringLen(name) <= UQ_GV_NAME_MAX_CHARS) return(name); //--- an over-long scope is folded into a bounded deterministic name built //--- from the identity hash, which covers server|login|portfolio. A //--- sanitised key always carries '_' separators while a hash is //--- hexadecimal, so the two forms cannot collide with each other, and a //--- collision would still fail closed through the timestamp comparison. return(UQ_GV_STATE_MARKER_PREFIX + StringSubstr(id.hash, 0, UQ_GV_NAME_MAX_CHARS - StringLen(UQ_GV_STATE_MARKER_PREFIX))); } //+------------------------------------------------------------------+ //| Read one marker global variable | //+------------------------------------------------------------------+ ENUM_UQ_MARKER_STATE UQMarkerCheckName(const string name, const datetime createdAt, datetime &markerTime) { markerTime = 0; if(StringLen(name) <= 0) return(UQ_MARKER_ABSENT); if(!GlobalVariableCheck(name)) return(UQ_MARKER_ABSENT); //--- an existing marker with an unusable value is NOT an absent marker. //--- Absence is "no evidence"; an invalid value is corrupt evidence, and it //--- proves that this scope was written before by something that cannot be //--- trusted. Every caller refuses instead of rebuilding the scope. const double v = GlobalVariableGet(name); if(!MathIsValidNumber(v)) return(UQ_MARKER_INVALID); if(v <= 0.0) return(UQ_MARKER_INVALID); if((double)(long)v != v) return(UQ_MARKER_INVALID); //--- a marker records the creation time of a risk cycle: a value beyond the //--- clock cannot have been written by a real initialization if(v > (double)(TimeLocal() + UQ_STATE_MAX_CLOCK_SKEW)) return(UQ_MARKER_INVALID); markerTime = (datetime)(long)v; if(markerTime != createdAt) return(UQ_MARKER_MISMATCH); return(UQ_MARKER_MATCH); } ENUM_UQ_MARKER_STATE UQStateMarkerCheck(UQIdentity &id, const datetime createdAt, datetime &markerTime) { return(UQMarkerCheckName(UQStateMarkerName(id), createdAt, markerTime)); } //--- legacy marker evidence. Called with createdAt = 0, so any existing value //--- reports MISMATCH, which is the "this scope was initialized before" signal. ENUM_UQ_MARKER_STATE UQStateLegacyMarkerCheck(UQIdentity &id, const datetime createdAt, datetime &markerTime) { return(UQMarkerCheckName(UQStateLegacyMarkerName(id), createdAt, markerTime)); } //+------------------------------------------------------------------+ //| Write one marker global variable and prove the terminal kept it | //| | //| A write the terminal silently declined to persist must never be | //| read as a recorded initialization, so the exact name and the exact | //| stored value are verified by read-back. | //+------------------------------------------------------------------+ bool UQMarkerSetVerified(const string name, const datetime createdAt) { if(createdAt <= 0) return(false); if(StringLen(name) <= 0 || StringLen(name) > UQ_GV_NAME_MAX_CHARS) { PrintFormat("[UQ] ERROR: initialization marker name is unusable (%d chars).", StringLen(name)); return(false); } if(!GlobalVariableSet(name, (double)(long)createdAt)) { PrintFormat("[UQ] ERROR: initialization marker '%s' could not be written (err=%d)", name, GetLastError()); return(false); } if(!GlobalVariableCheck(name)) { PrintFormat("[UQ] ERROR: initialization marker '%s' could not be read back (err=%d)", name, GetLastError()); return(false); } if((datetime)(long)GlobalVariableGet(name) != createdAt) { PrintFormat("[UQ] ERROR: initialization marker '%s' does not hold the value that was written.", name); return(false); } return(true); } bool UQStateMarkerSet(UQIdentity &id, const datetime createdAt) { return(UQMarkerSetVerified(UQStateMarkerName(id), createdAt)); } //+------------------------------------------------------------------+ //| Name of the V2 duplicate-instance guard | //| | //| Derived from the V2 fingerprint only, so two logical identities | //| that share a sanitised legacy key can never share a guard. 58 of | //| the 64 hexadecimal characters carry 232 of the 256 bits and keep | //| the whole name at exactly 63 characters. An unusable fingerprint | //| yields an empty name, which the publisher refuses: an unproven | //| guard always fails closed. | //+------------------------------------------------------------------+ string UQInstanceGuardName(const string fingerprintV2) { const string shortFp = UQIdentityFingerprintShort(fingerprintV2); if(StringLen(shortFp) != UQ_IDV2_GV_CHARS) return(""); const string name = UQ_GV_INSTANCE_V2_PREFIX + shortFp; if(StringLen(name) > UQ_GV_NAME_MAX_CHARS) return(""); return(name); } //+------------------------------------------------------------------+ //| Name of the legacy duplicate-instance guard | //| | //| Byte for byte the name every previously published build used. It | //| is only ever read, to prove that no instance started by an older | //| build is still running, and it is never written, published as the | //| active guard or deleted: it is derived from the lossy identity | //| key, and publishing it again would reintroduce exactly the | //| cross-portfolio false collision this correction removes. | //| | //| An identity whose name already fits keeps the name it always had. | //| An over-long identity is folded into a bounded deterministic name | //| built from the identity hash (UQHashString), which covers the | //| complete identity key. A hash collision would still fail closed | //| through the heartbeat read-back of the publisher. | //+------------------------------------------------------------------+ string UQInstanceGuardLegacyName(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)); } //+------------------------------------------------------------------+ //| Duplicate-instance ownership protocol | //| | //| MT5 exposes exactly one atomic primitive over terminal global | //| variables: GlobalVariableSetOnCondition() is documented as giving | //| atomic access and as the primitive intended for a mutex between | //| Expert Advisors running at the same time in one terminal. | //| GlobalVariableSet() is not atomic - it creates or overwrites with | //| no condition - so "check, then publish" cannot exclude a second | //| instance. The shared guard is therefore a PERSISTENT coordination | //| object: once it exists it is never deleted, and every transition of| //| its value is one compare-and-swap on one strictly increasing value:| //| | //| absent --Set(0)----------------> unclaimed (creation once) | //| unclaimed --CAS(0 -> t)-----------> owned(t) | //| owned(t) --CAS(t -> t'), t' > t--> owned(t') (heartbeat) | //| owned(t) --CAS(t -> t'), t' > t--> owned(t') (stale takeover)| //| owned(t) --CAS(t -> 0)-----------> unclaimed (clean release) | //| | //| There is no transition back to "absent" after creation, so no | //| release, rollback or superseded owner can delete the variable, and | //| none of them can write a value it does not hold: a failed CAS | //| leaves a successor's claim untouched. | //| | //| The unclaimed sentinel is the exact value 0.0. It is a legitimate | //| protocol state only for the V2 guard name and only where the caller| //| explicitly allows it; the legacy guard namespace of previous builds| //| has no sentinel, so a zero there remains corrupt evidence. Any | //| other zero, a negative, a fractional, a non-finite or a future | //| value stays INVALID and is never overwritten. | //| | //| The creation of the variable itself cannot be made atomic with | //| this API, so it is fenced by ballots: every contender announces | //| itself in its own private ballot variable (a name no other instance| //| ever writes, so the announcement cannot race) before it touches the| //| shared guard, and it keeps that ballot visible for as long as it | //| owns the guard. A contender may create the unclaimed sentinel only | //| when a scan proves that no other live ballot exists AND a fresh | //| re-read proves that the guard is still absent; it verifies the | //| sentinel it wrote, claims it by CAS, and re-scans afterwards. If a | //| rival is visible it gives the guard back with a CAS to the sentinel| //| and fails closed - it never deletes and never overwrites. | //+------------------------------------------------------------------+ #define UQ_GUARD_SENTINEL 0.0 #define UQ_GUARD_BALLOT_PREFIX "UQ2B_" #define UQ_GUARD_BALLOT_FP_CHARS 40 enum ENUM_UQ_GUARD_STATE { UQ_GUARD_ABSENT = 0, // no variable holds this name UQ_GUARD_ACTIVE = 1, // a live ownership record inside the TTL UQ_GUARD_STALE = 2, // a valid record whose owner stopped beating UQ_GUARD_INVALID = 3, // the variable exists but its value is no record UQ_GUARD_UNCLAIMED = 4 // the exact sentinel: nobody owns this scope }; enum ENUM_UQ_GUARD_RESULT { UQ_GUARD_ACQUIRED = 0, // ownership is proven by this instance UQ_GUARD_HELD = 1, // another live instance owns the guard UQ_GUARD_INVALID_EVIDENCE = 2, // the guard holds a value that is not a record UQ_GUARD_LOST_RACE = 3, // the atomic exchange was won by another contender UQ_GUARD_CONTENDED = 4, // another contender was creating the guard concurrently UQ_GUARD_IO_ERROR = 5, // the terminal did not store or return the value UQ_GUARD_UNUSABLE = 7 // the guard or ballot name cannot be derived }; //+------------------------------------------------------------------+ //| Classify one ownership record | //| | //| Pure, so the exact meaning of every value can be tested apart | //| from the terminal: absence, a live claim, an abandoned claim, the | //| unclaimed sentinel and a value that is not a record at all are five| //| distinct outcomes. | //+------------------------------------------------------------------+ ENUM_UQ_GUARD_STATE UQGuardClassify(const bool exists, const double value, const datetime now, const int ttlSeconds, const bool unclaimedAllowed = false) { if(!exists) return(UQ_GUARD_ABSENT); if(!MathIsValidNumber(value)) return(UQ_GUARD_INVALID); //--- the unclaimed sentinel is the one legitimate zero, and only in a //--- namespace that publishes it. A negative zero is numerically the same //--- value and is therefore the same protocol state. Every other zero, //--- every negative value and every fractional value stays INVALID. if(unclaimedAllowed && value == UQ_GUARD_SENTINEL) return(UQ_GUARD_UNCLAIMED); if(value <= 0.0) return(UQ_GUARD_INVALID); //--- a heartbeat is a whole number of seconds; anything else was not written //--- by this protocol and cannot be interpreted as an ownership record if((double)(long)value != value) return(UQ_GUARD_INVALID); //--- a stamp this far ahead of the clock was never produced by a heartbeat if(value > (double)(TimeLocal() + UQ_STATE_MAX_CLOCK_SKEW)) return(UQ_GUARD_INVALID); //--- a stamp slightly ahead is still a live claim, never a stale one if(value >= (double)TimeLocal()) return(UQ_GUARD_ACTIVE); if((long)((datetime)(long)value) > 0 && (long)(TimeLocal() - (datetime)(long)value) < (long)ttlSeconds) return(UQ_GUARD_ACTIVE); return(UQ_GUARD_STALE); } //+------------------------------------------------------------------+ //| Storage behind the ownership protocol | //| | //| The acquisition algorithm never calls the terminal directly, so it | //| can be driven against an adversarial store in a test without | //| touching one production global variable. | //+------------------------------------------------------------------+ class CUQGuardStore { public: virtual bool Exists(const string name) { return(false); } virtual bool Read(const string name, double &value) { return(false); } virtual bool Write(const string name, const double value) { return(false); } virtual bool Swap(const string name, const double value, const double expected) { return(false); } virtual bool Remove(const string name) { return(false); } virtual int Names(const string prefix, string &out[]) { return(0); } }; //+------------------------------------------------------------------+ //| Terminal-backed guard store | //| | //| Swap() is GlobalVariableSetOnCondition(): the terminal compares | //| and replaces the value as one indivisible operation, which is the | //| documented mutex primitive for Expert Advisors in one terminal. | //+------------------------------------------------------------------+ class CUQTerminalGuardStore : public CUQGuardStore { public: virtual bool Exists(const string name) { return(GlobalVariableCheck(name)); } virtual bool Read(const string name, double &value) { value = 0.0; if(!GlobalVariableCheck(name)) return(false); value = GlobalVariableGet(name); return(true); } virtual bool Write(const string name, const double value) { if(StringLen(name) <= 0 || StringLen(name) > UQ_GV_NAME_MAX_CHARS) return(false); return(GlobalVariableSet(name, value) != 0); } virtual bool Swap(const string name, const double value, const double expected) { if(StringLen(name) <= 0 || StringLen(name) > UQ_GV_NAME_MAX_CHARS) return(false); return(GlobalVariableSetOnCondition(name, value, expected)); } virtual bool Remove(const string name) { return(GlobalVariableDel(name)); } virtual int Names(const string prefix, string &out[]) { ArrayResize(out, 0); if(StringLen(prefix) <= 0) return(0); const int total = GlobalVariablesTotal(); for(int i = 0; i < total; i++) { const string name = GlobalVariableName(i); if(StringLen(name) <= 0 || StringFind(name, prefix, 0) != 0) continue; const int n = ArraySize(out); ArrayResize(out, n + 1); out[n] = name; } return(ArraySize(out)); } }; //+------------------------------------------------------------------+ //| Ballot locators: bounded, identity scoped, one name per instance | //+------------------------------------------------------------------+ string UQGuardBallotPrefix(const string fingerprintV2) { if(!UQIdentityFingerprintValid(fingerprintV2)) return(""); return(UQ_GUARD_BALLOT_PREFIX + StringSubstr(fingerprintV2, 0, UQ_GUARD_BALLOT_FP_CHARS) + "_"); } string UQGuardBallotName(const string fingerprintV2, const string tag) { const string prefix = UQGuardBallotPrefix(fingerprintV2); if(StringLen(prefix) <= 0 || StringLen(tag) <= 0) return(""); const string name = prefix + tag; if(StringLen(name) > UQ_GV_NAME_MAX_CHARS) return(""); return(name); } //+------------------------------------------------------------------+ //| Private tag of this program instance | //| | //| The chart identity plus a process-local sequence number makes the | //| ballot name unique among the instances of one terminal, so no | //| other instance can ever write it. | //+------------------------------------------------------------------+ string UQGuardInstanceTag(void) { static ulong seq = 0; seq++; const string seed = IntegerToString((long)ChartID()) + "|" + IntegerToString((long)GetTickCount64()) + "|" + IntegerToString((long)GetMicrosecondCount()) + "|" + IntegerToString((long)seq); return(StringSubstr(UQHashString(seed), 0, 8)); } //+------------------------------------------------------------------+ //| Ownership of one duplicate-instance guard | //| | //| The instance keeps the exact value it holds, so every later | //| transition is a compare-and-swap against that value. A value that | //| no longer matches proves that another instance took the guard over,| //| and every method then refuses to touch the guard again. | //| | //| The guard variable is never deleted once it exists: a release and a| //| rollback are both the transition back to the unclaimed sentinel, so | //| a late contender can never remove or overwrite a successor's claim. | //| The contender's own ballot, by contrast, is removed as soon as the | //| contender no longer owns the guard, because a name no other instance| //| writes can never be foreign evidence. | //| | //| Ownership loss is latched for the lifetime of the object: once a | //| heartbeat, a verification or a release proves that this instance | //| does not own the guard any more, no later call reports ownership. | //+------------------------------------------------------------------+ class CUQInstanceGuard : public CUQOwnershipVerifier { private: CUQGuardStore *m_store; string m_name; string m_prefix; string m_ballot; bool m_owned; bool m_lost; double m_value; int m_ttl; ENUM_UQ_GUARD_RESULT m_result; string m_detail; //--- only this instance's own ballot is ever removed, and only after this //--- instance no longer owns the guard. The name is private to this //--- instance, so removing it can never destroy foreign evidence. void DropBallot(void) { if(m_store != NULL && StringLen(m_ballot) > 0 && m_store.Exists(m_ballot)) m_store.Remove(m_ballot); } //--- ownership loss is latched: no later call may report ownership again void LatchLoss(const string detail) { m_owned = false; m_lost = true; m_detail = detail; } //--- announce or renew this contender's ballot and prove that the store //--- returned exactly the value that was written bool PublishBallot(const datetime now) { if(m_store == NULL || StringLen(m_ballot) <= 0) return(false); if(!m_store.Write(m_ballot, (double)(long)now)) return(false); double announced = 0.0; return(m_store.Read(m_ballot, announced) && (double)(long)announced == (double)(long)now); } //--- a ballot of another instance that is still inside the TTL proves that //--- a concurrent contender may be inside its own creation sequence bool ForeignBallotLive(const datetime now, const int ttlSeconds, string &rival) { rival = ""; if(m_store == NULL || StringLen(m_prefix) <= 0) return(false); string names[]; const int total = m_store.Names(m_prefix, names); for(int i = 0; i < total; i++) { if(names[i] == m_ballot) continue; double v = 0.0; if(!m_store.Read(names[i], v)) continue; //--- an unreadable or unusable ballot is still a contender that cannot //--- be ruled out, so it counts as live if(!MathIsValidNumber(v) || v <= 0.0 || (double)(long)v != v || (long)(TimeLocal() - (datetime)(long)v) < (long)ttlSeconds) { rival = names[i]; return(true); } } return(false); } public: CUQInstanceGuard(void) { m_store = NULL; m_name = ""; m_prefix = ""; m_ballot = ""; m_owned = false; m_lost = false; m_value = 0.0; m_ttl = UQ_INSTANCE_TTL_SECONDS; m_result = UQ_GUARD_UNUSABLE; m_detail = ""; } bool Configure(CUQGuardStore *store, const string guardName, const string ballotPrefix, const string ballotName) { m_store = store; m_name = guardName; m_prefix = ballotPrefix; m_ballot = ballotName; m_owned = false; m_lost = false; m_value = 0.0; m_ttl = UQ_INSTANCE_TTL_SECONDS; m_result = UQ_GUARD_UNUSABLE; m_detail = ""; return(m_store != NULL && StringLen(m_name) > 0 && StringLen(m_name) <= UQ_GV_NAME_MAX_CHARS && StringLen(m_ballot) > 0 && StringLen(m_ballot) <= UQ_GV_NAME_MAX_CHARS); } //+---------------------------------------------------------------+ //| Acquire ownership of the guard | //+---------------------------------------------------------------+ ENUM_UQ_GUARD_RESULT Acquire(const datetime now, const int ttlSeconds) { m_owned = false; m_lost = false; m_value = 0.0; m_ttl = ttlSeconds; m_result = UQ_GUARD_UNUSABLE; m_detail = ""; if(m_store == NULL) { m_detail = "the instance guard store is unavailable"; return(m_result); } if(StringLen(m_name) <= 0 || StringLen(m_name) > UQ_GV_NAME_MAX_CHARS) { m_detail = "the duplicate-instance guard name cannot be derived"; return(m_result); } if(StringLen(m_ballot) <= 0 || StringLen(m_ballot) > UQ_GV_NAME_MAX_CHARS) { m_detail = "the contender ballot name cannot be derived"; return(m_result); } //--- 1. announce this contender in its own private variable, before the //--- shared guard is touched. No other instance writes this name, so //--- the announcement itself cannot race, and every contender that //--- could still create the guard becomes visible here. if(!PublishBallot(now)) { m_result = UQ_GUARD_IO_ERROR; m_detail = "the contender ballot could not be written and read back"; DropBallot(); return(m_result); } //--- 2. read the ownership record and classify it const bool exists = m_store.Exists(m_name); double observed = 0.0; if(exists && !m_store.Read(m_name, observed)) { m_result = UQ_GUARD_IO_ERROR; m_detail = "the instance guard could not be read"; DropBallot(); return(m_result); } ENUM_UQ_GUARD_STATE state = UQGuardClassify(exists, observed, now, ttlSeconds, true); if(state == UQ_GUARD_INVALID) { m_result = UQ_GUARD_INVALID_EVIDENCE; m_detail = StringFormat("the instance guard '%s' exists but holds no usable ownership record (value %s); it is never treated as stale and never overwritten", m_name, IntegerToString((long)observed)); DropBallot(); return(m_result); } if(state == UQ_GUARD_ACTIVE) { m_result = UQ_GUARD_HELD; m_detail = StringFormat("another instance holds the instance guard (heartbeat %s)", TimeToString((datetime)(long)observed, TIME_DATE | TIME_SECONDS)); DropBallot(); return(m_result); } //--- 3. first creation. GlobalVariableSetOnCondition() cannot create a //--- missing variable, so the creation write is the one step the //--- terminal API cannot make atomic. It is fenced as follows: //--- a. no other live ballot may exist, because a rival that could //--- create the same guard is inside such a ballot; //--- b. the guard is re-read AFTER the scan and must still be //--- absent, so an observation taken before the scan is never //--- used to justify a write; //--- c. the sentinel that was written must read back exactly; //--- d. the claim must win its compare-and-swap; //--- e. the scan is repeated after the claim, and a rival found //--- there causes a CAS back to the sentinel - never a delete. //--- //--- Ordering argument: a contender is visible in its ballot from //--- before its scan until after its claim is verified, and it removes //--- that ballot only once it no longer owns the guard. For this //--- contender to miss a rival in (a), that rival must already have //--- removed its ballot, which happens only after its claim is proven; //--- (b) then reads that claim and this method never reaches the //--- creation write. Two contenders that both observed absence are //--- both visible to each other and both fail closed. if(state == UQ_GUARD_ABSENT) { string rival = ""; if(ForeignBallotLive(now, ttlSeconds, rival)) { m_result = UQ_GUARD_CONTENDED; m_detail = StringFormat("another contender ('%s') is inside its first-creation sequence, so the shared guard was not touched", rival); DropBallot(); return(m_result); } const bool stillExists = m_store.Exists(m_name); double current = 0.0; if(stillExists && !m_store.Read(m_name, current)) { m_result = UQ_GUARD_IO_ERROR; m_detail = "the instance guard appeared and could not be read"; DropBallot(); return(m_result); } const ENUM_UQ_GUARD_STATE recheck = UQGuardClassify(stillExists, current, now, ttlSeconds, true); if(recheck == UQ_GUARD_ACTIVE) { m_result = UQ_GUARD_HELD; m_detail = StringFormat("another instance claimed the instance guard while this contender was creating it (heartbeat %s)", TimeToString((datetime)(long)current, TIME_DATE | TIME_SECONDS)); DropBallot(); return(m_result); } if(recheck == UQ_GUARD_INVALID) { m_result = UQ_GUARD_INVALID_EVIDENCE; m_detail = StringFormat("the instance guard '%s' appeared with no usable ownership record (value %s); it is never overwritten", m_name, IntegerToString((long)current)); DropBallot(); return(m_result); } if(recheck == UQ_GUARD_ABSENT) { //--- c. create the unclaimed sentinel. This is the only //--- unconditional write of the protocol and it is fenced by //--- (a) and (b): no live rival exists and the guard is still //--- absent after the scan. if(!m_store.Write(m_name, UQ_GUARD_SENTINEL)) { m_result = UQ_GUARD_IO_ERROR; m_detail = "the instance guard could not be created"; DropBallot(); return(m_result); } double created = 0.0; if(!m_store.Read(m_name, created) || created != UQ_GUARD_SENTINEL) { //--- the value is not the sentinel this instance wrote, so a //--- rival owns it now: it is neither deleted nor overwritten m_result = UQ_GUARD_CONTENDED; m_detail = "the created instance guard does not hold the unclaimed sentinel that was written"; DropBallot(); return(m_result); } observed = UQ_GUARD_SENTINEL; state = UQ_GUARD_UNCLAIMED; } else { //--- an unclaimed sentinel or a stale record appeared while the scan //--- was running: it is claimed with the atomic exchange, never a write observed = current; state = (recheck == UQ_GUARD_UNCLAIMED ? UQ_GUARD_UNCLAIMED : UQ_GUARD_STALE); } } //--- 4. the atomic acquisition. An unclaimed sentinel and a stale record //--- reach the same compare-and-swap; only its winner may own the scope. const double expected = (state == UQ_GUARD_UNCLAIMED ? UQ_GUARD_SENTINEL : observed); double claim = (double)(long)now; if(claim <= expected) claim = expected + 1.0; if(!m_store.Swap(m_name, claim, expected)) { double current = 0.0; if(m_store.Read(m_name, current)) m_detail = StringFormat("the ownership exchange was won by another contender (guard now holds %s)", IntegerToString((long)current)); else m_detail = "the ownership exchange was lost and the guard could not be re-read"; m_result = UQ_GUARD_LOST_RACE; DropBallot(); return(m_result); } //--- 5. the exchange is only an acquisition when the terminal still //--- returns exactly the value this instance wrote double stored = 0.0; if(!m_store.Read(m_name, stored) || (double)(long)stored != claim) { m_result = UQ_GUARD_IO_ERROR; m_detail = "the instance guard does not hold the value that was written"; DropBallot(); return(m_result); } //--- 6. a claim on a guard this instance created is only exclusive when no //--- rival became visible during the creation sequence. The guard is //--- given back with a CAS to the sentinel: this instance never deletes //--- the shared variable and never writes a value it does not own. if(state == UQ_GUARD_UNCLAIMED) { string rival = ""; if(ForeignBallotLive(now, ttlSeconds, rival)) { const bool returned = m_store.Swap(m_name, UQ_GUARD_SENTINEL, claim); m_result = UQ_GUARD_CONTENDED; m_detail = StringFormat("another contender ('%s') was creating the same guard at the same time; exclusive ownership cannot be proven%s", rival, (returned ? " and the guard was returned to the unclaimed sentinel" : " and the guard was left as it is because it no longer carries this claim")); DropBallot(); return(m_result); } } //--- the ballot stays visible from here on, for as long as this instance //--- owns the guard, so a contender can never mistake an owned guard for //--- an abandoned first-creation sequence m_value = claim; m_owned = true; m_result = UQ_GUARD_ACQUIRED; m_detail = ""; return(m_result); } //+---------------------------------------------------------------+ //| Publish a heartbeat and prove that this instance still owns it| //+---------------------------------------------------------------+ bool Heartbeat(const datetime now) { if(!m_owned || m_store == NULL || m_lost) return(false); //--- the ballot is renewed first, so this instance is never an owner that //--- a contender scanning for rivals cannot see if(!PublishBallot(now)) { LatchLoss("the contender ballot could not be renewed, so continued ownership cannot be proven"); DropBallot(); return(false); } double next = (double)(long)now; if(next <= m_value) next = m_value + 1.0; if(!m_store.Swap(m_name, next, m_value)) { //--- the value this instance held is gone, so a successor owns the //--- guard now and this instance must never touch it again LatchLoss("the instance guard is no longer owned by this instance"); DropBallot(); return(false); } double stored = 0.0; if(!m_store.Read(m_name, stored) || (double)(long)stored != next) { LatchLoss("the instance guard heartbeat could not be verified after the exchange"); DropBallot(); return(false); } m_value = next; return(true); } //+---------------------------------------------------------------+ //| Release ownership at deinitialization | //| | //| The release is the transition back to the unclaimed sentinel. | //| The shared variable is never deleted, so a contender that | //| acquires it between this compare-and-swap and the ballot cleanup| //| keeps its claim: only a value this instance still owns is ever | //| replaced, and a failed CAS leaves the successor untouched. | //+---------------------------------------------------------------+ bool Release(void) { if(m_store == NULL) { m_owned = false; return(true); } if(m_owned) { const bool released = m_store.Swap(m_name, UQ_GUARD_SENTINEL, m_value); m_owned = false; if(!released) { m_detail = "the instance guard is held by a successor and was left untouched"; DropBallot(); return(false); } } else m_owned = false; //--- the ballot is removed only after this instance no longer owns the //--- guard, so a contender scanning after this point finds the persistent //--- sentinel instead of an absent guard DropBallot(); return(true); } //+---------------------------------------------------------------+ //| Read-only proof that this instance still owns the guard | //| | //| The record must exist, carry exactly the value this instance | //| holds and still be inside the TTL, because past the TTL another| //| instance may take the scope over. Any failure is latched: this | //| object never reports ownership again. | //+---------------------------------------------------------------+ virtual bool OwnershipVerified(string &detail) { detail = ""; if(!m_owned || m_store == NULL) { detail = "this instance does not hold the duplicate-instance guard"; return(false); } if(m_lost) { detail = (StringLen(m_detail) > 0 ? m_detail : "instance ownership was already declared lost"); return(false); } double stored = 0.0; if(!m_store.Read(m_name, stored)) { LatchLoss("the duplicate-instance guard could not be read, so continued ownership cannot be proven"); detail = m_detail; return(false); } if((double)(long)stored != m_value) { LatchLoss(StringFormat("the duplicate-instance guard now holds %s instead of the value this instance owns", IntegerToString((long)stored))); detail = m_detail; return(false); } if(UQGuardClassify(true, stored, TimeLocal(), m_ttl, true) != UQ_GUARD_ACTIVE) { LatchLoss("the duplicate-instance guard heartbeat is stale, so another instance may take this scope over"); detail = m_detail; return(false); } return(true); } virtual string OwnershipScope(void) { return(m_name); } virtual bool OwnershipLost(void) { return(m_lost); } bool Owned(void) const { return(m_owned); } bool Lost(void) const { return(m_lost); } double Value(void) const { return(m_value); } string Name(void) const { return(m_name); } string Detail(void) const { return(m_detail); } ENUM_UQ_GUARD_RESULT Result(void) const { return(m_result); } }; //+------------------------------------------------------------------+ //| Outcome of the initialization ownership gate | //+------------------------------------------------------------------+ enum ENUM_UQ_OWNERSHIP { UQ_OWNERSHIP_READY = 0, // exclusive ownership is proven UQ_OWNERSHIP_IDENTITY = 1, // no usable identity, no guard locator UQ_OWNERSHIP_LEGACY_ACTIVE = 2, // an instance started by an older build is live UQ_OWNERSHIP_LEGACY_INVALID = 3, // the legacy guard holds no usable record UQ_OWNERSHIP_V2_HELD = 4, // another V2 instance owns this scope UQ_OWNERSHIP_V2_INVALID = 5, // the V2 guard holds no usable record UQ_OWNERSHIP_V2_CONTENDED = 6, // simultaneous first start, not provable UQ_OWNERSHIP_V2_ERROR = 7 // the guard could not be written or verified }; //+------------------------------------------------------------------+ //| Operator readable name of one ownership outcome | //+------------------------------------------------------------------+ string UQOwnershipText(const ENUM_UQ_OWNERSHIP o) { switch(o) { case UQ_OWNERSHIP_READY: return("exclusive ownership proven"); case UQ_OWNERSHIP_IDENTITY: return("the persistence identity is unusable"); case UQ_OWNERSHIP_LEGACY_ACTIVE: return("a previous-build instance is still running"); case UQ_OWNERSHIP_LEGACY_INVALID: return("the legacy instance guard holds no usable record"); case UQ_OWNERSHIP_V2_HELD: return("another V2 instance owns this scope"); case UQ_OWNERSHIP_V2_INVALID: return("the V2 instance guard holds no usable record"); case UQ_OWNERSHIP_V2_CONTENDED: return("a simultaneous first start could not be proven exclusive"); case UQ_OWNERSHIP_V2_ERROR: return("the V2 instance guard could not be written or verified"); } return("unrecognized ownership outcome"); } //+------------------------------------------------------------------+ //| Establish exclusive instance ownership | //| | //| This is the only place where initialization decides that one | //| instance may touch a persistent risk scope. It returns READY only | //| when the V2 guard was acquired through the atomic exchange above, | //| and it refuses on every form of unusable evidence. | //+------------------------------------------------------------------+ ENUM_UQ_OWNERSHIP UQInstanceOwnershipAcquire(CUQInstanceGuard &guard, CUQGuardStore *store, const UQIdentity &id, const datetime now, const int ttlSeconds, string &detail) { detail = ""; if(store == NULL) { detail = "the instance guard store is unavailable"; return(UQ_OWNERSHIP_IDENTITY); } if(!UQIdentityFingerprintValid(id.fingerprintV2)) { detail = "the V2 identity fingerprint is unavailable; instance ownership cannot be established"; return(UQ_OWNERSHIP_IDENTITY); } const string v2Name = UQInstanceGuardName(id.fingerprintV2); const string ballotName = UQGuardBallotName(id.fingerprintV2, UQGuardInstanceTag()); const string legacyName = UQInstanceGuardLegacyName(id.key); if(StringLen(v2Name) <= 0 || StringLen(ballotName) <= 0) { detail = "the duplicate-instance guard name cannot be derived"; return(UQ_OWNERSHIP_IDENTITY); } //--- an instance started by a previously published build is inspected before //--- anything is written. That build cannot recognise the V2 namespace, so //--- while its heartbeat is fresh the V2 guard is deliberately not acquired. //--- A guard cannot stop an older binary from starting later: the deployment //--- constraint that every old instance is stopped first still holds. if(StringLen(legacyName) > 0) { const bool legacyExists = store.Exists(legacyName); double legacyValue = 0.0; if(legacyExists && !store.Read(legacyName, legacyValue)) { detail = "the legacy instance guard could not be read"; return(UQ_OWNERSHIP_LEGACY_INVALID); } const ENUM_UQ_GUARD_STATE legacyState = UQGuardClassify(legacyExists, legacyValue, now, ttlSeconds); if(legacyState == UQ_GUARD_INVALID) { detail = StringFormat("the legacy instance guard '%s' exists but holds no usable ownership record (value %s); it is never treated as stale, never overwritten and never deleted by this build", legacyName, IntegerToString((long)legacyValue)); return(UQ_OWNERSHIP_LEGACY_INVALID); } if(legacyState == UQ_GUARD_ACTIVE) { detail = StringFormat("an instance started by a previous build is still active (legacy guard '%s', heartbeat %s); stop every old instance before deploying this build", legacyName, TimeToString((datetime)(long)legacyValue, TIME_DATE | TIME_SECONDS)); return(UQ_OWNERSHIP_LEGACY_ACTIVE); } } if(!guard.Configure(store, v2Name, UQGuardBallotPrefix(id.fingerprintV2), ballotName)) { detail = "the duplicate-instance guard could not be configured"; return(UQ_OWNERSHIP_IDENTITY); } const ENUM_UQ_GUARD_RESULT acquired = guard.Acquire(now, ttlSeconds); detail = guard.Detail(); switch(acquired) { case UQ_GUARD_ACQUIRED: return(UQ_OWNERSHIP_READY); case UQ_GUARD_HELD: return(UQ_OWNERSHIP_V2_HELD); case UQ_GUARD_INVALID_EVIDENCE: return(UQ_OWNERSHIP_V2_INVALID); case UQ_GUARD_CONTENDED: return(UQ_OWNERSHIP_V2_CONTENDED); default: return(UQ_OWNERSHIP_V2_ERROR); } } //+------------------------------------------------------------------+ //| May a legacy state found under the legacy locators be migrated? | //| | //| Pure decision, so the refusal of ambiguous legacy evidence can be | //| tested without a file system. A legacy state is only ever migrated| //| when the legacy serialisation of this identity decodes back to | //| exactly this tuple: otherwise the same legacy key and the same | //| legacy hash are shared with a different tuple and the state cannot| //| be proven to be this identity's own. | //+------------------------------------------------------------------+ bool UQLegacyMigrationAllowed(const UQIdentity &id, const bool legacyStatePresent, const bool legacyMarkerPresent, string &reason) { reason = ""; if(id.legacyAmbiguous) { reason = "the legacy identity serialisation of this scope is ambiguous: 'server|login|portfolio' of this tuple is identical to that of a different tuple, so a legacy state found under this legacy locator cannot be proven to belong to this identity"; return(false); } if(legacyMarkerPresent) { reason = "a legacy initialization marker exists while no verified legacy state of this exact identity does; this scope was initialized before and must not be treated as a fresh installation"; return(false); } if(!legacyStatePresent) { reason = "no legacy state exists for this identity"; return(false); } return(true); } //+------------------------------------------------------------------+ //| Classification of the persistence scope that is about to load | //| | //| The decision is pure: it is derived from evidence only, so it can | //| be reviewed and tested apart from the file system and the terminal | //| state. Every input is "the evidence exists", never "the evidence | //| loaded successfully", so an unusable file can never be mistaken | //| for an absent one. | //+------------------------------------------------------------------+ enum ENUM_UQ_SCOPE_DECISION { UQ_SCOPE_V2_AUTHORITATIVE = 0, // a V2 persistence scope exists and is authoritative UQ_SCOPE_V2_MARKER_ONLY = 1, // V2 marker proven while the V2 state is missing: refuse UQ_SCOPE_LEGACY_MIGRATE = 2, // verified legacy state of this exact identity: migrate UQ_SCOPE_LEGACY_REFUSE = 3, // legacy evidence that must never be migrated: refuse UQ_SCOPE_FRESH = 4 // no evidence at all: authorized first initialization only }; //+------------------------------------------------------------------+ //| Decide how a persistence scope may be entered | //+------------------------------------------------------------------+ ENUM_UQ_SCOPE_DECISION UQScopeDecision(const bool v2StatePresent, const bool v2MarkerPresent, const bool legacyStatePresent, const bool legacyStateUsable, const bool legacyMarkerPresent, string &reason, const bool v2MarkerInvalid = false, const bool legacyMarkerInvalid = false) { reason = ""; //--- 0. an existing marker whose value is unusable is corrupt evidence, not //--- absence. It proves that this scope was written before, so neither a //--- fresh initialization nor a migration may rebuild it from scratch. if(legacyMarkerInvalid) { reason = "the legacy initialization marker of this scope exists but holds no usable value; the persistence evidence is corrupt and cannot authorize a new risk cycle or a migration"; return(UQ_SCOPE_LEGACY_REFUSE); } if(v2MarkerInvalid) { reason = "the V2 initialization marker of this scope exists but holds no usable value; the persistence evidence is corrupt and cannot authorize a new risk cycle"; return(UQ_SCOPE_V2_MARKER_ONLY); } //--- A. the V2 scope exists: only V2 evidence is ever consulted from here if(v2StatePresent) return(UQ_SCOPE_V2_AUTHORITATIVE); //--- B. the V2 marker proves that this scope was initialized under V2. Its //--- missing state is a loss of evidence, not a fresh installation, and //--- it is never repaired from stale legacy data. if(v2MarkerPresent) { reason = "the V2 initialization marker exists while both V2 state files are absent; this scope was initialized before and it must not be rebuilt from older data"; return(UQ_SCOPE_V2_MARKER_ONLY); } //--- C/D. no V2 evidence: a legacy state of this exact identity may migrate if(legacyStatePresent && legacyStateUsable) return(UQ_SCOPE_LEGACY_MIGRATE); //--- E. legacy evidence exists but nothing of it belongs to this identity: //--- this is the confirmed collision case, and it is a persistence //--- ownership conflict, never permission to discard existing history if(legacyStatePresent) { reason = "a legacy state file exists for this locator but it is unusable or belongs to a different identity; it is never overwritten, replaced or treated as a fresh installation"; return(UQ_SCOPE_LEGACY_REFUSE); } if(legacyMarkerPresent) { reason = "a legacy initialization marker exists while no legacy state file does; this scope was initialized before and must not be treated as a fresh installation"; return(UQ_SCOPE_LEGACY_REFUSE); } return(UQ_SCOPE_FRESH); } //+------------------------------------------------------------------+ //| Prove that a migration preserved every risk-bearing field | //| | //| Relocating the persistence scope must not change a single risk | //| decision. The values are compared through the canonical field | //| serializer, i.e. exactly the text that is persisted, so a value | //| that survives the round trip unchanged compares equal. | //| | //| The schema version, the update timestamp and the persistence | //| generation are expected to advance, and the two identity fields | //| are checked separately against the live identity. | //+------------------------------------------------------------------+ bool UQStatePreserved(UQRiskState &before, UQRiskState &after, string &detail) { detail = ""; for(int i = 0; i < (int)UQ_F_COUNT; i++) { if(i == (int)UQ_F_FORMAT || i == (int)UQ_F_UPDATED_AT || i == (int)UQ_F_STATE_REVISION) continue; if(i == (int)UQ_F_IDENTITY || i == (int)UQ_F_IDENTITY_V2) continue; const string a = UQStateFieldValue(before, i); const string b = UQStateFieldValue(after, i); if(a != b) { detail = StringFormat("field '%s' changed from '%s' to '%s'", UQFieldKey(i), a, b); return(false); } } return(true); } //+------------------------------------------------------------------+ //| Prove that a migrated state carries no weaker control than the | //| state it was migrated from | //| | //| A relocation is allowed to keep a lock armed that the evaluation | //| pass has just re-armed, but it is never allowed to disarm one. | //+------------------------------------------------------------------+ bool UQStateLocksNotCleared(UQRiskState &before, UQRiskState &after, string &detail) { detail = ""; if(before.staticLock && !after.staticLock) { detail = "the static-drawdown lock was cleared by the migration"; return(false); } if(before.targetLock && !after.targetLock) { detail = "the equity-target lock was cleared by the migration"; return(false); } if(before.dailyLock && !after.dailyLock && before.dailyDate == after.dailyDate) { detail = "the daily-loss lock was cleared inside the same trading day by the migration"; return(false); } if(before.recoveryHold && !after.recoveryHold) { detail = "the recovery hold was cleared by the migration"; return(false); } if(before.gapHold && !after.gapHold && before.dailyDate == after.dailyDate) { detail = "the monitoring-gap hold was cleared inside the same trading day by the migration"; return(false); } if(before.reserveState != UQ_RESERVE_NONE && after.reserveState == UQ_RESERVE_NONE) { detail = "an outstanding entry reservation was discarded by the migration"; return(false); } if(before.cycleId != after.cycleId || before.cycleSequence != after.cycleSequence) { detail = "the risk-cycle identity changed during the migration"; return(false); } return(true); } #endif // UQ_STATE_MQH //+------------------------------------------------------------------+