| //+------------------------------------------------------------------+
|
| //| 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"
|
|
|
| #define UQ_STATE_TEMP_EXT ".tmp"
|
| #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
|
|
|
| //+------------------------------------------------------------------+
|
| //| 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);
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| 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);
|
| }
|
|
|
| //+------------------------------------------------------------------+
|
| //| 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)
|
| {
|
| //--- 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;
|
| st.updatedAt = TimeCurrent();
|
| if(st.updatedAt <= 0)
|
| st.updatedAt = UQServerNow();
|
| if(st.createdAt <= 0)
|
| st.createdAt = st.updatedAt;
|
|
|
| const string fname = UQStateFileName(id);
|
| const string tmp = fname + UQ_STATE_TEMP_EXT;
|
| const string bak = UQStateBackupFileName(id);
|
|
|
| //--- 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);
|
| }
|
|
|
| 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);
|
|
|
| FileDelete(tmp, FILE_COMMON);
|
|
|
| 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"))
|
| {
|
| FileDelete(tmp, FILE_COMMON);
|
| return(false);
|
| }
|
|
|
| //--- 4. the backup receives exactly the same verified generation
|
| if(!UQStateCopyFile(tmp, bak))
|
| {
|
| PrintFormat("[UQ] ERROR: the verified generation could not be written to the backup '%s'.", bak);
|
| FileDelete(tmp, FILE_COMMON);
|
| return(false);
|
| }
|
| if(!UQStateVerifyFile(bak, id, st.stateRevision, canonical, "backup"))
|
| {
|
| FileDelete(tmp, FILE_COMMON);
|
| return(false);
|
| }
|
|
|
| //--- 5. commit the verified generation as the primary
|
| if(!FileMove(tmp, FILE_COMMON, fname, FILE_COMMON | FILE_REWRITE))
|
| {
|
| PrintFormat("[UQ] ERROR: cannot commit state file '%s' (err=%d)", fname, GetLastError());
|
| FileDelete(tmp, FILE_COMMON);
|
| 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. Every ownership transition below is therefore one |
|
| //| compare-and-swap on one strictly increasing value: |
|
| //| |
|
| //| absent --Set(0)----------------> unclaimed (creation) |
|
| //| 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), delete---> absent (release) |
|
| //| |
|
| //| Exactly one contender can win a single CAS; because the value only|
|
| //| moves forward, a superseded owner can never pass its own heartbeat|
|
| //| again and a release can never remove a successor's claim. |
|
| //| |
|
| //| The creation of the variable itself cannot be made atomic with |
|
| //| this API, so it is fenced: 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 a contender that wins the creation CAS gives the guard |
|
| //| back and fails closed while any other live ballot is visible. Two |
|
| //| instances starting at the same moment against an absent guard both |
|
| //| refuse; a retry then acquires normally. |
|
| //+------------------------------------------------------------------+
|
| #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
|
| };
|
|
|
| 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 and |
|
| //| a value that is not a record at all are four distinct outcomes. |
|
| //+------------------------------------------------------------------+
|
| ENUM_UQ_GUARD_STATE UQGuardClassify(const bool exists, const double value,
|
| const datetime now, const int ttlSeconds)
|
| {
|
| if(!exists)
|
| return(UQ_GUARD_ABSENT);
|
| if(!MathIsValidNumber(value))
|
| return(UQ_GUARD_INVALID);
|
| 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. |
|
| //+------------------------------------------------------------------+
|
| class CUQInstanceGuard
|
| {
|
| private:
|
| CUQGuardStore *m_store;
|
| string m_name;
|
| string m_prefix;
|
| string m_ballot;
|
| bool m_owned;
|
| double m_value;
|
| ENUM_UQ_GUARD_RESULT m_result;
|
| string m_detail;
|
|
|
| void DropBallot(void)
|
| {
|
| if(m_store != NULL && StringLen(m_ballot) > 0 && m_store.Exists(m_ballot))
|
| m_store.Remove(m_ballot);
|
| }
|
|
|
| //--- 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_value = 0.0;
|
| 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_value = 0.0;
|
| 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_value = 0.0;
|
| 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(!m_store.Write(m_ballot, (double)(long)now))
|
| {
|
| m_result = UQ_GUARD_IO_ERROR;
|
| m_detail = "the contender ballot could not be written";
|
| return(m_result);
|
| }
|
| double announced = 0.0;
|
| if(!m_store.Read(m_ballot, announced) || (double)(long)announced != (double)(long)now)
|
| {
|
| m_result = UQ_GUARD_IO_ERROR;
|
| m_detail = "the contender ballot could not be read back";
|
| DropBallot();
|
| return(m_result);
|
| }
|
|
|
| //--- 2. read the ownership record once 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);
|
| }
|
|
|
| const ENUM_UQ_GUARD_STATE state = UQGuardClassify(exists, observed, now, ttlSeconds);
|
| 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. the atomic acquisition. A stale record and an absent record reach
|
| //--- the same compare-and-swap; only its winner may own the scope.
|
| double expected = UQ_GUARD_SENTINEL;
|
| if(state == UQ_GUARD_STALE)
|
| expected = observed;
|
| else
|
| {
|
| //--- creation: the sentinel carries no ownership, so writing it can
|
| //--- never make this instance the owner by itself
|
| 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 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);
|
| }
|
|
|
| //--- 4. 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);
|
| }
|
|
|
| //--- 5. the creation write in step 3 is the one step the terminal API
|
| //--- cannot make atomic. A contender that observed the same absence is
|
| //--- visible in its ballot, so while one is live this claim cannot be
|
| //--- proven exclusive and is given back instead of being trusted.
|
| if(state == UQ_GUARD_ABSENT)
|
| {
|
| string rival = "";
|
| if(ForeignBallotLive(now, ttlSeconds, rival))
|
| {
|
| m_detail = StringFormat("another contender ('%s') was creating the same guard at the same time; exclusive ownership cannot be proven", rival);
|
| if(m_store.Swap(m_name, UQ_GUARD_SENTINEL, claim))
|
| m_store.Remove(m_name);
|
| m_result = UQ_GUARD_CONTENDED;
|
| DropBallot();
|
| return(m_result);
|
| }
|
| }
|
|
|
| m_value = claim;
|
| m_owned = true;
|
| m_result = UQ_GUARD_ACQUIRED;
|
| m_detail = "";
|
| DropBallot();
|
| 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)
|
| 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
|
| m_owned = false;
|
| m_detail = "the instance guard is no longer owned by this instance";
|
| return(false);
|
| }
|
|
|
| double stored = 0.0;
|
| if(!m_store.Read(m_name, stored) || (double)(long)stored != next)
|
| {
|
| m_owned = false;
|
| m_detail = "the instance guard heartbeat could not be verified after the exchange";
|
| return(false);
|
| }
|
|
|
| m_value = next;
|
| return(true);
|
| }
|
|
|
| //+---------------------------------------------------------------+
|
| //| Release ownership at deinitialization |
|
| //+---------------------------------------------------------------+
|
| bool Release(void)
|
| {
|
| if(m_store == NULL || !m_owned)
|
| {
|
| m_owned = false;
|
| return(true);
|
| }
|
|
|
| //--- only a guard that still carries this instance's own value is
|
| //--- released. A claim a successor acquired is never deleted or
|
| //--- overwritten, so a late deinitialization cannot free a live instance.
|
| 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";
|
| return(false);
|
| }
|
| m_store.Remove(m_name);
|
| return(true);
|
| }
|
|
|
| bool Owned(void) const { return(m_owned); }
|
| 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
|
| //+------------------------------------------------------------------+
|