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
<set>/summary.json, re-simulated
at the full max_iter = 2000.
(6 − 2 + 1) × 24 = 120 candidate windows. Sorted by summed P&L.
min(3, length) hours or more. Up to 5 windows recorded.
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.
| Spec | Means |
|---|---|
"" or empty | All 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. |
{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
The half the window was chosen on. Looks good by construction — this is the number that selected it.
The half it was not chosen on. This is the informative one.
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
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:
| Key | Contents |
|---|---|
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_pnl | Per-hour P&L, keys "0"–"23". The raw histogram. |
hour_n | Trade count per hour. |
slot_magic | Which 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
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:
| Build | How | File |
|---|---|---|
| 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
hour_pnl distribution — the raw evidence behind the chosen window.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
- 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.
| Symptom | Cause | Fix |
|---|---|---|
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. |