forked from animatedread/Warrior_EA
The //| box blocks were excluded from0b06f8eand5efdb48and were what remained: 160 of them ran to 10+ lines, the longest to 88. Compressed to their leading topic sentences - 5 lines for a function header, 8 for a file header - keeping the box format and the standard MQL5 name/author lines verbatim. Verified at the BYTE level this time, across every in-scope file: the list of non-comment lines is byte-identical to HEAD and braces balance. The first check compared a locale-decoded 'git show' against a UTF-8 read and flagged 25 files that had not changed at all - every BOM and every non-ASCII line mismatched. 47,696 -> 40,665 lines in scope; comment share 38% -> 26%. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
552 lines
27 KiB
MQL5
552 lines
27 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. |
|
|
//+------------------------------------------------------------------+
|
|
#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. |
|
|
//+------------------------------------------------------------------+
|
|
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. |
|
|
//+------------------------------------------------------------------+
|
|
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
|
|
//+------------------------------------------------------------------+
|