← Back to Home
Beating the False Breakout - Why Directional Filters Matter

Beating the False Breakout - Why Directional Filters Matter

Volatility breakout systems often stumble not at the setup but at the moment of choosing long vs. short. This study holds the setup and exits constant and swaps only the directional filter. The reference implementation detects statistical quiet with Standard Deviation, triggers on ATR fast-over-slow, and manages exits with an ATR-based trailing stop. Direction is chosen by a momentum gate (ROC).

Indicators

All of the above are declared in __init__ and used in next() to gate entries; parameters are exposed via params.

Entry preconditions

  1. Quiet: stddev[0] <= lowest_stddev[-1]

  2. Vol “wake-up”: atr_cross[0] > 0
    Only then does the strategy consult a directional filter.

Exit management
After a fill, the strategy tracks the highest (long) or lowest (short) price since entry and trails a stop by ATR_stop * atr_stop_multiplier. Long: stop ratchets upward with new highs. Short: stop ratchets downward with new lows.

What We’re Comparing (only this changes)

  1. No filter (price-tick direction)
  2. ROC filter (reference logic)
  3. SMA trend filter (regime gate)

Everything else—setup, trigger, trailing stop—stays identical.

Reference Strategy (ROC Filter)

The original logic in next() looks like this (simplified). When both setup and trigger conditions are true, direction comes from ROC: long if ROC > 0, short if ROC < 0.

# inside next()
is_consolidating = self.stddev[0] <= self.lowest_stddev[-1]
is_vol_expanding = self.atr_cross[0] > 0

if not self.position and is_consolidating and is_vol_expanding:
    if self.roc[0] > 0:
        self.order = self.buy()
    elif self.roc[0] < 0:
        self.order = self.sell()

Trailing-stop ratchet (long side shown): compute new high since entry, shift stop to high_since - ATR_stop * mult, and only ratchet tighter (never loosen). Mirror logic for shorts.

# long management (excerpt)
self.highest_price_since_entry = max(self.highest_price_since_entry, self.data.high[0])
new_stop = self.highest_price_since_entry - (self.atr_stop[0] * self.p.atr_stop_multiplier)
self.stop_price = max(self.stop_price, new_stop)
if self.data.close[0] < self.stop_price:
    self.order = self.close()

Direction Filter

Refactor direction picking into a small helper so you can A/B test filters without touching setup/trigger/exit logic.

def pick_direction_no_filter(data):
    # “tick-direction”: long if current close > prior close, else short
    return 1 if data.close[0] > data.close[-1] else -1

def pick_direction_roc(roc):
    # reference behavior; 0 = skip if exactly flat
    return 1 if roc[0] > 0 else (-1 if roc[0] < 0 else 0)

def pick_direction_sma(close, sma):
    # regime filter: long above SMA, short below
    return 1 if close[0] > sma[0] else -1

In your strategy, inject a filter_type param and route to the appropriate function. The rest of the code (StdDev/ATR setup and the ATR trail) is unchanged from the reference.

