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