DEV Community

Cover image for We Tested 12,720 Exit Rules. The Result Wasn't Validated.
Pavel Kazantsev 💎
Pavel Kazantsev 💎

Posted on Edited on Originally published at pkazantsev.com

We Tested 12,720 Exit Rules. The Result Wasn't Validated.

HFM's exit simulator evaluated 12,720 configurations over 14,399 qualifying events across 15 crypto symbols. One selected configuration reported an average of +25.98 basis points per event after the simulator's 10 bps fee deduction. That is about 0.26% per event, enough to warrant a closer look.

Removing one session changed the average to −3.69 bps. The exit search had also used the validation split. The archive contained summaries rather than the joint outcome record needed to test the whole search.

The configuration behind +25.98 bps

The audited rule used a momentum exit: a 50 bps initial stop, a 60-second momentum window, a 30 bps reversal threshold, and a maximum hold of 600 seconds. The backtest covered September 2025 through September 2026. These are simulated outcomes with modeled fills and fees, not live-trading returns:

Metric Audited configuration
Qualifying events 14,399 across 15 symbols
Mean net outcome per event +25.98 bps
Median net outcome per event −16.2 bps
Win rate 34%
Mean net outcome excluding 2025-10-10 −3.69 bps

The median event lost money, while a small part of the sample pulled the mean positive. The separate day-block audit treats the market session as the relevant unit: removing 10 October 2025 removed the positive event-level average. This is a sensitivity diagnostic. It does not justify discarding an inconvenient day and calling the remainder an unbiased estimate.

These are statistics for the selected rule's events. They are not the best and median scores across all 12,720 configurations; the archived material available for this article does not establish that distribution.

12,720 rules used the same history

The grid contained 240 fixed-stop configurations, 8,640 trailing stops, 1,680 break-even-plus-trail rules, and 2,160 momentum exits. Changing a stop or trailing distance did not produce an independent sample: every configuration was evaluated on the same history.

A test of one rule specified in advance asks a different question from a test of a rule chosen after inspecting thousands. For the search, the null is that none of the candidates beats a specified benchmark. The Reality Check tests that family-level claim; testing only the selected column leaves out how it was selected.

Four exit-rule families totalling 12,720 searched rules

The trailing-stop family alone contributed 8,640 configurations. Nearby settings can share trades and market shocks.

# A significance threshold, not an observed p-value.
alpha = 0.05
n_rules = 12_720
per_rule_alpha = alpha / n_rules

print(f"{per_rule_alpha:.2e}")
# 3.93e-06
Enter fullscreen mode Exit fullscreen mode

The 3.93×10⁻⁶ value is the per-test significance threshold for a 5% Bonferroni family-wise error bound, not a p-value measured in HFM. The bound does not require independent rules, but each input p-value must already handle dependence in the observations. Dividing alpha by 12,720 cannot repair a test that treats events from the same market session as independent.

VAL had already entered the search

The event-score threshold came from development scores. That is a normal fitting step, but results on those development events are in-sample. The exit-rule search then used both DEV and VAL, including the September 2026 validation period. There was no remaining untouched split for the selected configuration.

Reusing VAL is a separate problem from the number of exit rules. A Reality Check can address a documented candidate search under its assumptions without requiring a separate holdout. It does not make VAL independent again or automatically account for earlier decisions about event filters, models, and which experiments to pursue.

Split How it was used
DEV Fit the event-score threshold; included in the exit search
VAL, September 2026 Included in exit selection
Untouched holdout None remained for final evaluation

DEV and VAL both contributed to exit selection; no untouched holdout remained

Where the simulator lost the audit trail

In exit_sim3.py, every candidate produces an array of event outcomes. The grid's add() helper sends that array to stats() and keeps a dictionary of means, quantiles, win rate, and parameters. It does not keep the underlying array. Earlier, load_pass_paths() has already dropped dates and symbols when it returns the numeric paths.

We therefore could not tell from the summaries which rules made money on the same dates. Two rules can have similar means and standard deviations but very different joint behavior. A bootstrap of the maximum needs that joint record, with every candidate carried through the same resampled time blocks.

Without the retained rule-level outcome matrix, we could not reproduce a search-aware test from the archived results alone. The source code can recompute outcomes from events, model artifacts, and prices; an instrumented rerun could restore the record if the original inputs and versions are recoverable. That reconstruction has not been performed here.

A proposed output contract for the rerun

I would retain one row per event and candidate before computing summaries. The following is a proposed schema, not a change already implemented in HFM:

run_id, event_id, detected_at_utc, symbol, candidate_id,
candidate_net_bps, benchmark_net_bps

