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

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

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

- refuse legacy migration when the legacy identity serialisation is ambiguous (identical legacy key and identical legacy hash for a different tuple)
2026-10-31 13:47:47 +07:00
2378 lines
101 KiB
MQL5
//+------------------------------------------------------------------+
//| UQ_Risk.mqh |
//| Universal Quant EA - centralized account risk controller |
//| |
//| All three account-level thresholds are evaluated here, in the |
//| priority order required by the specification: |
//| 1. static-drawdown and equity-target permanent locks |
//| 2. daily-loss lock |
//| 3. baseline / persistent-state validity |
//| 4. persistence health (a risk event that cannot be saved must |
//| never be forgotten after a restart) |
//| A risk event always outranks a trading signal. |
//| |
//| A new risk cycle - and therefore a new B0 - is only ever |
//| created by an explicit owner authorization. A missing, empty, |
//| truncated, corrupt or foreign state file is never reinterpreted |
//| as a fresh installation. |
//+------------------------------------------------------------------+
#ifndef UQ_RISK_MQH
#define UQ_RISK_MQH
#include "UQ_Defs.mqh"
#include "UQ_State.mqh"
//+------------------------------------------------------------------+
//| Risk controller configuration |
//+------------------------------------------------------------------+
struct UQRiskConfig
{
ulong magic; // portfolio magic: decides order/position ownership
string portfolio; // EA portfolio identifier
double balanceOverride; // explicit owner-declared B0
double dailyLimitPct; // default 5.0
double staticLimitPct; // default 8.0
double targetPct; // default 10.0
ENUM_UQ_RESET_REQUEST resetRequest; // owner reset request
string resetToken; // confirmation string
double resetBaseline; // explicit B0 of the new risk cycle
string resetNonce; // unique nonce of this reset operation
bool resetPreconditionsOk; // caller-verified flat account scope
string resetPreconditionDetail; // reason when the scope is not clear
bool allowFirstInit; // owner authorizes a new risk cycle
bool allowBackupRestore; // owner authorizes backup recovery
};
//+------------------------------------------------------------------+
//| Aggregate exposure of the portfolio, in deposit-currency money. |
//| |
//| One authoritative calculation covers every form the exposure can |
//| take: |
//| * stop-loss risk of the EA's open positions; |
//| * stop-loss risk of the EA's pending orders; |
//| * money still reserved by an accepted entry that has not yet |
//| been reconciled with the broker state. |
//| |
//| An owned position or pending order without a protective stop, or |
//| one whose profit cannot be converted into the deposit currency, |
//| makes the exposure unbounded: the caller must then refuse new |
//| entries instead of assuming zero risk. |
//+------------------------------------------------------------------+
struct UQExposure
{
double positions; // stop-loss risk of owned open positions
double pending; // stop-loss risk of owned pending orders
double reserved; // money held by an unreconciled entry order
int positionCount;
int pendingCount;
bool bounded; // false when any part of the exposure is unknown
string detail; // what could not be quantified
double Total(void) const { return(positions + pending + reserved); }
};
//+------------------------------------------------------------------+
//| Maximum length of the unbounded-exposure diagnostic text |
//+------------------------------------------------------------------+
#define UQ_EXPOSURE_DETAIL_MAX_CHARS 480
//+------------------------------------------------------------------+
//| Record one unquantifiable exposure item with a bounded reason |
//+------------------------------------------------------------------+
void UQExposureNote(UQExposure &out, const string symbol, const ulong ticket,
const string reason)
{
out.bounded = false;
if(StringLen(out.detail) >= UQ_EXPOSURE_DETAIL_MAX_CHARS)
return;
out.detail += StringFormat("%s#%s(%s) ",
(StringLen(symbol) > 0 ? symbol : "?"),
IntegerToString((long)ticket),
(StringLen(reason) > 0 ? reason : "unquantifiable"));
}
//+------------------------------------------------------------------+
//| Short, display-safe form of an unbounded-exposure diagnostic |
//+------------------------------------------------------------------+
string UQExposureBrief(const string detail, const int maxLen = 160)
{
if(StringLen(detail) <= maxLen)
return(detail);
return(StringSubstr(detail, 0, maxLen) + "...");
}
//+------------------------------------------------------------------+
//| Broker evidence for one outstanding entry reservation. |
//| |
//| Correlation is deliberately narrow. A reservation is proven |
//| executed only when either its own order ticket is still an active |
//| order, or a deal whose DEAL_ORDER is that ticket can be found. |
//| Any other position or pending order carrying the same Magic Number |
//| is unrelated exposure, and its presence is never evidence that |
//| this request produced it. |
//+------------------------------------------------------------------+
struct UQReserveEvidence
{
bool connected; // the broker can be queried
bool orderLive; // the request is still an active order
ulong liveOrder; // active order ticket
int dealCount; // deals traceable to the reservation
int positionCount; // owned positions carrying the traced identifier
int unrelatedCount; // owned positions that are NOT this request
ulong dealTicket; // first traced deal ticket
ulong positionId; // position identifier traced from a deal
bool ambiguous; // more than one candidate: never guess
bool noExposureProven; // the order history proves nothing executed
string detail; // bounded diagnostic
};
//+------------------------------------------------------------------+
//| Collect the broker evidence of one reservation. |
//| |
//| Every field is evidence, never an assumption: the order pool, the |
//| deal history keyed by DEAL_ORDER/DEAL_TICKET and |
//| POSITION_IDENTIFIER are the only accepted links. A missing link |
//| leaves the reservation unresolved instead of guessing. |
//+------------------------------------------------------------------+
void UQReserveCorrelate(const ulong magic, UQRiskState &st, UQReserveEvidence &ev)
{
ev.connected = ((bool)TerminalInfoInteger(TERMINAL_CONNECTED) ||
(bool)MQLInfoInteger(MQL_TESTER));
ev.orderLive = false;
ev.liveOrder = 0;
ev.dealCount = 0;
ev.positionCount = 0;
ev.unrelatedCount = 0;
ev.dealTicket = 0;
ev.positionId = 0;
ev.ambiguous = false;
ev.noExposureProven = false;
ev.detail = "";
if(st.reserveState == UQ_RESERVE_NONE)
return;
const ulong orderTicket = st.reserveTicket;
//--- 1. the request may still be an active pending order. The ticket must
//--- belong to this symbol and Magic Number, otherwise it is not ours.
if(orderTicket > 0 && OrderSelect(orderTicket))
{
const string osym = OrderGetString(ORDER_SYMBOL);
const ulong omag = (ulong)OrderGetInteger(ORDER_MAGIC);
if(osym == st.reserveSymbol && (magic == 0 || omag == magic))
{
ev.orderLive = true;
ev.liveOrder = orderTicket;
}
else
{
ev.ambiguous = true;
ev.detail += StringFormat("%s#%s(order identity mismatch) ",
osym, IntegerToString((long)orderTicket));
}
}
//--- 2. deals produced by that order ticket. HistorySelect is required
//--- before any deal can be read; the window is anchored on the request
//--- time so a restart does not have to scan the whole history.
const datetime to = UQServerNow();
const datetime from = (st.reserveTime > 0 ?
(datetime)(st.reserveTime - (datetime)UQ_HISTORY_LOOKBACK_SECONDS) :
(datetime)(to - (datetime)(2 * UQ_HISTORY_LOOKBACK_SECONDS)));
if(ev.connected && to > 0 && HistorySelect(from, (datetime)(to + 60)))
{
ulong foundPos = 0;
const int deals = HistoryDealsTotal();
for(int i = 0; i < deals; i++)
{
const ulong dt = HistoryDealGetTicket(i);
if(dt == 0)
continue;
if(HistoryDealGetString(dt, DEAL_SYMBOL) != st.reserveSymbol)
continue;
if(magic != 0 && (ulong)HistoryDealGetInteger(dt, DEAL_MAGIC) != magic)
continue;
bool match = false;
if(orderTicket > 0 &&
(ulong)HistoryDealGetInteger(dt, DEAL_ORDER) == orderTicket)
match = true;
else if(st.reserveDeal > 0 && dt == st.reserveDeal)
match = true;
if(!match)
continue;
ev.dealCount++;
if(ev.dealTicket == 0)
ev.dealTicket = dt;
const ulong pid = (ulong)HistoryDealGetInteger(dt, DEAL_POSITION_ID);
if(pid > 0)
{
if(foundPos == 0)
{
foundPos = pid;
ev.positionId = pid;
}
else if(foundPos != pid)
{
ev.ambiguous = true;
ev.detail += "several position identifiers ";
}
}
}
}
//--- 3. count the owned positions, separating the traced one from the
//--- unrelated ones. Only the traced identifier may convert a reservation.
const int total = PositionsTotal();
for(int i = 0; i < total; i++)
{
const ulong pt = PositionGetTicket(i);
if(pt == 0)
continue;
if(magic != 0 && (ulong)PositionGetInteger(POSITION_MAGIC) != magic)
continue;
if(ev.positionId > 0 &&
(ulong)PositionGetInteger(POSITION_IDENTIFIER) == ev.positionId)
ev.positionCount++;
else
ev.unrelatedCount++;
}
if(ev.dealCount > 0 && ev.positionId > 0 && ev.positionCount == 0)
ev.detail += "traced position already closed ";
//--- 4. authoritative proof that nothing was executed: the order exists in
//--- history in a terminal state that carries no fill and it produced no
//--- deal at all. Anything less proves nothing.
if(ev.connected && !ev.orderLive && ev.dealCount == 0 && orderTicket > 0)
{
if(HistoryOrderSelect(orderTicket))
{
const long state = (long)HistoryOrderGetInteger(orderTicket, ORDER_STATE);
const double volInit = HistoryOrderGetDouble(orderTicket, ORDER_VOLUME_INITIAL);
const double volCur = HistoryOrderGetDouble(orderTicket, ORDER_VOLUME_CURRENT);
//--- a terminal state alone is not enough: a cancelled order may have
//--- been filled in part, so the executed volume must also be zero
if((state == ORDER_STATE_REJECTED || state == ORDER_STATE_CANCELED ||
state == ORDER_STATE_EXPIRED) && !(volInit - volCur > 0.0))
ev.noExposureProven = true;
}
}
}
//+------------------------------------------------------------------+
//| Validate the monetary result of a stop-loss calculation. |
//| |
//| A stop-loss distance is only allowed to produce an adverse loss. |
//| Zero or positive means the value cannot describe risk at all: a |
//| missing currency conversion, a stale quote or a malformed price |
//| all end here, and MathAbs() must never turn such a value into a |
//| plausible positive exposure. |
//+------------------------------------------------------------------+
bool UQAdverseRiskFromProfit(const double p, double &risk, string &reason)
{
risk = 0.0;
reason = "";
if(!MathIsValidNumber(p))
{
reason = "profit is not a finite number";
return(false);
}
if(!(p < 0.0))
{
reason = StringFormat("profit %s is not an adverse loss", DoubleToString(p, 2));
return(false);
}
risk = -p;
if(!MathIsValidNumber(risk) || !(risk > 0.0))
{
reason = "monetary risk is not usable";
risk = 0.0;
return(false);
}
return(true);
}
//+------------------------------------------------------------------+
//| Verified stop-loss risk of one owned exposure item, expressed in |
//| the deposit currency. |
//| |
//| Every condition must hold before the monetary value may be used: |
//| * the direction is one OrderCalcProfit() understands; |
//| * the instrument is still described by the terminal; |
//| * the volume and the reference price are positive and finite; |
//| * a protective stop exists and sits on the adverse side of the |
//| reference price for the direction it belongs to; |
//| * OrderCalcProfit() succeeds; |
//| * its result is a finite number and a strictly adverse loss. |
//| |
//| A zero or positive result is NOT a risk of zero. It means the |
//| calculation cannot be trusted - a missing currency conversion, a |
//| stale quote or a malformed stop all end here - so the caller must|
//| report an unbounded exposure instead of inventing a plausible |
//| positive number with MathAbs(). |
//+------------------------------------------------------------------+
bool UQStopLossRisk(const string symbol, const ulong ticket,
const ENUM_ORDER_TYPE dir,
const double volume, const double reference, const double stop,
double &risk, string &reason)
{
risk = 0.0;
reason = "";
//--- 1. the direction must be one OrderCalcProfit() accepts
if(dir != ORDER_TYPE_BUY && dir != ORDER_TYPE_SELL)
{
reason = "no tradable direction";
return(false);
}
//--- 2. a valid symbol, still described by the terminal
if(StringLen(symbol) <= 0)
{
reason = "no symbol";
return(false);
}
if(StringFind(symbol, "=") >= 0 || StringFind(symbol, "|") >= 0 ||
StringFind(symbol, "\r") >= 0 || StringFind(symbol, "\n") >= 0)
{
reason = "invalid symbol name";
return(false);
}
if(!(SymbolInfoDouble(symbol, SYMBOL_POINT) > 0.0) ||
!(SymbolInfoDouble(symbol, SYMBOL_TRADE_TICK_SIZE) > 0.0))
{
reason = "symbol is not priced by the terminal";
return(false);
}
//--- 3. a valid volume
if(!MathIsValidNumber(volume) || !(volume > 0.0))
{
reason = "invalid volume";
return(false);
}
//--- 4. a valid entry / reference price
if(!MathIsValidNumber(reference) || !(reference > 0.0))
{
reason = "invalid reference price";
return(false);
}
//--- 5. a valid protective stop on the correct side of the reference
if(!MathIsValidNumber(stop) || !(stop > 0.0))
{
reason = "no protective stop";
return(false);
}
if(dir == ORDER_TYPE_BUY && !(stop < reference))
{
reason = "buy stop is not below the reference price";
return(false);
}
if(dir == ORDER_TYPE_SELL && !(stop > reference))
{
reason = "sell stop is not above the reference price";
return(false);
}
//--- 6. the broker-side monetary calculation
double p = 0.0;
if(!OrderCalcProfit(dir, symbol, volume, reference, stop, p))
{
reason = StringFormat("OrderCalcProfit failed (%d)", GetLastError());
return(false);
}
//--- 7..10. finite, strictly adverse and usable monetary exposure
return(UQAdverseRiskFromProfit(p, risk, reason));
}
//+------------------------------------------------------------------+
//| Measure the exposure from the live broker pools and add the money |
//| that an accepted but unreconciled entry still reserves. |
//| |
//| Unquantifiable exposure is never represented as zero: the affected |
//| symbol and ticket are reported, bounded is cleared, and the caller |
//| refuses every new entry. |
//+------------------------------------------------------------------+
bool UQExposureFromPools(const ulong magic, const double reservedMoney, UQExposure &out)
{
out.positions = 0.0;
out.pending = 0.0;
out.reserved = 0.0;
out.positionCount = 0;
out.pendingCount = 0;
out.bounded = true;
out.detail = "";
//--- money still held by an unreconciled entry order
if(MathIsValidNumber(reservedMoney) && reservedMoney > 0.0)
out.reserved = reservedMoney;
else if(!MathIsValidNumber(reservedMoney))
UQExposureNote(out, "", 0, "reserved money is not a finite number");
const int total = PositionsTotal();
for(int i = 0; i < total; i++)
{
const ulong ticket = PositionGetTicket(i);
if(ticket == 0)
continue;
if((ulong)PositionGetInteger(POSITION_MAGIC) != magic)
continue;
out.positionCount++;
const string sym = PositionGetString(POSITION_SYMBOL);
const double vol = PositionGetDouble(POSITION_VOLUME);
const double sl = PositionGetDouble(POSITION_SL);
const double cur = PositionGetDouble(POSITION_PRICE_CURRENT);
const double open = PositionGetDouble(POSITION_PRICE_OPEN);
const long ptype = PositionGetInteger(POSITION_TYPE);
if(ptype != POSITION_TYPE_BUY && ptype != POSITION_TYPE_SELL)
{
UQExposureNote(out, sym, ticket, "position type is not BUY or SELL");
continue;
}
//--- the entry price is part of the conservative reference: without it
//--- the worse-of rule cannot be applied and the risk is not quantifiable
if(!MathIsValidNumber(open) || !(open > 0.0) ||
!MathIsValidNumber(cur) || !(cur > 0.0))
{
UQExposureNote(out, sym, ticket, "position price is unavailable");
continue;
}
const bool isBuy = (ptype == POSITION_TYPE_BUY);
//--- Potential loss down to the stop, measured from the worse of the
//--- entry price and the current price: for a buy the higher price, for
//--- a sell the lower one. Measuring from the current price alone would
//--- let the bid/ask spread release aggregate budget the moment a
//--- position is opened, which would let the aggregate ceiling be
//--- exceeded. The risk only falls once the stop itself has been moved.
const double from = isBuy ? MathMax(cur, open) : MathMin(cur, open);
double risk = 0.0;
string reason = "";
if(!UQStopLossRisk(sym, ticket, (isBuy ? ORDER_TYPE_BUY : ORDER_TYPE_SELL),
vol, from, sl, risk, reason))
{
UQExposureNote(out, sym, ticket, reason);
continue;
}
out.positions += risk;
}
const int orders = OrdersTotal();
for(int i = 0; i < orders; i++)
{
const ulong ticket = OrderGetTicket(i);
if(ticket == 0)
continue;
if((ulong)OrderGetInteger(ORDER_MAGIC) != magic)
continue;
out.pendingCount++;
const string sym = OrderGetString(ORDER_SYMBOL);
const double vol = OrderGetDouble(ORDER_VOLUME_CURRENT);
const double price = OrderGetDouble(ORDER_PRICE_OPEN);
const double sl = OrderGetDouble(ORDER_SL);
const long otype = OrderGetInteger(ORDER_TYPE);
//--- OrderCalcProfit only accepts the plain buy/sell direction, so the
//--- pending type is mapped explicitly and an unknown type fails closed
ENUM_ORDER_TYPE dir = ORDER_TYPE_SELL;
bool mapped = false;
if(otype == ORDER_TYPE_BUY || otype == ORDER_TYPE_BUY_LIMIT ||
otype == ORDER_TYPE_BUY_STOP || otype == ORDER_TYPE_BUY_STOP_LIMIT)
{
dir = ORDER_TYPE_BUY;
mapped = true;
}
else if(otype == ORDER_TYPE_SELL || otype == ORDER_TYPE_SELL_LIMIT ||
otype == ORDER_TYPE_SELL_STOP || otype == ORDER_TYPE_SELL_STOP_LIMIT)
{
dir = ORDER_TYPE_SELL;
mapped = true;
}
if(!mapped)
{
UQExposureNote(out, sym, ticket, "pending order type is not tradable");
continue;
}
double risk = 0.0;
string reason = "";
if(!UQStopLossRisk(sym, ticket, dir, vol, price, sl, risk, reason))
{
UQExposureNote(out, sym, ticket, reason);
continue;
}
out.pending += risk;
}
return(out.bounded);
}
//+------------------------------------------------------------------+
//| Prove that account equity cannot have changed in a window |
//| |
//| A daily baseline may only be reconstructed when the equity path |
//| across the unobserved window is provably constant: no position |
//| and no pending order survives and no account deal was recorded. |
//| Without that proof the window is unmeasurable instead of assumed |
//| flat, so the caller fails closed. |
//+------------------------------------------------------------------+
bool UQAccountProvablyFlat(const datetime from, const datetime to, string &detail)
{
detail = "";
if(from <= 0 || to <= from)
{
detail = "the unobserved window is not a usable interval";
return(false);
}
const int positions = PositionsTotal();
if(positions > 0)
{
detail = StringFormat("%d position(s) are open, so equity was exposed during the window", positions);
return(false);
}
const int orders = OrdersTotal();
if(orders > 0)
{
detail = StringFormat("%d pending order(s) exist, so an unobserved fill cannot be excluded", orders);
return(false);
}
if(!HistorySelect(from, to))
{
detail = "the account deal history cannot be read, so no flat equity path can be proven";
return(false);
}
const int deals = HistoryDealsTotal();
if(deals > 0)
{
detail = StringFormat("%d account deal(s) were recorded inside the window", deals);
return(false);
}
return(true);
}
//+------------------------------------------------------------------+
//| Centralized risk controller |
//+------------------------------------------------------------------+
class CUQRisk
{
private:
UQIdentity m_id;
UQRiskState m_st;
UQRiskConfig m_cfg;
bool m_ready;
bool m_healthy;
bool m_freshInstall;
bool m_migrated;
bool m_restoredBackup;
bool m_resetRejected;
bool m_resetApplied;
string m_cycleId;
int m_cycleSequence;
string m_lastError;
string m_lastSaveError;
datetime m_saveRetryAt;
ulong m_reserveSeq;
datetime m_reserveCheckAt;
double m_equity;
double m_b0;
double m_d0;
double m_dailyLossPct;
double m_staticDdPct;
double m_targetEquity;
double m_dailyFloor;
double m_staticFloor;
datetime m_today;
ENUM_UQ_BLOCK m_block;
bool Save(void);
void ApplyBlock(void);
void ClearReservation(void);
bool ApplyResetRequest(void);
bool RejectReset(const string why);
bool EvaluateLocks(void);
bool EstablishBaselineFromState(void);
bool EstablishInitialBaseline(void);
bool EstablishDailyBaseline(void);
bool UpdateMonitoring(const datetime now, const datetime today);
bool LoadPersistentState(void);
bool FirstInitialization(void);
bool LoadLegacyPersistenceScope(void);
bool CommitMigratedState(const bool holdNeeded, const string holdReason);
bool SelectVerifiedState(UQRiskState &prim, const long revPrimary, const string perr,
UQRiskState &back, const long revBackup, const string berr,
const ENUM_UQ_STATE_LOAD loadPrimary,
const ENUM_UQ_STATE_LOAD loadBackup,
bool &holdNeeded, string &holdReason);
public:
CUQRisk(void);
~CUQRisk(void);
bool Init(UQRiskConfig &cfg);
void Evaluate(void);
bool IsReady(void) const { return(m_ready); }
bool Healthy(void) const { return(m_healthy); }
string LastError(void) const { return(m_lastError); }
string LastSaveError(void) const { return(m_lastSaveError); }
string IdentityKey(void) const { return(m_id.key); }
//--- complete V2 identity fingerprint: the basis of every new persistence and
//--- operational locator, and the value embedded in schema 8
string IdentityFingerprintV2(void) const { return(m_id.fingerprintV2); }
string Portfolio(void) const { return(m_cfg.portfolio); }
long Login(void) const { return(m_id.login); }
bool StateMigrated(void) const { return(m_migrated); }
bool StateFromBackup(void) const { return(m_restoredBackup); }
//--- persistence generation of the authoritative state and the recovery
//--- hold that a recovered state always starts behind
long StateRevision(void) const { return(m_st.stateRevision); }
bool RecoveryHold(void) const { return(m_st.recoveryHold); }
datetime RecoveryHoldTime(void) const { return(m_st.recoveryHoldTime); }
//--- daily monitoring continuity: the last verified heartbeat, the most
//--- recent unobserved interval and the hold that blocks entries while the
//--- daily baseline cannot be reconstructed
datetime LastMonitorTime(void) const { return(m_st.monitorTime); }
int MonitorGapSeconds(void) const { return(m_st.monitorGapSeconds); }
bool MonitorGapHold(void) const { return(m_st.gapHold); }
datetime MonitorGapHoldTime(void) const { return(m_st.gapHoldTime); }
bool DailyMonitored(void) const { return(!m_st.gapHold); }
string CycleId(void) const { return(m_cycleId); }
int CycleSequence(void) const { return(m_cycleSequence); }
bool ResetApplied(void) const { return(m_resetApplied); }
bool ResetRejected(void) const { return(m_resetRejected); }
//--- permanent locks recorded by the most recent owner reset. Kind 3 means
//--- the closed cycle carried both permanent locks; the state schema
//--- preserves that value across restarts.
int PriorLockKind(void) const { return(m_st.priorLockKind); }
datetime PriorLockTime(void) const { return(m_st.priorLockTime); }
string PriorLockText(void) const { return(UQPriorLockText(m_st.priorLockKind)); }
//--- aggregate exposure, including unreconciled entry reservations
bool Exposure(const ulong magic, UQExposure &out);
//--- entry-risk reservation: taken before an order is submitted and
//--- released only after the broker state proves where the order went
bool ReserveEntryRisk(const ulong magic, const string symbol,
const int direction, const double volume,
const double entry, const double stop,
const double money, string &err);
bool NoteSubmittedTickets(const ulong orderTicket, const ulong dealTicket);
void ReleaseReservation(const string why);
void MarkReservationUnresolved(const string why, UQReserveEvidence &ev);
void ReconcileReservation(void);
void ReconcileReservationDepth(const bool forced);
//--- a submission whose result cannot be established keeps its reserved
//--- risk: the caller has no proof that the request left no exposure
void MarkReservationUncertain(const string why)
{
UQReserveEvidence ev;
UQReserveCorrelate(m_cfg.magic, m_st, ev);
MarkReservationUnresolved(why, ev);
}
ENUM_UQ_RESERVE_STATE ReserveState(void) const { return(m_st.reserveState); }
double ReservedRisk(void) const { return(m_st.reserveMoney); }
ulong ReserveTicket(void) const { return(m_st.reserveTicket); }
string ReserveSymbol(void) const { return(m_st.reserveSymbol); }
string ReserveId(void) const { return(m_st.reserveId); }
ulong ReserveDeal(void) const { return(m_st.reserveDeal); }
ulong ReservePosition(void) const { return(m_st.reservePosition); }
double Baseline(void) const { return(m_b0); }
double DailyStart(void) const { return(m_d0); }
double Equity(void) const { return(m_equity); }
double DailyLossPct(void) const { return(m_dailyLossPct); }
double StaticDdPct(void) const { return(m_staticDdPct); }
double TargetEquity(void) const { return(m_targetEquity); }
double DailyFloor(void) const { return(m_dailyFloor); }
double StaticFloor(void) const { return(m_staticFloor); }
double DailyLimitPct(void) const { return(m_cfg.dailyLimitPct); }
double StaticLimitPct(void) const { return(m_cfg.staticLimitPct); }
double TargetPct(void) const { return(m_cfg.targetPct); }
datetime TradingDate(void) const { return(m_today); }
datetime DailyStartTime(void) const { return(m_st.dailyStartTime); }
bool StaticLock(void) const { return(m_st.staticLock); }
bool TargetLock(void) const { return(m_st.targetLock); }
bool DailyLock(void) const { return(m_st.dailyLock); }
ENUM_UQ_BLOCK Block(void) const { return(m_block); }
string BlockText(void) const { return(UQBlockText(m_block)); }
//--- a permanent lock or the daily lock demands immediate flattening
bool LiquidationRequired(void) const
{
return(m_block == UQ_BLOCK_STATIC || m_block == UQ_BLOCK_TARGET || m_block == UQ_BLOCK_DAILY);
}
//--- new entries permitted only when nothing blocks them
bool CanOpenNew(void) const
{
return(m_ready && m_healthy && m_block == UQ_BLOCK_NONE);
}
};
//+------------------------------------------------------------------+
CUQRisk::CUQRisk(void)
{
m_ready = false;
m_healthy = false;
m_freshInstall = false;
m_migrated = false;
m_restoredBackup = false;
m_resetRejected = false;
m_resetApplied = false;
m_cycleId = "";
m_cycleSequence = 0;
m_lastError = "";
m_lastSaveError = "";
m_saveRetryAt = 0;
m_reserveSeq = 0;
m_reserveCheckAt = 0;
m_equity = 0.0;
m_b0 = 0.0;
m_d0 = 0.0;
m_dailyLossPct = 0.0;
m_staticDdPct = 0.0;
m_targetEquity = 0.0;
m_dailyFloor = 0.0;
m_staticFloor = 0.0;
m_today = 0;
m_block = UQ_BLOCK_BASELINE;
UQStateReset(m_st);
}
//+------------------------------------------------------------------+
CUQRisk::~CUQRisk(void)
{
}
//+------------------------------------------------------------------+
//| Persist the current state. A failure degrades the controller to |
//| "unhealthy": every new entry is blocked until persistence works |
//| again, while the in-memory lock is preserved. |
//+------------------------------------------------------------------+
bool CUQRisk::Save(void)
{
if(UQStateSave(m_id, m_st))
{
m_healthy = true;
m_lastSaveError = "";
m_saveRetryAt = 0;
return(true);
}
m_healthy = false;
m_lastSaveError = "persistent risk state could not be written";
m_lastError = m_lastSaveError;
m_saveRetryAt = UQServerNow() + (datetime)UQ_STATE_RETRY_SECONDS;
m_block = UQ_BLOCK_STATE;
PrintFormat("[UQ] ERROR: %s. New entries are blocked and the in-memory lock is preserved.",
m_lastSaveError);
return(false);
}
//+------------------------------------------------------------------+
//| Priority of the active block reason. Liquidation-triggering locks |
//| always outrank the persistence-health block. |
//+------------------------------------------------------------------+
void CUQRisk::ApplyBlock(void)
{
if(m_st.staticLock)
m_block = UQ_BLOCK_STATIC;
else if(m_st.targetLock)
m_block = UQ_BLOCK_TARGET;
else if(m_st.dailyLock)
m_block = UQ_BLOCK_DAILY;
else if(m_st.gapHold)
m_block = UQ_BLOCK_GAP;
else if(m_st.recoveryHold)
m_block = UQ_BLOCK_RECOVERY;
else if(m_resetRejected)
m_block = UQ_BLOCK_RESET;
else if(!m_st.baselineValid || m_b0 <= 0.0 || m_d0 <= 0.0)
m_block = UQ_BLOCK_BASELINE;
else if(m_st.reserveState != UQ_RESERVE_NONE)
m_block = UQ_BLOCK_RESERVATION;
else if(!m_healthy)
m_block = UQ_BLOCK_STATE;
else
m_block = UQ_BLOCK_NONE;
}
//+------------------------------------------------------------------+
//| Record a rejected owner reset. Rejection is fail closed: the lock |
//| stays active, the reason is reported and new entries are blocked. |
//+------------------------------------------------------------------+
bool CUQRisk::RejectReset(const string why)
{
m_resetRejected = true;
m_resetApplied = false;
m_lastError = "owner reset rejected: " + why;
PrintFormat("[UQ] ERROR: OWNER RESET REJECTED - %s. The permanent lock stays active, the risk cycle is unchanged and new entries are blocked.", why);
return(false);
}
//+------------------------------------------------------------------+
//| Apply an explicit owner reset as a new risk cycle. |
//| |
//| A permanent lock is never cleared in place. A successful reset |
//| closes the current cycle and opens a new one with an explicitly |
//| authorized baseline, a new daily baseline, a new cycle identifier |
//| and a consumed nonce, and it is persisted before entries may be |
//| allowed again. |
//| |
//| The daily-loss lock is never affected: only a broker-server |
//| trading-day rollover clears it. |
//| |
//| A nonce that was consumed by an earlier cycle is not applied |
//| again. That is the restart case (the inputs still carry the old |
//| request), so it is reported and never treated as a new reset. |
//+------------------------------------------------------------------+
bool CUQRisk::ApplyResetRequest(void)
{
m_resetRejected = false;
m_resetApplied = false;
if(m_cfg.resetRequest == UQ_RESET_NONE)
return(true);
//--- a recovery acknowledgement is a distinct owner operation: it exists
//--- only while a persistence recovery hold is active, and it never clears
//--- a permanent lock that the recovered state carries
const bool isRecovery = (m_cfg.resetRequest == UQ_RESET_RECOVERY);
if(isRecovery && !m_st.recoveryHold)
return(RejectReset("no persistence recovery hold is active, so there is nothing to acknowledge"));
//--- 1. operational confirmation phrase (a safeguard, not a secret)
if(m_cfg.resetToken != UQ_RESET_TOKEN)
return(RejectReset("the confirmation phrase does not match the required value"));
//--- 2. nonce syntax: unique per operation, never a secret
if(!UQResetNonceValid(m_cfg.resetNonce))
return(RejectReset(StringFormat("the reset nonce must be %d..%d printable characters without '=' or '|'",
UQ_RESET_NONCE_MIN_LEN, UQ_RESET_NONCE_MAX_LEN)));
//--- 3. a nonce is consumed at most once
if(UQResetNonceConsumed(m_st.resetNonceHistory, m_cfg.resetNonce))
{
PrintFormat("[UQ] Owner reset nonce was already consumed by an earlier risk cycle (%s); it is not applied again. Remove the reset inputs.",
m_st.cycleId);
return(true);
}
//--- 4. the new cycle needs a deliberate positive baseline
if(!(m_cfg.resetBaseline > 0.0))
return(RejectReset("a new risk cycle requires an explicitly supplied positive baseline B0"));
//--- 5. the account scope must be verified clear of this EA's exposure
if(!m_cfg.resetPreconditionsOk)
return(RejectReset(StringLen(m_cfg.resetPreconditionDetail) > 0
? m_cfg.resetPreconditionDetail
: "the account scope is not verified flat"));
//--- 6. the requested lock must actually be active. A recovery
//--- acknowledgement clears no lock, so it requires no active lock and
//--- any lock the recovered state carries keeps blocking entries.
const bool wantStatic = (!isRecovery &&
(m_cfg.resetRequest == UQ_RESET_STATIC ||
m_cfg.resetRequest == UQ_RESET_BOTH));
const bool wantTarget = (!isRecovery &&
(m_cfg.resetRequest == UQ_RESET_TARGET ||
m_cfg.resetRequest == UQ_RESET_BOTH));
if(!isRecovery && !(wantStatic && m_st.staticLock) && !(wantTarget && m_st.targetLock))
return(RejectReset("no matching permanent lock is active, so there is nothing to reset"));
//--- 7. the new baseline must not re-arm the lock it is meant to clear
const double eq = AccountInfoDouble(ACCOUNT_EQUITY);
if(!(eq > 0.0))
return(RejectReset("account equity is unavailable"));
if(wantStatic && m_st.staticLock)
{
const double newFloor = m_cfg.resetBaseline * (1.0 - m_cfg.staticLimitPct / 100.0);
if(eq <= newFloor)
return(RejectReset(StringFormat("baseline %.2f re-arms the static-drawdown lock immediately (floor %.2f, equity %.2f)",
m_cfg.resetBaseline, newFloor, eq)));
}
if(wantTarget && m_st.targetLock)
{
const double newTarget = m_cfg.resetBaseline * (1.0 + m_cfg.targetPct / 100.0);
if(eq >= newTarget)
return(RejectReset(StringFormat("baseline %.2f re-arms the equity-target lock immediately (target %.2f, equity %.2f)",
m_cfg.resetBaseline, newTarget, eq)));
}
//--- 8. open the new cycle
const datetime now = UQServerNow();
if(now <= 0)
return(RejectReset("broker-server time is unavailable"));
const string priorCycleId = m_st.cycleId;
const double priorB0 = m_st.baselineBalance;
//--- record EVERY permanent lock the closed cycle carried, never only the
//--- first one: both locks can be armed in the same cycle, and the recorded
//--- kind 3 keeps that fact verifiable after a restart
const int priorKind = UQPriorLockKindFromFlags(m_st.staticLock, m_st.targetLock);
datetime priorTime = 0;
if(UQPriorLockHasStatic(priorKind))
priorTime = (m_st.staticLockTime > 0 ? m_st.staticLockTime : priorTime);
if(UQPriorLockHasTarget(priorKind))
priorTime = (priorTime > 0 && m_st.targetLockTime > 0)
? (datetime)MathMin((double)priorTime, (double)m_st.targetLockTime)
: m_st.targetLockTime;
m_st.cycleSequence++;
m_st.cycleId = UQBuildCycleId(m_st.cycleSequence, m_cfg.resetNonce);
//--- the prior-cycle slot is defined as "the lock event that authorized the
//--- reset": without a prior permanent lock (a recovery acknowledgement of a
//--- clean cycle) it stays empty instead of recording an inconsistent
//--- reference, and the closed cycle is still reported in the log
const bool priorRecorded = (priorKind != UQ_PRIOR_LOCK_NONE);
m_st.priorBaselineBalance = (priorRecorded ? priorB0 : 0.0);
m_st.priorCycleId = (priorRecorded ? priorCycleId : "");
m_st.priorLockKind = priorKind;
m_st.priorLockTime = priorTime;
//--- only the requested permanent locks are cleared
if(wantStatic && m_st.staticLock)
{
m_st.staticLock = false;
m_st.staticLockTime = 0;
}
if(wantTarget && m_st.targetLock)
{
m_st.targetLock = false;
m_st.targetLockTime = 0;
}
//--- new cycle baselines
m_st.baselineBalance = m_cfg.resetBaseline;
m_st.baselineValid = true;
m_st.baselineTime = now;
m_b0 = m_st.baselineBalance;
m_st.dailyStartEquity = eq;
m_st.dailyValid = true;
m_st.dailyDate = UQDayStart(now);
m_st.dailyStartTime = now;
m_d0 = m_st.dailyStartEquity;
m_equity = eq;
m_today = m_st.dailyDate;
//--- the explicit owner authorization is what clears the recovery hold,
//--- and it also opens a new monitoring window: the gap hold is released
//--- expressly, never silently
m_st.recoveryHold = false;
m_st.recoveryHoldTime = 0;
if(m_st.gapHold)
PrintFormat("[UQ] MONITORING GAP HOLD released by the explicit owner risk cycle %s.", m_st.cycleId);
m_st.gapHold = false;
m_st.gapHoldTime = 0;
m_st.monitorGapSeconds = 0;
m_st.monitorTime = now;
m_st.resetSignature = "";
m_st.resetNonceHistory = UQResetNonceHistoryAppend(m_st.resetNonceHistory, m_cfg.resetNonce);
m_st.createdAt = now;
m_st.updatedAt = now;
m_cycleId = m_st.cycleId;
m_cycleSequence = m_st.cycleSequence;
m_dailyFloor = m_d0 * (1.0 - m_cfg.dailyLimitPct / 100.0);
m_staticFloor = m_b0 * (1.0 - m_cfg.staticLimitPct / 100.0);
m_targetEquity = m_b0 * (1.0 + m_cfg.targetPct / 100.0);
//--- the new cycle must be durable before entries may be allowed
if(!Save())
return(RejectReset("the new risk cycle could not be persisted"));
//--- the initialization marker follows the cycle creation timestamp
if(!UQStateMarkerSet(m_id, m_st.createdAt))
{
PrintFormat("[UQ] ERROR: the initialization marker could not be updated for cycle %s; it is removed so the verified state can re-establish it.",
m_st.cycleId);
GlobalVariableDel(UQStateMarkerName(m_id));
if(GlobalVariableCheck(UQStateMarkerName(m_id)))
return(RejectReset("the initialization marker could not be reconciled with the new cycle"));
}
m_resetApplied = true;
PrintFormat("[UQ] NEW RISK CYCLE %s (sequence %d) | previous cycle %s B0=%.2f priorLocks=%d (%s) at %s",
m_st.cycleId, m_st.cycleSequence, priorCycleId, priorB0, priorKind,
UQPriorLockText(priorKind), TimeToString(priorTime, TIME_DATE | TIME_SECONDS));
if(isRecovery)
PrintFormat("[UQ] RECOVERY ACKNOWLEDGED: the persistence recovery hold is cleared by explicit owner authorization; risk cycle %s is active and the recovered state was never treated as a fresh installation.",
m_st.cycleId);
//--- a partial reset clears only the requested permanent lock: the other
//--- lock was not authorized to be cleared and keeps blocking entries
if(m_st.staticLock || m_st.targetLock)
PrintFormat("[UQ] RESIDUAL PERMANENT LOCK: %s is still active after the partial reset; new entries stay blocked and liquidation remains required.",
UQBlockText(m_st.staticLock ? UQ_BLOCK_STATIC : UQ_BLOCK_TARGET));
PrintFormat("[UQ] New cycle baselines: B0=%.2f D0=%.2f dailyFloor=%.2f staticFloor=%.2f target=%.2f. Remove the reset inputs now.",
m_b0, m_d0, m_dailyFloor, m_staticFloor, m_targetEquity);
return(true);
}
//+------------------------------------------------------------------+
//| Restore the persisted fixed baseline B0. Never derives a value. |
//+------------------------------------------------------------------+
bool CUQRisk::EstablishBaselineFromState(void)
{
if(!m_st.baselineValid || !(m_st.baselineBalance > 0.0))
{
m_lastError = "persisted baseline B0 is missing or not positive";
return(false);
}
if(m_cfg.balanceOverride > 0.0 && MathAbs(m_cfg.balanceOverride - m_st.baselineBalance) > 0.005)
PrintFormat("[UQ] WARNING: configured baseline override %.2f is ignored; persisted B0 %.2f is authoritative.",
m_cfg.balanceOverride, m_st.baselineBalance);
m_b0 = m_st.baselineBalance;
return(true);
}
//+------------------------------------------------------------------+
//| Create B0 for a new risk cycle. Requires an explicit owner- |
//| declared positive baseline: the current account balance is never |
//| used as an automatic substitute. |
//+------------------------------------------------------------------+
bool CUQRisk::EstablishInitialBaseline(void)
{
if(!(m_cfg.balanceOverride > 0.0))
{
m_lastError = "creating a risk cycle requires an explicit positive initial balance B0";
return(false);
}
m_st.baselineBalance = m_cfg.balanceOverride;
m_st.baselineValid = true;
m_st.baselineTime = TimeCurrent();
if(m_st.baselineTime <= 0)
m_st.baselineTime = UQServerNow();
m_b0 = m_cfg.balanceOverride;
//--- the first persisted cycle must carry its own identity: the state
//--- schema rejects a non-positive cycle sequence and an empty cycle
//--- identifier, so without this a fresh deployment could never persist
//--- its authorised baseline and could never start at all
m_st.cycleSequence = 1;
m_st.cycleId = UQBuildCycleId(1, m_id.hash + "|" + IntegerToString((long)m_st.baselineTime));
m_st.resetNonceHistory = "";
m_st.priorBaselineBalance = 0.0;
m_st.priorCycleId = "";
m_st.priorLockKind = 0;
m_st.priorLockTime = 0;
PrintFormat("[UQ] OWNER INITIALIZATION: new risk cycle %s baseline B0 = %.2f",
m_st.cycleId, m_b0);
return(true);
}
//+------------------------------------------------------------------+
//| Establish D0 for the current broker-server trading day |
//+------------------------------------------------------------------+
bool CUQRisk::EstablishDailyBaseline(void)
{
m_equity = AccountInfoDouble(ACCOUNT_EQUITY);
if(!(m_equity > 0.0))
{
m_lastError = "account equity is unavailable";
return(false);
}
const datetime now = UQServerNow();
if(now <= 0)
{
m_lastError = "broker-server time is unavailable";
return(false);
}
//--- a cycle created by this initialization has no daily baseline yet. It
//--- is the only case in which D0 may be created here: a restored state is
//--- schema-validated, so a missing daily baseline would already have been
//--- refused instead of being replaced by the current equity.
if(!m_st.dailyValid || !(m_st.dailyStartEquity > 0.0) || m_st.dailyDate <= 0)
{
if(m_st.dailyValid || !m_st.baselineValid || m_st.cycleSequence < 1 || !(m_b0 > 0.0))
{
m_lastError = "the persisted daily baseline D0 is missing or invalid";
return(false);
}
m_st.dailyStartEquity = m_equity;
m_st.dailyValid = true;
m_st.dailyDate = UQDayStart(now);
m_st.dailyStartTime = now;
m_st.dailyLock = false;
m_st.dailyLockTime = 0;
m_st.monitorTime = now;
PrintFormat("[UQ] Monitoring starts with risk cycle %s: D0 = %.2f for broker-server day %s. Equity changes before this moment are not measured.",
m_st.cycleId, m_equity, TimeToString(m_st.dailyDate, TIME_DATE));
}
m_d0 = m_st.dailyStartEquity;
m_today = m_st.dailyDate;
//--- D0 is adopted from the state and never re-measured here. Whether the
//--- current broker-server day may replace it is decided by the monitoring
//--- pass, which either proves an observed boundary, proves a constant
//--- equity path, or holds entries.
if(UQDayStart(now) != m_st.dailyDate)
PrintFormat("[UQ] The persisted daily baseline D0 = %.2f belongs to broker-server day %s. It is preserved until the monitoring pass establishes the current day %s.",
m_d0, TimeToString(m_st.dailyDate, TIME_DATE),
TimeToString(UQDayStart(now), TIME_DATE));
m_dailyLossPct = ((m_d0 - m_equity) / m_d0) * 100.0;
if(m_dailyLossPct < 0.0)
m_dailyLossPct = 0.0;
m_dailyFloor = m_d0 * (1.0 - m_cfg.dailyLimitPct / 100.0);
m_staticFloor = m_b0 * (1.0 - m_cfg.staticLimitPct / 100.0);
m_targetEquity = m_b0 * (1.0 + m_cfg.targetPct / 100.0);
return(true);
}
//+------------------------------------------------------------------+
//| Establish and verify daily monitoring continuity. |
//| |
//| The heartbeat is persisted with the risk state, so a trading day |
//| is only adopted when its boundary was observed while the EA was |
//| executing, or when the equity path across that boundary is |
//| provably constant (no position, no pending order, no deal). In |
//| every other case the previous D0 is preserved and a monitoring- |
//| gap hold blocks new entries until the next observed day boundary. |
//| The unobserved interval is reported with its exact length and is |
//| never presented as a successfully monitored trading day. |
//+------------------------------------------------------------------+
bool CUQRisk::UpdateMonitoring(const datetime now, const datetime today)
{
bool changed = false;
if(now <= 0 || today <= 0)
return(false);
const datetime last = m_st.monitorTime;
//--- the unobserved window starts at the last proven moment: the heartbeat
//--- when there is one, otherwise the moment the persisted state was last
//--- written. Without either, nothing about the interval is proven.
const datetime ref = (last > 0 ? last : m_st.updatedAt);
const long gap = ((ref > 0 && now > ref) ? (long)(now - ref) : 0);
const long maxGap = (long)UQ_MONITOR_GAP_MAX_SECONDS;
const long shown = (gap > maxGap ? maxGap : gap);
//--- 1. a broker-server day can only move backwards when the server clock
//--- is inconsistent; the daily history is then unverifiable
if(today < m_st.dailyDate)
{
if(!m_st.gapHold)
{
m_st.gapHold = true;
m_st.gapHoldTime = now;
changed = true;
PrintFormat("[UQ] MONITORING GAP HOLD: broker-server day moved backwards (%s -> %s). The daily baseline D0 = %.2f is preserved and new entries stay blocked until an observed day boundary.",
TimeToString(m_st.dailyDate, TIME_DATE), TimeToString(today, TIME_DATE),
m_st.dailyStartEquity);
}
}
//--- 2. a new broker-server trading day
else if(m_st.dailyDate != today)
{
//--- continuity requires a heartbeat that is recent enough to prove the
//--- EA was executing at the boundary. A state with no heartbeat (a
//--- schema migrated from the previous build) proves nothing, so it must
//--- be reconstructed or held instead of being trusted.
const bool continuous = (last > 0 && gap <= (long)UQ_MONITOR_GAP_SECONDS);
string detail = "";
const bool flat = (continuous ? true : UQAccountProvablyFlat(ref, now, detail));
if(!flat)
{
if(!m_st.gapHold)
{
m_st.gapHold = true;
m_st.gapHoldTime = now;
PrintFormat("[UQ] MONITORING GAP HOLD: the broker-server day boundary between %s and %s was never observed (%d s) and the equity path cannot be reconstructed (%s). D0 stays at %.2f from %s, new entries are blocked and the loss of the unobserved interval is not claimed as measured. Entries resume only at a day boundary observed while the EA is executing.",
(ref > 0 ? TimeToString(ref, TIME_DATE | TIME_SECONDS) : "an unknown moment"),
TimeToString(now, TIME_DATE | TIME_SECONDS), (int)gap, detail,
m_st.dailyStartEquity, TimeToString(m_st.dailyDate, TIME_DATE));
}
else
PrintFormat("[UQ] MONITORING GAP HOLD stays active: the day boundary between %s and %s is still unverified.",
(ref > 0 ? TimeToString(ref, TIME_DATE | TIME_SECONDS) : "an unknown moment"),
TimeToString(now, TIME_DATE | TIME_SECONDS));
m_st.dailyDate = today;
m_today = today;
if(m_st.monitorGapSeconds != (int)shown)
m_st.monitorGapSeconds = (int)shown;
changed = true;
}
else
{
m_st.dailyStartEquity = m_equity;
m_st.dailyDate = today;
m_st.dailyValid = true;
m_st.dailyStartTime = now;
m_st.dailyLock = false;
m_st.dailyLockTime = 0;
m_st.monitorGapSeconds = 0;
m_d0 = m_equity;
m_dailyFloor = m_d0 * (1.0 - m_cfg.dailyLimitPct / 100.0);
m_today = today;
changed = true;
if(m_st.gapHold)
{
m_st.gapHold = false;
m_st.gapHoldTime = 0;
PrintFormat("[UQ] MONITORING GAP HOLD cleared: broker-server day %s began while the EA was executing, so D0 = %.2f is established from an observed boundary.",
TimeToString(today, TIME_DATE), m_equity);
}
if(continuous)
PrintFormat("[UQ] New broker-server trading day %s: D0 = %.2f, daily loss lock cleared. Permanent locks are never cleared by a rollover.",
TimeToString(today, TIME_DATE), m_equity);
else
PrintFormat("[UQ] RECONSTRUCTED DAY: no position, no pending order and no deal existed between %s and %s (%s), so the equity path was constant and D0 = %.2f is exact for broker-server day %s.",
(ref > 0 ? TimeToString(ref, TIME_DATE | TIME_SECONDS) : "an unknown moment"),
TimeToString(now, TIME_DATE | TIME_SECONDS), detail,
m_equity, TimeToString(today, TIME_DATE));
}
}
//--- 3. the same trading day is still monitored: D0 stands, so the daily
//--- loss of an unobserved interval is measured late, never skipped
else if(gap > (long)UQ_MONITOR_GAP_SECONDS && !m_st.gapHold)
{
if(m_st.monitorGapSeconds != (int)shown)
{
m_st.monitorGapSeconds = (int)shown;
changed = true;
PrintFormat("[UQ] WARNING: risk evaluation resumed after %d s without a monitoring pass. The daily baseline D0 = %.2f for broker-server day %s is preserved, so the equity change is still measured against it; during the gap no threshold was evaluated, so a breach could have gone unanswered for that interval.",
(int)gap, m_st.dailyStartEquity, TimeToString(today, TIME_DATE));
}
}
//--- 4. heartbeat: refreshed on every pass and persisted at a bounded
//--- interval, so the state file is not rewritten on every tick
if(m_st.monitorTime <= 0 ||
now >= m_st.monitorTime + (datetime)UQ_MONITOR_HEARTBEAT_SECONDS)
{
m_st.monitorTime = now;
changed = true;
}
return(changed);
}
//+------------------------------------------------------------------+
//| Re-evaluate the three thresholds. |
//| Returns true when persisted state was modified. |
//+------------------------------------------------------------------+
bool CUQRisk::EvaluateLocks(void)
{
bool changed = false;
m_equity = AccountInfoDouble(ACCOUNT_EQUITY);
if(!(m_equity > 0.0))
{
m_block = UQ_BLOCK_BASELINE;
return(false);
}
//--- Monitoring continuity is established before any threshold is trusted.
//--- A new broker-server day is adopted only when its boundary was observed
//--- while the EA was executing, or when the equity path across it can be
//--- proven constant. Otherwise the previous D0 is preserved and a hold
//--- blocks entries: an unmonitored interval is never reported as a
//--- monitored trading day and D0 is never silently re-measured.
const datetime now = UQServerNow();
const datetime today = (now > 0 ? UQDayStart(now) : 0);
if(now <= 0 || today <= 0)
{
m_block = UQ_BLOCK_BASELINE;
return(changed);
}
if(UpdateMonitoring(now, today))
changed = true;
if(!(m_b0 > 0.0) || !(m_d0 > 0.0) || !m_st.baselineValid)
{
m_block = UQ_BLOCK_BASELINE;
return(changed);
}
//--- the daily loss is always measured against the baseline the state
//--- carries. While a monitoring-gap hold is active that baseline is the
//--- preserved one, so a breach is still acted on instead of being skipped.
m_dailyLossPct = ((m_d0 - m_equity) / m_d0) * 100.0;
if(m_dailyLossPct < 0.0)
m_dailyLossPct = 0.0;
m_staticDdPct = ((m_b0 - m_equity) / m_b0) * 100.0;
//--- permanent static-drawdown lock
if(!m_st.staticLock && m_staticDdPct >= m_cfg.staticLimitPct)
{
m_st.staticLock = true;
m_st.staticLockTime = TimeCurrent();
changed = true;
PrintFormat("[UQ] PERMANENT LOCK: static drawdown %.3f%% >= %.2f%% (equity %.2f, B0 %.2f).",
m_staticDdPct, m_cfg.staticLimitPct, m_equity, m_b0);
}
//--- permanent equity-target lock
if(!m_st.targetLock && m_equity >= m_targetEquity)
{
m_st.targetLock = true;
m_st.targetLockTime = TimeCurrent();
changed = true;
PrintFormat("[UQ] PERMANENT LOCK: equity %.2f reached target %.2f (B0 %.2f + %.2f%%).",
m_equity, m_targetEquity, m_b0, m_cfg.targetPct);
}
//--- daily loss lock
if(!m_st.dailyLock && m_dailyLossPct >= m_cfg.dailyLimitPct)
{
m_st.dailyLock = true;
m_st.dailyLockTime = TimeCurrent();
changed = true;
PrintFormat("[UQ] LOCK: daily loss %.3f%% >= %.2f%% (equity %.2f, D0 %.2f).",
m_dailyLossPct, m_cfg.dailyLimitPct, m_equity, m_d0);
}
ApplyBlock();
return(changed);
}
//+------------------------------------------------------------------+
//| Load the persistent state, order it against its backup by the |
//| generation each file carries, and reconcile the redundant |
//| initialization marker. Fails closed on every uncertain condition. |
//| |
//| A backup is evidence that this scope was already initialized, so a |
//| missing primary is never a fresh installation. A state that had to |
//| be recovered, that was adopted from a newer backup or that had to |
//| be ordered against a conflict is never entry-permitted: it starts |
//| behind a persisted recovery hold that only an explicit owner |
//| authorization can clear. |
//+------------------------------------------------------------------+
bool CUQRisk::LoadPersistentState(void)
{
//--- without a V2 fingerprint this scope has no locator of its own, and a
//--- derived name would be shared with every other identity: refuse before
//--- any file or marker is touched
if(!UQIdentityFingerprintValid(m_id.fingerprintV2))
{
m_lastError = "the V2 identity fingerprint is unavailable; the persistence scope cannot be identified";
PrintFormat("[UQ] ERROR: %s", m_lastError);
return(false);
}
const string primary = UQStateFileName(m_id);
const string backup = UQStateBackupFileName(m_id);
UQRiskState prim;
UQRiskState back;
string perr = "";
string berr = "";
long revPrimary = 0;
long revBackup = 0;
const ENUM_UQ_STATE_LOAD loadPrimary = UQStateLoadGenerational(primary, m_id, prim, revPrimary, perr);
const ENUM_UQ_STATE_LOAD loadBackup = UQStateLoadGenerational(backup, m_id, back, revBackup, berr);
//--- A/B/C. a V2 state file exists as soon as one of the two is present. The
//--- absence of both is not read here: the legacy evidence decides whether
//--- this is a fresh installation, a migration or a refusal.
if(loadPrimary == UQ_LOAD_ABSENT && loadBackup == UQ_LOAD_ABSENT)
return(LoadLegacyPersistenceScope());
string holdReason = "";
bool holdNeeded = false;
if(!SelectVerifiedState(prim, revPrimary, perr, back, revBackup, berr,
loadPrimary, loadBackup, holdNeeded, holdReason))
return(false);
//--- D. the redundant marker must agree with the state that was chosen
datetime markerTime = 0;
const ENUM_UQ_MARKER_STATE marker = UQStateMarkerCheck(m_id, m_st.createdAt, markerTime);
if(marker == UQ_MARKER_INVALID)
{
m_lastError = StringFormat("the initialization marker of this scope exists but holds no usable value (the verified state records %s); the persistence evidence is corrupt and it is never rebuilt",
TimeToString(m_st.createdAt, TIME_DATE | TIME_SECONDS));
PrintFormat("[UQ] ERROR: %s", m_lastError);
return(false);
}
if(marker == UQ_MARKER_MISMATCH)
{
m_lastError = StringFormat("initialization marker (%s) disagrees with the persisted state (%s); refusing to start",
TimeToString(markerTime, TIME_DATE | TIME_SECONDS),
TimeToString(m_st.createdAt, TIME_DATE | TIME_SECONDS));
PrintFormat("[UQ] ERROR: %s", m_lastError);
return(false);
}
//--- E. a recovered or reordered state is never entry-permitted
if(holdNeeded && !m_st.recoveryHold)
{
m_st.recoveryHold = true;
m_st.recoveryHoldTime = UQServerNow();
if(m_st.recoveryHoldTime <= 0)
m_st.recoveryHoldTime = TimeCurrent();
PrintFormat("[UQ] RECOVERY HOLD: %s. New entries are blocked until the owner authorizes a recovery risk cycle.",
holdReason);
}
if(m_st.recoveryHold)
PrintFormat("[UQ] RECOVERY HOLD is active since %s. New entries stay blocked until the owner authorizes recovery with a fresh nonce.",
TimeToString(m_st.recoveryHoldTime, TIME_DATE | TIME_SECONDS));
if(loadPrimary == UQ_LOAD_LEGACY || loadBackup == UQ_LOAD_LEGACY)
{
m_migrated = true;
PrintFormat("[UQ] A persisted state uses the previous published schema; it is migrated to schema %d after validation.",
UQ_STATE_FORMAT);
}
if(!EstablishBaselineFromState())
return(false);
if(!EstablishDailyBaseline())
return(false);
//--- a rejected reset must not stop risk management: it blocks entries
ApplyResetRequest();
EvaluateLocks();
//--- rewriting migrates a legacy schema, mirrors the backup with the same
//--- verified generation and persists the recovery hold
if(!Save())
return(false);
m_cycleId = m_st.cycleId;
m_cycleSequence = m_st.cycleSequence;
if(marker == UQ_MARKER_ABSENT)
{
PrintFormat("[UQ] WARNING: the initialization marker was absent; it is re-established from the verified state file.");
if(!UQStateMarkerSet(m_id, m_st.createdAt))
{
m_lastError = "the initialization marker could not be restored from the verified state";
PrintFormat("[UQ] ERROR: %s. The persistence scope is not healthy and new entries stay blocked.", m_lastError);
return(false);
}
datetime restored = 0;
if(UQStateMarkerCheck(m_id, m_st.createdAt, restored) != UQ_MARKER_MATCH)
{
m_lastError = "the restored initialization marker could not be verified";
PrintFormat("[UQ] ERROR: %s. The persistence scope is not healthy and new entries stay blocked.", m_lastError);
return(false);
}
}
return(true);
}
//+------------------------------------------------------------------+
//| Authorized first initialization of a brand new persistence scope |
//| |
//| Reached only when no V2 evidence and no legacy evidence exists at |
//| all. A new risk cycle still requires an explicit owner |
//| authorization and a deliberately declared positive baseline B0. |
//+------------------------------------------------------------------+
bool CUQRisk::FirstInitialization(void)
{
if(!m_cfg.allowFirstInit)
{
m_lastError = "no persistent risk state exists and a new risk cycle is not authorized";
PrintFormat("[UQ] ERROR: %s. Set the first-initialization authorization input only after confirming that this portfolio has never been initialized.", m_lastError);
return(false);
}
if(!(m_cfg.balanceOverride > 0.0))
{
m_lastError = "a new risk cycle requires an explicit positive initial balance B0";
PrintFormat("[UQ] ERROR: %s. The current account balance is deliberately not used as a substitute.", m_lastError);
return(false);
}
if(!EstablishInitialBaseline())
return(false);
if(!EstablishDailyBaseline())
return(false);
EvaluateLocks();
//--- persist before the cycle may be used, then prove it exists
if(!Save())
return(false);
if(!UQStateMarkerSet(m_id, m_st.createdAt))
{
m_lastError = "initialization marker could not be persisted";
PrintFormat("[UQ] ERROR: %s", m_lastError);
return(false);
}
datetime markerCheck = 0;
if(UQStateMarkerCheck(m_id, m_st.createdAt, markerCheck) != UQ_MARKER_MATCH)
{
m_lastError = "initialization marker could not be verified after it was written";
PrintFormat("[UQ] ERROR: %s", m_lastError);
return(false);
}
m_cycleId = m_st.cycleId;
m_cycleSequence = m_st.cycleSequence;
if(m_cfg.resetRequest != UQ_RESET_NONE)
PrintFormat("[UQ] WARNING: the owner reset request is ignored because this scope holds no risk cycle yet; the new cycle is created by the initialization authorization instead.");
m_freshInstall = true;
return(true);
}
//+------------------------------------------------------------------+
//| Order one primary/backup pair of verified states by generation |
//| |
//| The chosen generation is assigned to m_st. A backup is never |
//| adopted without the explicit owner authorization, and any state |
//| that had to be ordered against a conflict or recovered from the |
//| backup starts behind a recovery hold. |
//+------------------------------------------------------------------+
bool CUQRisk::SelectVerifiedState(UQRiskState &prim, const long revPrimary, const string perr,
UQRiskState &back, const long revBackup, const string berr,
const ENUM_UQ_STATE_LOAD loadPrimary,
const ENUM_UQ_STATE_LOAD loadBackup,
bool &holdNeeded, string &holdReason)
{
holdNeeded = false;
holdReason = "";
const bool primaryUsable = (loadPrimary == UQ_LOAD_VALID || loadPrimary == UQ_LOAD_LEGACY);
const bool backupUsable = (loadBackup == UQ_LOAD_VALID || loadBackup == UQ_LOAD_LEGACY);
//--- an existing but unusable primary is never replaced silently, and a
//--- backup is never restored without the owner authorization
if(!primaryUsable)
{
if(!backupUsable)
{
m_lastError = StringFormat("the persistent risk state is %s%s and the backup is %s%s; recovery is ambiguous and a new risk cycle is refused",
UQStateLoadText(loadPrimary),
(StringLen(perr) > 0 ? " (" + perr + ")" : ""),
UQStateLoadText(loadBackup),
(StringLen(berr) > 0 ? " (" + berr + ")" : ""));
PrintFormat("[UQ] ERROR: %s", m_lastError);
return(false);
}
if(!m_cfg.allowBackupRestore)
{
m_lastError = StringFormat("the persistent risk state is %s%s and restoring the verified backup is not authorized; a new risk cycle is refused",
UQStateLoadText(loadPrimary),
(StringLen(perr) > 0 ? " (" + perr + ")" : ""));
PrintFormat("[UQ] ERROR: %s", m_lastError);
return(false);
}
}
if(primaryUsable && backupUsable)
{
if(revPrimary > revBackup)
{
m_st = prim;
PrintFormat("[UQ] Persistent state generation %I64d is newer than the backup generation %I64d; the primary is authoritative.",
revPrimary, revBackup);
}
else if(revPrimary == revBackup)
{
if(UQStateCanonicalBody(prim) != UQStateCanonicalBody(back))
{
m_lastError = StringFormat("the primary and the backup disagree at generation %I64d; the latest lock state cannot be established and a new risk cycle is refused",
revPrimary);
PrintFormat("[UQ] ERROR: %s", m_lastError);
return(false);
}
m_st = prim;
}
else
{
if(revBackup != revPrimary + 1)
{
m_lastError = StringFormat("the backup generation %I64d is inconsistent with the primary generation %I64d; the state ordering cannot be established",
revBackup, revPrimary);
PrintFormat("[UQ] ERROR: %s", m_lastError);
return(false);
}
//--- the interrupted commit is exactly the write-ahead window: the
//--- backup holds the newer verified generation, the primary the
//--- previous one. The newer verified generation wins, but never into
//--- an entry-permitted condition.
m_st = back;
m_restoredBackup = true;
holdNeeded = true;
holdReason = StringFormat("the latest verified generation %I64d exists only in the backup because an interrupted commit left the primary at generation %I64d",
revBackup, revPrimary);
}
}
else if(primaryUsable)
{
m_st = prim;
}
else
{
m_st = back;
m_restoredBackup = true;
holdNeeded = true;
holdReason = (loadPrimary == UQ_LOAD_ABSENT
? StringFormat("the primary state file is missing while a verified backup at generation %I64d proves that this scope was already initialized", revBackup)
: StringFormat("the primary state file is unusable and the verified backup at generation %I64d was restored", revBackup));
PrintFormat("[UQ] WARNING: risk state restored from the verified backup under explicit owner authorization.");
}
return(true);
}
//+------------------------------------------------------------------+
//| Enter a persistence scope that holds no V2 state yet |
//| |
//| Only a legacy state that validated against the CURRENT legacy |
//| identity hash may be migrated. Every other legacy evidence |
//| refuses initialization without touching anything: a legacy file |
//| shared by two different logical identities is a persistence |
//| ownership conflict, never permission to overwrite it, delete its |
//| backup, replace its state or invent a new risk cycle. |
//+------------------------------------------------------------------+
bool CUQRisk::LoadLegacyPersistenceScope(void)
{
const string legacyPrimary = UQStateLegacyFileName(m_id);
const string legacyBackup = UQStateLegacyBackupFileName(m_id);
UQRiskState prim;
UQRiskState back;
string perr = "";
string berr = "";
long revPrimary = 0;
long revBackup = 0;
const ENUM_UQ_STATE_LOAD loadPrimary = UQStateLoadGenerational(legacyPrimary, m_id, prim, revPrimary, perr);
const ENUM_UQ_STATE_LOAD loadBackup = UQStateLoadGenerational(legacyBackup, m_id, back, revBackup, berr);
const bool primaryUsable = (loadPrimary == UQ_LOAD_VALID || loadPrimary == UQ_LOAD_LEGACY);
const bool backupUsable = (loadBackup == UQ_LOAD_VALID || loadBackup == UQ_LOAD_LEGACY);
const bool legacyStatePresent = (loadPrimary != UQ_LOAD_ABSENT || loadBackup != UQ_LOAD_ABSENT);
//--- a marker queried with createdAt = 0 reports MISMATCH for every usable
//--- value ("this scope was initialized before") and INVALID for a value that
//--- is not a record at all. The two are never collapsed into "absent".
datetime legacyMarkerTime = 0;
const ENUM_UQ_MARKER_STATE legacyMarker = UQStateLegacyMarkerCheck(m_id, 0, legacyMarkerTime);
const bool legacyMarkerPresent = (legacyMarker == UQ_MARKER_MISMATCH);
const bool legacyMarkerInvalid = (legacyMarker == UQ_MARKER_INVALID);
datetime v2MarkerTime = 0;
const ENUM_UQ_MARKER_STATE v2Marker = UQStateMarkerCheck(m_id, 0, v2MarkerTime);
const bool v2MarkerPresent = (v2Marker == UQ_MARKER_MISMATCH);
const bool v2MarkerInvalid = (v2Marker == UQ_MARKER_INVALID);
string reason = "";
const ENUM_UQ_SCOPE_DECISION decision =
UQScopeDecision(false, v2MarkerPresent, legacyStatePresent,
(primaryUsable || backupUsable), legacyMarkerPresent, reason,
v2MarkerInvalid, legacyMarkerInvalid);
if(decision == UQ_SCOPE_FRESH)
return(FirstInitialization());
if(decision != UQ_SCOPE_LEGACY_MIGRATE)
{
m_lastError = reason;
if(legacyStatePresent)
m_lastError += StringFormat(" (legacy state %s%s, legacy backup %s%s)",
UQStateLoadText(loadPrimary),
(StringLen(perr) > 0 ? " (" + perr + ")" : ""),
UQStateLoadText(loadBackup),
(StringLen(berr) > 0 ? " (" + berr + ")" : ""));
PrintFormat("[UQ] ERROR: %s. Initialization is refused; no new risk cycle is created and no existing file is modified.", m_lastError);
return(false);
}
//--- legacy evidence is only ever adopted when the legacy serialisation of
//--- this identity decodes back to exactly this tuple. A tuple whose
//--- 'server|login|portfolio' preimage is shared with a different tuple can
//--- never prove that this legacy state is its own, and the state is left
//--- exactly as it is instead of being attached to a guessing identity.
string ambiguity = "";
if(!UQLegacyMigrationAllowed(m_id, legacyStatePresent, legacyMarkerPresent, ambiguity))
{
m_lastError = ambiguity;
PrintFormat("[UQ] ERROR: %s. Initialization is refused; the legacy state, the legacy backup and the legacy marker are left untouched and no V2 state is written.", m_lastError);
return(false);
}
//--- a legacy state file that is present but unusable or foreign was already
//--- refused above, so both files here validated against this identity
string holdReason = "";
bool holdNeeded = false;
if(!SelectVerifiedState(prim, revPrimary, perr, back, revBackup, berr,
loadPrimary, loadBackup, holdNeeded, holdReason))
return(false);
return(CommitMigratedState(holdNeeded, holdReason));
}
//+------------------------------------------------------------------+
//| Move one verified legacy state into the V2 persistence scope |
//| |
//| Evidence first: the state is written, verified and reconciled |
//| under the V2 locators, then the V2 marker is recorded with the |
//| ORIGINAL creation timestamp, and the persisted result is compared |
//| field by field against what was loaded. Initialization completes |
//| only when every one of those steps proved itself. |
//| |
//| The legacy files, the legacy backup and the legacy marker are |
//| never renamed, deleted or rewritten: they remain the only |
//| remaining copy of the pre-migration history. |
//+------------------------------------------------------------------+
bool CUQRisk::CommitMigratedState(const bool holdNeeded, const string holdReason)
{
//--- a migration stamps the legacy state with the V2 fingerprint of the
//--- identity that adopted it. An identity whose legacy serialisation is
//--- ambiguous cannot prove that ownership, so it must never stamp anything:
//--- the state would otherwise be claimed by whichever V2 identity happened
//--- to initialize first.
if(m_id.legacyAmbiguous)
{
m_lastError = "the legacy identity serialisation of this scope is ambiguous; a legacy state cannot be stamped with this V2 identity";
PrintFormat("[UQ] ERROR: %s.", m_lastError);
return(false);
}
//--- the migrated generation carries the live fingerprint of the
//--- identity that just passed the legacy identity validation
m_st.identityV2 = m_id.fingerprintV2;
PrintFormat("[UQ] PERSISTENCE LAYOUT: the verified legacy state is relocated to the V2 persistence scope. The legacy files and the legacy marker are left untouched.");
//--- a state adopted from the backup is never entry-permitted
if(holdNeeded && !m_st.recoveryHold)
{
m_st.recoveryHold = true;
m_st.recoveryHoldTime = UQServerNow();
if(m_st.recoveryHoldTime <= 0)
m_st.recoveryHoldTime = TimeCurrent();
PrintFormat("[UQ] RECOVERY HOLD: %s. New entries are blocked until the owner authorizes a recovery risk cycle.",
holdReason);
}
if(m_st.recoveryHold)
PrintFormat("[UQ] RECOVERY HOLD is active since %s. New entries stay blocked until the owner authorizes recovery with a fresh nonce.",
TimeToString(m_st.recoveryHoldTime, TIME_DATE | TIME_SECONDS));
//--- the baselines are adopted from the migrated state, never derived
if(!EstablishBaselineFromState())
return(false);
if(!EstablishDailyBaseline())
return(false);
//--- the same evaluation the normal load path performs. It can arm a lock,
//--- never disarm one.
EvaluateLocks();
//--- the expected outcome, captured after the evaluation pass
UQRiskState expected = m_st;
if(!Save())
return(false);
//--- the V2 marker records the creation timestamp of the ORIGINAL state, so
//--- the scope stays recognisable as initialized even if the state file is
//--- deleted later
if(!UQStateMarkerSet(m_id, m_st.createdAt))
{
m_lastError = "the V2 initialization marker could not be persisted after the migration";
PrintFormat("[UQ] ERROR: %s", m_lastError);
return(false);
}
datetime markerCheck = 0;
if(UQStateMarkerCheck(m_id, m_st.createdAt, markerCheck) != UQ_MARKER_MATCH)
{
m_lastError = "the V2 initialization marker could not be verified after it was written";
PrintFormat("[UQ] ERROR: %s", m_lastError);
return(false);
}
//--- read the persisted generation back and prove that nothing was lost
UQRiskState migrated;
string merr = "";
long mrev = 0;
const ENUM_UQ_STATE_LOAD mload =
UQStateLoadGenerational(UQStateFileName(m_id), m_id, migrated, mrev, merr);
if(mload != UQ_LOAD_VALID)
{
m_lastError = StringFormat("the migrated V2 state could not be verified (%s%s)",
UQStateLoadText(mload),
(StringLen(merr) > 0 ? " (" + merr + ")" : ""));
PrintFormat("[UQ] ERROR: %s.", m_lastError);
return(false);
}
if(migrated.identity != m_id.hash || migrated.identityV2 != m_id.fingerprintV2)
{
m_lastError = "the migrated V2 state does not carry this identity";
PrintFormat("[UQ] ERROR: %s.", m_lastError);
return(false);
}
if(migrated.stateRevision <= expected.stateRevision)
{
m_lastError = "the migrated V2 state did not advance the persistence generation";
PrintFormat("[UQ] ERROR: %s.", m_lastError);
return(false);
}
string detail = "";
if(!UQStatePreserved(expected, migrated, detail))
{
m_lastError = "the migration did not preserve the risk state: " + detail;
PrintFormat("[UQ] ERROR: %s.", m_lastError);
return(false);
}
if(!UQStateLocksNotCleared(expected, migrated, detail))
{
m_lastError = "the migration weakened a risk control: " + detail;
PrintFormat("[UQ] ERROR: %s.", m_lastError);
return(false);
}
m_migrated = true;
m_cycleId = m_st.cycleId;
m_cycleSequence = m_st.cycleSequence;
PrintFormat("[UQ] PERSISTENCE LAYOUT COMPLETE: risk cycle %s (sequence %d), B0=%.2f D0=%.2f, schema %d, generation %I64d, static=%s target=%s daily=%s recoveryHold=%s. Future restarts use the V2 locators.",
m_st.cycleId, m_st.cycleSequence, m_b0, m_d0, m_st.format, m_st.stateRevision,
(m_st.staticLock ? "ON" : "off"), (m_st.targetLock ? "ON" : "off"),
(m_st.dailyLock ? "ON" : "off"),
(m_st.recoveryHold ? "ACTIVE" : "none"));
//--- a relocation never applies an owner reset: the preserved risk cycle is
//--- left exactly as it is and the request is reported instead of consumed
if(m_cfg.resetRequest != UQ_RESET_NONE)
PrintFormat("[UQ] WARNING: the owner reset request was not applied while the persistence layout was migrated. Remove it and restart to request the new risk cycle deliberately.");
return(true);
}
//+------------------------------------------------------------------+
//| Aggregate exposure of this portfolio: verified broker exposure |
//| plus the entry money that is still reserved. |
//+------------------------------------------------------------------+
bool CUQRisk::Exposure(const ulong magic, UQExposure &out)
{
UQExposureFromPools(magic, m_st.reserveMoney, out);
//--- an entry reservation that carries no usable monetary amount cannot be
//--- quantified either: it must never disappear from the aggregate as if it
//--- had been zero risk, so the exposure stays unbounded and entries closed
if(m_st.reserveState != UQ_RESERVE_NONE && !(m_st.reserveMoney > 0.0))
UQExposureNote(out, m_st.reserveSymbol, m_st.reserveTicket,
"reservation without a usable monetary risk");
return(out.bounded);
}
//+------------------------------------------------------------------+
//| Clear the reservation fields without persisting |
//+------------------------------------------------------------------+
void CUQRisk::ClearReservation(void)
{
m_st.reserveState = UQ_RESERVE_NONE;
m_st.reserveMoney = 0.0;
m_st.reserveTicket = 0;
m_st.reserveSymbol = "";
m_st.reserveTime = 0;
m_st.reserveId = "";
m_st.reserveMagic = 0;
m_st.reserveScope = "";
m_st.reserveDirection = 0;
m_st.reserveVolume = 0.0;
m_st.reserveEntry = 0.0;
m_st.reserveStop = 0.0;
m_st.reserveDeal = 0;
m_st.reservePosition = 0;
m_reserveCheckAt = 0;
}
//+------------------------------------------------------------------+
//| Reserve the monetary risk of an entry BEFORE the order is sent. |
//| |
//| The reservation is persisted first: an entry whose risk cannot be |
//| written to disk must not be submitted at all, otherwise a restart |
//| would forget exposure that the account actually carries. |
//+------------------------------------------------------------------+
bool CUQRisk::ReserveEntryRisk(const ulong magic, const string symbol,
const int direction, const double volume,
const double entry, const double stop,
const double money, string &err)
{
err = "";
if(!m_ready)
{
err = "the risk controller is not initialised";
return(false);
}
if(!m_healthy)
{
err = "risk-state persistence is unhealthy, so no entry risk can be reserved";
return(false);
}
if(m_st.reserveState != UQ_RESERVE_NONE)
{
err = StringFormat("an earlier entry (%s, %.2f) is still unreconciled",
m_st.reserveSymbol, m_st.reserveMoney);
return(false);
}
if(!(money > 0.0))
{
err = "the reserved entry risk is not positive";
return(false);
}
if(!UQReserveTextSafe(symbol, UQ_RESERVE_SYMBOL_MAX_LEN))
{
err = "the entry symbol is empty, too long or carries a format character";
return(false);
}
if(direction != 1 && direction != -1)
{
err = "the entry direction is not buy or sell";
return(false);
}
if(!(volume > 0.0) || !(entry > 0.0) || !(stop > 0.0))
{
err = "the entry request carries no usable volume, price and stop";
return(false);
}
const datetime now = UQServerNow();
if(now <= 0)
{
err = "broker-server time is unavailable";
return(false);
}
m_reserveSeq++;
const string rid = UQBuildReserveId(m_id.key, symbol, now, m_reserveSeq);
if(!UQReserveTextSafe(rid, UQ_RESERVE_ID_MAX_LEN))
{
err = "the entry reservation identifier could not be built";
return(false);
}
m_st.reserveState = UQ_RESERVE_SENT;
m_st.reserveMoney = money;
m_st.reserveTicket = 0;
m_st.reserveSymbol = symbol;
m_st.reserveTime = now;
m_st.reserveId = rid;
m_st.reserveMagic = magic;
m_st.reserveScope = (StringLen(m_cfg.portfolio) <= UQ_RESERVE_SCOPE_MAX_LEN ?
m_cfg.portfolio : "");
m_st.reserveDirection = direction;
m_st.reserveVolume = volume;
m_st.reserveEntry = entry;
m_st.reserveStop = stop;
m_st.reserveDeal = 0;
m_st.reservePosition = 0;
m_reserveCheckAt = 0;
if(!Save())
{
//--- nothing was submitted yet: the reservation is withdrawn again,
//--- while the persistence-health block keeps further entries closed
ClearReservation();
err = "the entry-risk reservation could not be persisted: " + m_lastSaveError;
ApplyBlock();
return(false);
}
ApplyBlock();
return(true);
}
//+------------------------------------------------------------------+
//| Record the tickets returned by an accepted entry order |
//| |
//| The order ticket and the deal ticket are different identities and |
//| are stored separately. Neither is promoted to a position |
//| identifier here: that link only comes from a verified deal record. |
//+------------------------------------------------------------------+
bool CUQRisk::NoteSubmittedTickets(const ulong orderTicket, const ulong dealTicket)
{
if(m_st.reserveState == UQ_RESERVE_NONE)
return(true);
m_st.reserveTicket = orderTicket;
m_st.reserveDeal = dealTicket;
if(!Save())
{
m_lastError = "the submitted order and deal tickets could not be persisted";
ApplyBlock();
return(false);
}
ApplyBlock();
return(true);
}
//+------------------------------------------------------------------+
//| Release a reservation after verified evidence that it carries no |
//| exposure any more. The release is persisted; if that fails the |
//| controller stays unhealthy and entries stay blocked. |
//+------------------------------------------------------------------+
void CUQRisk::ReleaseReservation(const string why)
{
if(m_st.reserveState == UQ_RESERVE_NONE)
return;
const string sym = m_st.reserveSymbol;
const double money = m_st.reserveMoney;
const ulong tick = m_st.reserveTicket;
ClearReservation();
if(!Save())
PrintFormat("[UQ] ERROR: the released reservation of %.2f %s could not be persisted; entries stay blocked.", money, sym);
ApplyBlock();
PrintFormat("[UQ] Entry risk released: %s | money=%.2f symbol=%s ticket=%s",
why, money, sym, IntegerToString((long)tick));
}
//+------------------------------------------------------------------+
//| Reconcile an accepted entry order with the live broker state. |
//| |
//| While the reconciliation window is open the reserved money keeps |
//| counting as exposure. After it, only a connected terminal may |
//| prove that nothing is exposed; otherwise the reservation becomes |
//| UNRESOLVED and new entries stay blocked. |
//+------------------------------------------------------------------+
void CUQRisk::ReconcileReservation(void)
{
ReconcileReservationDepth(false);
}
//+------------------------------------------------------------------+
//| Depth-limited reconciliation. 'forced' bypasses the throttle and |
//| is used by the transaction handler, so a fill converts the |
//| reservation as soon as the broker reports the deal. |
//+------------------------------------------------------------------+
void CUQRisk::ReconcileReservationDepth(const bool forced)
{
if(m_st.reserveState == UQ_RESERVE_NONE)
return;
const datetime now = UQServerNow();
//--- the correlation scans the deal history, so a repeated call inside the
//--- throttle window is skipped. The reserved money keeps counting while a
//--- check is skipped, so skipping can never free risk.
if(!forced && m_reserveCheckAt > 0 && now > 0 &&
now < m_reserveCheckAt + (datetime)(UQ_RESERVE_RECONCILE_SECONDS / 4))
return;
m_reserveCheckAt = now;
UQReserveEvidence ev;
UQReserveCorrelate(m_cfg.magic, m_st, ev);
const ulong order = m_st.reserveTicket;
const ulong deal = m_st.reserveDeal;
const string sym = m_st.reserveSymbol;
const string rid = m_st.reserveId;
const double money = m_st.reserveMoney;
//--- 1. more than one candidate: never guess, never release
if(ev.ambiguous)
{
MarkReservationUnresolved("several broker records could belong to this request (" + ev.detail + ")", ev);
return;
}
//--- 2. verified: an active order, or a deal traced to this request, is
//--- real exposure. The reserved money stops counting because the pools
//--- now measure the same exposure instead.
if(ev.orderLive || ev.dealCount > 0)
{
const ulong verifiedOrder = (ev.liveOrder > 0 ? ev.liveOrder : order);
ClearReservation();
if(!Save())
PrintFormat("[UQ] ERROR: the reconciled reservation could not be persisted; entries stay blocked.");
ApplyBlock();
PrintFormat("[UQ] Entry risk converted: id=%s %s order=%s deal=%s position=%s. %d unrelated owned position(s) were not used as evidence.",
rid, sym, IntegerToString((long)verifiedOrder),
IntegerToString((long)ev.dealTicket), IntegerToString((long)ev.positionId),
ev.unrelatedCount);
return;
}
//--- 3. the order history proves that the request left no exposure
if(ev.noExposureProven)
{
ReleaseReservation(StringFormat("the order history proves that ticket=%s never executed",
IntegerToString((long)order)));
return;
}
//--- 4. no evidence yet: the reservation keeps counting as exposure and no
//--- conclusion is drawn before the reconciliation window has elapsed
if(now <= 0 || m_st.reserveTime <= 0 ||
now < m_st.reserveTime + (datetime)UQ_RESERVE_RECONCILE_SECONDS)
return;
//--- 5. the window elapsed without a verifiable answer
if(!ev.connected)
{
MarkReservationUnresolved("the terminal is disconnected, so the broker state cannot be verified", ev);
return;
}
MarkReservationUnresolved(StringFormat("no broker transaction matches id=%s order=%s deal=%s within %d seconds; the request may still have been executed",
rid, IntegerToString((long)order), IntegerToString((long)deal),
UQ_RESERVE_RECONCILE_SECONDS), ev);
}
//+------------------------------------------------------------------+
//| Persist an unverifiable reservation as UNRESOLVED. |
//| |
//| The money keeps counting as exposure and every new entry stays |
//| blocked. This is the case the reservation exists for: an answer |
//| that cannot be established is not a rejection. |
//+------------------------------------------------------------------+
void CUQRisk::MarkReservationUnresolved(const string why, UQReserveEvidence &ev)
{
if(m_st.reserveState == UQ_RESERVE_NONE)
return;
const bool first = (m_st.reserveState != UQ_RESERVE_UNRESOLVED);
m_st.reserveState = UQ_RESERVE_UNRESOLVED;
if(ev.dealTicket > 0 && m_st.reserveDeal == 0)
m_st.reserveDeal = ev.dealTicket;
if(ev.positionId > 0 && m_st.reservePosition == 0)
m_st.reservePosition = ev.positionId;
m_lastError = "entry reservation unresolved: " + why;
//--- an already persisted UNRESOLVED reservation needs no rewrite; the
//--- block is re-applied so a persistence-health change still wins
if(first && !Save())
{
ApplyBlock();
return;
}
ApplyBlock();
if(first)
PrintFormat("[UQ] ERROR: %s. %.2f %s stays reserved (id=%s order=%s) and new entries are blocked until the broker state can be verified.",
m_lastError, m_st.reserveMoney, m_st.reserveSymbol, m_st.reserveId,
IntegerToString((long)m_st.reserveTicket));
}
//+------------------------------------------------------------------+
//| Initialise the controller. Fails closed on any integrity problem. |
//+------------------------------------------------------------------+
bool CUQRisk::Init(UQRiskConfig &cfg)
{
m_ready = false;
m_healthy = false;
m_freshInstall = false;
m_migrated = false;
m_restoredBackup = false;
m_resetRejected = false;
m_resetApplied = false;
m_cycleId = "";
m_cycleSequence = 0;
m_lastError = "";
m_lastSaveError = "";
m_saveRetryAt = 0;
m_equity = 0.0;
m_b0 = 0.0;
m_d0 = 0.0;
m_dailyLossPct = 0.0;
m_staticDdPct = 0.0;
m_targetEquity = 0.0;
m_dailyFloor = 0.0;
m_staticFloor = 0.0;
m_today = 0;
m_block = UQ_BLOCK_BASELINE;
m_cfg = cfg;
UQStateReset(m_st);
if(!(cfg.dailyLimitPct > 0.0 && cfg.dailyLimitPct < 100.0))
{
m_lastError = "daily maximum loss percentage is outside (0, 100)";
return(false);
}
if(!(cfg.staticLimitPct > 0.0 && cfg.staticLimitPct < 100.0))
{
m_lastError = "static maximum drawdown percentage is outside (0, 100)";
return(false);
}
if(!(cfg.targetPct > 0.0 && cfg.targetPct <= 1000.0))
{
m_lastError = "equity growth target percentage is outside (0, 1000]";
return(false);
}
if(cfg.balanceOverride < 0.0)
{
m_lastError = "initial balance override must not be negative";
return(false);
}
if(cfg.resetBaseline < 0.0)
{
m_lastError = "the baseline of a new risk cycle must not be negative";
return(false);
}
if(cfg.magic == 0)
{
m_lastError = "a positive Magic Number is required to account for the portfolio exposure";
return(false);
}
if(cfg.resetRequest != UQ_RESET_NONE &&
cfg.resetRequest != UQ_RESET_STATIC &&
cfg.resetRequest != UQ_RESET_TARGET &&
cfg.resetRequest != UQ_RESET_BOTH &&
cfg.resetRequest != UQ_RESET_RECOVERY)
{
m_lastError = "unrecognized owner reset request";
return(false);
}
string err = "";
if(!UQBuildIdentity(cfg.portfolio, m_id, err))
{
m_lastError = err;
return(false);
}
if(!LoadPersistentState())
return(false);
if(!m_healthy)
{
m_lastError = "persistent risk state could not be written during initialization";
return(false);
}
//--- the block reason must reflect the fully reconciled state
ApplyBlock();
m_ready = true;
PrintFormat("[UQ] Risk scope '%s' | B0=%.2f D0=%.2f equity=%.2f target=%.2f dailyFloor=%.2f staticFloor=%.2f state=%s",
m_id.key, m_b0, m_d0, m_equity, m_targetEquity, m_dailyFloor, m_staticFloor,
(m_freshInstall ? "new" : (m_restoredBackup ? "restored-from-backup" : "restored")));
PrintFormat("[UQ] Risk state: staticLock=%s targetLock=%s dailyLock=%s block=%s",
(m_st.staticLock ? "ON" : "off"), (m_st.targetLock ? "ON" : "off"),
(m_st.dailyLock ? "ON" : "off"), UQBlockText(m_block));
PrintFormat("[UQ] Persistence generation %I64d | recoveryHold=%s | primary=%s",
m_st.stateRevision,
(m_st.recoveryHold
? "ACTIVE since " + TimeToString(m_st.recoveryHoldTime, TIME_DATE | TIME_SECONDS)
: "none"),
(m_restoredBackup ? "restored from backup" : "verified"));
PrintFormat("[UQ] Monitoring: last heartbeat %s | gap %d s | %s | D0 day %s",
(m_st.monitorTime > 0 ? TimeToString(m_st.monitorTime, TIME_DATE | TIME_SECONDS) : "none"),
m_st.monitorGapSeconds,
(m_st.gapHold
? "GAP HOLD since " + TimeToString(m_st.gapHoldTime, TIME_DATE | TIME_SECONDS)
: "continuous"),
TimeToString(m_st.dailyDate, TIME_DATE));
PrintFormat("[UQ] Risk cycle %s (sequence %d) | consumed reset nonces=%d%s",
m_st.cycleId, m_st.cycleSequence,
UQResetNonceHistoryCount(m_st.resetNonceHistory),
(m_resetRejected ? " | RESET REQUEST REJECTED" : (m_resetApplied ? " | RESET APPLIED" : "")));
//--- the recorded prior lock history is displayed, never hidden: kind 3
//--- proves that both permanent locks were carried by the closed cycle
PrintFormat("[UQ] Prior permanent locks of the closed cycle: kind=%d (%s)%s",
m_st.priorLockKind, UQPriorLockText(m_st.priorLockKind),
(m_st.priorLockTime > 0
? " at " + TimeToString(m_st.priorLockTime, TIME_DATE | TIME_SECONDS)
: ""));
if(m_resetRejected)
PrintFormat("[UQ] ERROR: an owner reset request was rejected; new entries stay blocked until the request is corrected or removed. Reason: %s",
m_lastError);
return(true);
}
//+------------------------------------------------------------------+
//| Periodic evaluation. Persists when a threshold transitions and |
//| retries a previously failed persistence at a bounded interval. |
//+------------------------------------------------------------------+
void CUQRisk::Evaluate(void)
{
if(!m_ready)
return;
//--- an accepted entry order must be reconciled before it can stop
//--- counting as reserved exposure
ReconcileReservation();
const bool changed = EvaluateLocks();
if(changed)
{
Save();
ApplyBlock();
return;
}
if(!m_healthy)
{
const datetime now = UQServerNow();
if(m_saveRetryAt <= 0 || now >= m_saveRetryAt)
{
PrintFormat("[UQ] Retrying persistence of the pending risk-state change.");
Save();
ApplyBlock();
}
}
}
#endif // UQ_RISK_MQH
//+------------------------------------------------------------------+