Отслеживать
1
0
Ответвление
У вас уже есть ответвление Warrior_EA
0
ответвлён от animatedread/Warrior_EA
Warrior_EA/Variables/RiskBudget.mqh

602 строки
30 КиБ
MQL5
Исходный Постоянная ссылка Обычный вид История

//+------------------------------------------------------------------+
//| RiskBudget.mqh |
//| AnimateDread |
//| https://www.mql5.com |
//+------------------------------------------------------------------+
#ifndef WARRIOR_RISK_BUDGET_MQH
#define WARRIOR_RISK_BUDGET_MQH
#include <Trade\Trade.mqh>
#include "..\System\AtomicFile.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
//--- Seconds between persisted peak-equity updates. A new high-water mark can arrive on nearly
//--- every tick of a winning position; the compliance latches (halts, day-roll) still bypass this
//--- and save immediately - only the peak ratchet is worth batching, and the worst case of batching
//--- it (a peak that is up to this many seconds stale after a hard kill) moves the trailing floor
//--- DOWN, the conservative direction.
#define RISK_BUDGET_PEAK_SAVE_THROTTLE_SEC 20
//--- 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
feat: expectancy stop - halt when the measured result says the strategy loses 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>
2026-08-07 14:20:00 -04:00
//--- 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;
//--- Throttle for the PEAK-ONLY save (see Update()): a new equity high on every winning tick used
//--- to cost a full open-write-close-rename cycle every time, unlike the compliance latches
//--- (day-roll, daily/total halt) which still save immediately below.
datetime m_lastPeakSave;
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; }
feat: expectancy stop - halt when the measured result says the strategy loses 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>
2026-08-07 14:20:00 -04:00
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);
};
//+------------------------------------------------------------------+
feat: expectancy stop - halt when the measured result says the strategy loses 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>
2026-08-07 14:20:00 -04:00
//| 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. |
feat: expectancy stop - halt when the measured result says the strategy loses 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>
2026-08-07 14:20:00 -04:00
//+------------------------------------------------------------------+
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),
feat: expectancy stop - halt when the measured result says the strategy loses 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>
2026-08-07 14:20:00 -04:00
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),
m_lastPeakSave(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
//--- FULL fixed-length prefix, not just the 4-byte magic: FileRead* past EOF returns 0 with no
//--- error, so a truncated file (magic present, body cut short - a write interrupted mid-flush,
//--- before the atomic rename this file otherwise uses) used to silently ZERO m_dailyHalt/
//--- m_totalHalt/m_peakEquity/m_startEquity - a compliance latch defeated by a short read, not by
//--- the deliberate file-delete the comments below describe as the only way to clear it.
if(FileSize(handle) >= 4 + sizeof(long) + 3 * sizeof(double) + 2 * sizeof(int) &&
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);
feat: expectancy stop - halt when the measured result says the strategy loses 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>
2026-08-07 14:20:00 -04:00
//--- 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)
{
//--- Staged through a temp file + atomic rename (System\AtomicFile.mqh) - FileOpen(FILE_WRITE) alone
//--- truncates on open, and this file carries the daily/total halt LATCHES. A crash mid-write would
//--- leave a truncated file that LoadState()'s magic/size guard rejects, falling through to
//--- "re-anchoring from the current account state" - exactly the reattach-clears-the-latch failure
//--- mode this file's own comments say a compliance halt must survive.
string tmpName = "";
int handle = AtomicWriteBegin(StateFileName(), 0, tmpName);
if(handle == INVALID_HANDLE)
{
PrintFormat("%s: cannot write %s (error %d) - risk anchors will re-seed from live equity after a restart.",
__FUNCTION__, tmpName, GetLastError());
return;
}
bool ok = true;
if(FileWriteInteger(handle, (int)RISK_BUDGET_FILE_MAGIC, INT_VALUE) <= 0)
ok = false;
if(FileWriteLong(handle, (long)m_dayStart) <= 0)
ok = false;
if(FileWriteDouble(handle, m_dayAnchor) <= 0)
ok = false;
if(FileWriteDouble(handle, m_peakEquity) <= 0)
ok = false;
if(FileWriteDouble(handle, m_startEquity) <= 0)
ok = false;
if(FileWriteInteger(handle, (m_totalHalt ? 1 : 0), INT_VALUE) <= 0)
ok = false;
if(FileWriteInteger(handle, (m_dailyHalt ? 1 : 0), INT_VALUE) <= 0)
ok = false;
if(FileWriteInteger(handle, m_expCount, INT_VALUE) <= 0)
ok = false;
if(FileWriteDouble(handle, m_expSum) <= 0)
ok = false;
if(FileWriteDouble(handle, m_expSumSq) <= 0)
ok = false;
if(FileWriteInteger(handle, (m_expectancyHalt ? 1 : 0), INT_VALUE) <= 0)
ok = false;
AtomicWriteEnd(handle, StateFileName(), tmpName, 0, ok, __FUNCTION__);
}
//+------------------------------------------------------------------+
//| 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;
feat(trade): two books per symbol, and delete the vote exit Allow_Hedging (default ON, live only on a RETAIL_HEDGING account) gives the EA an independent long book and short book on its symbol: at most one long and at most one short, each opened on its own side's vote and each held to its own barrier. On a netting account, or with the input off, the original single-position path runs bit-for-bit unchanged and init says which one is live. WHY THIS INSTEAD OF A VOTE EXIT. The deploy gate certifies P(label agrees | vote fired) and the label runs to the barrier, so closing early on a reversal makes the realised outcome stop being the labelled one - the certified precision no longer describes what is traded. Opening the other side acts on the new signal and leaves the old position's certification intact, and costs no more than reversing: both pay the new side's spread, the difference is only that the existing position runs on to a barrier already measured as positive-expectancy. So Signal_ThresholdClose is DELETED rather than tuned, along with its SIGNAL_CLOSE_PRESETS enum; the threshold is pinned to an arithmetically unreachable 101 (the stock default of 100 is reachable by a weighted mean of values capped at 100). Note the two books can never both fill from one signal: CheckOpenLong and CheckOpenShort test opposite signs of the same m_direction, so at most one clears per tick. A hedge only forms when a LATER opposite vote fires - which is what keeps it from being a guaranteed-loss wash pair. The mechanism is a SelectPosition() override keyed on the active book's magic; every inherited close/trail path then operates on that book untouched. The long book keeps Expert_MagicNumber, so no existing position, journal row or risk-budget state file is re-addressed. Short book is +1. Four ownership filters had to widen from "== m_magic" to WarriorOwnsMagic(), or the short book would have been invisible to the code that must reach it: the scheduled close-all (positions and orders), the risk budget's emergency flatten, and the journal's MAE/MFE walk. WarriorOwnsMagic() is deliberately NOT gated on Allow_Hedging - turning the input off while a short-book position is open would otherwise orphan it with nothing left to close it. Risk sizing needed no change: CapRiskAmount already subtracts OpenRiskAtStops(), which counts every position regardless of magic, so the second book is sized inside what the first one left. Conservative for a hedged pair, which cannot lose both stops - the safe direction. Retrain-neutral: neither input is in BuildModelFingerprint() or ComputeDbConfigFingerprint(). Compiled clean; NOT yet run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 09:20:35 -04:00
//--- BOTH BOOKS. This is the emergency flatten; recognising only the long book would leave a
//--- short-book position running through the very event the budget exists to stop.
if(!WarriorOwnsMagic(PositionGetInteger(POSITION_MAGIC)))
continue;
feat(trade): two books per symbol, and delete the vote exit Allow_Hedging (default ON, live only on a RETAIL_HEDGING account) gives the EA an independent long book and short book on its symbol: at most one long and at most one short, each opened on its own side's vote and each held to its own barrier. On a netting account, or with the input off, the original single-position path runs bit-for-bit unchanged and init says which one is live. WHY THIS INSTEAD OF A VOTE EXIT. The deploy gate certifies P(label agrees | vote fired) and the label runs to the barrier, so closing early on a reversal makes the realised outcome stop being the labelled one - the certified precision no longer describes what is traded. Opening the other side acts on the new signal and leaves the old position's certification intact, and costs no more than reversing: both pay the new side's spread, the difference is only that the existing position runs on to a barrier already measured as positive-expectancy. So Signal_ThresholdClose is DELETED rather than tuned, along with its SIGNAL_CLOSE_PRESETS enum; the threshold is pinned to an arithmetically unreachable 101 (the stock default of 100 is reachable by a weighted mean of values capped at 100). Note the two books can never both fill from one signal: CheckOpenLong and CheckOpenShort test opposite signs of the same m_direction, so at most one clears per tick. A hedge only forms when a LATER opposite vote fires - which is what keeps it from being a guaranteed-loss wash pair. The mechanism is a SelectPosition() override keyed on the active book's magic; every inherited close/trail path then operates on that book untouched. The long book keeps Expert_MagicNumber, so no existing position, journal row or risk-budget state file is re-addressed. Short book is +1. Four ownership filters had to widen from "== m_magic" to WarriorOwnsMagic(), or the short book would have been invisible to the code that must reach it: the scheduled close-all (positions and orders), the risk budget's emergency flatten, and the journal's MAE/MFE walk. WarriorOwnsMagic() is deliberately NOT gated on Allow_Hedging - turning the input off while a short-book position is open would otherwise orphan it with nothing left to close it. Risk sizing needed no change: CapRiskAmount already subtracts OpenRiskAtStops(), which counts every position regardless of magic, so the second book is sized inside what the first one left. Conservative for a hedged pair, which cannot lose both stops - the safe direction. Retrain-neutral: neither input is in BuildModelFingerprint() or ComputeDbConfigFingerprint(). Compiled clean; NOT yet run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 09:20:35 -04:00
//--- Attribute the close to the book that owns the position, not to m_magic.
trade.SetExpertMagicNumber((ulong)PositionGetInteger(POSITION_MAGIC));
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;
}
//--- PEAK-ONLY DIRTY, separate from `dirty`: a new equity high can arrive on nearly every tick of
//--- a winning position, and until this split it triggered the SAME immediate SaveState() (a
//--- FileOpen/11 writes/FileClose/FileMove cycle) as a compliance latch. Batched below instead;
//--- every other trigger in this function still saves immediately via `dirty`.
bool peakDirty = false;
if(equity > m_peakEquity)
{
m_peakEquity = equity;
peakDirty = 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();
else if(peakDirty && (m_lastPeakSave == 0 || TimeCurrent() - m_lastPeakSave >= RISK_BUDGET_PEAK_SAVE_THROTTLE_SEC))
{
m_lastPeakSave = TimeCurrent();
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
//+------------------------------------------------------------------+