| //+------------------------------------------------------------------+
|
| //| 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_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);
|
|
|
| datetime legacyMarkerTime = 0;
|
| const bool legacyMarkerPresent =
|
| (UQStateLegacyMarkerCheck(m_id, 0, legacyMarkerTime) != UQ_MARKER_ABSENT);
|
|
|
| datetime v2MarkerTime = 0;
|
| const bool v2MarkerPresent =
|
| (UQStateMarkerCheck(m_id, 0, v2MarkerTime) != UQ_MARKER_ABSENT);
|
|
|
| string reason = "";
|
| const ENUM_UQ_SCOPE_DECISION decision =
|
| UQScopeDecision(false, v2MarkerPresent, legacyStatePresent,
|
| (primaryUsable || backupUsable), legacyMarkerPresent, reason);
|
|
|
| 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);
|
| }
|
|
|
| //--- 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)
|
| {
|
| //--- 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
|
| //+------------------------------------------------------------------+
|