The Resonance Scanner: Seven Filters and the One That Mattered
A scanner is a conjunction. Every filter must pass, so the output rate is the product of the individual pass rates. This is obvious arithmetic and almost nobody instruments for it. Instead, we tune filters one at a time, looking at each in isolation, and feel good about every one of them. "The trend filter is correct. The volume filter is correct. The momentum filter is correct." Seven correct filters, zero positions.
That was the situation with our resonance scanner: a seven-layer entry gate that produced watchlist entries for weeks and never opened a position. The forensics were not subtle once we looked. The binding constraint was pullback confirmation, the last layer, and the reason it bound was structural, not statistical: the moves we were trying to catch do not, by their nature, offer a clean three-candle pullback.
Anatomy of the Gate
The pipeline looked like this, in order:
- Trend — higher-timeframe EMA alignment.
- Volume — relative volume above a rolling baseline.
- Momentum — rate-of-change percentile.
- Volatility — ATR band, to skip dead and berserk regimes.
- Session — allowed trading hours.
- Blacklist — symbol exclusion list.
- Pullback confirmation — a defined retracement over N candles, then a resumption candle.
Each filter is a pure function of a dataframe slice, which makes the whole thing easy to test and easy to instrument. The entry point was a single evaluate() that short-circuited on the first failure. That short-circuit was efficient and it was also why we were blind: a candidate rejected at layer 2 never touched layers 3 through 7, so we had no idea how many candidates each layer would have killed independently.
Instrumenting the Filters
The fix for blindness is cheap. Keep the short-circuit for live use, but add a shadow pass that evaluates every filter regardless of earlier failures and records the result. Two things matter in the record: the per-filter pass boolean, and the order of the first failure. The second is what tells you which filter is binding.
from dataclasses import dataclass, field
@dataclass
class FilterTrace:
symbol: str
ts: int
results: dict = field(default_factory=dict) # name -> bool
@property
def first_failure(self):
for name, passed in self.results.items(): # insertion order = pipeline order
if not passed:
return name
return None
def shadow_evaluate(symbol, ts, ctx, filters):
"""Evaluate all filters without short-circuiting. Order preserved."""
trace = FilterTrace(symbol=symbol, ts=ts)
for name, fn in filters: # list of (name, callable), in pipeline order
try:
trace.results[name] = bool(fn(ctx))
except Exception:
trace.results[name] = False # fail closed, but record it
return trace
Note the except clause. A filter that throws is a filter that rejected, and silently swallowing the exception is how you end up with a layer that never passes and nobody notices. Fail closed, but count it separately so exceptions do not masquerade as legitimate rejections.
Live evaluation stays fast:
def live_evaluate(ctx, filters):
for name, fn in filters:
if not fn(ctx):
return False, name
return True, None
Both paths share the same filter list, so they cannot drift apart.
Aggregating the Traces
With a few weeks of traces stored, the analysis is a groupby. For every filter we want the independent pass rate (how often it would pass if it were the only gate), and the conditional pass rate given that all earlier filters passed. The gap between those two numbers is where the interesting failures live.
import pandas as pd
def filter_report(traces: list[FilterTrace]) -> pd.DataFrame:
rows = []
for t in traces:
rows.append({"symbol": t.symbol, "ts": t.ts,
"first_failure": t.first_failure, **t.results})
df = pd.DataFrame(rows)
order = list(traces[0].results.keys())
out = []
for i, name in enumerate(order):
independent = df[name].mean()
if i == 0:
conditional = independent
reached = 1.0
else:
prior = order[:i]
reached_mask = df[prior].all(axis=1)
reached = reached_mask.mean()
conditional = df.loc[reached_mask, name].mean() if reached_mask.any() else float("nan")
out.append({
"filter": name,
"independent_pass": round(independent, 4),
"reached_rate": round(reached, 4),
"conditional_pass": round(conditional, 4),
})
return pd.DataFrame(out)
reached_rate is the fraction of candidates that survived everything before this layer. conditional_pass is the pass rate among those that got there. A filter with a high independent pass rate and a low conditional pass rate is doing real work at the margin. A filter with a low independent pass rate is a candidate for removal regardless of where it sits.
Then the binding constraint is simply the mode of first_failure:
binding = df["first_failure"].value_counts(dropna=False)
print(binding.head())
Our output looked roughly like this (numbers rounded, structure exact):
| filter | independent_pass | reached_rate | conditional_pass |
|---|---|---|---|
| trend | 0.31 | 1.00 | 0.31 |
| volume | 0.44 | 0.31 | 0.52 |
| momentum | 0.38 | 0.16 | 0.61 |
| volatility | 0.72 | 0.10 | 0.79 |
| session | 0.83 | 0.08 | 0.88 |
| blacklist | 0.97 | 0.07 | 0.98 |
| pullback | 0.11 | 0.07 | 0.11 |
Read the last row carefully. Seven percent of candidates reached the pullback layer, and only eleven percent of those passed it. The first six filters were, individually and collectively, behaving as designed: they cut a large universe down to a small, sensible set of live movers. The seventh filter then threw away 89% of that set. The mode of first_failure confirmed it: pullback was the last rejection for the overwhelming majority of candidates that got that far.
The Structural Problem
The naive conclusion is "loosen the pullback filter." That would have been wrong, and it took a while to articulate why.
The scanner's thesis was resonance: trend, volume, and momentum aligned on a symbol that was being actively accumulated. In practice, the moves that satisfied the first six filters were fast, high-volume expansions, often on altcoins during a coordinated push. These moves have a characteristic shape: a steep impulse, a shallow consolidation or a single red candle, then continuation or exhaustion. What they almost never have is a textbook three-candle retracement followed by a clean resumption candle. The pullback filter was written for a different market behavior: orderly trends that breathe. By the time a clean three-candle pullback prints on a fast expansion, the expansion is over. The filter was not rejecting noise. It was rejecting the exact events the scanner existed to find.
This is the general failure mode of stacked filters: each one is validated against the full population, but the conjunction selects a subpopulation with different conditional statistics. A pullback filter that works beautifully on a broad universe of trending names can have near-zero pass rate on the narrow slice that survives six upstream gates, because the upstream gates have already selected for the behavior the pullback filter excludes. Filters are not independent, and a pipeline is not a bag of good ideas.
The Design Tradeoff
The real decision was not "keep or drop pullback." It was where to sit on the spectrum between few but clean and so strict it never trades.
The discipline argument for strictness is sound. Every filter you add is a degree of freedom, and every degree of freedom is an opportunity to fit the last regime. A seven-condition entry is nearly impossible to overfit by accident, because the search space of ways to be wrong is enormous and the sample of trades is tiny. If a strict system trades rarely, at least the trades it takes are well-supported.
The counterargument is that a scanner which never trades is not conservative, it is broken. It provides no information, accumulates no live sample, and its filters are never validated against real outcomes. You cannot distinguish a disciplined system from a mis-specified one if the mis-specification happens to be the last gate in the chain. Strictness that produces zero fills is not a virtue; it is a measurement failure wearing a virtue's clothes.
The resolution we landed on was to treat the pullback layer as a regime-conditional filter rather than a universal one, and to replace the hard three-candle rule with a graded score so the layer can be strict in one regime and permissive in another without being deleted outright.
def pullback_score(df, lookback=3, min_retrace=0.25, max_retrace=0.75):
"""
Score 0..1 instead of boolean. Measures whether the recent action
shows a controlled retracement rather than a vertical run.
"""
impulse = df["close"].iloc[-lookback-1] - df["close"].iloc[-lookback*2-1]
if impulse <= 0:
return 0.0
retrace = (df["close"].iloc[-lookback-1] - df["close"].iloc[-1]) / impulse
if retrace < min_retrace:
return 0.0 # no pullback at all: vertical run
if retrace > max_retrace:
return 0.0 # too deep: structure broken
# peak score at mid-retrace, taper toward both edges
mid = (min_retrace + max_retrace) / 2
width = (max_retrace - min_retrace) / 2
return max(0.0, 1.0 - abs(retrace - mid) / width)
def pullback_gate(ctx, threshold=0.5):
score = pullback_score(ctx.df)
# Fast-expansion regime: accept a shallower structure.
if ctx.regime == "expansion":
return score >= threshold * 0.6
return score >= threshold
Two changes matter here. First, the layer now returns a continuous score, so you can see how close near-misses were instead of only pass/fail. Second, the threshold is regime-dependent, which encodes the actual finding: fast expansions legitimately do not pull back like orderly trends do, and pretending otherwise is a modeling error, not a discipline.
Crucially, we did not just lower the threshold globally until trades appeared. That is the opposite failure: tuning a filter until it stops binding, then calling the result validated. The regime split came from the trace data (pullback pass rates differed sharply by regime upstream), and the new threshold was chosen on out-of-sample traces, not on the same window that produced the diagnosis.
What to Keep
The instrumented pipeline is worth keeping permanently, not just for this investigation. The FilterTrace records cost almost nothing, and the filter_report table is the single most useful diagnostic a multi-condition scanner can have. Run it monthly. If the binding constraint moves, you have learned something about the market. If it never moves, you have learned something about your filters: one of them is doing all the work, and the other six are decoration.
The lesson generalizes past this scanner. A conjunction of filters is only as good as its weakest link, and you cannot find the weakest link by inspecting filters individually. You find it by counting first failures, computing conditional pass rates, and asking whether the surviving subpopulation is the one you actually meant to trade. The filter that rejects last is the filter that defines your system, whether or not you intended it to.
More notes like this ship every week on this site.
Daily Picks
The following pairs are selected from the multi-timeframe trend scanner (Gate.io futures) and are for technical-analysis study only — not investment advice.
Data updated: 2026-10-29 08:46:59
Long
| Pair | Signal | Price | Take Profit | Stop Loss | R/R |
|---|---|---|---|---|---|
| BTC | 趋势低吸做多 | $81120.0 | $87204.0 | $80574.0602 | 1:11.1 |
| SNXX | 高质量标的做多 | $10.016 | $10.3866 | $9.8157 | 1:1.9 |
2 picks selected. Scanner runs every 15 minutes.
Top comments (0)