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
Quiet market: StdDev(close, stddev_period) and
Lowest(StdDev, squeeze_lookback)
Volatility trigger: ATR_fast and its SMA
(ATR_slow) with
CrossUp(ATR_fast, ATR_slow)
Directional feature: ROC(close, roc_period) (used in
one variant)
Exit risk: independent ATR_stop for the trailing
stop width
All of the above are declared in __init__ and used in
next() to gate entries; parameters are exposed via
params.
Entry preconditions
Quiet: stddev[0] <= lowest_stddev[-1]
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.
Everything else—setup, trigger, trailing stop—stays identical.
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()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 -1In 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_multiplierThe is_quiet and wakes_up conditions are
taken directly from the original strategy to ensure the comparison
isolates only the directional gate.
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)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)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.