Universal_Quant_EA/Include/UQ_State.mqh
UQ Review UQ Review 6eb215caac fix: acquire exclusive instance ownership before persistent risk access
- establish V2 guard ownership before CUQRisk::Init() can read, migrate, create or save a persistence scope; initialization fails closed when ownership is not proven

- replace check-then-publish with a compare-and-swap ownership protocol: atomic stale takeover, heartbeat that proves continued ownership, release that cannot touch a successor, and a private-ballot fence for the one non-atomic step (creating the variable)

- treat an existing guard or marker with an unusable value as an error instead of absence: zero, negative, non-finite, fractional and implausibly future values all refuse

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