Módulo 5 de 10

A Bad Day Is Not a Bad Strategy

Keisuke Kurosawa · Publicado: 2026-09-16

Module 4 answered how much. This one answers whether at all — and the answer comes from three separate layers that do not talk to each other, because they are watching three different lengths of time.

One asks about this bar: are we inside the session, is the spread sane, did we just trade. One asks about today: is the account down too far, have we traded enough, are we on a losing run. And one asks about this quarter: is the edge still there at all.

The third one is the one nobody builds, and it is the only one that can catch a strategy that is quietly dying. A daily loss limit cannot see a regime ending, because a regime ends slowly and politely, entirely inside your daily limits.

One function, and the first reason it says no

Every engine in the suite shares one gate function, so the rules cannot drift between EAs:

if(!IsTimeWindowByOffsetHours(startHour, endHour, utcOffset)) return GATE_BLOCK_SESSION;
if(!SpreadOK(sym, maxSpreadPoints))                           return GATE_BLOCK_SPREAD;
if(!CooldownOK(risk, cooldownMinutes, nowTime))               return GATE_BLOCK_COOLDOWN;
if(!DailyLossLimitOK(risk, dailyLossLimitPercent))            return GATE_BLOCK_MAXDAY;
if(!LossStreakOK(consecLosses, maxConsecLosses))              return GATE_BLOCK_LOSS;
if(maxTradesPerDay > 0 && risk.trades_today >= maxTradesPerDay) return GATE_BLOCK_MAXTRADES;
return GATE_OK;

It returns a reason, not a boolean — the same choice module 2 made for the strategy verdict, for the same payoff. And the ordering is deliberate: cheapest and most common first. "Outside the session" is the answer most of the time and costs one comparison against a clock, so on most bars nothing below the first line ever runs.

Note also what this function does not do. It takes no decision about the trade, does not look at the signal, and cannot place or cancel anything. It answers one question — may we open a position right now — and the engine maps its answer onto a counter. Gates that also act are how EAs end up with two pieces of code that both think they own the same decision.

Zero means off, and that is a feature

Every limit here treats zero as "disabled", all the way down to the leaf:

