On this page
The More Candidates Exist, The More Rejection Rules Matter
After the early baselines and sensitivity passes, the candidate universe grew. There were time-of-day reversals, session-extreme reversals, MACD crosses, ES/MES spreads, volatility compression rules, and roll-week relative-value ideas. Some were validation-positive. Some beat random baselines in null tests. Some retained validation net even after execution stress.
At this point, the system needed clearer rejection rules more than more candidates. As the candidate universe grows, the human naturally wants to look at the most attractive row: highest validation net, best profit factor, or most plausible name. That is why the quant trading system needed a research gate dashboard.
The goal was simple:
put every scored candidate in one table
record the first gate that kills each candidate
keep the test split locked
include pathwise execution stress
keep no_trade until every gate passes
The dashboard was less a visualization and more a decision artifact. Its job was not to say “this candidate looks good.” Its job was to say “this candidate died here.”
Fresh Hypotheses And Null Tests
Before the research gate dashboard, I added fresh OHLCV hypothesis families. Instead of endlessly tuning old parameter-sweep rows, I created more economically motivated families: time-of-day open reversal, volatility compression breakout, ES lead-lag, liquidity regime trend, MES/ES relative value, and roll-week transition.
The best validation-looking row was time_of_day_open_reversal_long.
best fresh validation hypothesis: time_of_day_open_reversal_long
validation net: 114.620 points across 60 trades
validation average net: 1.910333 points
validation top-5 day concentration: 0.385194
same hypothesis train net: -418.882 points
inner train-fold positive share: 0.0
On validation alone, this looks decent. Its top-five day concentration is much lower than the previous exit-label winner. Some validation rows also beat random baselines in null tests. But train net and inner train-fold stability remained the blockers.
The important decision was not “it beats a null, therefore it passes.” Null tests are necessary, not sufficient. If train is negative and inner train folds are unstable, beating random timestamps on validation does not make a candidate.
Market Advantage Scorecard
The market advantage scorecard then collected validation net, random-baseline percentile, execution feasibility, train failure, and concentration into one ranking surface.
The top rows looked like this:
| Candidate | Score | Validation net | Main failure |
|---|---|---|---|
time_of_day_open_reversal_long | 11.0 | 114.620 | train net, inner folds |
session_extreme_reversal_short...score_ge_50p0 | 11.0 | 67.576 | train net, inner folds |
time_of_day_open_reversal_short | 11.0 | 43.142 | train net, inner folds |
horizon_30m_mes_vs_es_spread_gt_0p0001 | 10.0 | 175.124 | train net, folds, concentration |
The interesting thing is that a high score is still not a pass. The advantage score is useful for ranking candidates worth inspecting. It does not decide whether a candidate is tradeable. Candidate promotion must depend on gate survival, not score.
The final scorecard selected policy was again no_trade.
Failure Attribution
The top candidates were then decomposed with failure attribution.
| Candidate | Validation-positive evidence | Failure evidence |
|---|---|---|
time_of_day_open_reversal_long | validation net 114.620 | train net -418.882, worst train era train_middle at -185.044, fold share 0.0 |
session_extreme_reversal_short... | validation net 67.576 | train net -151.662, worst train era train_late at -85.386, fold share 0.333333 |
horizon_30m_mes_vs_es_spread_gt_0p0001 | validation net 175.124 | train net -2,894.096, worst train era train_middle at -1,300.972, top-five validation day concentration 0.555726, fold share 0.0 |
This attribution was not meant to make candidates look better. It was meant to make their failure understandable. “Failed train net positive” is useful, but knowing the train era, session segment, and concentration pattern gives the next research question a sharper shape.
The Regime Map Was Not An Entry Generator
I also built an OHLCV regime map. It split market conditions by volatility, volume, session phase, roll phase, trend/chop state, ES trend direction, and ES/MES divergence.
The rule was that the regime map was not an entry generator. If I inspect validation and then pick favorable cells, I have created another overfit surface. So I treated the regime map only as an avoid-regime design surface.
The report conclusion followed that rule:
The regime map did not create entries.
The strongest validation single-factor rows still had negative average directional net after fixed OHLCV costs.
Any future regime filter must be frozen before validation and retested as a new deterministic candidate.
I think sentences like this create project quality. Some analyses exist not to create a result, but to constrain the next analysis so it does not fool itself.
Pathwise Execution Stress
The MBP execution diagnostics were then attached to OHLCV candidate trade paths. Active-symbol five-second p90 adverse penalties were about 0.625 points per side, while 30-second p90 adverse penalties were about 1.375 to 1.625 points. All-MES calibration was harsher.
Once this stress is applied, validation-positive candidates weaken quickly.
fixed OHLCV cost assumptions are not enough for candidate promotion
active-symbol 1000ms stress is now a gate
all-MES stress remains a stricter optional gate
At this point, the candidate dashboard was no longer just a train/validation PnL table. It became a control panel combining cost, nulls, concentration, fold stability, and execution stress.
Gate Dashboard Results
The final research gate dashboard produced 19 candidate rows and 171 gate rows.
| Metric | Value |
|---|---|
| Dashboard rows | 19 |
| Gate matrix rows | 171 |
| Passing candidates | 0 |
The main failed-gate counts were:
| Gate | Failed candidates |
|---|---|
inner_train_folds_stable | 19 |
train_net_positive | 18 |
pathwise_active_1000ms_positive | 15 |
beats_one_random_p95 | 14 |
cost_stress_net_positive | 14 |
validation_net_positive | 14 |
random_baseline_percentile_gte_0p50 | 12 |
day_concentration_lte_0p50 | 6 |
Even the first row of the dashboard did not pass.
time_of_day_open_reversal_long
advantage_score: 11
train_total_net_points: -418.882
validation_total_net_points: 114.620
pathwise_active_1000ms_net_points: 77.120
primary_kill_reason: train_net_positive
passes_advantage_gate: false
This row captures the message of the whole phase. Validation is positive, and validation net remains after pathwise stress. But train fails. Therefore it is rejected.
What The Dashboard Is For
Building this dashboard made the research loop calmer. When a candidate looks good, I do not have to manually restart suspicion from zero. The gate matrix tells me where it dies. And if it does not pass, it stays behind no_trade no matter how interesting it looks.
In trading research, a dashboard should be decision discipline, not decoration. It matters less which candidates survived, and more why candidates were rejected. In this phase, none survived. But the remaining standard became much stronger.
The current policy remained no_trade. That result is not a failure. It is evidence that the gates did their job.
The dashboard established a consistent rejection discipline, but one final question remained. I needed to know whether the conclusion depended too heavily on the five-minute horizon, and whether the same frozen entries and gates would survive a change in the time axis.
Why I Revisited The Horizon
By this point, the system had become fairly strict. Raw tick data had been turned into a silver dataset. One-minute OHLCV and MBP-based execution diagnostics were in place. Several hypothesis families were evaluated on the same chronological split. The result was no longer judged only by whether validation net was positive. A candidate had to survive the no-trade baseline, sign flip, random timestamp tests, same time-of-day random day tests, day shuffling, and execution stress before it could move forward.
A candidate could be weak at five minutes but structurally meaningful at thirty or sixty minutes. The opposite could also be true. A sixty-minute candidate could look good simply because holding longer allowed a lucky validation window to dominate the result. So in the last stage, I did not create new candidates. I kept the existing candidate entry timestamps fixed and changed only the label horizon.
The important part was not treating this as a new search surface. If I looked across horizons and picked whichever validation result looked best, the system would become another overfitting machine. The question I wanted to ask was not “which horizon made money?” It was “does the same candidate survive when the time axis is moved slightly?”
Horizon Was Not A Search Surface
The diagnostic scope was fixed on purpose. The horizon grid was 1m, 3m, 5m, 10m, 15m, 30m, 60m, and the candidate universe was every row in market_advantage_scorecard_v1. The entry policy was also fixed. The same candidate entry timestamps were relabeled. Entries were not reselected, and thresholds were not retuned by horizon.
Another important rule was train-first evaluation. If I found a good validation horizon first and checked train afterward, validation would already have been used as a search surface. So the order was reversed. Train net, inner train folds, and train adjacent-horizon stability were checked before validation gates. The test split remained locked and excluded.
The code makes that intention explicit.
HORIZON_GRID_MINUTES = (1, 3, 5, 10, 15, 30, 60)
GATE_ORDER = (
"train_net_positive",
"inner_train_folds_stable",
"adjacent_train_horizon_positive",
"validation_net_positive",
"adjacent_validation_horizon_positive",
"validation_day_concentration_lte_0p50",
"validation_beats_no_trade",
"validation_beats_all_random_medians",
"validation_beats_one_random_p95",
"validation_beats_sign_flip_and_no_trade",
"mbp_active_1000ms_cost_stress_positive",
"execution_feasibility_gte_0p50",
)
This gate order is more than a code constant. It is a research posture. It says that the prettiest validation number does not get first priority. A candidate that loses money on train, fails inner folds, or cannot survive nearby horizons does not move into model training just because it looks attractive in a validation table.
First, The Five-Minute Label Was Locked Again
Before running the multi-horizon diagnostic, I recomputed the existing five-minute label. If that check had shown a mismatch, every later horizon comparison would have been unstable. The five-minute label in the existing training matrix had to match the newly computed five-minute label exactly so that the rest of the horizon grid could be interpreted on the same basis.
| Metric | Value |
|---|---|
| Matrix rows | 281,145 |
| Compared rows | 281,145 |
| Missing 5m label rows | 0 |
| Mismatches | 0 |
| Max gross abs diff | 0 |
| Max net abs diff | 0 |
I liked this table not because the numbers were impressive, but because nothing happened. All 281,145 rows were compared, and the five-minute gross/net label difference was zero. That meant the multi-horizon result was not an artifact of a silently changed label path.
I keep adding checks like this for a simple reason. In quant research, the most dangerous failure is not a model failing openly. It is a data generation path changing quietly while the researcher mistakes the movement for performance. The label recompute check was a small guard against that mistake.
The Best-Looking Validation Rows Still Failed
The most tempting part of the report was the set of top validation-looking rows. A few candidates were clearly positive on validation. At the sixty-minute horizon especially, some rows looked good enough to make training feel plausible.
| candidate_id | horizon | train net | validation net | active 1000ms stressed net | primary kill |
|---|---|---|---|---|---|
horizon_30m_mes_vs_es_spread_gt_0p0001 | 60m | -1980.15 | 402.11 | 312.735 | train_net_positive |
vol_compression_breakout_long | 60m | -426.906 | 330.782 | 101.657 | train_net_positive |
vol_compression_breakout_short | 60m | -134.448 | 180.368 | 84.493 | train_net_positive |
time_of_day_open_reversal_long | 30m | -206.882 | 155.37 | 57.87 | train_net_positive |
If this table were read only from the validation side, it would be attractive. The first candidate had 402.11 validation net points, and still had 312.735 points after active MES 1000ms stress. vol_compression_breakout_long also showed 330.782 validation net points.
But all of these candidates died for the same reason. Their train net was negative.
This was the point where I felt the most temptation. When validation is good, some null tests are beaten, and execution stress still leaves a positive number, it is very easy to want to train a model. But changing the rule at that moment would mean letting the result rewrite the research process. What I wanted from this project was not a way to force one candidate through. I wanted a process where the pass conditions were defined in advance, and only candidates that survived those conditions could move forward.
Beating A Null Test Does Not Override The Train Gate
One interesting detail was that some candidates did beat validation null tests quite well. For example, the sixty-minute version of horizon_30m_mes_vs_es_spread_gt_0p0001 produced 402.11 validation net points and beat no-trade, sign-flipped direction, same trade count random timestamps, circular shifts, same time-of-day random days, and shuffled day labels. Against random timestamps, its actual percentile was 0.995.
{
"candidate_id": "horizon_30m_mes_vs_es_spread_gt_0p0001",
"horizon_minutes": 60,
"actual_total_net_points": 402.11,
"same_trade_count_random_timestamps_p95": 215.547,
"actual_percentile_vs_random_timestamps": 0.995
}
Even so, the candidate did not pass. The reason is simple. A null test checks whether the validation result looks accidental under that validation setting. It does not give a candidate permission to ignore a negative train result.
vol_compression_breakout_long showed a similar pattern. Its sixty-minute validation actual net was 330.782 points, but the same trade count random timestamps p95 was 345.307 points, and the actual percentile was 0.945. It also failed the p95 threshold under same time-of-day random days. Even a good-looking candidate did not beat every null test.
This made the role of multi-horizon diagnostics clearer. Extending the horizon should not be a way to rescue a candidate. It should be a pressure test that reveals whether the candidate shines only on one convenient time scale.
Execution Realism Reduced The Edge Further
The last layer was MBP-based execution stress. This is not a perfect simulation of fills, but adding active MES symbol assumptions, 1000ms latency, and adverse movement penalties shows how much validation net survives once execution becomes less idealized.
| candidate_id | horizon | trades | base net | stressed net | avg adverse penalty | feasibility |
|---|---|---|---|---|---|---|
horizon_30m_mes_vs_es_spread_gt_0p0001 | 60m | 55 | 402.11 | 312.735 | 1.625 | 0.782653 |
vol_compression_breakout_long | 60m | 141 | 330.782 | 101.657 | 1.625 | 0.782653 |
time_of_day_open_reversal_long | 5m | 60 | 114.62 | 77.12 | 0.625 | 0.912182 |
Some candidates remained positive even after this stress. But positivity alone was not the point. vol_compression_breakout_long fell from 330.782 base net to 101.657 stressed net. It was still positive, but a large part of the apparent edge depended on execution assumptions.
Numbers like this should lead to more questions, not immediate model training. Did the candidate capture a real market structure, or did a validation window, horizon choice, latency assumption, and specific date mix leave behind a fragile positive residue? The kind of result I kept trying to avoid was exactly this ambiguous positive number. A negative result is simple. The difficult result is a positive number that becomes convincing if the explanation is adjusted slightly.
The Gate Matrix Made The Decision
The full gate failure count made the conclusion clearer.
| gate_name | failed rows |
|---|---|
inner_train_folds_stable | 132 |
train_net_positive | 120 |
adjacent_train_horizon_positive | 118 |
mbp_active_1000ms_cost_stress_positive | 112 |
validation_beats_one_random_p95 | 106 |
validation_net_positive | 98 |
adjacent_validation_horizon_positive | 88 |
validation_beats_all_random_medians | 78 |
validation_day_concentration_lte_0p50 | 38 |
execution_feasibility_gte_0p50 | 7 |
The most frequent failures were inner_train_folds_stable, train_net_positive, and adjacent_train_horizon_positive. That matters. The candidates were not failing only on validation. They were already breaking at earlier stability gates.
So the final decision was recorded like this.
{
"candidate_ready_for_test_or_ml": false,
"horizon_minutes": null,
"reason": "no candidate horizon passed train, fold, adjacent-horizon, validation, null, cost-stress, and MBP feasibility gates",
"selected_candidate_id": "no_trade",
"source_benchmark": "none"
}
This JSON is disappointing, but I like it. The research system did not say, “one candidate looks good, so train a model anyway.” It passed every candidate through the same procedure and recorded that no candidate passed.
Not Training A Model Is Also A Result
After this stage, I still did not open the test split. I did not train an ML model. I did not start paper trading. From the outside, that can look like the project stopped. To me, it is the opposite. The system finally gained the ability to decide not to act.
The dangerous flow in quant research is usually similar. Generate many candidates, find one that looks good on validation, attach a plausible explanation, train a model, and keep running more backtests. That flow is fast and enjoyable, but almost every step can hide overfitting.
The multi-horizon diagnostic was built to interrupt that flow. Keep the same entry policy across horizons. Look at train first. Check adjacent horizons. Add null tests. Add execution stress. Only then allow a candidate to move forward. If the result is no_trade, that is still a legitimate system output.
In fact, the conclusion made the next step clearer. What the system needs now is not model training. It needs better candidate generation. Better features, clearer market microstructure hypotheses, stricter regime segmentation, and a more realistic execution model should come first. A model is a tool for compressing an edge when an edge exists. It is not a machine for creating edge where none has been demonstrated.
So the current conclusion of this series is simple. The Quant Trading System is not ready to trade yet. But it is ready to refuse weak trades. I think that difference matters more than it first appears.