StopFirst-Core/Include/StopFirst/AccountRisk.mqh
RUSLAN SLOBODIANIUK 6ba79702fd Make the header comments self-contained
They referred to project documents that are not part of this repository.
Comments only; PriceToPips is now documented as not rounding, which is what
it does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 21:48:01 +03:00

373 lines
16 KiB
MQL5

//+------------------------------------------------------------------+
//| 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 <Trade\PositionInfo.mqh>
#include <Trade\AccountInfo.mqh>
#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
//+------------------------------------------------------------------+