forked from animatedread/Warrior_EA
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>
602 lines
30 KiB
MQL5
602 lines
30 KiB
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
|
|
//--- 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; }
|
|
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),
|
|
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);
|
|
//--- 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;
|
|
//--- 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;
|
|
//--- 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
|
|
//+------------------------------------------------------------------+
|