# run manifest:
# code revision + input hashes + model/threshold artifacts
# complete grid + benchmark policy + fees/fills + ranking objective
# expected calendar + missing-input checks + split-use history
Enter fullscreen mode Exit fullscreen mode

For a concrete benchmark, this proposed rerun would use a fixed exit 600 seconds after the same entry, with no optimized stop or target. Both policies would use the same event cohort, price-path construction, and 10 bps fee deduction. The benchmark must be fixed before the rerun; it was not a documented comparison behind the original +25.98 bps figure.

The objective here is mean excess bps per event, not portfolio return. Each event has equal weight; overlapping signals are evaluated as separate hypothetical trades, without pretending that each can receive the same live capital. For every calendar date and candidate, save the sum of event-level excess outcomes and the event count. Dates without signals contribute zero to both; missing market data must be flagged rather than treated as a no-signal day.

Saving the sum and count preserves the event-weighted objective when days contain different numbers of signals. The bootstrap sketch below centers each candidate under a zero-mean excess-outcome null, then resamples whole date blocks jointly. Resampling the raw positive-mean outcomes would retain the observed advantage instead of imposing that null; see the least-favorable-null discussion in the INE review of predictive-ability tests.

import numpy as np

# Proposed event-mean test; not an HFM test result.
# daily_sum: dates x candidates, summed excess bps for each date.
# daily_count: events per date, shared cohort across all candidates.
s = np.asarray(daily_sum, dtype=float)
n = np.asarray(daily_count, dtype=float)
assert s.ndim == 2 and n.shape == (s.shape[0],)
assert s.shape[0] > 1 and s.shape[1] > 0
assert np.isfinite(s).all() and np.isfinite(n).all()
assert (n >= 0).all() and n.sum() > 0
assert (s[n == 0] == 0).all()
mean_excess = s.sum(axis=0) / n.sum()
observed = mean_excess.max()
centered = s - n[:, None] * mean_excess  # zero excess per event

rng = np.random.default_rng(42)
B, block_len = 2_000, 7  # illustrative, not calibrated for HFM
n_days = len(n)
n_blocks = (n_days + block_len - 1) // block_len
null_max = []

while len(null_max) < B:
    starts = rng.integers(0, n_days, size=n_blocks)
    idx = ((starts[:, None] + np.arange(block_len)) % n_days)
    idx = idx.ravel()[:n_days]
    if n[idx].sum() == 0:
        continue
    null_max.append((centered[idx].sum(axis=0) / n[idx].sum()).max())

null_max = np.asarray(null_max)
p_hat = (1 + np.count_nonzero(null_max >= observed)) / (B + 1)
Enter fullscreen mode Exit fullscreen mode

Max-statistic bootstrap diagram for the proposed event-weighted objective

The code is an unstudentized, Reality-Check-style illustration for the proposed event-level objective, not a validated test implementation. The block-length rule, replication count, and stationarity and dependence assumptions still need checking. Hansen's SPA uses studentization and a different, data-dependent construction of the null distribution.

Which correction answers which question?

Question Appropriate starting point What it controls
Can any pre-specified rule be called significant? Holm or Bonferroni Family-wise error; valid under arbitrary dependence between tests, given valid input p-values
How many discoveries can we tolerate in a broad screen? Benjamini–Hochberg under its assumptions; Benjamini–Yekutieli under arbitrary dependence False discovery rate, not the chance the best rule is real
Did the best rule from a time-series search beat a benchmark? Search-aware block bootstrap, Reality Check, or SPA A family-level comparison with the benchmark, accounting for time dependence under the method's assumptions

FDR fits a screen that reports many discoveries. This exit-rule audit instead asks whether any candidate beats its chosen benchmark. Even rejection of the family null would establish evidence for at least one candidate, not the future profitability of a particular configuration.

Why the result was not promoted

The audit did not establish +25.98 bps as an edge ready for trading. Its positive mean depended on one session, the selected rule had no untouched final split, and the archived summaries could not support a test of the candidate family. The execution audit also identified optimistic fill assumptions, outside the statistical correction discussed here.

Before considering promotion, I would require the versioned outcome record above, a defined benchmark and ranking criterion, and an evaluation period that had not informed development. The candidate would have to be fixed before opening that period, then checked with a capital-constrained execution model. No rerun or new holdout result is reported here.


Part of the HFM Research methodology series.

Top comments (1)

Collapse
 
edgelogtrading profile image
Edgelog •

The median of −16.2 bps next to a +25.98 bps mean says the most here. At a 34% win rate, a few events from one session carrying the whole average is exactly what a per-day breakdown should catch before anyone reaches for p-values. Did the archive keep enough per-event detail to split the selected rule's result by symbol as well? That would show whether 10 October was market-wide or driven by one or two coins.