Signals, filters & regimes
A raw signal fires everywhere; a good strategy only fires where its edge lives. Learn to wrap a base signal in trend, volatility, session and regime filters — and to avoid the trap of conflicting rules.
By now you can generate a signal. The problem is that a raw signal is indiscriminate — a mean-reversion rule will happily fade a runaway trend into oblivion, and a crossover will whipsaw itself to death in a dead range. The fix is a filter: a second condition that does not create trades, it *permits* them. Filters are how you tell a strategy "your edge is real, but only here".
The four filters you will use most
- Trend filter — only take longs when a long-horizon average is rising (e.g. price above its 200-bar MA). This is the single most valuable filter in trading: it stops mean-reversion longs during downtrends and aligns momentum trades with the bigger move.
- Volatility filter — only trade when volatility is in a workable band. Too quiet and moves cannot pay for costs; too wild and stops get blown out. ATR or rolling standard deviation measures it.
- Regime filter — classify the market as *trending* or *ranging* and switch which strategy is even allowed to run. The ADX indicator or an efficiency ratio is the usual tool.
- Session / time filter — only trade during liquid hours (London/New York overlap for FX) or avoid known landmines (the minutes around a rate decision, the weekend gap).
A base signal plus a regime filter
Let us make it concrete. We take the z-score mean-reversion signal from the last lesson — which is dangerous in a trend — and gate it behind a trend/regime filter so it only fires when the market is genuinely ranging. We measure "how trending is this?" with an *efficiency ratio*: net movement divided by total path length. A ratio near 1 means a straight, efficient trend; near 0 means choppy, directionless price — mean-reversion heaven.
import numpy as np
import pandas as pd
def efficiency_ratio(close: pd.Series, window: int = class="n">20) -> pd.Series:
class="s">""class="s">"Kaufman efficiency ratio: net change / sum of absolute changes.
~class="n">1 = clean trend, ~class="n">0 = choppy range. A regime thermometer."class="s">""
net = close.diff(window).abs()
path = close.diff().abs().rolling(window).sum()
return net / path
def filtered_reversion(df: pd.DataFrame, lookback: int = class="n">20,
entry: float = class="n">2.0, exit: float = class="n">0.5,
max_er: float = class="n">0.35) -> pd.DataFrame:
class="s">""class="s">"z-score reversion that is ONLY allowed to trade in ranging regimes."class="s">""
out = df.copy()
ma = out[class="s">"close"].rolling(lookback).mean()
sd = out[class="s">"close"].rolling(lookback).std()
out[class="s">"z"] = (out[class="s">"close"] - ma) / sd
out[class="s">"er"] = efficiency_ratio(out[class="s">"close"], lookback)
class="c"># FILTER: only permit trades when the market is choppy (er below threshold)
ranging = out[class="s">"er"] < max_er
position = np.zeros(len(out)); holding = class="n">0
z, ok = out[class="s">"z"].values, ranging.values
for i in range(len(out)):
if holding == class="n">0 and ok[i]: class="c"># signal AND filter passes
if z[i] <= -entry: holding = class="n">1
elif z[i] >= entry: holding = -class="n">1
elif holding == class="n">1 and z[i] >= -exit: holding = class="n">0
elif holding == -class="n">1 and z[i] <= exit: holding = class="n">0
class="c"># Note: we let open trades EXIT even if the regime flips —
class="c"># you never trap yourself in a position because a filter changed.
position[i] = holding
out[class="s">"position"] = pd.Series(position, index=out.index).shift(class="n">1).fillna(class="n">0)
return out
Regimes, drawn
The reason the filter matters is that markets visibly switch character. The same instrument spends weeks trending, then months chopping. The efficiency ratio simply puts a number on which mode you are in so your code can react to it:
The trap: conflicting signals
The moment you have more than one rule, you can generate contradictions — one condition says long, another says short, and naive code either flips wildly bar to bar or takes both. This is a top source of silent bugs and phantom backtest returns. Three disciplines keep you safe:
- Define a priority. Decide explicitly what wins when rules disagree — e.g. "the trend filter is a hard veto; if it says down, no longs, full stop." A hierarchy beats a democracy.
- Combine into one number. Resolve all conditions into a single target position (-1, 0, +1) *once per bar*, then act on that. Never let two sub-strategies both send orders for the same instrument.
- Add hysteresis. Require a condition to hold for confirmation (e.g. two closes, not one) or use separate entry/exit thresholds so you are not flip-flopping on noise around a single level.
Filters decide *whether* to trade. The final piece is *how* the trade is run once it is open — the entry, the stop, the target and the exit. That is where most of your actual return is decided, and it is the last lesson of the part.