On this page
Stopping At “Validation Good, Train Bad” Is Not Enough
When the parameter sweep produced its first good-looking row, the easy reaction would have been one of two extremes. Either trust validation and push forward, or reject it immediately because train is negative. Neither was enough.
The row was a session-extreme reversal short rule:
session_extreme_reversal_short__skip_open_1030_1500__max1__score_ge_50p0
The visible result was split sharply:
| Split | Trades | Net points | Avg net points |
|---|---|---|---|
| Train | 169 | -151.662 | -0.897 |
| Validation | 38 | 67.576 | 1.778 |
A gap this large should not be filed away as just “train/validation gap.” I needed to know whether it was regime change, entry-timing artifact, cost-model fragility, exit-path calculation error, or a contract-period effect. Otherwise, the next research step would be blind.
The Full Trigger Family Was Bad In Both Splits
The first step was to inspect what the selected sweep actually chose. The important finding was that the whole signal family was not good. If I looked at all qualifying triggers, both train and validation were negative.
train all triggers: 4,317 rows, -4,786.866 net points
validation all triggers: 880 rows, -416.490 net points
The selected sweep only takes the first qualifying trigger per day.
train first trigger per day: 169 trades, -151.662 net points
validation first trigger per day: 38 trades, 67.576 net points
So the validation result was not evidence that the whole session-extreme short family worked. It was evidence that a first-trigger timing rule worked in one validation period. That distinction matters. If all triggers had been positive, I could trust the family more. Here, entry timing was carrying the conclusion.
There was another tempting detail. If I selected max-score or last triggers, both train and validation looked strong:
train max-score trigger per day: 687.088 net points
validation max-score trigger per day: 296.326 net points
train last trigger per day: 606.838 net points
validation last trigger per day: 222.576 net points
But that is diagnostic, not a candidate. Max-score and last-trigger selection can easily require knowing the rest of the day. If the entry rule cannot know it at entry time, it is not tradeable. Good-looking numbers did not justify promoting it.
Time Of Day And Contract Periods
Next I split the result by time of day and contract period. Train losses were especially concentrated in the 14:00 ET hour.
train 14:00 ET: 45 trades, -98.910 net points
validation 14:00 ET: 5 trades, -18.240 net points
Validation gains came mostly from 11:00 ET through 13:00 ET.
validation 11:00 ET: 7 trades, 23.264 net points
validation 12:00 ET: 14 trades, 46.028 net points
validation 13:00 ET: 11 trades, 19.272 net points
Contract periods also gave clues. Train losses clustered in specific MES contracts:
MESU2: 24 trades, -65.952 net points
MESM4: 8 trades, -41.484 net points
MESH2: 24 trades, -29.702 net points
MESH3: 15 trades, -27.970 net points
Validation was mostly MESH5 and MESM5:
MESH5: 14 trades, 25.778 net points
MESM5: 21 trades, 46.292 net points
This does not prove the answer by itself. But it is enough to suspect that the validation period matched a narrow market condition. A good research system should turn that suspicion into a gate before turning it into a signal.
Hidden Issue: Short Exit Paths Were Calculated Like Longs
The deep dive also found a real bug. The source five-minute parameter-sweep trades already handled short direction correctly, so this bug did not explain the original -151.662 train result.
But the alternate exit-path diagnostics in OHLCV robustness were calculating short time exits, MFE, MAE, and stop/target first-touch outcomes as if every lead were long. That could distort later exit-label sensitivity.
After the fix, exit paths became direction-aware:
short time exits: direction-aware
short MFE/MAE: direction-aware
first-touch stop/target: direction-aware
unit coverage: added for short-direction exits
This kind of bug can inflate returns or kill a valid candidate. The point was not to find a bug to excuse the validation winner. It was to make the diagnostic surface trustworthy before candidate promotion.
Configured-Positive Still Failed Cost Stress
After the direction-aware rebuild, a session-extreme short 15-minute exit variant briefly looked promising:
variant:
parameter_session_extreme_reversal_short...time_exit_15m...session_early_or_middle
configured train: 27.230 net points over 115 trades
configured validation: 34.558 net points over 29 trades
At first glance, this seems to pass both train and validation. But the average train edge was too small.
configured average train edge: 0.236783 points per trade
required dominant-active p99 extra stress: 0.250000 points per trade
extra stress cost: 115 * 0.25 = 28.750 points
stressed train net: 27.230 - 28.750 = -1.520 points
The result is mathematically clear. The configured edge was smaller than one extra tick of spread stress. A candidate like that should not proceed to model training. Saying “train and validation are slightly positive” becomes much less meaningful when one extra tick turns train negative.
Filter Hardening: 13,650 Rows Checked, 0 Passed
I did not stop immediately. Without buying more data, I tried adding one train-derived filter at a time to see whether the candidate could be hardened. Thresholds were computed from train only, and the candidate had to pass early, middle, and late train folds.
The result was again no_trade.
| Metric | Value |
|---|---|
| Candidate filter rows checked | 13,650 |
| Prefold pass rows | 66 |
| All-gate pass rows | 0 |
| Selected filter | no_trade |
Even the best-looking prefold filter broke in late train:
| Split/Fold | Dominant p99 net |
|---|---|
| Train total | 25.726 |
| Validation total | 72.308 |
| Train early | 63.074 |
| Train middle | 8.826 |
| Train late | -46.174 |
This was fairly decisive. Early train and validation looked good, but late train was negative. The validation strength had not repeated across the whole train period.
Conclusion: Temporal Instability And Cost Fragility
The conclusion of this deep dive was more specific than “this candidate does not work.”
The signal family has a conditional reversal edge that appears only in some market periods.
The original selected sweep was negative on train and positive only on validation. The full trigger family was negative in both splits. The direction-aware bug fix did not explain the original train failure. The configured-positive variant failed one tick of p99 cost stress. Filter hardening checked 13,650 rows and found no row that passed all inner train folds.
So the decision stayed the same:
selected policy: no_trade
train/test split: test untouched
model training: not started
paper trading: not started
What I liked about this phase was that the failure became clearer. It was no longer just “validation good, train bad.” It became “a fragile entry-timing reversal family that fails late train and cannot survive one tick of extra cost stress.”
Once failure is explained that concretely, not training a model becomes much easier.
The analysis made the candidate’s temporal instability concrete. But OHLCV bars could not say whether the extra tick used in p99 stress was conservative or optimistic in the actual market. The next step therefore used MBP-1 not as a feature source, but as an execution-calibration surface.
OHLCV Backtests Do Not Prove Fills
OHLCV backtests are fast and convenient. I can build signals from one-minute bars, use the close five minutes later as the label, subtract costs in points, and run research quickly. But that does not prove the trade could be executed. For MES, where tick size is small and the strategy horizon is short, top-of-book spread and latency can change the entire conclusion.
The next stage of the quant trading system therefore used MBP-1 data as an execution-realism calibration surface. I deliberately say calibration surface, not feature source. The MBP coverage was mostly close to the canonical test period, and it was not a timestamp-matched train/validation fill dataset for older OHLCV trades. So I did not mix MBP into labels or model features.
Instead, I changed the question:
How realistic is the current top-of-book spread assumption?
Does a marketable order still satisfy that assumption after 0ms, 100ms, 500ms, or 1000ms latency?
How large is adverse mid movement over short horizons?
How different are dominant active MES/ES contracts from the all-symbol universe?
This does not find a strategy. But it is very useful for killing candidates.
First, The MBP Cleaning Policy
I did not use MBP data directly because the raw anomaly report had already found crossed top-of-book rows and negative prices. The first MBP cleaning policy therefore defined a contract for clean outright ES/MES top-of-book rows.
exclude spread symbols
exclude negative prices
drop missing or nonpositive top-of-book rows
drop locked books
drop crossed books
keep outright ES/MES rows only
The result was:
| Metric | Value |
|---|---|
| Raw MBP rows | 387,252,384 |
| Outright ES/MES target rows | 386,559,005 |
| Clean outright top-of-book rows | 386,556,485 |
| Spread-symbol rows excluded | 693,379 |
| Negative-price rows flagged | 78 |
| Nonpositive/missing top rows dropped | 1,884 |
| Locked top rows dropped | 69 |
| Crossed top rows dropped | 859 |
| Event dates | 29 |
| Symbols | 25 |
The important thing is not only that there are many clean rows. It is that each rejected row has a reason. If a later p99 spread report looks strange, I need to know whether the cause is a crossed book, stale contract, or real tail spread.
Static Spread Realism
The first execution-realism report measured spread and top-size distributions across clean top-of-book rows.
clean top-of-book rows: 386,556,485
event dates: 28
symbols: 10
median spread: 1.0 tick
p90 spread: 2.0 ticks
p99 spread: 7.0 ticks
rows within assumed 1 spread tick: 0.875708
rows inside full 0.998 point round-turn cost: 0.943512
dominant MESM6 rows within assumed spread tick: 0.931609
dominant ESM6 rows within assumed spread tick: 0.931816
The result cuts both ways. A median spread of one tick means the basic spread assumption is not absurd. But p90 is two ticks and p99 is seven ticks. Across the all-symbol universe, only about 87.6% of rows are inside the one-tick spread assumption.
So fixed OHLCV costs may be fine as a baseline assumption, but they are not enough for candidate promotion. If a validation edge is small or exposed to inactive contracts, tail spread can erase it.
Latency And Adverse Movement
The MBP execution diagnostics went one step beyond static spread. It created 30-second anchors, then used as-of joins to inspect bid/ask snapshots after 0ms, 100ms, 500ms, and 1000ms latency. It then measured mid-price drift and adverse markout over 1s, 5s, 30s, and 60s horizons.
The key is to inspect the quote when an order may actually arrive, not only the quote at the signal timestamp.
LATENCY_OFFSETS_MS = (0, 100, 500, 1000)
HORIZON_OFFSETS_MS = (1000, 5000, 30000, 60000)
MAX_EVENT_STALENESS_MS = 2000
diagnostic["market_buy_entry_px"] = diagnostic["latency_ask_px"]
diagnostic["market_sell_entry_px"] = diagnostic["latency_bid_px"]
diagnostic["market_buy_adverse_after_fill_points"] = (
-diagnostic["market_buy_entry_markout_points"]
).clip(lower=0.0)
The result was stricter than static spread:
| Metric | Value |
|---|---|
| Anchor rows | 60,035 |
| Diagnostic rows | 897,944 |
| Symbols | 10 |
| Median latency spread | 2.0 ticks |
| p90 latency spread | 4.0 ticks |
| p99 latency spread | 27.0 ticks |
| Latency spread within one-tick assumption | 0.484892 |
| Avg absolute horizon mid drift | 0.638999 points |
| Avg market-buy adverse mid movement | 0.313413 points |
| Avg market-sell adverse mid movement | 0.325586 points |
Across the all-symbol universe, the one-tick assumption breaks often. But dominant active contracts look different. Contracts such as MESM6 and ESM6 have median and p90 spreads close to one tick, with roughly 96-97% of diagnostic rows inside the one-tick assumption.
That distinction matters. Execution realism does not say “MES is always bad” or “MES is always fine.” It shows that cost realism depends heavily on the contract universe.
MBP Microstructure Report
I also built an MBP microstructure report. It measures update rates, quote/trade activity, spread distribution, top-size collapse, imbalance, and microprice drift.
clean seconds: 1,690,818
event dates: 22
symbols: 10
average event updates per second: 127.695576
average quote updates per second: 121.759004
average trades per second: 5.936572
all-symbol median spread: 3.0 ticks
all-symbol p90 spread: 4.0 ticks
top-size collapse share: 0.006857
dominant active MESM6 and ESM6 median/p90 spread: 1.0 tick
This report was not meant to create model features immediately. It was closer to a lab notebook for future microstructure features. The all-symbol aggregate is much rougher because stale and inactive contracts remain in the universe. Active symbols are much cleaner.
Preparing Pathwise Stress For OHLCV Candidates
This execution layer later feeds into pathwise execution stress. Even if an OHLCV candidate is validation-positive, it must survive MBP-calibrated p90 spread and adverse-movement penalties.
The important part was not overusing MBP. Because current MBP coverage is not full historical train/validation fill coverage, I did not attach timestamp-matched fills to every old OHLCV trade. Instead, I built scenarios such as active-symbol p90 stress and all-MES stress, then used them as candidate promotion gates.
That choice is conservative and honest. Use the data as much as it can support, but do not pretend it proves more than it does.
Fixed Costs Are Only The Beginning
After this phase, I trusted the fixed OHLCV cost assumption much less. A 0.998 point round-turn cost is useful for research baselines, but not sufficient for promotion. A candidate must survive not only configured costs, but also spread tails, latency, adverse movement, and contract-activity differences.
Later gates became stricter because of this. A validation-positive row can still be rejected if it fails pathwise active 1000ms stress. All-symbol or all-MES stress kills even more candidates. This is annoying, but it is the right kind of annoying for a trading system.
OHLCV backtests are a good start. They do not prove execution. After adding MBP execution realism, I could see the distance between a good-looking number and a number that might actually be tradable.