forked from animatedread/Warrior_EA
Hypotheses were registered in FX_PLAN.md before each round. Trend, breakout, cross reversion, hour seasonality, month-end USD, carry-cross dip-buy, metals dip-buy and flight-to-safety all fail the bar. The weekend-gap fade looked like the best result of the project on bar data (OOS t 20, 28/28 pairs) and loses on real ticks (EURCHF PF 0.52, AUDNZD PF 0.53): the Sunday-open spread is as wide as the gap. WarriorGapFade is kept as the research artifact that proved it and is flagged DO NOT TRADE. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
312 lines
13 KiB
MQL5
312 lines
13 KiB
MQL5
//+------------------------------------------------------------------+
|
|
//| WarriorGapFade.mq5 |
|
|
//| *** RESEARCH ARTIFACT - DO NOT TRADE. LOSES ON REAL TICKS. *** |
|
|
//| EURCHF PF 0.52 / AUDNZD PF 0.53 at the open, ~0.9 one hour later |
|
|
//| (2016-26, tester Model 4). See research/FX_RESULTS.md. |
|
|
//| Weekend-gap fade, forex. ONE CHART = ONE SYMBOL. |
|
|
//| |
|
|
//| When the first price after the weekend opens more than |
|
|
//| InpK x ATR(H1) away from Friday's last close, trade back toward |
|
|
//| Friday's close: |
|
|
//| target Friday's last close (take-profit order) |
|
|
//| stop InpStopATR x ATR from entry |
|
|
//| time flat before the end of the opening day, so no swap |
|
|
//| One decision per weekend per symbol, trade or no trade. |
|
|
//| |
|
|
//| Research: research/fx_round2.py X3 (registered in FX_PLAN.md |
|
|
//| round 2 before it was run). The whole question is whether the |
|
|
//| edge survives the REAL spread at the Sunday open, which is why |
|
|
//| this EA exists: bar data cannot answer it, the tick tester can. |
|
|
//| |
|
|
//| Account coordination is the same as WarriorDipZ: the kill |
|
|
//| switch watches ACCOUNT equity, keyed by InpMagic, persisted in |
|
|
//| terminal globals. |
|
|
//+------------------------------------------------------------------+
|
|
#property copyright "Warrior"
|
|
#property version "1.20"
|
|
#property strict
|
|
|
|
#include <Trade\Trade.mqh>
|
|
|
|
input double InpK = 1.0; // Min gap, x ATR(H1)
|
|
input double InpStopATR = 3.0; // Stop, x ATR(H1)
|
|
input int InpATRPeriod = 14; // Wilder ATR period (H1)
|
|
input double InpRiskPct = 0.25; // Risk per trade, % of equity
|
|
input int InpEntryDelayMin = 0; // Wait after the open before deciding (spread settles)
|
|
input int InpMinGapHours = 36; // A bar gap this long = a weekend
|
|
input int InpExitHour = 23; // Time exit, server hour on the opening day
|
|
input int InpExitMinute = 50; // ... and minute (before the rollover)
|
|
input double InpMaxDDPct = 4.5; // Kill switch: account equity DD from peak, %
|
|
input long InpMagic = 20260923; // Magic number
|
|
input bool InpJournal = true; // Write trade journal on deinit
|
|
|
|
CTrade g_trade;
|
|
datetime g_doneBar = 0; // H1 open time of the weekend-open bar already decided
|
|
double g_target = 0.0; // Friday close - managed in-EA, see Decide()
|
|
double g_stop = 0.0; // intended stop - enforced in-EA until the broker accepts it
|
|
double g_peak = 0.0;
|
|
bool g_halted = false;
|
|
|
|
string GvPeak(void) { return "Warrior_peak_" + IntegerToString(InpMagic); }
|
|
string GvHalt(void) { return "Warrior_halt_" + IntegerToString(InpMagic); }
|
|
|
|
//--- Wilder ATR on H1 over closed bars, seeded far enough back to converge
|
|
//--- ending at bar `from` - the last bar before the weekend, as the backtest
|
|
//--- measured it; with an entry delay, bar 1 would be a Monday bar instead
|
|
double WilderATR(const int from)
|
|
{
|
|
int n = InpATRPeriod * 20;
|
|
MqlRates r[];
|
|
if(CopyRates(_Symbol, PERIOD_H1, from, n, r) != n)
|
|
return 0.0;
|
|
double atr = 0.0;
|
|
for(int i = 0; i < n; i++)
|
|
{
|
|
double tr = (i == 0) ? r[i].high - r[i].low
|
|
: MathMax(r[i].high - r[i].low,
|
|
MathMax(MathAbs(r[i].high - r[i - 1].close), MathAbs(r[i].low - r[i - 1].close)));
|
|
if(i < InpATRPeriod)
|
|
{
|
|
atr += tr / InpATRPeriod;
|
|
continue;
|
|
}
|
|
atr = (atr * (InpATRPeriod - 1) + tr) / InpATRPeriod;
|
|
}
|
|
return atr;
|
|
}
|
|
|
|
bool FindPos(ulong &ticket)
|
|
{
|
|
for(int i = PositionsTotal() - 1; i >= 0; i--)
|
|
{
|
|
ulong tk = PositionGetTicket(i);
|
|
if(tk != 0 && PositionGetString(POSITION_SYMBOL) == _Symbol &&
|
|
PositionGetInteger(POSITION_MAGIC) == InpMagic)
|
|
{
|
|
ticket = tk;
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
double LotsFor(const double dist)
|
|
{
|
|
double tv = SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_VALUE);
|
|
double ts = SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_SIZE);
|
|
double st = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_STEP);
|
|
double mn = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MIN);
|
|
double mx = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MAX);
|
|
if(tv <= 0 || ts <= 0 || st <= 0 || dist <= 0)
|
|
return 0.0;
|
|
double lots = AccountInfoDouble(ACCOUNT_EQUITY) * InpRiskPct / 100.0 / (dist * tv / ts);
|
|
lots = MathFloor(lots / st) * st;
|
|
return (lots < mn) ? 0.0 : MathMin(lots, mx);
|
|
}
|
|
|
|
void Guards(void)
|
|
{
|
|
double eq = AccountInfoDouble(ACCOUNT_EQUITY);
|
|
if(eq > g_peak)
|
|
{
|
|
g_peak = eq;
|
|
GlobalVariableSet(GvPeak(), g_peak);
|
|
}
|
|
if(!g_halted && GlobalVariableCheck(GvHalt()) && GlobalVariableGet(GvHalt()) > 0.0)
|
|
g_halted = true;
|
|
if(!g_halted && InpMaxDDPct > 0 && g_peak > 0 && (g_peak - eq) / g_peak * 100.0 >= InpMaxDDPct)
|
|
{
|
|
g_halted = true;
|
|
GlobalVariableSet(GvHalt(), 1.0);
|
|
PrintFormat("GapFade: KILL SWITCH - equity %.2f is %.2f%% below peak %.2f", eq,
|
|
(g_peak - eq) / g_peak * 100.0, g_peak);
|
|
}
|
|
}
|
|
|
|
void Manage(void)
|
|
{
|
|
ulong tk;
|
|
if(!FindPos(tk))
|
|
return;
|
|
//--- time exit: before the first rollover after entry, so the trade never
|
|
//--- pays swap - the backtest's "end of the opening day"
|
|
datetime opened = (datetime)PositionGetInteger(POSITION_TIME);
|
|
MqlDateTime o, n;
|
|
TimeToStruct(opened, o);
|
|
TimeToStruct(TimeCurrent(), n);
|
|
bool late = (n.day_of_year != o.day_of_year) ||
|
|
(n.hour > InpExitHour) || (n.hour == InpExitHour && n.min >= InpExitMinute);
|
|
bool isLong = (PositionGetInteger(POSITION_TYPE) == POSITION_TYPE_BUY);
|
|
MqlTick tick;
|
|
bool hit = false, stopped = false;
|
|
if(SymbolInfoTick(_Symbol, tick) && g_target > 0.0)
|
|
{
|
|
//--- target on the price we would actually close at: bid for a long,
|
|
//--- ask for a short (the backtest used the bid for both - optimistic)
|
|
hit = isLong ? (tick.bid >= g_target) : (tick.ask <= g_target);
|
|
stopped = (g_stop > 0.0) && (isLong ? (tick.bid <= g_stop) : (tick.ask >= g_stop));
|
|
}
|
|
//--- attach the broker-side stop as soon as the broker accepts it; until
|
|
//--- then the in-EA check above is the stop
|
|
if(PositionGetDouble(POSITION_SL) == 0.0 && g_stop > 0.0)
|
|
g_trade.PositionModify(tk, g_stop, 0.0);
|
|
if(late || g_halted || hit || stopped)
|
|
g_trade.PositionClose(tk);
|
|
}
|
|
|
|
void Decide(void)
|
|
{
|
|
//--- Find the first bar after the weekend among the last few bars. It is
|
|
//--- NOT necessarily bar 0: with an entry delay the decision happens one or
|
|
//--- more bars later, and testing only bar 0 against bar 1 would never see
|
|
//--- the weekend gap again - the delayed variant would silently never trade.
|
|
datetime t0 = 0;
|
|
int wk = -1;
|
|
for(int k = 0; k <= 4; k++)
|
|
{
|
|
datetime a = iTime(_Symbol, PERIOD_H1, k);
|
|
datetime b = iTime(_Symbol, PERIOD_H1, k + 1);
|
|
if(a == 0 || b == 0)
|
|
return;
|
|
if(a - b >= InpMinGapHours * 3600)
|
|
{
|
|
t0 = a;
|
|
wk = k;
|
|
break;
|
|
}
|
|
}
|
|
if(wk < 0 || t0 == g_doneBar)
|
|
return; // no weekend open in reach, or already decided
|
|
if(TimeCurrent() < t0 + InpEntryDelayMin * 60)
|
|
return;
|
|
//--- decide within the hour after the delay, never later
|
|
if(TimeCurrent() >= t0 + (InpEntryDelayMin + 60) * 60)
|
|
{
|
|
g_doneBar = t0;
|
|
return;
|
|
}
|
|
ulong tk;
|
|
if(g_halted || FindPos(tk))
|
|
{
|
|
g_doneBar = t0;
|
|
return;
|
|
}
|
|
double friClose = iClose(_Symbol, PERIOD_H1, wk + 1); // last bar before the weekend
|
|
double atr = WilderATR(wk + 1);
|
|
MqlTick tick;
|
|
if(friClose <= 0 || atr <= 0 || !SymbolInfoTick(_Symbol, tick) || tick.bid <= 0)
|
|
return; // data not ready - try next tick
|
|
double gap = tick.bid - friClose;
|
|
g_doneBar = t0; // one decision per weekend
|
|
if(MathAbs(gap) < InpK * atr)
|
|
return;
|
|
int digits = (int)SymbolInfoInteger(_Symbol, SYMBOL_DIGITS);
|
|
bool longSide = (gap < 0); // gapped down -> buy back up
|
|
double entry = longSide ? tick.ask : tick.bid;
|
|
double sl = NormalizeDouble(longSide ? entry - InpStopATR * atr : entry + InpStopATR * atr, digits);
|
|
double tp = NormalizeDouble(friClose, digits);
|
|
//--- a gap already closed by the spread leaves nothing to trade
|
|
if((longSide && tp <= entry) || (!longSide && tp >= entry))
|
|
return;
|
|
double lots = LotsFor(MathAbs(entry - sl));
|
|
if(lots <= 0)
|
|
return;
|
|
g_trade.SetTypeFillingBySymbol(_Symbol);
|
|
//--- NO stops on the order itself. At the Sunday open the broker's minimum
|
|
//--- stop distance is widest, and a take-profit this close to the price
|
|
//--- was REJECTED (rc 10016 "invalid stops") - which silently dropped real
|
|
//--- trades from the first real-tick test. The target is managed in-EA and
|
|
//--- the stop is attached right after the fill (Manage()).
|
|
g_target = tp;
|
|
g_stop = sl;
|
|
bool ok = longSide ? g_trade.Buy(lots, _Symbol, 0, 0, 0, "gapfade")
|
|
: g_trade.Sell(lots, _Symbol, 0, 0, 0, "gapfade");
|
|
if(!ok || (g_trade.ResultRetcode() != TRADE_RETCODE_DONE && g_trade.ResultRetcode() != TRADE_RETCODE_PLACED))
|
|
{
|
|
PrintFormat("GapFade: %s %s failed rc=%u %s", _Symbol, longSide ? "buy" : "sell",
|
|
g_trade.ResultRetcode(), g_trade.ResultRetcodeDescription());
|
|
if(g_trade.ResultRetcode() == TRADE_RETCODE_MARKET_CLOSED)
|
|
g_doneBar = 0; // session not really open yet - retry
|
|
}
|
|
}
|
|
|
|
void OnTick(void)
|
|
{
|
|
Guards();
|
|
Manage();
|
|
Decide();
|
|
}
|
|
|
|
void OnTimer(void)
|
|
{
|
|
Guards();
|
|
Manage();
|
|
}
|
|
|
|
int OnInit(void)
|
|
{
|
|
g_trade.SetExpertMagicNumber(InpMagic);
|
|
g_trade.SetDeviationInPoints(100);
|
|
g_peak = AccountInfoDouble(ACCOUNT_EQUITY);
|
|
if(GlobalVariableCheck(GvPeak()))
|
|
g_peak = MathMax(g_peak, GlobalVariableGet(GvPeak()));
|
|
GlobalVariableSet(GvPeak(), g_peak);
|
|
g_halted = GlobalVariableCheck(GvHalt()) && GlobalVariableGet(GvHalt()) > 0.0;
|
|
EventSetTimer(60);
|
|
PrintFormat("GapFade: %s k=%.2f stop=%.1fxATR risk=%.2f%% delay=%dmin", _Symbol, InpK, InpStopATR,
|
|
InpRiskPct, InpEntryDelayMin);
|
|
return INIT_SUCCEEDED;
|
|
}
|
|
|
|
void WriteJournal(void)
|
|
{
|
|
if(!HistorySelect(0, TimeCurrent() + 86400))
|
|
return;
|
|
int h = FileOpen("gapfade_trades_" + _Symbol + ".csv", FILE_WRITE | FILE_CSV | FILE_ANSI | FILE_COMMON, ',');
|
|
if(h == INVALID_HANDLE)
|
|
return;
|
|
FileWrite(h, "position", "symbol", "entry_time", "entry_price", "volume", "exit_time", "exit_price",
|
|
"net_profit", "exit_reason", "gross_profit", "swap", "commission", "side");
|
|
int deals = HistoryDealsTotal();
|
|
for(int i = 0; i < deals; i++)
|
|
{
|
|
ulong d = HistoryDealGetTicket(i);
|
|
if(HistoryDealGetInteger(d, DEAL_MAGIC) != InpMagic || HistoryDealGetInteger(d, DEAL_ENTRY) != DEAL_ENTRY_OUT ||
|
|
HistoryDealGetString(d, DEAL_SYMBOL) != _Symbol)
|
|
continue;
|
|
long pos = HistoryDealGetInteger(d, DEAL_POSITION_ID);
|
|
datetime et = 0;
|
|
double ep = 0, vol = 0, gross = 0, swp = 0, com = 0;
|
|
long side = 0;
|
|
for(int j = 0; j < deals; j++)
|
|
{
|
|
ulong e = HistoryDealGetTicket(j);
|
|
if(HistoryDealGetInteger(e, DEAL_POSITION_ID) != pos)
|
|
continue;
|
|
gross += HistoryDealGetDouble(e, DEAL_PROFIT);
|
|
swp += HistoryDealGetDouble(e, DEAL_SWAP);
|
|
com += HistoryDealGetDouble(e, DEAL_COMMISSION);
|
|
if(HistoryDealGetInteger(e, DEAL_ENTRY) == DEAL_ENTRY_IN)
|
|
{
|
|
et = (datetime)HistoryDealGetInteger(e, DEAL_TIME);
|
|
ep = HistoryDealGetDouble(e, DEAL_PRICE);
|
|
vol = HistoryDealGetDouble(e, DEAL_VOLUME);
|
|
side = (HistoryDealGetInteger(e, DEAL_TYPE) == DEAL_TYPE_BUY) ? 1 : -1;
|
|
}
|
|
}
|
|
long rsn = HistoryDealGetInteger(d, DEAL_REASON);
|
|
string why = (rsn == DEAL_REASON_SL ? "stop" : (rsn == DEAL_REASON_TP ? "target" : "expert"));
|
|
FileWrite(h, (string)pos, _Symbol, TimeToString(et, TIME_DATE | TIME_MINUTES), DoubleToString(ep, 5),
|
|
DoubleToString(vol, 2), TimeToString((datetime)HistoryDealGetInteger(d, DEAL_TIME), TIME_DATE | TIME_MINUTES),
|
|
DoubleToString(HistoryDealGetDouble(d, DEAL_PRICE), 5), DoubleToString(gross + swp + com, 2), why,
|
|
DoubleToString(gross, 2), DoubleToString(swp, 2), DoubleToString(com, 2), (string)side);
|
|
}
|
|
FileClose(h);
|
|
}
|
|
|
|
void OnDeinit(const int reason)
|
|
{
|
|
EventKillTimer();
|
|
if(InpJournal)
|
|
WriteJournal();
|
|
}
|