Universal_Quant_EA/Include/UQ_State.mqh
UQ Review UQ Review 6408907b92 Fix duplicate-instance ownership integrity (V2 guard + persistence gate)
Defect A - shared guard deletion and first-creation race:

- The shared V2 guard is now a persistent coordination object. It is never
  deleted after creation; release and rollback are both a compare-and-swap
  back to the exact unclaimed sentinel (0.0), so a contender that acquires
  the guard between an owner's CAS and its cleanup keeps its claim.
- First creation is fenced by ballots: the contender announces its own
  ballot, scans for live foreign ballots, re-reads the guard AFTER the scan
  (a stale observation of absence is never used), verifies the sentinel it
  wrote, claims it by CAS and re-scans afterwards. A rival found at any
  point fails the claim closed and the guard is given back with a CAS to
  the sentinel - never deleted, never overwritten.
- The owner's ballot stays visible for as long as it owns the guard and is
  renewed by the heartbeat; only a contender's own ballot is ever removed,
  and only after it no longer owns the guard.
- UQ_GUARD_UNCLAIMED is a distinct protocol state, accepted for the V2
  guard namespace only. The legacy guard namespace keeps zero as corrupt
  evidence, and negative, fractional, non-finite and future values stay
  invalid everywhere.
- Ownership loss is latched for the lifetime of the guard object.

Defect B - ownership loss must stop persistence:

- CUQInstanceGuard implements CUQOwnershipVerifier: a read-only proof that
  the guard still carries this instance's value and is still inside the TTL.
- CUQRisk binds that verifier after the guard is acquired and before Init(),
  and refuses to initialize without one.
- A persistence boundary inside CUQRisk gates the primary write, the backup
  write, the staging write, legacy migration, V2 marker creation, marker
  restoration, marker removal and reservation reconciliation. Any failure
  latches the loss: the controller is unhealthy, entries are blocked, and
  no retry or reacquisition is possible in that instance.
- UQStateSave() takes the verifier and fences its three file mutations
  individually, so a loss proven before the primary commit cannot write it.

EA event ordering:

- OnTick() and OnTimer() verify ownership before risk evaluation, lock
  servicing, trailing and symbol processing.
- OnTradeTransaction() does not invoke persistence-capable reservation
  reconciliation after the loss.
- OnDeinit() releases only a guard this instance provably still holds.
- Heartbeats are throttled; every event performs the read-only proof, so a
  two second timer is not the only discovery path.

Input semantics:

- InpPreventDuplicateInstances is deprecated and reserved. Exclusive
  persistence ownership is unconditional, and an ownership loss always
  blocks entries, so the input has no effect. It is kept so existing .set
  files keep loading, and a runtime note reports that it is ignored.

