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>
373 lines
16 KiB
MQL5
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
|
|
//+------------------------------------------------------------------+
|