Universal_Quant_EA/Include/UQ_State.mqh
2261 lines
87 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
};
//+------------------------------------------------------------------+
//| 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 (or unusable value)
UQ_MARKER_MATCH = 1, // marker agrees with the persisted state
UQ_MARKER_MISMATCH = 2 // marker disagrees with the persisted state
};
//+------------------------------------------------------------------+
//| 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 = "";
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);
//--- 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);
const double v = GlobalVariableGet(name);
if(!MathIsValidNumber(v) || v <= 0.0)
return(UQ_MARKER_ABSENT);
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));
}
//+------------------------------------------------------------------+
//| 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)
{
reason = "";
//--- 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
//+------------------------------------------------------------------+