Preserved unchanged: V2 fingerprint and file locators, legacy identity hash
and key, legacy ambiguity refusal, risk limits and strategy logic.
2026-10-31 19:20:50 +07:00
3224 lines
130 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
//+------------------------------------------------------------------+
//| Ownership verification boundary |
//| |
//| The risk controller and the state writer ask this interface |
//| before every persistent mutation, so the check sits at the |
//| side-effect boundary and not only in an event handler. The default |
//| answer is "not verified": a caller that was never bound to a live |
//| guard refuses to write. The concrete implementation is the |
//| duplicate-instance guard defined further below. |
//+------------------------------------------------------------------+
class CUQOwnershipVerifier
{
public:
virtual bool OwnershipVerified(string &detail) { detail = "no instance-ownership verifier is bound"; return(false); }
virtual string OwnershipScope(void) { return(""); }
virtual bool OwnershipLost(void) { return(true); }
};
//+------------------------------------------------------------------+
//| Fail-closed ownership fence for one file mutation |
//| |
//| Called immediately before the staging write, the backup write and |
//| the primary commit of a state save, so a controller that lost the |
//| guard cannot mutate any of them. |
//+------------------------------------------------------------------+
bool UQOwnershipSaveGate(CUQOwnershipVerifier *owner, const string operation)
{
if(owner == NULL)
{
PrintFormat("[UQ] ERROR: no instance-ownership verifier is bound, so the %s is never written.", operation);
return(false);
}
string detail = "";
if(!owner.OwnershipVerified(detail))
{
PrintFormat("[UQ] ERROR: the %s is not written because this instance does not own the persistent risk scope (%s).",
operation, detail);
return(false);
}
return(true);
}
//+------------------------------------------------------------------+
//| Identity of the persistence scope |
//+------------------------------------------------------------------+
struct UQIdentity
{
string server; // broker server name (never written to disk)
long login; // account login (never written to disk)
string portfolio; // EA portfolio identifier
string key; // file-system safe scope key
string hash; // integrity hash of the scope triple (LEGACY)
string fingerprintV2; // SHA-256 of the canonical, unsanitised identity tuple
//--- true when the LEGACY serialisation of this tuple cannot be decoded back
//--- to its components: 'server + "|" + login + "|" + portfolio' is then
//--- shared with a different tuple, so a legacy state found under the legacy
//--- locators can never be proven to belong to this identity and is never
//--- migrated
bool legacyAmbiguous;
};
//+------------------------------------------------------------------+
//| Persisted risk state |
//+------------------------------------------------------------------+
struct UQRiskState
{
int format;
string identity; // integrity hash of the owning scope (LEGACY)
string identityV2; // full V2 fingerprint of the owning scope (schema 8)
double baselineBalance; // B0 - fixed initial balance baseline
bool baselineValid;
datetime baselineTime;
double dailyStartEquity; // D0 - starting equity of the trading day
bool dailyValid;
datetime dailyDate; // broker-server calendar day of D0
datetime dailyStartTime;
bool dailyLock;
datetime dailyLockTime;
bool staticLock;
datetime staticLockTime;
bool targetLock;
datetime targetLockTime;
string resetSignature; // legacy pre-cycle reset signature, migrated and unused
datetime createdAt;
datetime updatedAt;
int cycleSequence; // risk-cycle counter, 1 for the first cycle
string cycleId; // unique identifier of the current risk cycle
string resetNonceHistory; // consumed reset nonces, '|' separated
double priorBaselineBalance; // B0 of the cycle closed by the most recent reset
string priorCycleId; // identifier of that previous cycle
int priorLockKind; // 0 none, 1 static-drawdown, 2 equity-target, 3 both
datetime priorLockTime; // lock event that authorized the most recent reset
//--- outstanding entry risk. A submitted entry is reserved before the
//--- request leaves the terminal and released only after the broker state
//--- proves where the order went, so a restart cannot forget it.
ENUM_UQ_RESERVE_STATE reserveState;
double reserveMoney;
ulong reserveTicket; // order ticket returned by the server
string reserveSymbol;
datetime reserveTime;
string reserveId; // identity of this request, unique per scope
ulong reserveMagic; // Magic Number of the request
string reserveScope; // portfolio scope the request belongs to
int reserveDirection; // +1 buy, -1 sell, 0 when it cannot be proven
double reserveVolume; // requested volume
double reserveEntry; // expected entry price of the request
double reserveStop; // submitted protective stop
ulong reserveDeal; // deal ticket reported by the server
ulong reservePosition; // position identifier reported for the deal
//--- persistence generation. Every authoritative state carries a strictly
//--- increasing revision, so an interrupted commit is always detectable and
//--- a stale copy can never overwrite a newer verified state.
long stateRevision;
bool recoveryHold; // entries blocked until the owner authorizes recovery
datetime recoveryHoldTime; // when the recovery hold was recorded
//--- monitoring continuity. The heartbeat records the last verified
//--- execution moment, so a restart can distinguish a trading day that was
//--- observed from its boundary from one whose equity path was never seen.
datetime monitorTime; // last verified monitoring heartbeat (server time)
int monitorGapSeconds; // most recent unobserved interval, 0 = none
bool gapHold; // entries blocked until an observed day boundary
datetime gapHoldTime; // when the monitoring-gap hold was recorded
};
//+------------------------------------------------------------------+
//| Classification of one load attempt |
//+------------------------------------------------------------------+
enum ENUM_UQ_STATE_LOAD
{
UQ_LOAD_ERROR = -1, // file exists but is unusable
UQ_LOAD_ABSENT = 0, // no file at this location
UQ_LOAD_VALID = 1, // current format, checksum verified
UQ_LOAD_LEGACY = 2 // previous published format, schema verified
};
//+------------------------------------------------------------------+
//| Reconciliation state of the redundant initialization marker |
//+------------------------------------------------------------------+
enum ENUM_UQ_MARKER_STATE
{
UQ_MARKER_ABSENT = 0, // marker missing: no evidence either way
UQ_MARKER_MATCH = 1, // marker agrees with the persisted state
UQ_MARKER_MISMATCH = 2, // marker disagrees with the persisted state
UQ_MARKER_INVALID = 3 // marker exists but holds no usable record
};
//+------------------------------------------------------------------+
//| Canonical persisted field table. The order of this enumeration |
//| defines the order of the canonical block that is hashed, so the |
//| checksum is independent of the physical line order in the file. |
//+------------------------------------------------------------------+
enum ENUM_UQ_STATE_FIELD
{
UQ_F_FORMAT = 0,
UQ_F_IDENTITY,
UQ_F_BASELINE_BALANCE,
UQ_F_BASELINE_VALID,
UQ_F_BASELINE_TIME,
UQ_F_DAILY_EQUITY,
UQ_F_DAILY_VALID,
UQ_F_DAILY_DATE,
UQ_F_DAILY_START_TIME,
UQ_F_DAILY_LOCK,
UQ_F_DAILY_LOCK_TIME,
UQ_F_STATIC_LOCK,
UQ_F_STATIC_LOCK_TIME,
UQ_F_TARGET_LOCK,
UQ_F_TARGET_LOCK_TIME,
UQ_F_RESET_SIGNATURE,
UQ_F_CREATED_AT,
UQ_F_UPDATED_AT,
UQ_F_CYCLE_SEQ,
UQ_F_CYCLE_ID,
UQ_F_RESET_NONCE_HISTORY,
UQ_F_PRIOR_B0,
UQ_F_PRIOR_CYCLE_ID,
UQ_F_PRIOR_LOCK_KIND,
UQ_F_PRIOR_LOCK_TIME,
UQ_F_RESERVE_STATE,
UQ_F_RESERVE_MONEY,
UQ_F_RESERVE_TICKET,
UQ_F_RESERVE_SYMBOL,
UQ_F_RESERVE_TIME,
UQ_F_RESERVE_ID,
UQ_F_RESERVE_MAGIC,
UQ_F_RESERVE_SCOPE,
UQ_F_RESERVE_DIRECTION,
UQ_F_RESERVE_VOLUME,
UQ_F_RESERVE_ENTRY,
UQ_F_RESERVE_STOP,
UQ_F_RESERVE_DEAL,
UQ_F_RESERVE_POSITION,
UQ_F_STATE_REVISION,
UQ_F_RECOVERY_HOLD,
UQ_F_RECOVERY_TIME,
UQ_F_MONITOR_TIME,
UQ_F_MONITOR_GAP,
UQ_F_GAP_HOLD,
UQ_F_GAP_HOLD_TIME,
//--- schema 8. The V2 identity is appended LAST on purpose: every earlier
//--- schema hashes only the leading N fields of this table, so inserting a
//--- field anywhere else would silently change what an old checksum covers
//--- and no previously persisted state could be verified any more.
UQ_F_IDENTITY_V2,
UQ_F_COUNT
};
//+------------------------------------------------------------------+
//+------------------------------------------------------------------+
//| Number of fields written by the schemas that predate the risk- |
//| cycle schema. They lead the canonical table, so a legacy checksum |
//| is recomputed over exactly the block that produced it. |
//+------------------------------------------------------------------+
#define UQ_STATE_BASE_FIELDS 18
#define UQ_STATE_CYCLE_FIELDS 25
#define UQ_STATE_RESERVE_FIELDS 30
#define UQ_STATE_IDENTITY_FIELDS 39
#define UQ_STATE_GENERATION_FIELDS 42
#define UQ_STATE_MONITOR_FIELDS 46
#define UQ_STATE_V2_FIELDS 47
//+------------------------------------------------------------------+
//| Number of fields that a given schema serialises. A checksum is |
//| only valid over the exact block that produced it, so the field |
//| count is derived from the schema version and never from the |
//| current build. |
//+------------------------------------------------------------------+
int UQFieldCountForFormat(const int fmt)
{
if(fmt >= UQ_STATE_FORMAT)
return((int)UQ_F_COUNT);
if(fmt >= UQ_STATE_MONITOR_FORMAT)
return((int)UQ_STATE_MONITOR_FIELDS);
if(fmt >= UQ_STATE_GENERATION_FORMAT)
return((int)UQ_STATE_GENERATION_FIELDS);
if(fmt >= UQ_STATE_IDENTITY_FORMAT)
return((int)UQ_STATE_IDENTITY_FIELDS);
if(fmt == UQ_STATE_RESERVE_FORMAT)
return((int)UQ_STATE_RESERVE_FIELDS);
if(fmt == UQ_STATE_CYCLE_FORMAT)
return((int)UQ_STATE_CYCLE_FIELDS);
return((int)UQ_STATE_BASE_FIELDS);
}
//+------------------------------------------------------------------+
//| Reset a state structure to defaults |
//+------------------------------------------------------------------+
void UQStateReset(UQRiskState &st)
{
st.format = UQ_STATE_FORMAT;
st.identity = "";
st.identityV2 = "";
st.baselineBalance = 0.0;
st.baselineValid = false;
st.baselineTime = 0;
st.dailyStartEquity = 0.0;
st.dailyValid = false;
st.dailyDate = 0;
st.dailyStartTime = 0;
st.dailyLock = false;
st.dailyLockTime = 0;
st.staticLock = false;
st.staticLockTime = 0;
st.targetLock = false;
st.targetLockTime = 0;
st.resetSignature = "";
st.createdAt = 0;
st.updatedAt = 0;
st.cycleSequence = 0;
st.cycleId = "";
st.resetNonceHistory = "";
st.priorBaselineBalance = 0.0;
st.priorCycleId = "";
st.priorLockKind = 0;
st.priorLockTime = 0;
st.reserveState = UQ_RESERVE_NONE;
st.reserveMoney = 0.0;
st.reserveTicket = 0;
st.reserveSymbol = "";
st.reserveTime = 0;
st.reserveId = "";
st.reserveMagic = 0;
st.reserveScope = "";
st.reserveDirection = 0;
st.reserveVolume = 0.0;
st.reserveEntry = 0.0;
st.reserveStop = 0.0;
st.reserveDeal = 0;
st.reservePosition = 0;
st.stateRevision = 0;
st.recoveryHold = false;
st.recoveryHoldTime = 0;
st.monitorTime = 0;
st.monitorGapSeconds = 0;
st.gapHold = false;
st.gapHoldTime = 0;
}
//+------------------------------------------------------------------+
//| Canonical key of a persisted field |
//+------------------------------------------------------------------+
string UQFieldKey(const int idx)
{
switch(idx)
{
case UQ_F_FORMAT: return("format");
case UQ_F_IDENTITY: return("identity");
case UQ_F_BASELINE_BALANCE: return("baselineBalance");
case UQ_F_BASELINE_VALID: return("baselineValid");
case UQ_F_BASELINE_TIME: return("baselineTime");
case UQ_F_DAILY_EQUITY: return("dailyStartEquity");
case UQ_F_DAILY_VALID: return("dailyValid");
case UQ_F_DAILY_DATE: return("dailyDate");
case UQ_F_DAILY_START_TIME: return("dailyStartTime");
case UQ_F_DAILY_LOCK: return("dailyLock");
case UQ_F_DAILY_LOCK_TIME: return("dailyLockTime");
case UQ_F_STATIC_LOCK: return("staticLock");
case UQ_F_STATIC_LOCK_TIME: return("staticLockTime");
case UQ_F_TARGET_LOCK: return("targetLock");
case UQ_F_TARGET_LOCK_TIME: return("targetLockTime");
case UQ_F_RESET_SIGNATURE: return("resetSignature");
case UQ_F_CREATED_AT: return("createdAt");
case UQ_F_UPDATED_AT: return("updatedAt");
case UQ_F_CYCLE_SEQ: return("cycleSequence");
case UQ_F_CYCLE_ID: return("cycleId");
case UQ_F_RESET_NONCE_HISTORY: return("resetNonceHistory");
case UQ_F_PRIOR_B0: return("priorBaselineBalance");
case UQ_F_PRIOR_CYCLE_ID: return("priorCycleId");
case UQ_F_PRIOR_LOCK_KIND: return("priorLockKind");
case UQ_F_PRIOR_LOCK_TIME: return("priorLockTime");
case UQ_F_RESERVE_STATE: return("reserveState");
case UQ_F_RESERVE_MONEY: return("reserveMoney");
case UQ_F_RESERVE_TICKET: return("reserveTicket");
case UQ_F_RESERVE_SYMBOL: return("reserveSymbol");
case UQ_F_RESERVE_TIME: return("reserveTime");
case UQ_F_RESERVE_ID: return("reserveId");
case UQ_F_RESERVE_MAGIC: return("reserveMagic");
case UQ_F_RESERVE_SCOPE: return("reserveScope");
case UQ_F_RESERVE_DIRECTION: return("reserveDirection");
case UQ_F_RESERVE_VOLUME: return("reserveVolume");
case UQ_F_RESERVE_ENTRY: return("reserveEntry");
case UQ_F_RESERVE_STOP: return("reserveStop");
case UQ_F_RESERVE_DEAL: return("reserveDeal");
case UQ_F_RESERVE_POSITION: return("reservePosition");
case UQ_F_STATE_REVISION: return("stateRevision");
case UQ_F_RECOVERY_HOLD: return("recoveryHold");
case UQ_F_RECOVERY_TIME: return("recoveryHoldTime");
case UQ_F_MONITOR_TIME: return("monitorTime");
case UQ_F_MONITOR_GAP: return("monitorGapSeconds");
case UQ_F_GAP_HOLD: return("gapHold");
case UQ_F_GAP_HOLD_TIME: return("gapHoldTime");
case UQ_F_IDENTITY_V2: return("identityV2");
}
return("");
}
//+------------------------------------------------------------------+
//| Index of a persisted field, or -1 when unknown |
//+------------------------------------------------------------------+
int UQFieldIndex(const string key)
{
for(int i = 0; i < (int)UQ_F_COUNT; i++)
if(UQFieldKey(i) == key)
return(i);
return(-1);
}
//+------------------------------------------------------------------+
//| Canonical serialised value of one persisted field |
//+------------------------------------------------------------------+
string UQStateFieldValue(UQRiskState &st, const int idx)
{
switch(idx)
{
case UQ_F_FORMAT: return(IntegerToString(st.format));
case UQ_F_IDENTITY: return(st.identity);
case UQ_F_BASELINE_BALANCE: return(DoubleToString(st.baselineBalance, 8));
case UQ_F_BASELINE_VALID: return(st.baselineValid ? "1" : "0");
case UQ_F_BASELINE_TIME: return(IntegerToString((long)st.baselineTime));
case UQ_F_DAILY_EQUITY: return(DoubleToString(st.dailyStartEquity, 8));
case UQ_F_DAILY_VALID: return(st.dailyValid ? "1" : "0");
case UQ_F_DAILY_DATE: return(IntegerToString((long)st.dailyDate));
case UQ_F_DAILY_START_TIME: return(IntegerToString((long)st.dailyStartTime));
case UQ_F_DAILY_LOCK: return(st.dailyLock ? "1" : "0");
case UQ_F_DAILY_LOCK_TIME: return(IntegerToString((long)st.dailyLockTime));
case UQ_F_STATIC_LOCK: return(st.staticLock ? "1" : "0");
case UQ_F_STATIC_LOCK_TIME: return(IntegerToString((long)st.staticLockTime));
case UQ_F_TARGET_LOCK: return(st.targetLock ? "1" : "0");
case UQ_F_TARGET_LOCK_TIME: return(IntegerToString((long)st.targetLockTime));
case UQ_F_RESET_SIGNATURE: return(st.resetSignature);
case UQ_F_CREATED_AT: return(IntegerToString((long)st.createdAt));
case UQ_F_UPDATED_AT: return(IntegerToString((long)st.updatedAt));
case UQ_F_CYCLE_SEQ: return(IntegerToString((long)st.cycleSequence));
case UQ_F_CYCLE_ID: return(st.cycleId);
case UQ_F_RESET_NONCE_HISTORY: return(st.resetNonceHistory);
case UQ_F_PRIOR_B0: return(DoubleToString(st.priorBaselineBalance, 8));
case UQ_F_PRIOR_CYCLE_ID: return(st.priorCycleId);
case UQ_F_PRIOR_LOCK_KIND: return(IntegerToString((long)st.priorLockKind));
case UQ_F_PRIOR_LOCK_TIME: return(IntegerToString((long)st.priorLockTime));
case UQ_F_RESERVE_STATE: return(IntegerToString((long)st.reserveState));
case UQ_F_RESERVE_MONEY: return(DoubleToString(st.reserveMoney, 8));
case UQ_F_RESERVE_TICKET: return(IntegerToString((long)st.reserveTicket));
case UQ_F_RESERVE_SYMBOL: return(st.reserveSymbol);
case UQ_F_RESERVE_TIME: return(IntegerToString((long)st.reserveTime));
case UQ_F_RESERVE_ID: return(st.reserveId);
case UQ_F_RESERVE_MAGIC: return(IntegerToString((long)st.reserveMagic));
case UQ_F_RESERVE_SCOPE: return(st.reserveScope);
case UQ_F_RESERVE_DIRECTION: return(IntegerToString((long)st.reserveDirection));
case UQ_F_RESERVE_VOLUME: return(DoubleToString(st.reserveVolume, 8));
case UQ_F_RESERVE_ENTRY: return(DoubleToString(st.reserveEntry, 8));
case UQ_F_RESERVE_STOP: return(DoubleToString(st.reserveStop, 8));
case UQ_F_RESERVE_DEAL: return(IntegerToString((long)st.reserveDeal));
case UQ_F_RESERVE_POSITION: return(IntegerToString((long)st.reservePosition));
case UQ_F_STATE_REVISION: return(IntegerToString(st.stateRevision));
case UQ_F_RECOVERY_HOLD: return(st.recoveryHold ? "1" : "0");
case UQ_F_RECOVERY_TIME: return(IntegerToString((long)st.recoveryHoldTime));
case UQ_F_MONITOR_TIME: return(IntegerToString((long)st.monitorTime));
case UQ_F_MONITOR_GAP: return(IntegerToString((long)st.monitorGapSeconds));
case UQ_F_GAP_HOLD: return(st.gapHold ? "1" : "0");
case UQ_F_GAP_HOLD_TIME: return(IntegerToString((long)st.gapHoldTime));
case UQ_F_IDENTITY_V2: return(st.identityV2);
}
return("");
}
//+------------------------------------------------------------------+
//| Canonical block that is covered by the integrity checksum |
//+------------------------------------------------------------------+
string UQStateCanonicalBody(UQRiskState &st)
{
//--- every published schema is hashed over its own field set only, so an
//--- older checksum still verifies and can be migrated deterministically
const int fields = UQFieldCountForFormat(st.format);
string body = "";
for(int i = 0; i < fields; i++)
body += UQFieldKey(i) + "=" + UQStateFieldValue(st, i) + "\r\n";
return(body);
}
//+------------------------------------------------------------------+
//| Strict integer parser: rejects empty, blank and non-numeric text |
//+------------------------------------------------------------------+
bool UQParseLong(const string v, long &out)
{
out = 0;
const int n = StringLen(v);
if(n <= 0 || n > 20)
return(false);
int start = 0;
const ushort first = StringGetCharacter(v, 0);
if(first == '-' || first == '+')
start = 1;
if(start >= n)
return(false);
int digits = 0;
for(int i = start; i < n; i++)
{
const ushort c = StringGetCharacter(v, i);
if(c < '0' || c > '9')
return(false);
digits++;
}
if(digits == 0)
return(false);
out = StringToInteger(v);
return(true);
}
//+------------------------------------------------------------------+
//| Strict double parser: one optional leading sign, one decimal |
//| point, at least one digit and no trailing garbage |
//+------------------------------------------------------------------+
bool UQParseDoubleStrict(const string v, double &out)
{
out = 0.0;
const int n = StringLen(v);
if(n <= 0 || n > 40)
return(false);
int start = 0;
const ushort first = StringGetCharacter(v, 0);
if(first == '-' || first == '+')
start = 1;
if(start >= n)
return(false);
int digits = 0;
int points = 0;
for(int i = start; i < n; i++)
{
const ushort c = StringGetCharacter(v, i);
if(c == '.')
{
points++;
if(points > 1)
return(false);
continue;
}
if(c < '0' || c > '9')
return(false);
digits++;
}
if(digits == 0)
return(false);
out = StringToDouble(v);
return(MathIsValidNumber(out));
}
//+------------------------------------------------------------------+
//| Strict boolean parser: only the literals 0 and 1 are accepted |
//+------------------------------------------------------------------+
bool UQParseBoolStrict(const string v, bool &out)
{
out = false;
if(v == "0")
return(true);
if(v == "1")
{
out = true;
return(true);
}
return(false);
}
//+------------------------------------------------------------------+
//| Parse a state block into a structure. Every required field must |
//| be present exactly once; unknown fields are rejected. |
//+------------------------------------------------------------------+
bool UQStateParseBody(const string body, UQRiskState &st,
bool &hasChecksum, string &checksumStored, string &err)
{
hasChecksum = false;
checksumStored = "";
err = "";
UQStateReset(st);
char seen[];
ArrayResize(seen, (int)UQ_F_COUNT);
ArrayInitialize(seen, (char)0);
string lines[];
const int n = StringSplit(body, '\n', lines);
for(int i = 0; i < n; i++)
{
string line = lines[i];
StringTrimRight(line);
StringTrimLeft(line);
if(StringLen(line) == 0)
continue;
const int eq = StringFind(line, "=");
if(eq <= 0)
{
err = StringFormat("malformed line %d", i + 1);
return(false);
}
const string key = StringSubstr(line, 0, eq);
const string val = StringSubstr(line, eq + 1);
if(key == UQ_STATE_CHECKSUM_KEY)
{
if(hasChecksum)
{
err = "duplicate checksum field";
return(false);
}
if(StringLen(val) != UQ_STATE_CHECKSUM_LENGTH)
{
err = "malformed checksum field";
return(false);
}
hasChecksum = true;
checksumStored = val;
continue;
}
const int idx = UQFieldIndex(key);
if(idx < 0)
{
err = StringFormat("unknown field '%s'", key);
return(false);
}
if(seen[idx] != 0)
{
err = StringFormat("duplicate field '%s'", key);
return(false);
}
seen[idx] = (char)1;
long iv = 0;
double dv = 0.0;
bool bv = false;
switch(idx)
{
case UQ_F_FORMAT:
if(!UQParseLong(val, iv))
{ err = "field 'format' is not an integer"; return(false); }
st.format = (int)iv;
break;
case UQ_F_IDENTITY:
st.identity = val;
break;
case UQ_F_BASELINE_BALANCE:
if(!UQParseDoubleStrict(val, dv))
{ err = "field 'baselineBalance' is not numeric"; return(false); }
st.baselineBalance = dv;
break;
case UQ_F_BASELINE_VALID:
if(!UQParseBoolStrict(val, bv))
{ err = "field 'baselineValid' is not a 0/1 flag"; return(false); }
st.baselineValid = bv;
break;
case UQ_F_BASELINE_TIME:
if(!UQParseLong(val, iv))
{ err = "field 'baselineTime' is not an integer"; return(false); }
st.baselineTime = (datetime)iv;
break;
case UQ_F_DAILY_EQUITY:
if(!UQParseDoubleStrict(val, dv))
{ err = "field 'dailyStartEquity' is not numeric"; return(false); }
st.dailyStartEquity = dv;
break;
case UQ_F_DAILY_VALID:
if(!UQParseBoolStrict(val, bv))
{ err = "field 'dailyValid' is not a 0/1 flag"; return(false); }
st.dailyValid = bv;
break;
case UQ_F_DAILY_DATE:
if(!UQParseLong(val, iv))
{ err = "field 'dailyDate' is not an integer"; return(false); }
st.dailyDate = (datetime)iv;
break;
case UQ_F_DAILY_START_TIME:
if(!UQParseLong(val, iv))
{ err = "field 'dailyStartTime' is not an integer"; return(false); }
st.dailyStartTime = (datetime)iv;
break;
case UQ_F_DAILY_LOCK:
if(!UQParseBoolStrict(val, bv))
{ err = "field 'dailyLock' is not a 0/1 flag"; return(false); }
st.dailyLock = bv;
break;
case UQ_F_DAILY_LOCK_TIME:
if(!UQParseLong(val, iv))
{ err = "field 'dailyLockTime' is not an integer"; return(false); }
st.dailyLockTime = (datetime)iv;
break;
case UQ_F_STATIC_LOCK:
if(!UQParseBoolStrict(val, bv))
{ err = "field 'staticLock' is not a 0/1 flag"; return(false); }
st.staticLock = bv;
break;
case UQ_F_STATIC_LOCK_TIME:
if(!UQParseLong(val, iv))
{ err = "field 'staticLockTime' is not an integer"; return(false); }
st.staticLockTime = (datetime)iv;
break;
case UQ_F_TARGET_LOCK:
if(!UQParseBoolStrict(val, bv))
{ err = "field 'targetLock' is not a 0/1 flag"; return(false); }
st.targetLock = bv;
break;
case UQ_F_TARGET_LOCK_TIME:
if(!UQParseLong(val, iv))
{ err = "field 'targetLockTime' is not an integer"; return(false); }
st.targetLockTime = (datetime)iv;
break;
case UQ_F_RESET_SIGNATURE:
st.resetSignature = val;
break;
case UQ_F_CREATED_AT:
if(!UQParseLong(val, iv))
{ err = "field 'createdAt' is not an integer"; return(false); }
st.createdAt = (datetime)iv;
break;
case UQ_F_UPDATED_AT:
if(!UQParseLong(val, iv))
{ err = "field 'updatedAt' is not an integer"; return(false); }
st.updatedAt = (datetime)iv;
break;
case UQ_F_CYCLE_SEQ:
if(!UQParseLong(val, iv))
{ err = "field 'cycleSequence' is not an integer"; return(false); }
st.cycleSequence = (int)iv;
break;
case UQ_F_CYCLE_ID:
st.cycleId = val;
break;
case UQ_F_RESET_NONCE_HISTORY:
st.resetNonceHistory = val;
break;
case UQ_F_PRIOR_B0:
if(!UQParseDoubleStrict(val, dv))
{ err = "field 'priorBaselineBalance' is not numeric"; return(false); }
st.priorBaselineBalance = dv;
break;
case UQ_F_PRIOR_CYCLE_ID:
st.priorCycleId = val;
break;
case UQ_F_PRIOR_LOCK_KIND:
if(!UQParseLong(val, iv))
{ err = "field 'priorLockKind' is not an integer"; return(false); }
st.priorLockKind = (int)iv;
break;
case UQ_F_PRIOR_LOCK_TIME:
if(!UQParseLong(val, iv))
{ err = "field 'priorLockTime' is not an integer"; return(false); }
st.priorLockTime = (datetime)iv;
break;
case UQ_F_RESERVE_STATE:
if(!UQParseLong(val, iv))
{ err = "field 'reserveState' is not an integer"; return(false); }
st.reserveState = (ENUM_UQ_RESERVE_STATE)iv;
break;
case UQ_F_RESERVE_MONEY:
if(!UQParseDoubleStrict(val, dv))
{ err = "field 'reserveMoney' is not numeric"; return(false); }
st.reserveMoney = dv;
break;
case UQ_F_RESERVE_TICKET:
if(!UQParseLong(val, iv))
{ err = "field 'reserveTicket' is not an integer"; return(false); }
st.reserveTicket = (ulong)iv;
break;
case UQ_F_RESERVE_SYMBOL:
st.reserveSymbol = val;
break;
case UQ_F_RESERVE_TIME:
if(!UQParseLong(val, iv))
{ err = "field 'reserveTime' is not an integer"; return(false); }
st.reserveTime = (datetime)iv;
break;
case UQ_F_RESERVE_ID:
st.reserveId = val;
break;
case UQ_F_RESERVE_MAGIC:
if(!UQParseLong(val, iv) || iv < 0)
{ err = "field 'reserveMagic' is not an integer"; return(false); }
st.reserveMagic = (ulong)iv;
break;
case UQ_F_RESERVE_SCOPE:
st.reserveScope = val;
break;
case UQ_F_RESERVE_DIRECTION:
if(!UQParseLong(val, iv))
{ err = "field 'reserveDirection' is not an integer"; return(false); }
st.reserveDirection = (int)iv;
break;
case UQ_F_RESERVE_VOLUME:
if(!UQParseDoubleStrict(val, dv))
{ err = "field 'reserveVolume' is not numeric"; return(false); }
st.reserveVolume = dv;
break;
case UQ_F_RESERVE_ENTRY:
if(!UQParseDoubleStrict(val, dv))
{ err = "field 'reserveEntry' is not numeric"; return(false); }
st.reserveEntry = dv;
break;
case UQ_F_RESERVE_STOP:
if(!UQParseDoubleStrict(val, dv))
{ err = "field 'reserveStop' is not numeric"; return(false); }
st.reserveStop = dv;
break;
case UQ_F_RESERVE_DEAL:
if(!UQParseLong(val, iv) || iv < 0)
{ err = "field 'reserveDeal' is not an integer"; return(false); }
st.reserveDeal = (ulong)iv;
break;
case UQ_F_RESERVE_POSITION:
if(!UQParseLong(val, iv) || iv < 0)
{ err = "field 'reservePosition' is not an integer"; return(false); }
st.reservePosition = (ulong)iv;
break;
case UQ_F_STATE_REVISION:
if(!UQParseLong(val, iv) || iv < 0)
{ err = "field 'stateRevision' is not an integer"; return(false); }
st.stateRevision = (long)iv;
break;
case UQ_F_RECOVERY_HOLD:
if(!UQParseBoolStrict(val, bv))
{ err = "field 'recoveryHold' is not a 0/1 flag"; return(false); }
st.recoveryHold = bv;
break;
case UQ_F_RECOVERY_TIME:
if(!UQParseLong(val, iv) || iv < 0)
{ err = "field 'recoveryHoldTime' is not an integer"; return(false); }
st.recoveryHoldTime = (datetime)iv;
break;
case UQ_F_MONITOR_TIME:
if(!UQParseLong(val, iv) || iv < 0)
{ err = "field 'monitorTime' is not an integer"; return(false); }
st.monitorTime = (datetime)iv;
break;
case UQ_F_MONITOR_GAP:
if(!UQParseLong(val, iv) || iv < 0 || iv > (long)UQ_MONITOR_GAP_MAX_SECONDS)
{ err = "field 'monitorGapSeconds' is not a bounded integer"; return(false); }
st.monitorGapSeconds = (int)iv;
break;
case UQ_F_GAP_HOLD:
if(!UQParseBoolStrict(val, bv))
{ err = "field 'gapHold' is not a 0/1 flag"; return(false); }
st.gapHold = bv;
break;
case UQ_F_GAP_HOLD_TIME:
if(!UQParseLong(val, iv) || iv < 0)
{ err = "field 'gapHoldTime' is not an integer"; return(false); }
st.gapHoldTime = (datetime)iv;
break;
case UQ_F_IDENTITY_V2:
//--- the complete 64 character fingerprint is required, so a
//--- truncated or empty value can never pass as an identity
if(!UQIdentityFingerprintValid(val))
{ err = "field 'identityV2' is not a 64 character fingerprint"; return(false); }
st.identityV2 = val;
break;
default:
err = "unexpected field index";
return(false);
}
}
//--- a schema published before the risk-cycle fields existed carries no
//--- cycle block: only its own field set is mandatory
const int required = UQFieldCountForFormat(st.format);
for(int f = 0; f < required; f++)
if(seen[f] == 0)
{
err = StringFormat("required field '%s' is missing", UQFieldKey(f));
return(false);
}
return(true);
}
//+------------------------------------------------------------------+
//| Schema and semantics validation of a parsed state |
//+------------------------------------------------------------------+
bool UQStateValidate(UQIdentity &id, UQRiskState &st, string &err)
{
err = "";
if(st.format < UQ_STATE_LEGACY_FORMAT || st.format > UQ_STATE_FORMAT)
{
err = StringFormat("unsupported state format %d", st.format);
return(false);
}
//--- the legacy identity field keeps its exact previous meaning and is
//--- validated for every schema, including schema 8
if(st.identity != id.hash)
{
err = "state belongs to a different identity scope";
return(false);
}
if(st.format < UQ_STATE_FORMAT)
{
//--- a state written before schema 8 has no V2 identity, and its checksum
//--- does not cover one: a value appearing there was never proven
if(StringLen(st.identityV2) > 0)
{
err = "legacy state unexpectedly carries a V2 identity";
return(false);
}
}
else
{
//--- schema 8 must carry the complete V2 fingerprint of exactly this
//--- logical identity. An empty or missing value is never accepted.
if(!UQIdentityFingerprintValid(st.identityV2))
{
err = "V2 identity fingerprint is missing or malformed";
return(false);
}
if(!UQIdentityFingerprintValid(id.fingerprintV2))
{
err = "the live V2 identity fingerprint is unavailable";
return(false);
}
if(st.identityV2 != id.fingerprintV2)
{
err = "state belongs to a different V2 identity scope";
return(false);
}
}
if(st.createdAt <= 0)
{
err = "state creation timestamp is missing";
return(false);
}
if(st.updatedAt < st.createdAt)
{
err = "state update timestamp precedes the creation timestamp";
return(false);
}
const datetime now = UQServerNow();
if(now > 0 && st.updatedAt > now + (datetime)UQ_STATE_MAX_CLOCK_SKEW)
{
err = "state update timestamp lies implausibly far in the future";
return(false);
}
//--- fixed baseline
if(!st.baselineValid)
{
err = "baseline validity flag is not set";
return(false);
}
if(!(st.baselineBalance > 0.0))
{
err = "baseline B0 is not positive";
return(false);
}
if(st.baselineTime <= 0)
{
err = "baseline timestamp is missing";
return(false);
}
//--- daily baseline
if(!st.dailyValid)
{
err = "daily validity flag is not set";
return(false);
}
if(!(st.dailyStartEquity > 0.0))
{
err = "daily baseline D0 is not positive";
return(false);
}
if(st.dailyDate <= 0)
{
err = "broker-server trading date is missing";
return(false);
}
if(UQDayStart(st.dailyDate) != st.dailyDate)
{
err = "broker-server trading date is not day-aligned";
return(false);
}
if(st.dailyStartTime <= 0)
{
err = "daily baseline timestamp is missing";
return(false);
}
//--- lock consistency
if(st.dailyLock && st.dailyLockTime <= 0)
{
err = "daily lock is active without a consistent lock event";
return(false);
}
if(!st.dailyLock && st.dailyLockTime != 0)
{
err = "daily lock time is set while the daily lock is inactive";
return(false);
}
if(st.staticLock && st.staticLockTime <= 0)
{
err = "static-drawdown lock is active without a consistent lock event";
return(false);
}
if(!st.staticLock && st.staticLockTime != 0)
{
err = "static-drawdown lock time is set while the lock is inactive";
return(false);
}
if(st.targetLock && st.targetLockTime <= 0)
{
err = "equity-target lock is active without a consistent lock event";
return(false);
}
if(!st.targetLock && st.targetLockTime != 0)
{
err = "equity-target lock time is set while the lock is inactive";
return(false);
}
if(st.staticLock && st.targetLock)
{
//--- the two permanent locks are independent: an equity target can be
//--- reached before a drawdown (or the reverse), and neither may be lost
//--- just because the other is also armed. The entry gate reports the
//--- static-drawdown lock first, and liquidation is required either way.
if(!(st.staticLockTime > 0 && st.targetLockTime > 0))
{
err = "both permanent locks are active without consistent lock events";
return(false);
}
}
//--- reset identifier must stay a single serialisable token
if(StringLen(st.resetSignature) > UQ_STATE_MAX_RESET_SIG)
{
err = "reset-operation identifier is too long";
return(false);
}
if(StringFind(st.resetSignature, "=") >= 0 ||
StringFind(st.resetSignature, "\r") >= 0 ||
StringFind(st.resetSignature, "\n") >= 0)
{
err = "reset-operation identifier contains a forbidden character";
return(false);
}
//--- risk-cycle identity, only present from the cycle schema onwards
if(st.format >= UQ_STATE_CYCLE_FORMAT)
{
if(st.cycleSequence < 1)
{
err = "risk-cycle sequence is not positive";
return(false);
}
if(StringLen(st.cycleId) <= 0 || StringLen(st.cycleId) > UQ_RESET_CYCLE_ID_MAX_LEN)
{
err = "risk-cycle identifier is missing or too long";
return(false);
}
if(StringFind(st.cycleId, "=") >= 0 ||
StringFind(st.cycleId, "|") >= 0 ||
StringFind(st.cycleId, "\r") >= 0 ||
StringFind(st.cycleId, "\n") >= 0)
{
err = "risk-cycle identifier contains a forbidden character";
return(false);
}
//--- consumed reset nonces
if(StringLen(st.resetNonceHistory) > UQ_RESET_HISTORY_MAX_CHARS)
{
err = "consumed reset-nonce history is too long";
return(false);
}
if(StringLen(st.resetNonceHistory) > 0)
{
if(UQResetNonceHistoryCount(st.resetNonceHistory) > UQ_RESET_HISTORY_MAX)
{
err = "consumed reset-nonce history holds too many entries";
return(false);
}
string nonces[];
const int nn = StringSplit(st.resetNonceHistory, (ushort)'|', nonces);
for(int k = 0; k < nn; k++)
{
if(StringLen(nonces[k]) == 0)
continue;
if(!UQResetNonceValid(nonces[k]))
{
err = "consumed reset-nonce history holds an invalid nonce";
return(false);
}
}
}
//--- reference to the cycle that the last reset closed.
//--- The recorded kind is a bit field: 1 = static-drawdown, 2 =
//--- equity-target, 3 = both. A cycle can legitimately end with both
//--- permanent locks armed, so value 3 is accepted and preserved instead
//--- of rejecting the whole state and losing the surviving lock.
if(!UQPriorLockKindValid(st.priorLockKind))
{
err = "recorded prior lock kind is not recognized";
return(false);
}
if(st.priorLockKind == UQ_PRIOR_LOCK_NONE)
{
if(st.priorLockTime != 0 || StringLen(st.priorCycleId) > 0 ||
st.priorBaselineBalance != 0.0)
{
err = "prior-cycle reference is recorded without a prior lock event";
return(false);
}
}
else
{
if(st.priorLockTime <= 0)
{
err = "prior lock event timestamp is missing";
return(false);
}
if(StringLen(st.priorCycleId) <= 0 ||
StringLen(st.priorCycleId) > UQ_RESET_CYCLE_ID_MAX_LEN)
{
err = "prior cycle identifier is missing or too long";
return(false);
}
if(!(st.priorBaselineBalance > 0.0))
{
err = "prior cycle baseline is not positive";
return(false);
}
//--- a permanent lock of a closed cycle can only exist because an
//--- owner reset closed that cycle, and every applied reset consumes
//--- exactly one nonce: the recorded history must prove it
if(UQResetNonceHistoryCount(st.resetNonceHistory) < 1)
{
err = "recorded prior lock kind has no consumed reset nonce";
return(false);
}
if(st.priorCycleId == st.cycleId || st.priorLockTime > st.createdAt)
{
err = "prior-cycle reference is inconsistent with the current cycle";
return(false);
}
}
if(st.cycleSequence == 1 && st.priorLockKind != UQ_PRIOR_LOCK_NONE)
{
err = "the first risk cycle cannot reference a previous cycle";
return(false);
}
}
//--- entry-risk reservation, only present from the reservation schema onwards
if(st.format >= UQ_STATE_RESERVE_FORMAT)
{
if(st.reserveState != UQ_RESERVE_NONE &&
st.reserveState != UQ_RESERVE_SENT &&
st.reserveState != UQ_RESERVE_UNRESOLVED)
{
err = "entry-risk reservation state is not recognized";
return(false);
}
if(st.reserveState == UQ_RESERVE_NONE)
{
if(st.reserveMoney != 0.0 || st.reserveTicket != 0 ||
StringLen(st.reserveSymbol) > 0 || st.reserveTime != 0)
{
err = "entry-risk reservation is recorded while its state is empty";
return(false);
}
}
else
{
if(!(st.reserveMoney > 0.0))
{
err = "reserved entry risk is not positive";
return(false);
}
if(st.reserveTime <= 0)
{
err = "reserved entry risk has no timestamp";
return(false);
}
if(StringLen(st.reserveSymbol) <= 0 ||
StringLen(st.reserveSymbol) > UQ_RESERVE_SYMBOL_MAX_LEN)
{
err = "reserved entry symbol is missing or too long";
return(false);
}
if(StringFind(st.reserveSymbol, "=") >= 0 ||
StringFind(st.reserveSymbol, "|") >= 0 ||
StringFind(st.reserveSymbol, "\r") >= 0 ||
StringFind(st.reserveSymbol, "\n") >= 0)
{
err = "reserved entry symbol contains a forbidden character";
return(false);
}
//--- a reservation is persisted before the order is submitted, so it
//--- legitimately carries no ticket until the server has answered
}
}
//--- reservation identity, only present from the identity schema onwards.
//--- A reserved request that cannot be correlated with a broker record
//--- must be recorded as UNRESOLVED; it may never look like a normal
//--- submitted request that the reconciler could quietly release.
if(st.format >= UQ_STATE_IDENTITY_FORMAT)
{
if(st.reserveState == UQ_RESERVE_NONE)
{
if(StringLen(st.reserveId) > 0 || st.reserveMagic != 0 ||
StringLen(st.reserveScope) > 0 || st.reserveDirection != 0 ||
st.reserveVolume != 0.0 || st.reserveEntry != 0.0 ||
st.reserveStop != 0.0 || st.reserveDeal != 0 ||
st.reservePosition != 0)
{
err = "reservation identity is recorded while its state is empty";
return(false);
}
}
else
{
if(!UQReserveTextSafe(st.reserveId, UQ_RESERVE_ID_MAX_LEN))
{
err = "reservation identifier is missing or malformed";
return(false);
}
if(StringLen(st.reserveScope) > UQ_RESERVE_SCOPE_MAX_LEN ||
(StringLen(st.reserveScope) > 0 && !UQReserveTextSafe(st.reserveScope, UQ_RESERVE_SCOPE_MAX_LEN)))
{
err = "reservation scope is malformed";
return(false);
}
if(st.reserveDirection != -1 && st.reserveDirection != 0 &&
st.reserveDirection != 1)
{
err = "reservation direction is not buy, sell or unknown";
return(false);
}
if(!MathIsValidNumber(st.reserveVolume) || st.reserveVolume < 0.0 ||
!MathIsValidNumber(st.reserveEntry) || st.reserveEntry < 0.0 ||
!MathIsValidNumber(st.reserveStop) || st.reserveStop < 0.0)
{
err = "reservation price or volume is not a finite non-negative number";
return(false);
}
//--- a request that is still expected to produce exposure must carry
//--- the identity of that exposure, otherwise it is UNRESOLVED
if(st.reserveState == UQ_RESERVE_SENT)
{
if(st.reserveDirection == 0 || !(st.reserveVolume > 0.0) ||
!(st.reserveEntry > 0.0) || !(st.reserveStop > 0.0))
{
err = "a submitted reservation carries no correlatable identity";
return(false);
}
if(st.reserveDirection > 0 && !(st.reserveStop < st.reserveEntry))
{
err = "a buy reservation carries a stop that is not below its entry";
return(false);
}
if(st.reserveDirection < 0 && !(st.reserveStop > st.reserveEntry))
{
err = "a sell reservation carries a stop that is not above its entry";
return(false);
}
}
}
}
//--- persistence generation, only present from the generation schema
//--- onwards. A state without a positive generation can neither prove that
//--- it is the newest one nor be ordered against its backup, so it is
//--- refused instead of being trusted.
if(st.format >= UQ_STATE_GENERATION_FORMAT)
{
if(st.stateRevision < 1)
{
err = "state generation revision is not positive";
return(false);
}
if(st.recoveryHold && st.recoveryHoldTime <= 0)
{
err = "recovery hold is active without a consistent hold event";
return(false);
}
if(!st.recoveryHold && st.recoveryHoldTime != 0)
{
err = "recovery hold time is set while no recovery hold is active";
return(false);
}
}
//--- monitoring continuity, only present from the monitoring schema
//--- onwards. A heartbeat in the future, a negative gap or a hold without
//--- an event would make the daily-loss history unverifiable, so the state
//--- is refused instead of being trusted.
if(st.format >= UQ_STATE_MONITOR_FORMAT)
{
if(st.monitorTime > 0 && st.monitorTime > UQServerNow() + (datetime)UQ_STATE_MAX_CLOCK_SKEW)
{
err = "monitoring heartbeat lies implausibly far in the future";
return(false);
}
if(st.monitorGapSeconds < 0 || st.monitorGapSeconds > UQ_MONITOR_GAP_MAX_SECONDS)
{
err = "recorded monitoring gap is out of range";
return(false);
}
if(st.gapHold && st.gapHoldTime <= 0)
{
err = "monitoring-gap hold is active without a consistent hold event";
return(false);
}
if(!st.gapHold && st.gapHoldTime != 0)
{
err = "monitoring-gap hold time is set while no gap hold is active";
return(false);
}
}
return(true);
}
//+------------------------------------------------------------------+
//| Persistence locators |
//| |
//| The V2 locators are derived from the full 256 bit fingerprint and |
//| are the only locators a new state is ever written to. The legacy |
//| locators keep the exact names every previously published build |
//| used, because an existing installation still owns its state file |
//| under that name and must be found (and validated) for migration. |
//| |
//| The two families are separate functions on purpose: no caller can |
//| accidentally derive a V2 name from the lossy legacy key, and the |
//| legacy names are never recomputed from the new fingerprint. |
//+------------------------------------------------------------------+
string UQStateFileName(UQIdentity &id)
{
if(!UQIdentityFingerprintValid(id.fingerprintV2))
return("");
return(UQ_STATE_V2_FILE_PREFIX + id.fingerprintV2 + ".cfg");
}
string UQStateBackupFileName(UQIdentity &id)
{
if(!UQIdentityFingerprintValid(id.fingerprintV2))
return("");
return(UQ_STATE_V2_FILE_PREFIX + id.fingerprintV2 + UQ_STATE_BACKUP_EXT);
}
//+------------------------------------------------------------------+
//| 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, CUQOwnershipVerifier *owner = NULL)
{
//--- a state may only be persisted together with the identity it belongs.
//--- Without a valid V2 fingerprint no locator exists, so saving would write
//--- a shared or empty name: the save fails closed instead.
if(!UQIdentityFingerprintValid(id.fingerprintV2))
{
PrintFormat("[UQ] ERROR: the V2 identity fingerprint is unavailable, so no risk state may be persisted for this scope.");
return(false);
}
st.format = UQ_STATE_FORMAT;
st.identity = id.hash;
st.identityV2 = id.fingerprintV2;
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);
//--- staging write: the first file mutation of the save
if(!UQOwnershipSaveGate(owner, "staged risk state"))
return(false);
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. It is a
//--- separate file mutation, so it is fenced separately: a loss proven
//--- here leaves the backup exactly as it was.
if(!UQOwnershipSaveGate(owner, "backup risk state"))
{
FileDelete(tmp, FILE_COMMON);
return(false);
}
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. The commit is the
//--- authoritative mutation and is fenced on its own.
if(!UQOwnershipSaveGate(owner, "primary risk state"))
{
FileDelete(tmp, FILE_COMMON);
return(false);
}
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. The shared guard is therefore a PERSISTENT coordination |
//| object: once it exists it is never deleted, and every transition of|
//| its value is one compare-and-swap on one strictly increasing value:|
//| |
//| absent --Set(0)----------------> unclaimed (creation once) |
//| unclaimed --CAS(0 -> t)-----------> owned(t) |
//| owned(t) --CAS(t -> t'), t' > t--> owned(t') (heartbeat) |
//| owned(t) --CAS(t -> t'), t' > t--> owned(t') (stale takeover)|
//| owned(t) --CAS(t -> 0)-----------> unclaimed (clean release) |
//| |
//| There is no transition back to "absent" after creation, so no |
//| release, rollback or superseded owner can delete the variable, and |
//| none of them can write a value it does not hold: a failed CAS |
//| leaves a successor's claim untouched. |
//| |
//| The unclaimed sentinel is the exact value 0.0. It is a legitimate |
//| protocol state only for the V2 guard name and only where the caller|
//| explicitly allows it; the legacy guard namespace of previous builds|
//| has no sentinel, so a zero there remains corrupt evidence. Any |
//| other zero, a negative, a fractional, a non-finite or a future |
//| value stays INVALID and is never overwritten. |
//| |
//| The creation of the variable itself cannot be made atomic with |
//| this API, so it is fenced by ballots: every contender announces |
//| itself in its own private ballot variable (a name no other instance|
//| ever writes, so the announcement cannot race) before it touches the|
//| shared guard, and it keeps that ballot visible for as long as it |
//| owns the guard. A contender may create the unclaimed sentinel only |
//| when a scan proves that no other live ballot exists AND a fresh |
//| re-read proves that the guard is still absent; it verifies the |
//| sentinel it wrote, claims it by CAS, and re-scans afterwards. If a |
//| rival is visible it gives the guard back with a CAS to the sentinel|
//| and fails closed - it never deletes and never overwrites. |
//+------------------------------------------------------------------+
#define UQ_GUARD_SENTINEL 0.0
#define UQ_GUARD_BALLOT_PREFIX "UQ2B_"
#define UQ_GUARD_BALLOT_FP_CHARS 40
enum ENUM_UQ_GUARD_STATE
{
UQ_GUARD_ABSENT = 0, // no variable holds this name
UQ_GUARD_ACTIVE = 1, // a live ownership record inside the TTL
UQ_GUARD_STALE = 2, // a valid record whose owner stopped beating
UQ_GUARD_INVALID = 3, // the variable exists but its value is no record
UQ_GUARD_UNCLAIMED = 4 // the exact sentinel: nobody owns this scope
};
enum ENUM_UQ_GUARD_RESULT
{
UQ_GUARD_ACQUIRED = 0, // ownership is proven by this instance
UQ_GUARD_HELD = 1, // another live instance owns the guard
UQ_GUARD_INVALID_EVIDENCE = 2, // the guard holds a value that is not a record
UQ_GUARD_LOST_RACE = 3, // the atomic exchange was won by another contender
UQ_GUARD_CONTENDED = 4, // another contender was creating the guard concurrently
UQ_GUARD_IO_ERROR = 5, // the terminal did not store or return the value
UQ_GUARD_UNUSABLE = 7 // the guard or ballot name cannot be derived
};
//+------------------------------------------------------------------+
//| Classify one ownership record |
//| |
//| Pure, so the exact meaning of every value can be tested apart |
//| from the terminal: absence, a live claim, an abandoned claim, the |
//| unclaimed sentinel and a value that is not a record at all are five|
//| distinct outcomes. |
//+------------------------------------------------------------------+
ENUM_UQ_GUARD_STATE UQGuardClassify(const bool exists, const double value,
const datetime now, const int ttlSeconds,
const bool unclaimedAllowed = false)
{
if(!exists)
return(UQ_GUARD_ABSENT);
if(!MathIsValidNumber(value))
return(UQ_GUARD_INVALID);
//--- the unclaimed sentinel is the one legitimate zero, and only in a
//--- namespace that publishes it. A negative zero is numerically the same
//--- value and is therefore the same protocol state. Every other zero,
//--- every negative value and every fractional value stays INVALID.
if(unclaimedAllowed && value == UQ_GUARD_SENTINEL)
return(UQ_GUARD_UNCLAIMED);
if(value <= 0.0)
return(UQ_GUARD_INVALID);
//--- a heartbeat is a whole number of seconds; anything else was not written
//--- by this protocol and cannot be interpreted as an ownership record
if((double)(long)value != value)
return(UQ_GUARD_INVALID);
//--- a stamp this far ahead of the clock was never produced by a heartbeat
if(value > (double)(TimeLocal() + UQ_STATE_MAX_CLOCK_SKEW))
return(UQ_GUARD_INVALID);
//--- a stamp slightly ahead is still a live claim, never a stale one
if(value >= (double)TimeLocal())
return(UQ_GUARD_ACTIVE);
if((long)((datetime)(long)value) > 0 &&
(long)(TimeLocal() - (datetime)(long)value) < (long)ttlSeconds)
return(UQ_GUARD_ACTIVE);
return(UQ_GUARD_STALE);
}
//+------------------------------------------------------------------+
//| Storage behind the ownership protocol |
//| |
//| The acquisition algorithm never calls the terminal directly, so it |
//| can be driven against an adversarial store in a test without |
//| touching one production global variable. |
//+------------------------------------------------------------------+
class CUQGuardStore
{
public:
virtual bool Exists(const string name) { return(false); }
virtual bool Read(const string name, double &value) { return(false); }
virtual bool Write(const string name, const double value) { return(false); }
virtual bool Swap(const string name, const double value, const double expected) { return(false); }
virtual bool Remove(const string name) { return(false); }
virtual int Names(const string prefix, string &out[]) { return(0); }
};
//+------------------------------------------------------------------+
//| Terminal-backed guard store |
//| |
//| Swap() is GlobalVariableSetOnCondition(): the terminal compares |
//| and replaces the value as one indivisible operation, which is the |
//| documented mutex primitive for Expert Advisors in one terminal. |
//+------------------------------------------------------------------+
class CUQTerminalGuardStore : public CUQGuardStore
{
public:
virtual bool Exists(const string name)
{
return(GlobalVariableCheck(name));
}
virtual bool Read(const string name, double &value)
{
value = 0.0;
if(!GlobalVariableCheck(name))
return(false);
value = GlobalVariableGet(name);
return(true);
}
virtual bool Write(const string name, const double value)
{
if(StringLen(name) <= 0 || StringLen(name) > UQ_GV_NAME_MAX_CHARS)
return(false);
return(GlobalVariableSet(name, value) != 0);
}
virtual bool Swap(const string name, const double value, const double expected)
{
if(StringLen(name) <= 0 || StringLen(name) > UQ_GV_NAME_MAX_CHARS)
return(false);
return(GlobalVariableSetOnCondition(name, value, expected));
}
virtual bool Remove(const string name)
{
return(GlobalVariableDel(name));
}
virtual int Names(const string prefix, string &out[])
{
ArrayResize(out, 0);
if(StringLen(prefix) <= 0)
return(0);
const int total = GlobalVariablesTotal();
for(int i = 0; i < total; i++)
{
const string name = GlobalVariableName(i);
if(StringLen(name) <= 0 || StringFind(name, prefix, 0) != 0)
continue;
const int n = ArraySize(out);
ArrayResize(out, n + 1);
out[n] = name;
}
return(ArraySize(out));
}
};
//+------------------------------------------------------------------+
//| Ballot locators: bounded, identity scoped, one name per instance |
//+------------------------------------------------------------------+
string UQGuardBallotPrefix(const string fingerprintV2)
{
if(!UQIdentityFingerprintValid(fingerprintV2))
return("");
return(UQ_GUARD_BALLOT_PREFIX + StringSubstr(fingerprintV2, 0, UQ_GUARD_BALLOT_FP_CHARS) + "_");
}
string UQGuardBallotName(const string fingerprintV2, const string tag)
{
const string prefix = UQGuardBallotPrefix(fingerprintV2);
if(StringLen(prefix) <= 0 || StringLen(tag) <= 0)
return("");
const string name = prefix + tag;
if(StringLen(name) > UQ_GV_NAME_MAX_CHARS)
return("");
return(name);
}
//+------------------------------------------------------------------+
//| Private tag of this program instance |
//| |
//| The chart identity plus a process-local sequence number makes the |
//| ballot name unique among the instances of one terminal, so no |
//| other instance can ever write it. |
//+------------------------------------------------------------------+
string UQGuardInstanceTag(void)
{
static ulong seq = 0;
seq++;
const string seed = IntegerToString((long)ChartID()) + "|" +
IntegerToString((long)GetTickCount64()) + "|" +
IntegerToString((long)GetMicrosecondCount()) + "|" +
IntegerToString((long)seq);
return(StringSubstr(UQHashString(seed), 0, 8));
}
//+------------------------------------------------------------------+
//| Ownership of one duplicate-instance guard |
//| |
//| The instance keeps the exact value it holds, so every later |
//| transition is a compare-and-swap against that value. A value that |
//| no longer matches proves that another instance took the guard over,|
//| and every method then refuses to touch the guard again. |
//| |
//| The guard variable is never deleted once it exists: a release and a|
//| rollback are both the transition back to the unclaimed sentinel, so |
//| a late contender can never remove or overwrite a successor's claim. |
//| The contender's own ballot, by contrast, is removed as soon as the |
//| contender no longer owns the guard, because a name no other instance|
//| writes can never be foreign evidence. |
//| |
//| Ownership loss is latched for the lifetime of the object: once a |
//| heartbeat, a verification or a release proves that this instance |
//| does not own the guard any more, no later call reports ownership. |
//+------------------------------------------------------------------+
class CUQInstanceGuard : public CUQOwnershipVerifier
{
private:
CUQGuardStore *m_store;
string m_name;
string m_prefix;
string m_ballot;
bool m_owned;
bool m_lost;
double m_value;
int m_ttl;
ENUM_UQ_GUARD_RESULT m_result;
string m_detail;
//--- only this instance's own ballot is ever removed, and only after this
//--- instance no longer owns the guard. The name is private to this
//--- instance, so removing it can never destroy foreign evidence.
void DropBallot(void)
{
if(m_store != NULL && StringLen(m_ballot) > 0 && m_store.Exists(m_ballot))
m_store.Remove(m_ballot);
}
//--- ownership loss is latched: no later call may report ownership again
void LatchLoss(const string detail)
{
m_owned = false;
m_lost = true;
m_detail = detail;
}
//--- announce or renew this contender's ballot and prove that the store
//--- returned exactly the value that was written
bool PublishBallot(const datetime now)
{
if(m_store == NULL || StringLen(m_ballot) <= 0)
return(false);
if(!m_store.Write(m_ballot, (double)(long)now))
return(false);
double announced = 0.0;
return(m_store.Read(m_ballot, announced) && (double)(long)announced == (double)(long)now);
}
//--- a ballot of another instance that is still inside the TTL proves that
//--- a concurrent contender may be inside its own creation sequence
bool ForeignBallotLive(const datetime now, const int ttlSeconds, string &rival)
{
rival = "";
if(m_store == NULL || StringLen(m_prefix) <= 0)
return(false);
string names[];
const int total = m_store.Names(m_prefix, names);
for(int i = 0; i < total; i++)
{
if(names[i] == m_ballot)
continue;
double v = 0.0;
if(!m_store.Read(names[i], v))
continue;
//--- an unreadable or unusable ballot is still a contender that cannot
//--- be ruled out, so it counts as live
if(!MathIsValidNumber(v) || v <= 0.0 || (double)(long)v != v ||
(long)(TimeLocal() - (datetime)(long)v) < (long)ttlSeconds)
{
rival = names[i];
return(true);
}
}
return(false);
}
public:
CUQInstanceGuard(void)
{
m_store = NULL;
m_name = "";
m_prefix = "";
m_ballot = "";
m_owned = false;
m_lost = false;
m_value = 0.0;
m_ttl = UQ_INSTANCE_TTL_SECONDS;
m_result = UQ_GUARD_UNUSABLE;
m_detail = "";
}
bool Configure(CUQGuardStore *store, const string guardName,
const string ballotPrefix, const string ballotName)
{
m_store = store;
m_name = guardName;
m_prefix = ballotPrefix;
m_ballot = ballotName;
m_owned = false;
m_lost = false;
m_value = 0.0;
m_ttl = UQ_INSTANCE_TTL_SECONDS;
m_result = UQ_GUARD_UNUSABLE;
m_detail = "";
return(m_store != NULL &&
StringLen(m_name) > 0 && StringLen(m_name) <= UQ_GV_NAME_MAX_CHARS &&
StringLen(m_ballot) > 0 && StringLen(m_ballot) <= UQ_GV_NAME_MAX_CHARS);
}
//+---------------------------------------------------------------+
//| Acquire ownership of the guard |
//+---------------------------------------------------------------+
ENUM_UQ_GUARD_RESULT Acquire(const datetime now, const int ttlSeconds)
{
m_owned = false;
m_lost = false;
m_value = 0.0;
m_ttl = ttlSeconds;
m_result = UQ_GUARD_UNUSABLE;
m_detail = "";
if(m_store == NULL)
{
m_detail = "the instance guard store is unavailable";
return(m_result);
}
if(StringLen(m_name) <= 0 || StringLen(m_name) > UQ_GV_NAME_MAX_CHARS)
{
m_detail = "the duplicate-instance guard name cannot be derived";
return(m_result);
}
if(StringLen(m_ballot) <= 0 || StringLen(m_ballot) > UQ_GV_NAME_MAX_CHARS)
{
m_detail = "the contender ballot name cannot be derived";
return(m_result);
}
//--- 1. announce this contender in its own private variable, before the
//--- shared guard is touched. No other instance writes this name, so
//--- the announcement itself cannot race, and every contender that
//--- could still create the guard becomes visible here.
if(!PublishBallot(now))
{
m_result = UQ_GUARD_IO_ERROR;
m_detail = "the contender ballot could not be written and read back";
DropBallot();
return(m_result);
}
//--- 2. read the ownership record and classify it
const bool exists = m_store.Exists(m_name);
double observed = 0.0;
if(exists && !m_store.Read(m_name, observed))
{
m_result = UQ_GUARD_IO_ERROR;
m_detail = "the instance guard could not be read";
DropBallot();
return(m_result);
}
ENUM_UQ_GUARD_STATE state = UQGuardClassify(exists, observed, now, ttlSeconds, true);
if(state == UQ_GUARD_INVALID)
{
m_result = UQ_GUARD_INVALID_EVIDENCE;
m_detail = StringFormat("the instance guard '%s' exists but holds no usable ownership record (value %s); it is never treated as stale and never overwritten",
m_name, IntegerToString((long)observed));
DropBallot();
return(m_result);
}
if(state == UQ_GUARD_ACTIVE)
{
m_result = UQ_GUARD_HELD;
m_detail = StringFormat("another instance holds the instance guard (heartbeat %s)",
TimeToString((datetime)(long)observed, TIME_DATE | TIME_SECONDS));
DropBallot();
return(m_result);
}
//--- 3. first creation. GlobalVariableSetOnCondition() cannot create a
//--- missing variable, so the creation write is the one step the
//--- terminal API cannot make atomic. It is fenced as follows:
//--- a. no other live ballot may exist, because a rival that could
//--- create the same guard is inside such a ballot;
//--- b. the guard is re-read AFTER the scan and must still be
//--- absent, so an observation taken before the scan is never
//--- used to justify a write;
//--- c. the sentinel that was written must read back exactly;
//--- d. the claim must win its compare-and-swap;
//--- e. the scan is repeated after the claim, and a rival found
//--- there causes a CAS back to the sentinel - never a delete.
//---
//--- Ordering argument: a contender is visible in its ballot from
//--- before its scan until after its claim is verified, and it removes
//--- that ballot only once it no longer owns the guard. For this
//--- contender to miss a rival in (a), that rival must already have
//--- removed its ballot, which happens only after its claim is proven;
//--- (b) then reads that claim and this method never reaches the
//--- creation write. Two contenders that both observed absence are
//--- both visible to each other and both fail closed.
if(state == UQ_GUARD_ABSENT)
{
string rival = "";
if(ForeignBallotLive(now, ttlSeconds, rival))
{
m_result = UQ_GUARD_CONTENDED;
m_detail = StringFormat("another contender ('%s') is inside its first-creation sequence, so the shared guard was not touched", rival);
DropBallot();
return(m_result);
}
const bool stillExists = m_store.Exists(m_name);
double current = 0.0;
if(stillExists && !m_store.Read(m_name, current))
{
m_result = UQ_GUARD_IO_ERROR;
m_detail = "the instance guard appeared and could not be read";
DropBallot();
return(m_result);
}
const ENUM_UQ_GUARD_STATE recheck = UQGuardClassify(stillExists, current, now, ttlSeconds, true);
if(recheck == UQ_GUARD_ACTIVE)
{
m_result = UQ_GUARD_HELD;
m_detail = StringFormat("another instance claimed the instance guard while this contender was creating it (heartbeat %s)",
TimeToString((datetime)(long)current, TIME_DATE | TIME_SECONDS));
DropBallot();
return(m_result);
}
if(recheck == UQ_GUARD_INVALID)
{
m_result = UQ_GUARD_INVALID_EVIDENCE;
m_detail = StringFormat("the instance guard '%s' appeared with no usable ownership record (value %s); it is never overwritten",
m_name, IntegerToString((long)current));
DropBallot();
return(m_result);
}
if(recheck == UQ_GUARD_ABSENT)
{
//--- c. create the unclaimed sentinel. This is the only
//--- unconditional write of the protocol and it is fenced by
//--- (a) and (b): no live rival exists and the guard is still
//--- absent after the scan.
if(!m_store.Write(m_name, UQ_GUARD_SENTINEL))
{
m_result = UQ_GUARD_IO_ERROR;
m_detail = "the instance guard could not be created";
DropBallot();
return(m_result);
}
double created = 0.0;
if(!m_store.Read(m_name, created) || created != UQ_GUARD_SENTINEL)
{
//--- the value is not the sentinel this instance wrote, so a
//--- rival owns it now: it is neither deleted nor overwritten
m_result = UQ_GUARD_CONTENDED;
m_detail = "the created instance guard does not hold the unclaimed sentinel that was written";
DropBallot();
return(m_result);
}
observed = UQ_GUARD_SENTINEL;
state = UQ_GUARD_UNCLAIMED;
}
else
{
//--- an unclaimed sentinel or a stale record appeared while the scan
//--- was running: it is claimed with the atomic exchange, never a write
observed = current;
state = (recheck == UQ_GUARD_UNCLAIMED ? UQ_GUARD_UNCLAIMED : UQ_GUARD_STALE);
}
}
//--- 4. the atomic acquisition. An unclaimed sentinel and a stale record
//--- reach the same compare-and-swap; only its winner may own the scope.
const double expected = (state == UQ_GUARD_UNCLAIMED ? UQ_GUARD_SENTINEL : observed);
double claim = (double)(long)now;
if(claim <= expected)
claim = expected + 1.0;
if(!m_store.Swap(m_name, claim, expected))
{
double current = 0.0;
if(m_store.Read(m_name, current))
m_detail = StringFormat("the ownership exchange was won by another contender (guard now holds %s)",
IntegerToString((long)current));
else
m_detail = "the ownership exchange was lost and the guard could not be re-read";
m_result = UQ_GUARD_LOST_RACE;
DropBallot();
return(m_result);
}
//--- 5. the exchange is only an acquisition when the terminal still
//--- returns exactly the value this instance wrote
double stored = 0.0;
if(!m_store.Read(m_name, stored) || (double)(long)stored != claim)
{
m_result = UQ_GUARD_IO_ERROR;
m_detail = "the instance guard does not hold the value that was written";
DropBallot();
return(m_result);
}
//--- 6. a claim on a guard this instance created is only exclusive when no
//--- rival became visible during the creation sequence. The guard is
//--- given back with a CAS to the sentinel: this instance never deletes
//--- the shared variable and never writes a value it does not own.
if(state == UQ_GUARD_UNCLAIMED)
{
string rival = "";
if(ForeignBallotLive(now, ttlSeconds, rival))
{
const bool returned = m_store.Swap(m_name, UQ_GUARD_SENTINEL, claim);
m_result = UQ_GUARD_CONTENDED;
m_detail = StringFormat("another contender ('%s') was creating the same guard at the same time; exclusive ownership cannot be proven%s",
rival,
(returned
? " and the guard was returned to the unclaimed sentinel"
: " and the guard was left as it is because it no longer carries this claim"));
DropBallot();
return(m_result);
}
}
//--- the ballot stays visible from here on, for as long as this instance
//--- owns the guard, so a contender can never mistake an owned guard for
//--- an abandoned first-creation sequence
m_value = claim;
m_owned = true;
m_result = UQ_GUARD_ACQUIRED;
m_detail = "";
return(m_result);
}
//+---------------------------------------------------------------+
//| Publish a heartbeat and prove that this instance still owns it|
//+---------------------------------------------------------------+
bool Heartbeat(const datetime now)
{
if(!m_owned || m_store == NULL || m_lost)
return(false);
//--- the ballot is renewed first, so this instance is never an owner that
//--- a contender scanning for rivals cannot see
if(!PublishBallot(now))
{
LatchLoss("the contender ballot could not be renewed, so continued ownership cannot be proven");
DropBallot();
return(false);
}
double next = (double)(long)now;
if(next <= m_value)
next = m_value + 1.0;
if(!m_store.Swap(m_name, next, m_value))
{
//--- the value this instance held is gone, so a successor owns the
//--- guard now and this instance must never touch it again
LatchLoss("the instance guard is no longer owned by this instance");
DropBallot();
return(false);
}
double stored = 0.0;
if(!m_store.Read(m_name, stored) || (double)(long)stored != next)
{
LatchLoss("the instance guard heartbeat could not be verified after the exchange");
DropBallot();
return(false);
}
m_value = next;
return(true);
}
//+---------------------------------------------------------------+
//| Release ownership at deinitialization |
//| |
//| The release is the transition back to the unclaimed sentinel. |
//| The shared variable is never deleted, so a contender that |
//| acquires it between this compare-and-swap and the ballot cleanup|
//| keeps its claim: only a value this instance still owns is ever |
//| replaced, and a failed CAS leaves the successor untouched. |
//+---------------------------------------------------------------+
bool Release(void)
{
if(m_store == NULL)
{
m_owned = false;
return(true);
}
if(m_owned)
{
const bool released = m_store.Swap(m_name, UQ_GUARD_SENTINEL, m_value);
m_owned = false;
if(!released)
{
m_detail = "the instance guard is held by a successor and was left untouched";
DropBallot();
return(false);
}
}
else
m_owned = false;
//--- the ballot is removed only after this instance no longer owns the
//--- guard, so a contender scanning after this point finds the persistent
//--- sentinel instead of an absent guard
DropBallot();
return(true);
}
//+---------------------------------------------------------------+
//| Read-only proof that this instance still owns the guard |
//| |
//| The record must exist, carry exactly the value this instance |
//| holds and still be inside the TTL, because past the TTL another|
//| instance may take the scope over. Any failure is latched: this |
//| object never reports ownership again. |
//+---------------------------------------------------------------+
virtual bool OwnershipVerified(string &detail)
{
detail = "";
if(!m_owned || m_store == NULL)
{
detail = "this instance does not hold the duplicate-instance guard";
return(false);
}
if(m_lost)
{
detail = (StringLen(m_detail) > 0 ? m_detail : "instance ownership was already declared lost");
return(false);
}
double stored = 0.0;
if(!m_store.Read(m_name, stored))
{
LatchLoss("the duplicate-instance guard could not be read, so continued ownership cannot be proven");
detail = m_detail;
return(false);
}
if((double)(long)stored != m_value)
{
LatchLoss(StringFormat("the duplicate-instance guard now holds %s instead of the value this instance owns",
IntegerToString((long)stored)));
detail = m_detail;
return(false);
}
if(UQGuardClassify(true, stored, TimeLocal(), m_ttl, true) != UQ_GUARD_ACTIVE)
{
LatchLoss("the duplicate-instance guard heartbeat is stale, so another instance may take this scope over");
detail = m_detail;
return(false);
}
return(true);
}
virtual string OwnershipScope(void) { return(m_name); }
virtual bool OwnershipLost(void) { return(m_lost); }
bool Owned(void) const { return(m_owned); }
bool Lost(void) const { return(m_lost); }
double Value(void) const { return(m_value); }
string Name(void) const { return(m_name); }
string Detail(void) const { return(m_detail); }
ENUM_UQ_GUARD_RESULT Result(void) const { return(m_result); }
};
//+------------------------------------------------------------------+
//| Outcome of the initialization ownership gate |
//+------------------------------------------------------------------+
enum ENUM_UQ_OWNERSHIP
{
UQ_OWNERSHIP_READY = 0, // exclusive ownership is proven
UQ_OWNERSHIP_IDENTITY = 1, // no usable identity, no guard locator
UQ_OWNERSHIP_LEGACY_ACTIVE = 2, // an instance started by an older build is live
UQ_OWNERSHIP_LEGACY_INVALID = 3, // the legacy guard holds no usable record
UQ_OWNERSHIP_V2_HELD = 4, // another V2 instance owns this scope
UQ_OWNERSHIP_V2_INVALID = 5, // the V2 guard holds no usable record
UQ_OWNERSHIP_V2_CONTENDED = 6, // simultaneous first start, not provable
UQ_OWNERSHIP_V2_ERROR = 7 // the guard could not be written or verified
};
//+------------------------------------------------------------------+
//| Operator readable name of one ownership outcome |
//+------------------------------------------------------------------+
string UQOwnershipText(const ENUM_UQ_OWNERSHIP o)
{
switch(o)
{
case UQ_OWNERSHIP_READY: return("exclusive ownership proven");
case UQ_OWNERSHIP_IDENTITY: return("the persistence identity is unusable");
case UQ_OWNERSHIP_LEGACY_ACTIVE: return("a previous-build instance is still running");
case UQ_OWNERSHIP_LEGACY_INVALID: return("the legacy instance guard holds no usable record");
case UQ_OWNERSHIP_V2_HELD: return("another V2 instance owns this scope");
case UQ_OWNERSHIP_V2_INVALID: return("the V2 instance guard holds no usable record");
case UQ_OWNERSHIP_V2_CONTENDED: return("a simultaneous first start could not be proven exclusive");
case UQ_OWNERSHIP_V2_ERROR: return("the V2 instance guard could not be written or verified");
}
return("unrecognized ownership outcome");
}
//+------------------------------------------------------------------+
//| Establish exclusive instance ownership |
//| |
//| This is the only place where initialization decides that one |
//| instance may touch a persistent risk scope. It returns READY only |
//| when the V2 guard was acquired through the atomic exchange above, |
//| and it refuses on every form of unusable evidence. |
//+------------------------------------------------------------------+
ENUM_UQ_OWNERSHIP UQInstanceOwnershipAcquire(CUQInstanceGuard &guard, CUQGuardStore *store,
const UQIdentity &id, const datetime now,
const int ttlSeconds, string &detail)
{
detail = "";
if(store == NULL)
{
detail = "the instance guard store is unavailable";
return(UQ_OWNERSHIP_IDENTITY);
}
if(!UQIdentityFingerprintValid(id.fingerprintV2))
{
detail = "the V2 identity fingerprint is unavailable; instance ownership cannot be established";
return(UQ_OWNERSHIP_IDENTITY);
}
const string v2Name = UQInstanceGuardName(id.fingerprintV2);
const string ballotName = UQGuardBallotName(id.fingerprintV2, UQGuardInstanceTag());
const string legacyName = UQInstanceGuardLegacyName(id.key);
if(StringLen(v2Name) <= 0 || StringLen(ballotName) <= 0)
{
detail = "the duplicate-instance guard name cannot be derived";
return(UQ_OWNERSHIP_IDENTITY);
}
//--- an instance started by a previously published build is inspected before
//--- anything is written. That build cannot recognise the V2 namespace, so
//--- while its heartbeat is fresh the V2 guard is deliberately not acquired.
//--- A guard cannot stop an older binary from starting later: the deployment
//--- constraint that every old instance is stopped first still holds.
if(StringLen(legacyName) > 0)
{
const bool legacyExists = store.Exists(legacyName);
double legacyValue = 0.0;
if(legacyExists && !store.Read(legacyName, legacyValue))
{
detail = "the legacy instance guard could not be read";
return(UQ_OWNERSHIP_LEGACY_INVALID);
}
const ENUM_UQ_GUARD_STATE legacyState =
UQGuardClassify(legacyExists, legacyValue, now, ttlSeconds);
if(legacyState == UQ_GUARD_INVALID)
{
detail = StringFormat("the legacy instance guard '%s' exists but holds no usable ownership record (value %s); it is never treated as stale, never overwritten and never deleted by this build",
legacyName, IntegerToString((long)legacyValue));
return(UQ_OWNERSHIP_LEGACY_INVALID);
}
if(legacyState == UQ_GUARD_ACTIVE)
{
detail = StringFormat("an instance started by a previous build is still active (legacy guard '%s', heartbeat %s); stop every old instance before deploying this build",
legacyName, TimeToString((datetime)(long)legacyValue, TIME_DATE | TIME_SECONDS));
return(UQ_OWNERSHIP_LEGACY_ACTIVE);
}
}
if(!guard.Configure(store, v2Name, UQGuardBallotPrefix(id.fingerprintV2), ballotName))
{
detail = "the duplicate-instance guard could not be configured";
return(UQ_OWNERSHIP_IDENTITY);
}
const ENUM_UQ_GUARD_RESULT acquired = guard.Acquire(now, ttlSeconds);
detail = guard.Detail();
switch(acquired)
{
case UQ_GUARD_ACQUIRED: return(UQ_OWNERSHIP_READY);
case UQ_GUARD_HELD: return(UQ_OWNERSHIP_V2_HELD);
case UQ_GUARD_INVALID_EVIDENCE: return(UQ_OWNERSHIP_V2_INVALID);
case UQ_GUARD_CONTENDED: return(UQ_OWNERSHIP_V2_CONTENDED);
default: return(UQ_OWNERSHIP_V2_ERROR);
}
}
//+------------------------------------------------------------------+
//| May a legacy state found under the legacy locators be migrated? |
//| |
//| Pure decision, so the refusal of ambiguous legacy evidence can be |
//| tested without a file system. A legacy state is only ever migrated|
//| when the legacy serialisation of this identity decodes back to |
//| exactly this tuple: otherwise the same legacy key and the same |
//| legacy hash are shared with a different tuple and the state cannot|
//| be proven to be this identity's own. |
//+------------------------------------------------------------------+
bool UQLegacyMigrationAllowed(const UQIdentity &id, const bool legacyStatePresent,
const bool legacyMarkerPresent, string &reason)
{
reason = "";
if(id.legacyAmbiguous)
{
reason = "the legacy identity serialisation of this scope is ambiguous: 'server|login|portfolio' of this tuple is identical to that of a different tuple, so a legacy state found under this legacy locator cannot be proven to belong to this identity";
return(false);
}
if(legacyMarkerPresent)
{
reason = "a legacy initialization marker exists while no verified legacy state of this exact identity does; this scope was initialized before and must not be treated as a fresh installation";
return(false);
}
if(!legacyStatePresent)
{
reason = "no legacy state exists for this identity";
return(false);
}
return(true);
}
//+------------------------------------------------------------------+
//| Classification of the persistence scope that is about to load |
//| |
//| The decision is pure: it is derived from evidence only, so it can |
//| be reviewed and tested apart from the file system and the terminal |
//| state. Every input is "the evidence exists", never "the evidence |
//| loaded successfully", so an unusable file can never be mistaken |
//| for an absent one. |
//+------------------------------------------------------------------+
enum ENUM_UQ_SCOPE_DECISION
{
UQ_SCOPE_V2_AUTHORITATIVE = 0, // a V2 persistence scope exists and is authoritative
UQ_SCOPE_V2_MARKER_ONLY = 1, // V2 marker proven while the V2 state is missing: refuse
UQ_SCOPE_LEGACY_MIGRATE = 2, // verified legacy state of this exact identity: migrate
UQ_SCOPE_LEGACY_REFUSE = 3, // legacy evidence that must never be migrated: refuse
UQ_SCOPE_FRESH = 4 // no evidence at all: authorized first initialization only
};
//+------------------------------------------------------------------+
//| Decide how a persistence scope may be entered |
//+------------------------------------------------------------------+
ENUM_UQ_SCOPE_DECISION UQScopeDecision(const bool v2StatePresent, const bool v2MarkerPresent,
const bool legacyStatePresent, const bool legacyStateUsable,
const bool legacyMarkerPresent, string &reason,
const bool v2MarkerInvalid = false,
const bool legacyMarkerInvalid = false)
{
reason = "";
//--- 0. an existing marker whose value is unusable is corrupt evidence, not
//--- absence. It proves that this scope was written before, so neither a
//--- fresh initialization nor a migration may rebuild it from scratch.
if(legacyMarkerInvalid)
{
reason = "the legacy initialization marker of this scope exists but holds no usable value; the persistence evidence is corrupt and cannot authorize a new risk cycle or a migration";
return(UQ_SCOPE_LEGACY_REFUSE);
}
if(v2MarkerInvalid)
{
reason = "the V2 initialization marker of this scope exists but holds no usable value; the persistence evidence is corrupt and cannot authorize a new risk cycle";
return(UQ_SCOPE_V2_MARKER_ONLY);
}
//--- A. the V2 scope exists: only V2 evidence is ever consulted from here
if(v2StatePresent)
return(UQ_SCOPE_V2_AUTHORITATIVE);
//--- B. the V2 marker proves that this scope was initialized under V2. Its
//--- missing state is a loss of evidence, not a fresh installation, and
//--- it is never repaired from stale legacy data.
if(v2MarkerPresent)
{
reason = "the V2 initialization marker exists while both V2 state files are absent; this scope was initialized before and it must not be rebuilt from older data";
return(UQ_SCOPE_V2_MARKER_ONLY);
}
//--- C/D. no V2 evidence: a legacy state of this exact identity may migrate
if(legacyStatePresent && legacyStateUsable)
return(UQ_SCOPE_LEGACY_MIGRATE);
//--- E. legacy evidence exists but nothing of it belongs to this identity:
//--- this is the confirmed collision case, and it is a persistence
//--- ownership conflict, never permission to discard existing history
if(legacyStatePresent)
{
reason = "a legacy state file exists for this locator but it is unusable or belongs to a different identity; it is never overwritten, replaced or treated as a fresh installation";
return(UQ_SCOPE_LEGACY_REFUSE);
}
if(legacyMarkerPresent)
{
reason = "a legacy initialization marker exists while no legacy state file does; this scope was initialized before and must not be treated as a fresh installation";
return(UQ_SCOPE_LEGACY_REFUSE);
}
return(UQ_SCOPE_FRESH);
}
//+------------------------------------------------------------------+
//| Prove that a migration preserved every risk-bearing field |
//| |
//| Relocating the persistence scope must not change a single risk |
//| decision. The values are compared through the canonical field |
//| serializer, i.e. exactly the text that is persisted, so a value |
//| that survives the round trip unchanged compares equal. |
//| |
//| The schema version, the update timestamp and the persistence |
//| generation are expected to advance, and the two identity fields |
//| are checked separately against the live identity. |
//+------------------------------------------------------------------+
bool UQStatePreserved(UQRiskState &before, UQRiskState &after, string &detail)
{
detail = "";
for(int i = 0; i < (int)UQ_F_COUNT; i++)
{
if(i == (int)UQ_F_FORMAT || i == (int)UQ_F_UPDATED_AT ||
i == (int)UQ_F_STATE_REVISION)
continue;
if(i == (int)UQ_F_IDENTITY || i == (int)UQ_F_IDENTITY_V2)
continue;
const string a = UQStateFieldValue(before, i);
const string b = UQStateFieldValue(after, i);
if(a != b)
{
detail = StringFormat("field '%s' changed from '%s' to '%s'",
UQFieldKey(i), a, b);
return(false);
}
}
return(true);
}
//+------------------------------------------------------------------+
//| Prove that a migrated state carries no weaker control than the |
//| state it was migrated from |
//| |
//| A relocation is allowed to keep a lock armed that the evaluation |
//| pass has just re-armed, but it is never allowed to disarm one. |
//+------------------------------------------------------------------+
bool UQStateLocksNotCleared(UQRiskState &before, UQRiskState &after, string &detail)
{
detail = "";
if(before.staticLock && !after.staticLock)
{
detail = "the static-drawdown lock was cleared by the migration";
return(false);
}
if(before.targetLock && !after.targetLock)
{
detail = "the equity-target lock was cleared by the migration";
return(false);
}
if(before.dailyLock && !after.dailyLock &&
before.dailyDate == after.dailyDate)
{
detail = "the daily-loss lock was cleared inside the same trading day by the migration";
return(false);
}
if(before.recoveryHold && !after.recoveryHold)
{
detail = "the recovery hold was cleared by the migration";
return(false);
}
if(before.gapHold && !after.gapHold &&
before.dailyDate == after.dailyDate)
{
detail = "the monitoring-gap hold was cleared inside the same trading day by the migration";
return(false);
}
if(before.reserveState != UQ_RESERVE_NONE && after.reserveState == UQ_RESERVE_NONE)
{
detail = "an outstanding entry reservation was discarded by the migration";
return(false);
}
if(before.cycleId != after.cycleId || before.cycleSequence != after.cycleSequence)
{
detail = "the risk-cycle identity changed during the migration";
return(false);
}
return(true);
}
#endif // UQ_STATE_MQH
//+------------------------------------------------------------------+