Warrior_EA/Variables/RiskBudget.mqh
AnimateDread b91c7b1f7a refactor(comments): box headers to stdlib length
The //| box blocks were excluded from 0b06f8e and 5efdb48 and 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>
2026-08-22 00:30:14 -04:00

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
//+------------------------------------------------------------------+