// True if spread <= maxSpreadPoints.
// If maxSpreadPoints <= 0, always true (spread gate disabled).
bool SpreadOK(const string sym, const int maxSpreadPoints)
{
   if(maxSpreadPoints <= 0)
      return true;

This is not laziness about validation. It means a strategy that genuinely should not have a given limit switches it off with a number instead of earning a special code path — and the gate function stays identical across every EA in the suite.

Here is what that buys, using two strategies that are both live right now. Same gate code, opposite settings:

Tokyo fix (M5, proven)        London range-revert (M15, regime)
  session      00 - 24          session      16 - 22 at UTC+9
  max/day       1               max/day      20
  cooldown      0 min           cooldown     30 min
  loss streak   0  (off)        loss streak   3
  daily loss    3.0%            daily loss    1.8%
  max hold      0  (off)        max hold    240 min

The Tokyo fix fires once a day, at 09:55 JST, aiming at a specific fixing. So its session window is wide open — the fix time is the constraint, not the session — and max trades per day = 1 does all the work on its own. A cooldown between trades is meaningless when there is only ever one. A three-loss streak limit would take three days to trigger and three more to clear, which is too slow an instrument to be a safety device.

The range-revert strategy trades up to twenty times inside a six-hour London window, so it needs every one of them.

The gates you enable are a description of your strategy's shape. Copying another EA's risk settings without copying its trade frequency gives you limits that either never fire or fire constantly.

The session window does not follow daylight saving

This comment is the kind of thing that usually goes undocumented and then bites someone twice a year:

// DST WARNING (by design): these windows use a FIXED UTC offset and do
// NOT auto-adjust for daylight saving. So a window pinned to a local
// market session (e.g. London open) drifts by 1 hour twice a year when
// that region flips DST but the fixed offset does not. This is a
// deliberate tradeoff for determinism/reproducibility across brokers.

Both halves matter. The behaviour is wrong in one sense — a window aimed at the London open slips an hour in March and again in October. And it is chosen, because the alternative is worse: brokers sit in different server timezones and flip DST on different dates, so a window that chases wall-clock London is a window whose backtest cannot be reproduced on another broker, or by you next year.

The gate also handles the case people forget:

   // Normal window (e.g., 9 -> 17)
   if(startHour <= endHour)
      return (dt.hour >= startHour && dt.hour < endHour);

   // Midnight-crossing window (e.g., 22 -> 5)
   return (dt.hour >= startHour || dt.hour < endHour);

A naive hour >= start && hour < end silently never fires for any session that crosses midnight, which is most of the interesting ones. The EA does not error; it just never trades, and you go looking in the strategy.

Seventeen ways to decline

Blocking is only useful if the block is visible. Every engine keeps one counter per reason:

   int               block_session;
   int               block_spread;
   int               block_adx;
   int               block_atr;
   int               block_cooldown;
   int               block_haspos;
   int               block_loss;
   int               block_maxday;
   int               block_maxtrades;
   int               block_nosignal;   // no entry signal on this bar
   int               block_ambig;      // both buy and sell true (conflict)
   int               block_indfail;    // indicators/data not available (CopyBuffer, handles, SymbolInfo)
   int               block_wick;       // wick/edge quality filter rejected
   int               block_stops;
   int               block_orderfail;
   int               block_nobias;    // EMA fast == slow (no trend)
   int               block_portfolio; // account-level cap refused the entry (KurosawaPortfolio.mqh)

Seventeen distinct reasons an EA can decline to trade, each counted separately, printed once a day alongside bars evaluated, signals produced and trades placed.

That list is worth reading as a design document, because it spans every layer this course has covered. block_nosignal and block_atr are the strategy from module 2. block_stops and block_orderfail are the execution layer from module 4. block_session through block_maxtrades are the gates in this one. block_portfolio is the layer below.

The question "why didn't my EA trade this week?" has seventeen possible answers and the EA knows which. That is the entire argument for counters: without them the answer is a guess, and a guess sends you to rewrite a strategy whose real problem was a spread cap.

One account, seven charts

Everything above is per-instance. Each chart politely keeps its own daily loss limit — and none of them is looking at your account:

//| - Seven charts run on one account, each with its own daily-loss  |
//|   and streak gates. Nothing looked at the account as a whole: at |
//|   09:55 JST three fix charts sell yen at once, and a London      |
//|   morning can stack four longs. At 0.01 lot that is trivia; it   |
//|   is the thing that has to exist BEFORE size goes up.            |

Correlation is what the per-instance view cannot see. Three simultaneous yen shorts are not three independent trades, they are one position in three costumes, and one Bank of Japan headline settles all of them the same way. Note that the three-fix-charts example is not hypothetical — those EAs all fire at the same minute by design, which is exactly why it took an account-level view to notice.

So the cap counts open positions per currency rather than per symbol:

      const string base  = SymbolInfoString(sym, SYMBOL_CURRENCY_BASE);
      const string quote = SymbolInfoString(sym, SYMBOL_CURRENCY_PROFIT);
      int nb = 0, nq = 0;

And it sums real money-at-risk across everything open, including trades this EA did not place, because — as the header puts it — "a manual trade uses the same margin and the same luck."

The implementation is pleasingly boring. There is no messaging between the EAs, no shared file, no coordination protocol: every instance reads the same account state before it sends, and whoever gets there first takes the last slot. All eight live EAs carry identical caps — 4 open positions, 1% total risk, 2% account daily loss, 3 positions per currency — enforced by the two engines those presets run on. The older engines in the tree predate this layer and do not call it; none of them is live, and retrofitting a rejected strategy is work with no reader.

One more decision worth copying: this layer never closes anything. It only refuses new entries. A risk layer that starts closing positions is competing with your exit logic, and when two pieces of code both manage a position, neither of them is in charge.

A bad day is not a bad strategy

Now the layer almost nobody builds, and the reason it exists:

//| - The presets that passed 2023-2026 failed 2019-2022 (PF 0.88-  |
//|   0.94). They are a REGIME edge. A regime edge is allowed to run |
//|   live at minimum lot, but it must notice when the regime ends.  |
//|   The daily-loss and consecutive-loss gates cannot: a regime     |
//|   fades slowly, inside those limits, over weeks.                 |

Those numbers are published, so you can check them. The same EURUSD range-revert preset that returns a profit factor of 1.27 over 2023–2024 returns 0.90 over 2019–2022, across 194 out-of-sample trades. Its USDJPY sibling: 1.48 in the recent window, 0.88 and 0.93 in the older one. Every run is on the preset's page, losing windows included.

That is not a strategy that works. It is a strategy that worked during a particular four years. Which is fine — regime edges are real and tradeable — as long as the system knows that is what it owns, and can tell when the weather changes.

No gate above can tell. Spread caps and session windows are about this bar. Daily loss limits are about today. A regime does not end with a bad day; it ends with eighteen months of slightly-worse-than-breakeven, every single day of which passes a 1.8% daily loss limit comfortably.

So the third layer measures the edge itself — the profit factor of this instance's own last thirty closed trades — and when it drops below 0.8 it stops opening new positions for twenty days.

The interesting part is what happens after the pause:

   if(k.closedTrades < rollingTrades) return true;   // not enough history to judge
   if(k.everPaused && (k.closedTrades - k.tradesAtResume) < probationTrades) return true;   // probation

Without the second line the switch deadlocks in a way that is hard to spot: the pause ends, the EA looks at its last thirty trades — which are the same thirty trades that triggered the pause, since it has not traded since — and immediately re-pauses on them. Forever. So a resumed instance gets a ten-trade probation before it can be judged again, and the judgement is made on evidence gathered after the pause rather than before it.

That is the same bug shape as module 3's loss-streak deadlock: a limit whose release condition requires the activity it just blocked. It is worth checking every safety mechanism you write for that shape, because it always looks correct and always presents as "the EA stopped working".

Two more choices in that file are worth stealing. The rolling profit factor uses DEAL_PROFIT + DEAL_SWAP + DEAL_COMMISSION — what the account actually saw, not the gross idea. And the header refuses to treat the thresholds as tunables: "It is not a tuning knob. N=30, PF 0.8 come from the gate document, and every preset carries the same values." A kill switch you tune per strategy is a kill switch you will eventually tune until it never fires.

Consistent with that, the switch is enabled on the four regime-tier instances and absent on the proven-tier Tokyo fix, whose evidence does not have the same shape. A mechanism aimed at regime decay belongs on the strategies suspected of being a regime.

What you should have now

One gate function shared by every EA, returning the first reason rather than a boolean; limits whose zero means off, configured to the shape of the strategy rather than copied; an account-level view that counts currencies instead of symbols and refuses rather than closes; and a measurement of the edge itself on a horizon long enough to see it fade.

And one habit worth more than any of them: when your EA does nothing for a week, the answer should already be in the log.

Next module: the strategy tester — why a backtest that passes is not evidence, what tune-then-validate actually protects you from, and how the same preset can be a 1.27 and a 0.90 depending only on which four years you asked about.