//+------------------------------------------------------------------+ //| AccountRisk.mqh| //| Copyright 2026, bandersoft | //| https://www.mql5.com/en/users/fats | //+------------------------------------------------------------------+ //| | //| Total money at risk across open positions. | //| | //| Read-only: it inspects positions and calculates. | //| | //| The number worth having is not "how much am I risking per | //| trade" but "if every stop I have is hit, what do I lose". Those | //| differ once positions overlap, and the second one is the one | //| that ends accounts. | //| | //| Positions with no stop loss are the interesting case. Their | //| risk is unbounded, so they cannot be summed into a total | //| without making it a lie. They are counted and reported | //| separately, never silently skipped and never treated as zero. | //| | //+------------------------------------------------------------------+ #ifndef STOPFIRST_ACCOUNTRISK_MQH #define STOPFIRST_ACCOUNTRISK_MQH #include #include #include "Defs.mqh" #include "Symbol.mqh" #include "Risk.mqh" // PipValuePerLot - this header did rely on includers for it #include "Positions.mqh" // PositionCommission //+------------------------------------------------------------------+ //| What one open position stands to lose | //+------------------------------------------------------------------+ struct SPositionRisk { ulong ticket; string symbol; ENUM_POSITION_TYPE type; double volume; double openPrice; double stopLoss; // 0 when none is set double riskMoney; // account currency; 0 when unbounded double riskPips; // distance to the stop; 0 when unbounded bool bounded; // false when no stop loss is set double currentProfit; // profit + swap + commission }; //+------------------------------------------------------------------+ //| The account-level picture | //+------------------------------------------------------------------+ struct SAccountRisk { int positions; // matching the filter int bounded; // with a stop loss int unbounded; // without one double riskMoney; // total loss if every stop is hit double riskPercent; // that loss as a share of balance double unboundedVolume; // lots with no stop behind them double openProfit; // current floating P/L of the same set }; //+------------------------------------------------------------------+ //| Money a position loses if its stop is hit | //| | //| Uses the same tick-value route as the sizing functions, so the | //| number agrees with what CalcValueAtRisk said when the trade was | //| planned. | //| | //| Returns: the loss in account currency, or 0 when there is no stop | //| or the symbol reports no usable tick data. Check | //| `bounded` rather than testing the return for 0. | //+------------------------------------------------------------------+ bool PositionRisk(const ulong ticket, SPositionRisk &risk) { CPositionInfo position; if(!position.SelectByTicket(ticket)) return false; risk.ticket = ticket; risk.symbol = position.Symbol(); risk.type = position.PositionType(); risk.volume = position.Volume(); risk.openPrice = position.PriceOpen(); risk.stopLoss = position.StopLoss(); risk.currentProfit = position.Profit() + position.Swap() + PositionCommission(ticket); risk.riskMoney = 0; risk.riskPips = 0; risk.bounded = (risk.stopLoss > 0); if(!risk.bounded) return true; double pipSize = GetPipSize(risk.symbol); if(pipSize <= 0) { risk.bounded = false; // cannot express the distance, so cannot bound it return true; } bool isBuy = (risk.type == POSITION_TYPE_BUY); double distance = isBuy ? (risk.openPrice - risk.stopLoss) : (risk.stopLoss - risk.openPrice); //--- a stop already past entry is locking profit in, not risking //--- money. Clamp at zero rather than reporting a negative risk. if(distance < 0) distance = 0; risk.riskPips = distance / pipSize; double pipValue = PipValuePerLot(risk.symbol); if(pipValue <= 0) { risk.bounded = false; risk.riskPips = 0; return true; } risk.riskMoney = NormalizeDouble(risk.volume * risk.riskPips * pipValue, 2); return true; } //+------------------------------------------------------------------+ //| Total risk across matching open positions | //| | //| Parameters: | //| symbol - "" for every symbol | //| magic - STOPFIRST_ANY_MAGIC for any | //| | //| riskMoney sums only positions that HAVE a stop. Positions without | //| one are counted in `unbounded` and their volume in | //| `unboundedVolume`. Read both: a small riskMoney beside a non-zero | //| unbounded count is not a safe account, it is an unmeasured one. | //| | //| Returns: the filled summary. All zeros when nothing matches. | //+------------------------------------------------------------------+ SAccountRisk TotalAccountRisk(const string symbol = "", const ulong magic = STOPFIRST_ANY_MAGIC) { SAccountRisk total; total.positions = 0; total.bounded = 0; total.unbounded = 0; total.riskMoney = 0; total.riskPercent = 0; total.unboundedVolume = 0; total.openProfit = 0; CPositionInfo position; for(int i = PositionsTotal() - 1; i >= 0; i--) { if(!position.SelectByIndex(i)) continue; if(symbol != "" && position.Symbol() != symbol) continue; if(magic != STOPFIRST_ANY_MAGIC && position.Magic() != magic) continue; SPositionRisk risk; if(!PositionRisk(position.Ticket(), risk)) continue; total.positions++; total.openProfit += risk.currentProfit; if(risk.bounded) { total.bounded++; total.riskMoney += risk.riskMoney; } else { total.unbounded++; total.unboundedVolume += risk.volume; } } total.riskMoney = NormalizeDouble(total.riskMoney, 2); total.openProfit = NormalizeDouble(total.openProfit, 2); CAccountInfo account; double balance = account.Balance(); if(balance > 0) total.riskPercent = NormalizeDouble((total.riskMoney / balance) * 100.0, 2); return total; } //+------------------------------------------------------------------+ //| Collect the per-position detail behind the total | //| | //| Parameters: | //| risks - receives one entry per matching position | //| | //| Returns: how many entries were written. | //+------------------------------------------------------------------+ int CollectPositionRisks(SPositionRisk &risks[], const string symbol = "", const ulong magic = STOPFIRST_ANY_MAGIC) { ArrayResize(risks, 0); CPositionInfo position; int count = 0; for(int i = PositionsTotal() - 1; i >= 0; i--) { if(!position.SelectByIndex(i)) continue; if(symbol != "" && position.Symbol() != symbol) continue; if(magic != STOPFIRST_ANY_MAGIC && position.Magic() != magic) continue; SPositionRisk risk; if(!PositionRisk(position.Ticket(), risk)) continue; ArrayResize(risks, count + 1); risks[count] = risk; count++; } return count; } //+------------------------------------------------------------------+ //| Lots with no stop behind them | //| | //| A single number for a panel or an alert. Anything above zero | //| means the account's real exposure cannot be calculated. | //| | //| Returns: summed volume of matching positions with no stop loss. | //+------------------------------------------------------------------+ double UnboundedVolume(const string symbol = "", const ulong magic = STOPFIRST_ANY_MAGIC) { SAccountRisk total = TotalAccountRisk(symbol, magic); return total.unboundedVolume; } //+------------------------------------------------------------------+ //| What pending orders would risk if they all filled | //+------------------------------------------------------------------+ struct SPendingRisk { int orders; // pending orders matching the filter int unbounded; // of them, without a stop loss double riskMoney; // loss at their stops, once filled double unboundedVolume; // lots that would fill with no stop }; //+------------------------------------------------------------------+ //| Risk carried by pending orders, as if every one of them filled | //| | //| A pending order with a stop is a position-in-waiting: ten of them | //| at 1% each are 10% the moment the market touches them. Counting | //| only open positions would let each one pass a 5% ceiling on its | //| own. Stops are measured from the order's | //| own price, the price it would fill at. | //| | //| Parameters: | //| symbol - "" for every symbol | //| magic - STOPFIRST_ANY_MAGIC for any | //| | //| Returns: the summary. All zeros when nothing matches. | //+------------------------------------------------------------------+ SPendingRisk PendingOrdersRisk(const string symbol = "", const ulong magic = STOPFIRST_ANY_MAGIC) { SPendingRisk total; total.orders = 0; total.unbounded = 0; total.riskMoney = 0; total.unboundedVolume = 0; for(int i = OrdersTotal() - 1; i >= 0; i--) { ulong ticket = OrderGetTicket(i); if(ticket == 0) continue; string orderSymbol = OrderGetString(ORDER_SYMBOL); if(symbol != "" && orderSymbol != symbol) continue; if(magic != STOPFIRST_ANY_MAGIC && (ulong)OrderGetInteger(ORDER_MAGIC) != magic) continue; ENUM_ORDER_TYPE type = (ENUM_ORDER_TYPE)OrderGetInteger(ORDER_TYPE); if(type == ORDER_TYPE_BUY || type == ORDER_TYPE_SELL) continue; // not pending double volume = OrderGetDouble(ORDER_VOLUME_CURRENT); double price = OrderGetDouble(ORDER_PRICE_OPEN); double stop = OrderGetDouble(ORDER_SL); total.orders++; double pipSize = GetPipSize(orderSymbol); double pipValue = PipValuePerLot(orderSymbol); if(stop <= 0 || pipSize <= 0 || pipValue <= 0) { total.unbounded++; total.unboundedVolume += volume; continue; } bool isBuy = IsBuySide(type); double distance = isBuy ? price - stop : stop - price; if(distance < 0) distance = 0; total.riskMoney += volume * (distance / pipSize) * pipValue; } total.riskMoney = NormalizeDouble(total.riskMoney, 2); return total; } //+------------------------------------------------------------------+ //| Would opening this position breach a total-risk ceiling? | //| | //| Parameters: | //| maxRiskPercent - ceiling on combined risk, e.g. 5.0 | //| includePending - also count pending orders as if filled. Off | //| by default to keep existing callers' results; | //| a panel placing pending orders wants it on. | //| | //| Positions and orders with no stop are NOT counted, because their | //| risk is unknown rather than zero. Check UnboundedVolume | //| separately; this function cannot see what it cannot measure. | //| | //| Returns: true if the existing risk plus this trade stays at or | //| below the ceiling. false if it would exceed it, or if | //| the inputs are unusable. | //+------------------------------------------------------------------+ bool RiskBudgetAllows(const double additionalRiskMoney, const double maxRiskPercent, const string symbol = "", const ulong magic = STOPFIRST_ANY_MAGIC, const bool includePending = false) { if(additionalRiskMoney < 0 || maxRiskPercent <= 0) return false; CAccountInfo account; double balance = account.Balance(); if(balance <= 0) return false; SAccountRisk total = TotalAccountRisk(symbol, magic); double combined = total.riskMoney + additionalRiskMoney; if(includePending) combined += PendingOrdersRisk(symbol, magic).riskMoney; return (combined / balance) * 100.0 <= maxRiskPercent; } //+------------------------------------------------------------------+ //| Multi-line risk summary, for a log line or Comment() | //| Returns: the report. Never empty. | //+------------------------------------------------------------------+ string AccountRiskReport(const string symbol = "", const ulong magic = STOPFIRST_ANY_MAGIC) { SAccountRisk total = TotalAccountRisk(symbol, magic); string currency = AccountInfoString(ACCOUNT_CURRENCY); string report = "=== Account risk ===\n"; report += "Open positions: " + IntegerToString(total.positions) + "\n"; report += "With a stop: " + IntegerToString(total.bounded) + "\n"; report += "Risk if all hit: " + DoubleToString(total.riskMoney, 2) + " " + currency + " (" + DoubleToString(total.riskPercent, 2) + "%)\n"; report += "Open P/L: " + DoubleToString(total.openProfit, 2) + " " + currency + "\n"; if(total.unbounded > 0) report += "UNBOUNDED: " + IntegerToString(total.unbounded) + " position(s), " + DoubleToString(total.unboundedVolume, 2) + " lots with no stop - total risk above EXCLUDES these\n"; else report += "Unbounded: none\n"; return report; } #endif // STOPFIRST_ACCOUNTRISK_MQH //+------------------------------------------------------------------+