Step 4 of 7

Trading Hours

Finds, per feature set, the contiguous block of server-time hours the model has actually earned in — and then checks whether that pick survives on the half of the data it was not chosen on. A large total with a negative second half is an artefact of the search space, not a session edge.

What this stage does

best_hours.py --engine lr --symbol XAUUSD --tf M5 --cores 14
Pick each set's best slot
The highest-scoring slot from <set>/summary.json, re-simulated at the full max_iter = 2000.
Attribute every trade to its entry hour
Each closed trade's P&L is added to the server-time hour of its entry bar. This builds a 24-slot histogram of P&L and trade counts.
Enumerate every contiguous cyclic window
All windows of length 2 to 6 hours, wrapping around midnight: (6 − 2 + 1) × 24 = 120 candidate windows. Sorted by summed P&L.
Greedy selection with overlap rejection
Take the best, then reject any subsequent window overlapping an already-picked one by min(3, length) hours or more. Up to 5 windows recorded.
Split-half scoring
Each picked window is scored separately on the first and second half of the bars — in_pnl and oos_pnl. This is the number that decides whether the window is real.

Why entry hour, not exit hour

A trade is attributed to the hour of the bar that generated the signal. That is the decision you are actually gating: "should we take new positions during this hour?". Exits happen whenever the stop or target is reached, which is not something a session filter controls.

This is also consistent with how the filter works in the engine: the hour set gates entries only. Training is unaffected, and an open position is never force-closed because the clock moved out of the window.

The window format

A window is a set of allowed hours, compressed into a compact spec that fits in one EA input string. The same format is used in best_hours.json, in the generated InpSlot<N>Hours inputs, and in the MQL5 ParseHours function.

SpecMeans
"" or emptyAll 24 hours — no filter.
"4"Hour 4 only.
"8-10"Hours 8, 9, 10 inclusive.
"22-2"Wrap-around: 22, 23, 0, 1, 2.
"4,8-10"Union: hour 4 plus hours 8–10.
A wrap-around window is compressed as two ranges The hour list is sorted numerically before compression, so the cyclic window {22, 23, 0, 1} is written as "0-1,22-23" rather than one wrapping range. Both parse to the same set of hours, so this is cosmetic — but it explains why a window that visually spans midnight may appear as two ranges.

The two scores, and why both are shown

in_pnl / in_n
First half

The half the window was chosen on. Looks good by construction — this is the number that selected it.

oos_pnl / oos_n
Second half

The half it was not chosen on. This is the informative one.

A big total with a negative OOS half is a search artefact With 120 candidate windows ranked on one dataset, some will look excellent purely by chance. The second-half split is what separates a real session effect from a lucky block.

Only windows positive in both halves are worth compiling. The script flags the bad ones inline: <== NEGATIVE OOS (likely curve-fit).

An important limitation to understand

This split is a stability check, not a true holdout The window ranking uses hour P&L computed over the whole sample. The first/second-half figures are therefore a within-sample consistency measure — useful, but weaker than a genuine out-of-sample test, because the window was still chosen with knowledge of the entire series.

The real out-of-sample evidence is the tail holdout in Step 5. Treat this stage as a filter that removes the obviously curve-fitted windows, not as proof that the surviving ones are real.

Progress output

best_hours symbol=XAUUSD tf=M5 data=data/XAUUSD_M5.csv root=strategies_lr_m5
  base9          window=08-12        pnl=     +812  oos=     +141 (n=52)  (magic 401)
  momentum       window=13-18        pnl=     +204  oos=      -61 (n=33)  <== NEGATIVE OOS (likely curve-fit)  (magic 411)
  volatility     window=11-16        pnl=     +190  oos=      +44 (n=41)  (magic 421)
  trend          no window found  (magic 441)
wrote strategies_lr_m5\best_hours.json

Outputs

One file, strategies_lr_m5/best_hours.json, keyed by feature set:

KeyContents
best_windows[]Up to 5 windows, each with hours, len, pnl, spec, n, and the split-half in_pnl/in_n/oos_pnl/oos_n.
hour_pnlPer-hour P&L, keys "0""23". The raw histogram.
hour_nTrade count per hour.
slot_magicWhich slot the analysis was run on.

Each set also gets a <set>/trades.csv — the full trade statement for its best slot, with side, entry, exit, pnl, reason, prob, bars_held, bar_entry and bar_exit.

The first window in best_windows is what gets baked into the EA. That is the resolution order in the generator: HOURS_DISABLE → an explicit override → best_hours.json → a hardcoded fallback table → empty (24 hours).

How the hours reach the EA

best_hours.json
best_windows[0].spec
gen_ea.py
InpSlot<N>Hours = "8-12"

The generated EA compiles these into a per-slot boolean array through ParseHours(), a faithful MQL5 mirror of the Python parser — including the wrap-around case.

24 hours versus compiled hours

There is a legitimate choice here, and the pipeline supports both:

BuildHowFile
Hours-baked Normal generation. Each slot gets its window from best_hours.json. …_ALL9_M5.mq5
24-hour --no-hours --tag 24X — forces InpSlot<N>Hours = "" on every slot. …_ALL9_24X_M5.mq5

The tag is what makes this safe: without it the second build would overwrite the first, and you would lose the ability to compare them.

--no-hours takes precedence over everything It is checked before best_hours.json and before the hardcoded fallback table, so a stale hours file cannot defeat it. Both switches default to off, which means normal generation is unchanged.

Which should you ship?

If most sets show positive windows in both halves, the hours build is defensible — it reduces exposure during hours where the model has no demonstrated edge, which is a genuine risk reduction rather than an optimisation. If most sets are OOS-negative, the windows are curve-fitted and the 24-hour build is the honest choice.

The delivery report quantifies the difference by exact replay: it re-runs one forward pass per slot for each hour configuration rather than filtering the trade list. The two approaches are not equivalent, because gating entries changes which trades exist at all — a slot blocked during its window takes a different sequence of later trades. The replay is the honest comparison.

The Trading hours page

Controls
Cores, plus the command preview.
Verdict banner
A summary judgement across sets — how many windows are positive in both halves.
Window table
Per set: the window spec, its length, total P&L, trade count, and both split-half scores side by side.
Hour histogram
The full hour_pnl distribution — the raw evidence behind the chosen window.
Open folder
Jump to best_hours.json and the per-set trade statements.

The split-half columns are deliberately the widest thing on the page, because the distinction is the entire reason the stage exists.

Checkpoint

Before moving to Step 5
  • Count how many sets are positive in both halves.
  • If most are: the hours build is defensible. Note the ones flagged NEGATIVE OOS.
  • If most are not: decide now to ship the 24-hour build, and plan to generate it with --no-hours --tag 24X.
  • Check the hour histogram looks plausible for the instrument — the busiest hours should match when the instrument actually moves.
SymptomCauseFix
no window found for a set No contiguous window of 2–6 hours produced positive summed P&L. Not necessarily a problem. The slot trades 24 hours in that case; consider whether that set belongs in the book at all.
Most sets show negative OOS The windows are curve-fitted to the first half. Ship the 24-hour build. This is a real and common outcome — the search space has 120 candidates.
All windows look implausibly wide (e.g. 20 hours) A window that wide is not a session filter; it is noise with a wide margin. Treat it as equivalent to 24 hours and consider the 24-hour build.
Hours look shifted by a fixed amount The data is in UTC rather than server time. Re-export from the terminal. Every hour figure in the system depends on this.
Union of windows covers nearly all hours Individually reasonable per-slot windows can sum to almost the whole day. Expected. Per-slot gating still helps risk; it just is not "trading less" overall.

Next: generate and compile the EA →