The daily (4%) and total (8%) rules bound how FAST an account can lose. Nothing
noticed WHETHER it was losing. A negative-expectancy signal traded at 1% inside
that envelope breaches no rule and still arrives at zero - it just takes longer,
with every limit green the whole way down. That is the realistic way this EA
destroys an account, and no existing guard could see it.
THE ARITHMETIC THIS ENFORCES. Expected value per trade is p*TP - (1-p)*SL - cost.
With no directional edge p equals SL/(SL+TP), which is also the break-even rate,
so the payoff terms cancel exactly and EV = -cost. Expected P&L is -(trades) x
cost: strictly negative, proportional to activity. Measured here: directional
precision 23-24% against a 25% break-even, flat across every confidence tier,
with 58 points of spread on SP500. Sizing, stop placement and trailing move
variance around that mean; none of them changes its sign.
So every closed position now reports its result in R (net profit over money
actually at risk) and the running mean is tested against zero. Above the
configured minimum sample, if mean + sigma*SE < 0, new entries stop.
- SIGNIFICANTLY below, not merely below. A run of losers is ordinary variance
even for a profitable system; halting on the raw mean would be the same
act-on-noise error the MI gates exist to prevent. Using the standard error
means a wide spread simply demands more trades before the rule can fire.
- NET of swap and commission (ResolveClose already sums all three). Deliberate
and load-bearing: when the edge is zero, cost IS the expectancy, so a gross
version would measure a strategy nobody can trade.
- Reported in R so symbols, lot sizes and balances share one scale and one
mean. Trades without a stop are not scored rather than assigned a guessed R.
- LATCHED across restarts, like the daily halt and for the same reason: a
latch a reattach clears is not a latch. Clearing it means deleting the risk
state file, deliberately, after looking at why.
State is appended to the risk file length-guarded, so files written before this
still load and start their sample at zero rather than misreading.
Defaults 40 trades / 2 sigma; ExpectancyMinTrades = 0 disables it.
This does not make the strategy profitable and is not meant to. It stops paying
tuition on one the results say is losing, and does it on measurement rather than
on a drawdown limit finally being reached.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
613 lines
31 KiB
MQL5
613 lines
31 KiB
MQL5
//+------------------------------------------------------------------+
|
|
//| RiskBudget.mqh |
|
|
//| AnimateDread |
|
|
//| https://www.mql5.com |
|
|
//+------------------------------------------------------------------+
|
|
#ifndef WARRIOR_RISK_BUDGET_MQH
|
|
#define WARRIOR_RISK_BUDGET_MQH
|
|
#include <Trade\Trade.mqh>
|
|
//+------------------------------------------------------------------+
|
|
//| 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
|
|
//+------------------------------------------------------------------+
|