class VE_DirectionalVariants(bt.Strategy):
    params = dict(
        stddev_period=30, squeeze_lookback=30,   # setup
        atr_fast=7, atr_slow=30,                 # trigger
        roc_period=30,                           # feature for ROC
        sma_period=50,                           # feature for SMA variant
        atr_stop_period=14, atr_stop_multiplier=3.0,  # exits
        filter_type='roc'                        # 'none' | 'roc' | 'sma'
    )

    def __init__(self):
        # setup
        self.stddev = bt.ind.StandardDeviation(self.data.close, period=self.p.stddev_period)
        self.lowest_std = bt.ind.Lowest(self.stddev, period=self.p.squeeze_lookback)
        # trigger
        self.atr_fast = bt.ind.AverageTrueRange(self.data, period=self.p.atr_fast)
        self.atr_slow = bt.ind.SMA(self.atr_fast, period=self.p.atr_slow)
        self.atr_cross = bt.ind.CrossUp(self.atr_fast, self.atr_slow)
        # direction features
        self.roc = bt.ind.RateOfChange(self.data.close, period=self.p.roc_period)
        self.sma = bt.ind.SMA(self.data.close, period=self.p.sma_period)
        # exit
        self.atr_stop = bt.ind.AverageTrueRange(self.data, period=self.p.atr_stop_period)
        self.stop_price = None
        self.high_since = None
        self.low_since = None

    def _direction(self):
        if self.p.filter_type == 'none':
            return 1 if self.data.close[0] > self.data.close[-1] else -1
        elif self.p.filter_type == 'roc':
            return 1 if self.roc[0] > 0 else (-1 if self.roc[0] < 0 else 0)
        else:  # 'sma'
            return 1 if self.data.close[0] > self.sma[0] else -1

    def next(self):
        if self.position:  # trailing stop identical to reference
            if self.position.size > 0:
                self.high_since = self.data.high[0] if self.high_since is None else max(self.high_since, self.data.high[0])
                new_stop = self.high_since - self.atr_stop[0]*self.p.atr_stop_multiplier
                self.stop_price = new_stop if self.stop_price is None else max(self.stop_price, new_stop)
                if self.data.close[0] < self.stop_price: self.close()
            else:
                self.low_since = self.data.low[0] if self.low_since is None else min(self.low_since, self.data.low[0])
                new_stop = self.low_since + self.atr_stop[0]*self.p.atr_stop_multiplier
                self.stop_price = new_stop if self.stop_price is None else min(self.stop_price, new_stop)
                if self.data.close[0] > self.stop_price: self.close()
            return

        # no position: apply setup + trigger, then pick direction
        is_quiet = self.stddev[0] <= self.lowest_std[-1]       # from reference
        wakes_up = self.atr_cross[0] > 0                       # from reference
        if is_quiet and wakes_up:
            d = self._direction()
            if d > 0:
                self.buy()
                self.high_since = self.data.high[0]
                self.stop_price = self.high_since - self.atr_stop[0]*self.p.atr_stop_multiplier
            elif d < 0:
                self.sell()
                self.low_since = self.data.low[0]
                self.stop_price = self.low_since + self.atr_stop[0]*self.p.atr_stop_multiplier

The is_quiet and wakes_up conditions are taken directly from the original strategy to ensure the comparison isolates only the directional gate.

Backtest

Run three passes over the same data and costs, changing only filter_type. The ATR trail and other logic remain identical to the reference.

import backtrader as bt

def run_variant(datafeed, filter_type, cash=100000, commission=0.0005):
    cerebro = bt.Cerebro()
    cerebro.adddata(datafeed)
    cerebro.broker.setcash(cash)
    cerebro.broker.setcommission(commission=commission)
    cerebro.addstrategy(VE_DirectionalVariants, filter_type=filter_type)
    return cerebro.run()[0], cerebro.broker.getvalue()

# Example usage:
# datafeed = ...  # load BTC-USD or SPY, same bar size and span for all runs
_, value_none = run_variant(datafeed, 'none')
_, value_roc  = run_variant(datafeed, 'roc')
_, value_sma  = run_variant(datafeed, 'sma')
print(value_none, value_roc, value_sma)

Measuring False Breakouts

Define a “false breakout” as: trade hits the ATR trailing stop within N bars (e.g., 10) without ever reaching +0.5R MFE. This focuses on immediate snap-backs after the volatility trigger.

Pseudo-collector (outline; integrate with your analyzers/logs):

def evaluate_trades(trades, N=10, min_mfe_r=0.5):
    # trades: list of dicts with entry_idx, exit_idx, entry_price, stop_at_entry, max_adverse, max_favorable
    false_breakouts = 0
    for t in trades:
        window = t['exit_idx'] - t['entry_idx']
        snapped = (window <= N) and (t['max_favorable_r'] < min_mfe_r)
        if snapped: false_breakouts += 1
    return false_breakouts / max(1, len(trades))

Report, per variant: false-breakout rate, hit rate, profit factor, Sharpe, max drawdown, median trade, average duration.

## Results

from backtest import *

strategy = load_strategy_from_package('VolatilityExpansionStrategy')

symbol = "DOGE/USDC"

df = fetch_binance_ohlcv_range(
    symbol=symbol,
    timeframe="1h",
    start="2025-01-01",
    )

collected = run_backtest_and_collect(strategy, df)

%matplotlib inline
plot_live(df, collected, title=symbol, instant_plot=True, log_mode="batch", frame_stride=1, w = 12, h = 8)

Takeaway

A volatility breakout without a directional gate is a coin-toss in disguise. The ROC gate—already present in the reference implementation—turns many of those tosses into informed bets by demanding minimal directional drift at the moment volatility expands.