Warrior_EA/mql5_patches/SwapWindow.mqh
AnimateDread 9d60289f9f research(vol): meta-label blueprint, and the premise test that sank the 70% forecast
BLUEPRINT.md reviews the feature/label layers and designs fractional
differencing, Garman-Klass / Yang-Zhang targets and a 48-72h expansion
label; mql5_patches/ holds the MQL5 side (FFD safe past the 1024-bar
series ceiling, vol estimators, the label + veto gate, NY-time swap window).

premise_test.py measured the premise on real broker bars: the headline
AUC 0.75 was a day-of-week / path-length artifact (a Friday-clipped path
is shorter, so it touches K*ATR less). Honest residual 0.56-0.62, mostly
within noise once overlap is deflated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 13:24:57 -04:00

282 lines
12 KiB
MQL5

//+------------------------------------------------------------------+
//| SwapWindow.mqh |
//| Intra-week CFD operations: Friday liquidation + triple-swap gate|
//+------------------------------------------------------------------+
//| THIS MODULE FIXES TWO DEFECTS IN THE EXISTING SCHEDULED CLOSE |
//| (CExpertCustom::OnTick, ExpertCustom.mqh ~line 878). |
//| |
//| DEFECT 1 - THE CLOSE IS A +-1 MINUTE TICK LOTTERY. |
//| The current test is |
//| MathAbs(nowMinOfDay - targetMinOfDay) <= 1 |
//| evaluated inside OnTick. It fires only if a TICK ARRIVES in that |
//| two-minute window. On a Friday CFD close - thin book, and on |
//| indices the session shutting - that is exactly when ticks stop. |
//| Miss the window and the position is carried over the weekend, |
//| which is the one outcome the rule exists to prevent. This is the |
//| same class of failure already recorded for the session-aware bar |
//| clock: work scheduled on a tick that never comes. |
//| FIX: a LATCH. Fire on the first tick at-or-after the deadline, |
//| once per week, and keep firing until flat rather than assuming |
//| one attempt succeeded. |
//| |
//| DEFECT 2 - BROKER SERVER TIME IS NOT NEW YORK TIME. |
//| TimeCurrent() is broker server time (commonly EET/GMT+2/+3). The |
//| triple-swap rollover is defined at 17:00 NEW YORK. EU and US DST |
//| switch on DIFFERENT dates, so the offset between the two is not |
//| constant - there are two ~3-week windows each year where any |
//| hardcoded server-hour constant is off by one hour. Deriving from |
//| TimeGMT() and applying US DST rules is offset-stable. |
//| |
//| AND ONE ASSUMPTION NOT TO MAKE: the triple-swap day is NOT always |
//| Wednesday. It is per-symbol and the broker publishes it as |
//| SYMBOL_SWAP_ROLLOVER3DAYS. Several index and metal CFDs bill on |
//| Friday. Read it; never hardcode Wednesday. |
//+------------------------------------------------------------------+
#ifndef SWAPWINDOW_MQH
#define SWAPWINDOW_MQH
//--- 17:00 New York, the standard CFD rollover instant
#define SW_ROLLOVER_HOUR_NY 17
#define SW_SECONDS_PER_HOUR 3600
//+------------------------------------------------------------------+
//| US DST: second Sunday of March 02:00 -> first Sunday of November. |
//| Returns the UTC offset in seconds (-5h EST / -4h EDT). |
//| |
//| Computed from the date, not from TimeGMTOffset(): in the strategy |
//| tester the terminal's own offset reporting follows the BROKER, so |
//| using it here would reintroduce defect 2. |
//+------------------------------------------------------------------+
int SWNewYorkUtcOffset(const datetime utc)
{
MqlDateTime t;
TimeToStruct(utc, t);
//--- day_of_week of the 1st of the month, from the given date
int dowFirst = (int)(t.day_of_week - ((t.day - 1) % 7) + 7) % 7;
//--- second Sunday of March
int firstSun = 1 + ((7 - dowFirst) % 7);
if(t.mon == 3)
{
int secondSun = firstSun + 7;
if(t.day > secondSun || (t.day == secondSun && t.hour >= 7)) // 02:00 EST = 07:00 UTC
return -4 * SW_SECONDS_PER_HOUR;
return -5 * SW_SECONDS_PER_HOUR;
}
if(t.mon == 11)
{
if(t.day > firstSun || (t.day == firstSun && t.hour >= 6)) // 02:00 EDT = 06:00 UTC
return -5 * SW_SECONDS_PER_HOUR;
return -4 * SW_SECONDS_PER_HOUR;
}
if(t.mon > 3 && t.mon < 11)
return -4 * SW_SECONDS_PER_HOUR;
return -5 * SW_SECONDS_PER_HOUR;
}
//--- New York wall-clock time for a given UTC instant.
datetime SWNewYorkTime(const datetime utc)
{
return utc + SWNewYorkUtcOffset(utc);
}
//+------------------------------------------------------------------+
//| The symbol's OWN triple-swap day, as the broker publishes it. |
//| Falls back to Wednesday only when the symbol reports nothing. |
//+------------------------------------------------------------------+
ENUM_DAY_OF_WEEK SWTripleSwapDay(const string symbol)
{
long day = 0;
if(!SymbolInfoInteger(symbol, SYMBOL_SWAP_ROLLOVER3DAYS, day))
return WEDNESDAY;
if(day < 0 || day > 6)
return WEDNESDAY;
return (ENUM_DAY_OF_WEEK)day;
}
//+------------------------------------------------------------------+
//| Are we inside the `windowMinutes` run-up to this symbol's triple- |
//| swap rollover? |
//+------------------------------------------------------------------+
bool SWInTripleSwapWindow(const string symbol, const int windowMinutes = 120)
{
datetime utc = TimeGMT();
datetime ny = SWNewYorkTime(utc);
MqlDateTime n;
TimeToStruct(ny, n);
if((ENUM_DAY_OF_WEEK)n.day_of_week != SWTripleSwapDay(symbol))
return false;
int nowMin = n.hour * 60 + n.min;
int rolloverMin = SW_ROLLOVER_HOUR_NY * 60;
return (nowMin >= rolloverMin - windowMinutes && nowMin < rolloverMin);
}
//+------------------------------------------------------------------+
//| SWAP DRAG, in ACCOUNT CURRENCY, for holding `lots` across the |
//| triple-swap rollover. |
//| |
//| Returns false when the symbol's swap mode is one this cannot price |
//| exactly. A false here must BLOCK the override (fall back to the |
//| plain volatility gate) - never be treated as "drag = 0", which |
//| would silently wave through the trades this rule exists to stop. |
//+------------------------------------------------------------------+
bool SWTripleSwapCost(const string symbol, const bool isLong, const double lots,
double &cost)
{
double rate = isLong ? SymbolInfoDouble(symbol, SYMBOL_SWAP_LONG)
: SymbolInfoDouble(symbol, SYMBOL_SWAP_SHORT);
if(!MathIsValidNumber(rate) || lots <= 0.0)
return false;
long mode = SymbolInfoInteger(symbol, SYMBOL_SWAP_MODE);
double tickValue = SymbolInfoDouble(symbol, SYMBOL_TRADE_TICK_VALUE);
double tickSize = SymbolInfoDouble(symbol, SYMBOL_TRADE_TICK_SIZE);
double point = SymbolInfoDouble(symbol, SYMBOL_POINT);
double price = SymbolInfoDouble(symbol, SYMBOL_BID);
double contract = SymbolInfoDouble(symbol, SYMBOL_TRADE_CONTRACT_SIZE);
double oneDay = 0.0;
switch((int)mode)
{
case SYMBOL_SWAP_MODE_POINTS:
if(tickSize <= 0.0 || tickValue <= 0.0 || point <= 0.0)
return false;
oneDay = rate * point * (tickValue / tickSize) * lots;
break;
case SYMBOL_SWAP_MODE_CURRENCY_SYMBOL:
case SYMBOL_SWAP_MODE_CURRENCY_MARGIN:
case SYMBOL_SWAP_MODE_CURRENCY_DEPOSIT:
//--- already money per lot; currency conversion for the first two is
//--- the broker's, and assuming parity here would be a fabrication.
oneDay = rate * lots;
break;
case SYMBOL_SWAP_MODE_INTEREST_CURRENT:
case SYMBOL_SWAP_MODE_INTEREST_OPEN:
if(price <= 0.0 || contract <= 0.0)
return false;
oneDay = (rate / 100.0) * price * contract * lots / 360.0;
break;
default:
return false; // unpriceable - caller must not override
}
//--- Triple charge. Sign convention is the broker's: a POSITIVE swap is a
//--- credit, so drag is the negative part. Report drag as a positive cost.
cost = -3.0 * oneDay;
return true;
}
//+------------------------------------------------------------------+
//| THE GATE. Should this trade be blocked ahead of the triple swap? |
//| |
//| Blocks unless the expected expansion pays for the carry by a real |
//| margin. `expectedMoveAtr` is the meta-label's forecast expansion |
//| in ATR units; `atrPrice` is ATR in price units. |
//+------------------------------------------------------------------+
bool SWBlockedByTripleSwap(const string symbol, const bool isLong, const double lots,
const double expectedMoveAtr, const double atrPrice,
const double overrideMultiple, string &why,
const int windowMinutes = 120)
{
if(!SWInTripleSwapWindow(symbol, windowMinutes))
return false;
double drag = 0.0;
if(!SWTripleSwapCost(symbol, isLong, lots, drag))
{
why = "triple-swap window and swap cost is unpriceable on this symbol - blocked";
return true; // refuse, never assume zero
}
if(drag <= 0.0)
{
why = "triple-swap window but the carry is a credit - allowed";
return false;
}
double tickValue = SymbolInfoDouble(symbol, SYMBOL_TRADE_TICK_VALUE);
double tickSize = SymbolInfoDouble(symbol, SYMBOL_TRADE_TICK_SIZE);
if(tickSize <= 0.0 || tickValue <= 0.0 || atrPrice <= 0.0 || expectedMoveAtr <= 0.0)
{
why = "triple-swap window and the expected move cannot be valued - blocked";
return true;
}
double expectedValue = expectedMoveAtr * atrPrice * (tickValue / tickSize) * lots;
if(expectedValue >= overrideMultiple * drag)
{
why = StringFormat("triple-swap override: expected %.2f >= %.1f x drag %.2f",
expectedValue, overrideMultiple, drag);
return false;
}
why = StringFormat("blocked: expected %.2f < %.1f x triple-swap drag %.2f",
expectedValue, overrideMultiple, drag);
return true;
}
//+------------------------------------------------------------------+
//| CWeeklyFlatLatch - the Friday liquidation, as a latch. |
//| |
//| Deliberately NOT a +-N minute window. Once the deadline passes the |
//| latch stays armed until the book is actually flat, so a missing |
//| tick delays the close instead of cancelling it. |
//+------------------------------------------------------------------+
class CWeeklyFlatLatch
{
private:
int m_armedWeek; // year*100 + ISO week, -1 = idle
int m_attempts;
int WeekKey(const datetime t) const
{
MqlDateTime d;
TimeToStruct(t, d);
return d.year * 100 + (d.day_of_year / 7);
}
public:
CWeeklyFlatLatch(void) : m_armedWeek(-1), m_attempts(0) { }
//+---------------------------------------------------------------+
//| True when the weekly flat deadline has passed and we are not |
//| yet flat. Call from OnTick AND OnTimer - the timer is what |
//| covers the case ticks have stopped entirely. |
//| |
//| `deadlineNy` is minutes-of-day in NEW YORK time on `flatDay`. |
//+---------------------------------------------------------------+
bool ShouldLiquidate(const ENUM_DAY_OF_WEEK flatDay, const int deadlineNyMin)
{
datetime utc = TimeGMT();
datetime ny = SWNewYorkTime(utc);
MqlDateTime n;
TimeToStruct(ny, n);
int key = WeekKey(ny);
if(m_armedWeek == key)
return true; // still armed: last attempt left something open
if((ENUM_DAY_OF_WEEK)n.day_of_week != flatDay)
return false;
int nowMin = n.hour * 60 + n.min;
//--- AT OR AFTER, not "within a minute of".
if(nowMin < deadlineNyMin)
return false;
m_armedWeek = key;
m_attempts = 0;
return true;
}
//--- Called after a liquidation attempt; disarm only once truly flat.
void ReportResult(const bool flat)
{
m_attempts++;
if(flat)
{
m_armedWeek = -1;
m_attempts = 0;
}
else
if(m_attempts % 20 == 0)
Print(__FUNCTION__ + ": weekly flat latch still armed after " +
IntegerToString(m_attempts) + " attempts - positions remain open");
}
bool Armed(void) const { return m_armedWeek != -1; }
};
#endif // SWAPWINDOW_MQH