//+------------------------------------------------------------------+ //| RiskBudget.mqh | //| AnimateDread | //| https://www.mql5.com | //+------------------------------------------------------------------+ #ifndef WARRIOR_RISK_BUDGET_MQH #define WARRIOR_RISK_BUDGET_MQH #include //+------------------------------------------------------------------+ //| Class CRiskBudget - account-level loss budget, evaluated live. | //| | //| Signals\SignalRiskGuard.mqh already blocked new entries once a | //| daily-loss or drawdown threshold was crossed. Three properties of | //| that design make it unable to enforce a HARD limit of the kind a | //| funded/prop account carries, and this class exists to fix them: | //| | //| 1. IT WAS POLLED ONCE PER BAR. The guard lived entirely inside | //| CSignalRiskGuard::Direction(), and with the shipped default | //| Expert_EveryTick=false that runs exactly once per bar at the | //| bar open (see ExpertSignalCustom.mqh's note on m_every_tick). | //| On H1 a 4% daily limit was therefore checked once an hour. | //| Update() below is driven from OnTick()/OnTimer() instead, so | //| the breach test runs at quote frequency regardless of the | //| signal timeframe. | //| 2. SIZING WAS DECOUPLED FROM THE BUDGET. CMoneyRiskBase sized | //| every trade off Balance*Money_Risk_Percent with no reference | //| to how much of the day's allowance was left, so at 3.2% into | //| a 4% day the next trade still sized for a full risk unit and | //| a perfectly normal stop-out breached the limit. CapRiskAmount()| //| is the clamp; MoneyRiskBase::CalculateLotSize() calls it. | //| 3. OPEN EXPOSURE WAS INVISIBLE. Remaining budget is reduced by | //| OpenRiskAtStops() - the additional loss every currently-open | //| position would inflict if it ran to its stop, across ALL | //| symbols and magic numbers. That is what makes the clamp hold | //| when several charts of this EA size trades at the same moment, | //| before any of them has lost anything yet. | //| | //| WHAT THIS STILL CANNOT DO. It cannot make a stop-loss guaranteed. | //| A weekend gap or a news spike straight through the stop loses more | //| than the sizing assumed, which is precisely what the per-trade | //| reserve fraction (RiskPerTradeOfBudget) is for: at the default 50% | //| a full stop-out consumes at most half the remaining allowance, so | //| a single stop that slips 2x still lands inside the limit. | //+------------------------------------------------------------------+ #define RISK_BUDGET_FILE_MAGIC 0x57524231 // 'WRB1' - see LoadState() #define RISK_BUDGET_LOG_THROTTLE 60 // seconds between repeats of the same breach line //--- Below this share of the intended risk, CapRiskAmount() refuses the trade outright instead of //--- shrinking it. Two independent reasons, and the second is a compliance one: //--- * a position sized at a few percent of normal cannot repay its own spread and commission; //--- * The5ers list "positions substantially larger OR SMALLER than your typical trading activity" //--- as prohibited disproportionate sizing, so a clamp that dribbles out shrinking micro-lots as //--- the allowance depletes manufactures exactly the pattern their surveillance looks for. //--- Sizing must therefore be near-binary: trade at close to normal size, or do not trade. #define RISK_BUDGET_MIN_SIZE_FRACTION 0.25 class CRiskBudget { private: //--- configuration (Configure(), from the Risk Guard inputs) bool m_enabled; double m_dailyLimitPct; // 0 = daily rule off double m_totalLimitPct; // 0 = total rule off bool m_totalIsTrailing; // true: measured from the equity peak; false: from start equity int m_resetHour; // broker hour the firm's trading day rolls at double m_reserve; // 0..1 - share of the remaining budget one trade may risk bool m_flatten; // close this instance's own positions on breach long m_magic; string m_symbolName; //--- persisted state datetime m_dayStart; // start of the risk day m_dayAnchor belongs to double m_dayAnchor; // equity the daily allowance is measured down from double m_peakEquity; // all-time equity high-water mark (trailing total DD) double m_startEquity; // equity the first time this ever ran (static total DD) bool m_totalHalt; // latched - see Evaluate() //--- session state //--- REALISED EXPECTANCY, in R (profit divided by the amount that was actually at risk). The daily and //--- total rules bound how FAST an account can lose; nothing here noticed WHETHER it was losing. A //--- negative-expectancy signal traded inside a 4%/8% envelope is fully compliant and still arrives at //--- zero - it just takes longer. This is the rule that stops paying for a strategy the results say //--- does not work. //--- Kept as running sums rather than a trade array: mean and standard error are all the test needs, //--- and sums survive a restart in a fixed-size state file. int m_expCount; double m_expSum; // sum of R double m_expSumSq; // sum of R^2, for the standard error bool m_expectancyHalt; // latched - see RecordTradeResult() int m_expMinTrades; double m_expSigma; // how many standard errors below zero before halting bool m_loaded; bool m_dailyHalt; // latched until the next reset hour datetime m_lastLog; datetime m_lastFlatten; string StateFileName(void) const; void LoadState(void); void SaveState(void); datetime RiskDayStart(datetime now) const; double DailyFloor(void) const; double TotalFloor(void) const; void FlattenOwnPositions(string reason); void Log(string text); public: CRiskBudget(void); void Configure(bool enabled, double dailyPct, double totalPct, bool trailing, int resetHour, double reservePct, bool flatten, long magic, string symbolName); //--- call every tick and every timer event; cheap, and the only thing that latches a halt void Update(void); bool Enabled(void) const { return m_enabled; } bool Halted(void) const { return m_dailyHalt || m_totalHalt || m_expectancyHalt; } //--- Call once per CLOSED position with its net result in R. Profit must already include swap and //--- commission (TradeJournalManager::ResolveClose sums all three) - excluding them would measure a //--- strategy nobody can trade, and cost is the entire quantity at issue when the edge is zero. void RecordTradeResult(double rMultiple); void ConfigureExpectancy(int minTrades, double sigma); int ExpectancyTrades(void) const { return m_expCount; } double ExpectancyR(void) const { return (m_expCount > 0) ? m_expSum / m_expCount : 0.0; } //--- remaining allowance in ACCOUNT CURRENCY, already net of open exposure double RemainingDaily(void); double RemainingTotal(void); //--- worst-case additional loss if every open position ran to its stop double OpenRiskAtStops(void); //--- the sizing clamp - returns 0 when nothing may be risked double CapRiskAmount(double amount); string StatusLine(void); }; //+------------------------------------------------------------------+ //| Expectancy configuration. Separate from Configure() so the risk | //| rules and this one can be enabled independently. | //+------------------------------------------------------------------+ void CRiskBudget::ConfigureExpectancy(int minTrades, double sigma) { m_expMinTrades = (int)MathMax(minTrades, 0); m_expSigma = MathMax(sigma, 0.0); } //+------------------------------------------------------------------+ //| THE RULE THAT STOPS THE BLEED. | //| | //| With no directional edge the win rate equals SL/(SL+TP), which is | //| also the break-even rate, so the two payoff terms cancel exactly | //| and expected value per trade is MINUS THE COST. Expected P&L is | //| therefore -(trades) x cost: strictly negative and proportional to | //| activity. Measured on this configuration: directional precision | //| 23-24% against a 25% break-even, flat across every confidence | //| tier. A daily or total drawdown rule does not see this - it bounds | //| the RATE of loss, not its sign - so the account drains to the 8% | //| limit without a single rule ever being breached. | //| | //| So test the realised mean against zero and stop when it is | //| significantly below. NOT on the mean alone: a handful of losers is | //| ordinary variance even for a profitable system, and halting on | //| that would be the same "act on noise" error the MI gates exist to | //| prevent. The standard error carries the sample size, so a wide | //| spread simply demands more trades before the guard can fire. | //| | //| ONE-WAY on purpose. Re-enabling itself the moment a couple of | //| winners drag the mean back up would let it oscillate around the | //| threshold and bleed anyway. A human should look at why. | //+------------------------------------------------------------------+ void CRiskBudget::RecordTradeResult(double rMultiple) { if(!MathIsValidNumber(rMultiple)) return; m_expCount++; m_expSum += rMultiple; m_expSumSq += rMultiple * rMultiple; SaveState(); if(m_expectancyHalt || m_expMinTrades <= 0 || m_expCount < m_expMinTrades) return; double mean = m_expSum / m_expCount; //--- Sample variance, then the standard error of the MEAN. Guarded because a run of identical results //--- gives zero variance, and dividing by it would halt or spare on an artefact. double var = (m_expSumSq - m_expCount * mean * mean) / MathMax(m_expCount - 1, 1); if(var < 0.0) var = 0.0; double se = MathSqrt(var / m_expCount); if(se <= 0.0) return; if(mean + m_expSigma * se < 0.0) { m_expectancyHalt = true; SaveState(); Log(StringFormat("EXPECTANCY HALT - realised %.3f R over %d closed trades (standard error %.3f), " "which is more than %.1f standard errors below zero. This is not a drawdown " "breach: it is the measurement saying the strategy loses money per trade, so " "trading it longer loses more. New entries are blocked until the EA is " "reattached. Expected value per trade with no directional edge is minus the " "cost, and cost is paid on every trade regardless of size.", mean, m_expCount, se, m_expSigma)); } } //+------------------------------------------------------------------+ //| One instance per chart. Equity/balance are account-wide, so every | //| instance observes the same numbers and reaches the same verdict; | //| the per-instance state file only caches the anchors. | //+------------------------------------------------------------------+ CRiskBudget g_riskBudget; //+------------------------------------------------------------------+ //| | //+------------------------------------------------------------------+ CRiskBudget::CRiskBudget(void) : m_enabled(false), m_dailyLimitPct(0.0), m_totalLimitPct(0.0), m_totalIsTrailing(true), m_resetHour(0), m_reserve(0.5), m_flatten(false), m_magic(0), m_symbolName(""), m_dayStart(0), m_dayAnchor(0.0), m_peakEquity(0.0), m_startEquity(0.0), m_totalHalt(false), m_expCount(0), m_expSum(0.0), m_expSumSq(0.0), m_expectancyHalt(false), m_expMinTrades(0), m_expSigma(2.0), m_loaded(false), m_dailyHalt(false), m_lastLog(0), m_lastFlatten(0) { } //+------------------------------------------------------------------+ //| | //+------------------------------------------------------------------+ void CRiskBudget::Configure(bool enabled, double dailyPct, double totalPct, bool trailing, int resetHour, double reservePct, bool flatten, long magic, string symbolName) { m_enabled = enabled; m_dailyLimitPct = (dailyPct > 0.0 ? dailyPct : 0.0); m_totalLimitPct = (totalPct > 0.0 ? totalPct : 0.0); m_totalIsTrailing = trailing; m_resetHour = (int)MathMax(0, MathMin(23, resetHour)); //--- a reserve of 0 would size every trade to nothing; 100% means a single stop-out is allowed to //--- consume the entire remaining allowance, which leaves no room for slippage past the stop. m_reserve = MathMax(0.01, MathMin(1.0, reservePct / 100.0)); m_flatten = flatten; m_magic = magic; m_symbolName = symbolName; } //+------------------------------------------------------------------+ //| Keyed by symbol+magic, deliberately NOT shared between charts. | //| The account-level numbers this class decides on (equity, balance, | //| every open position) are read live from the terminal and are | //| identical for every instance, so the file holds only the anchors | //| - and a shared file would reintroduce the cross-chart write | //| contention this codebase has been bitten by before. | //+------------------------------------------------------------------+ string CRiskBudget::StateFileName(void) const { return m_symbolName + "_" + IntegerToString(m_magic) + "_riskbudget.dat"; } //+------------------------------------------------------------------+ //| A missing or foreign file is not an error - Update() re-anchors | //| from the current account state. A file written by the OLD | //| CSignalRiskGuard layout (3 fields, no header) MUST NOT be read as | //| this one: the magic below is what makes that impossible rather | //| than merely unlikely, since misreading it would silently install | //| a wrong peak-equity anchor and mis-state every drawdown after it. | //+------------------------------------------------------------------+ void CRiskBudget::LoadState(void) { int handle = FileOpen(StateFileName(), FILE_BIN | FILE_READ | FILE_SHARE_READ | FILE_SHARE_WRITE); if(handle == INVALID_HANDLE) return; // first run on this symbol/magic - anchors seed from live state if(FileSize(handle) >= 4 && FileReadInteger(handle, INT_VALUE) == (int)RISK_BUDGET_FILE_MAGIC) { m_dayStart = (datetime)FileReadLong(handle); m_dayAnchor = FileReadDouble(handle); m_peakEquity = FileReadDouble(handle); m_startEquity = FileReadDouble(handle); m_totalHalt = (FileReadInteger(handle, INT_VALUE) != 0); //--- The daily halt is LATCHED for the rest of the risk day, so it has to survive a restart or //--- the latch is trivially defeated: trip the limit, have an open position recover equity back //--- above the floor, reattach the EA, and trading resumes inside a day the firm already counts //--- as breached. Cleared on the day roll in Update(), never here. m_dailyHalt = (FileReadInteger(handle, INT_VALUE) != 0); //--- APPENDED, length-guarded rather than version-bumped, so a state file written before the //--- expectancy rule shipped still loads and simply starts its sample at zero. FileRead past the //--- end returns 0 with no error, and a silently-zeroed trade count would reset the sample on //--- every restart - which is exactly how a guard like this gets quietly defeated. if(FileSize(handle) >= FileTell(handle) + 2 * sizeof(int) + 2 * sizeof(double)) { m_expCount = (int)FileReadInteger(handle, INT_VALUE); m_expSum = FileReadDouble(handle); m_expSumSq = FileReadDouble(handle); //--- LATCHED ACROSS RESTARTS for the same reason the daily halt is: a latch that a reattach //--- clears is not a latch. Only deleting the state file resets it, which is a deliberate act. m_expectancyHalt = (FileReadInteger(handle, INT_VALUE) != 0); } } else PrintFormat("%s: %s is not a risk-budget file (old format or corrupt) - re-anchoring from the current account state.", __FUNCTION__, StateFileName()); FileClose(handle); } //+------------------------------------------------------------------+ //| | //+------------------------------------------------------------------+ void CRiskBudget::SaveState(void) { int handle = FileOpen(StateFileName(), FILE_BIN | FILE_WRITE | FILE_SHARE_READ | FILE_SHARE_WRITE); if(handle == INVALID_HANDLE) { PrintFormat("%s: cannot write %s (error %d) - risk anchors will re-seed from live equity after a restart.", __FUNCTION__, StateFileName(), GetLastError()); return; } FileWriteInteger(handle, (int)RISK_BUDGET_FILE_MAGIC, INT_VALUE); FileWriteLong(handle, (long)m_dayStart); FileWriteDouble(handle, m_dayAnchor); FileWriteDouble(handle, m_peakEquity); FileWriteDouble(handle, m_startEquity); FileWriteInteger(handle, (m_totalHalt ? 1 : 0), INT_VALUE); FileWriteInteger(handle, (m_dailyHalt ? 1 : 0), INT_VALUE); FileWriteInteger(handle, m_expCount, INT_VALUE); FileWriteDouble(handle, m_expSum); FileWriteDouble(handle, m_expSumSq); FileWriteInteger(handle, (m_expectancyHalt ? 1 : 0), INT_VALUE); FileClose(handle); } //+------------------------------------------------------------------+ //| Start of the risk day `now` falls in, honouring the firm's reset | //| hour rather than assuming broker midnight - a limit measured on | //| the wrong window hands allowance back hours early or late. | //+------------------------------------------------------------------+ datetime CRiskBudget::RiskDayStart(datetime now) const { MqlDateTime s; TimeToStruct(now, s); int currentHour = s.hour; s.hour = m_resetHour; s.min = 0; s.sec = 0; datetime start = StructToTime(s); if(currentHour < m_resetHour) start -= 86400; // still inside the day that began at yesterday's reset hour return start; } //+------------------------------------------------------------------+ //| | //+------------------------------------------------------------------+ double CRiskBudget::DailyFloor(void) const { if(m_dailyLimitPct <= 0.0 || m_dayAnchor <= 0.0) return -DBL_MAX; return m_dayAnchor * (1.0 - m_dailyLimitPct / 100.0); } //+------------------------------------------------------------------+ //| | //+------------------------------------------------------------------+ double CRiskBudget::TotalFloor(void) const { if(m_totalLimitPct <= 0.0) return -DBL_MAX; double anchor = (m_totalIsTrailing ? m_peakEquity : m_startEquity); if(anchor <= 0.0) return -DBL_MAX; return anchor * (1.0 - m_totalLimitPct / 100.0); } //+------------------------------------------------------------------+ //| Additional loss, in account currency, that every OPEN position | //| would still inflict if it ran to its stop from here. | //| | //| Deliberately account-wide (no symbol/magic filter): the firm's | //| limit is on the ACCOUNT, so a second chart's open exposure spends | //| the same budget this one is about to size against. Current | //| floating P/L is already inside equity, so only the REMAINING move | //| to the stop counts - hence profit-at-stop subtracted from profit | //| now, never the raw stop distance. | //+------------------------------------------------------------------+ double CRiskBudget::OpenRiskAtStops(void) { double total = 0.0; for(int i = PositionsTotal() - 1; i >= 0; i--) { ulong ticket = PositionGetTicket(i); if(ticket == 0) continue; string sym = PositionGetString(POSITION_SYMBOL); double vol = PositionGetDouble(POSITION_VOLUME); double openPx = PositionGetDouble(POSITION_PRICE_OPEN); double sl = PositionGetDouble(POSITION_SL); double profitNow = PositionGetDouble(POSITION_PROFIT); long ptype = PositionGetInteger(POSITION_TYPE); if(sl <= 0.0) { // No stop = unbounded downside, and no honest way to bound it here. Charge the CURRENT // floating loss so the position is at least not free, and let the caller see it in the log. if(profitNow < 0.0) total += -profitNow; continue; } ENUM_ORDER_TYPE otype = (ptype == POSITION_TYPE_BUY ? ORDER_TYPE_BUY : ORDER_TYPE_SELL); double atStop = 0.0; if(!OrderCalcProfit(otype, sym, vol, openPx, sl, atStop)) continue; // symbol not selectable / no quote - skip rather than guess double additional = profitNow - atStop; if(additional > 0.0) total += additional; } return total; } //+------------------------------------------------------------------+ //| | //+------------------------------------------------------------------+ double CRiskBudget::RemainingDaily(void) { double floorEq = DailyFloor(); if(floorEq == -DBL_MAX) return DBL_MAX; return AccountInfoDouble(ACCOUNT_EQUITY) - floorEq; } //+------------------------------------------------------------------+ //| | //+------------------------------------------------------------------+ double CRiskBudget::RemainingTotal(void) { double floorEq = TotalFloor(); if(floorEq == -DBL_MAX) return DBL_MAX; return AccountInfoDouble(ACCOUNT_EQUITY) - floorEq; } //+------------------------------------------------------------------+ //| | //+------------------------------------------------------------------+ void CRiskBudget::Log(string text) { datetime now = TimeCurrent(); if(now - m_lastLog < RISK_BUDGET_LOG_THROTTLE) return; // this runs per tick - without a throttle it floods the journal m_lastLog = now; Print(text); } //+------------------------------------------------------------------+ //| Closes only THIS instance's positions (symbol + magic). Another | //| chart running the same EA is responsible for its own; closing | //| someone else's trades from here would be a surprise no input | //| asked for. | //+------------------------------------------------------------------+ void CRiskBudget::FlattenOwnPositions(string reason) { if(!MQLInfoInteger(MQL_TRADE_ALLOWED) || !TerminalInfoInteger(TERMINAL_TRADE_ALLOWED)) return; datetime now = TimeCurrent(); if(now - m_lastFlatten < 1) return; // one sweep per second; a rejected close retries on the next m_lastFlatten = now; CTrade trade; trade.SetExpertMagicNumber((ulong)m_magic); trade.SetAsyncMode(false); for(int i = PositionsTotal() - 1; i >= 0; i--) { ulong ticket = PositionGetTicket(i); if(ticket == 0) continue; if(PositionGetInteger(POSITION_MAGIC) != m_magic) continue; if(PositionGetString(POSITION_SYMBOL) != m_symbolName) continue; if(!trade.PositionClose(ticket)) PrintFormat("%s: FAILED to close #%I64u on %s (%s / retcode %d) - %s. Retrying next tick.", __FUNCTION__, ticket, m_symbolName, trade.ResultRetcodeDescription(), trade.ResultRetcode(), reason); else PrintFormat("%s: closed #%I64u on %s - %s", __FUNCTION__, ticket, m_symbolName, reason); } } //+------------------------------------------------------------------+ //| The whole point of the class: called at QUOTE frequency, not at | //| bar frequency. Rolls the risk day, tracks the anchors, latches a | //| breach and (optionally) flattens. | //+------------------------------------------------------------------+ void CRiskBudget::Update(void) { if(!m_enabled) return; if(!m_loaded) { LoadState(); m_loaded = true; } double equity = AccountInfoDouble(ACCOUNT_EQUITY); double balance = AccountInfoDouble(ACCOUNT_BALANCE); if(equity <= 0.0) return; // no account data yet (fresh attach, reconnecting) bool dirty = false; //--- roll the risk day. The anchor is fixed at the reset instant and held for the whole day, which //--- is how the firm measures it - a floor that drifted with equity would let a slow bleed run //--- forever. max(balance, equity) is the conservative reading: firms anchor on the day's starting //--- balance, so anchoring at or above it means this halts no later than they do, never later. datetime dayStart = RiskDayStart(TimeCurrent()); if(dayStart != m_dayStart) { m_dayStart = dayStart; m_dayAnchor = MathMax(balance, equity); m_dailyHalt = false; // new day, new allowance dirty = true; PrintFormat("%s: risk day rolled at %s - daily anchor %.2f, floor %.2f (%.2f%% limit).", __FUNCTION__, TimeToString(dayStart, TIME_DATE | TIME_MINUTES), m_dayAnchor, DailyFloor(), m_dailyLimitPct); } if(m_startEquity <= 0.0) { m_startEquity = equity; // static total-DD anchor, recorded once and never moved dirty = true; } if(equity > m_peakEquity) { m_peakEquity = equity; dirty = true; } //--- BREACH TESTS use realized equity only. Open exposure is deliberately NOT counted here: it //--- belongs in the SIZING decision (CapRiskAmount) because a position that has not yet lost //--- anything must not halt trading, while a position that has must not be sized against twice. if(m_dailyLimitPct > 0.0 && !m_dailyHalt && equity <= DailyFloor()) { m_dailyHalt = true; dirty = true; // latched AND persisted - see LoadState() PrintFormat("%s: DAILY LOSS LIMIT REACHED - equity %.2f <= floor %.2f (anchor %.2f, limit %.2f%%). " "No new entries until %s.", __FUNCTION__, equity, DailyFloor(), m_dayAnchor, m_dailyLimitPct, TimeToString(m_dayStart + 86400, TIME_DATE | TIME_MINUTES)); } if(m_totalLimitPct > 0.0 && !m_totalHalt && equity <= TotalFloor()) { m_totalHalt = true; dirty = true; // latched and PERSISTED - see below PrintFormat("%s: MAX DRAWDOWN LIMIT REACHED - equity %.2f <= floor %.2f (%s anchor %.2f, limit %.2f%%). " "Trading is halted permanently. This latch survives a restart on purpose; delete " "MQL5\\Files\\%s to clear it deliberately.", __FUNCTION__, equity, TotalFloor(), (m_totalIsTrailing ? "trailing" : "static"), (m_totalIsTrailing ? m_peakEquity : m_startEquity), m_totalLimitPct, StateFileName()); } if(dirty) SaveState(); if(Halted()) { Log(StringFormat("CRiskBudget: HALTED (%s%s) - equity %.2f, daily floor %.2f, total floor %.2f.", (m_dailyHalt ? "daily" : ""), (m_totalHalt ? (m_dailyHalt ? "+total" : "total") : ""), equity, DailyFloor(), TotalFloor())); if(m_flatten) FlattenOwnPositions(m_dailyHalt ? "daily loss limit" : "max drawdown limit"); } } //+------------------------------------------------------------------+ //| THE SIZING CLAMP. Returns the largest amount this trade may risk. | //| | //| `amount` arrives as Balance*Money_Risk_Percent (optionally Kelly- | //| scaled). It is capped to a fraction of what is genuinely left of | //| the tighter of the two limits, AFTER subtracting the loss already | //| committed to open positions. A 0 return means "do not trade". | //+------------------------------------------------------------------+ double CRiskBudget::CapRiskAmount(double amount) { if(!m_enabled) return amount; if(!m_loaded) Update(); // never size a trade before the budget has been established if(Halted()) { Log(StringFormat("CRiskBudget: trade rejected - %s limit already reached.", (m_dailyHalt ? "daily loss" : "max drawdown"))); return 0.0; } double room = MathMin(RemainingDaily(), RemainingTotal()); if(room >= DBL_MAX) return amount; // both rules disabled room -= OpenRiskAtStops(); if(room <= 0.0) { Log(StringFormat("CRiskBudget: trade rejected - open positions already commit the whole remaining " "allowance (daily %.2f, total %.2f, committed %.2f).", RemainingDaily(), RemainingTotal(), OpenRiskAtStops())); return 0.0; } double cap = room * m_reserve; if(cap >= amount) return amount; // full intended size fits inside the allowance if(cap < amount * RISK_BUDGET_MIN_SIZE_FRACTION) { Log(StringFormat("CRiskBudget: trade rejected - allowance would only fund %.0f%% of normal size " "(%.2f of %.2f). Sizing stays near-normal or stands aside; see " "RISK_BUDGET_MIN_SIZE_FRACTION.", 100.0 * cap / amount, cap, amount)); return 0.0; } Log(StringFormat("CRiskBudget: risk cut %.2f -> %.2f (%.0f%% of %.2f left after open exposure).", amount, cap, m_reserve * 100.0, room)); return cap; } //+------------------------------------------------------------------+ //| One-line summary for the status panel / journal. | //+------------------------------------------------------------------+ string CRiskBudget::StatusLine(void) { if(!m_enabled) return "Risk budget: off"; if(Halted()) return StringFormat("Risk budget: HALTED (%s)", (m_dailyHalt ? "daily" : "max DD")); double d = RemainingDaily(), t = RemainingTotal(); double committed = OpenRiskAtStops(); return StringFormat("Risk budget: daily %.2f / total %.2f left, %.2f committed to open stops", (d >= DBL_MAX ? 0.0 : d), (t >= DBL_MAX ? 0.0 : t), committed); } #endif // WARRIOR_RISK_BUDGET_MQH //+------------------------------------------------------------------+