ADF‑Filtered Weekly Breakout Strategy
Momentum-driven breakouts are a cornerstone of systematic trading. I thought it might be a good idea to try a weekly breakout strategy that combines statistical trend filtering with simple range‑break entries to capture high‑probability moves in any liquid asset class — stocks, ETFs, commodities, forex, or crypto. I tried sample backtests on Bitcoin (BTC‑USD) and Ethereum (ETH‑USD) to demonstrate the strategy’s mechanics. I used augmented Dickey–Fuller test (ADF) to check for stationarity of the price series, i.e. if it is mean-reverting or trending. I also included other ideas in the end you might want to try. I will try to improve this and also do more backtesting with plots to see how it performs. let me know if you try it and how it performs or if you have ideas to improve it.
Strategy Framework
1️⃣ Data Window
- Lookback Period: 30 trading days ending one day before each weekly cycle.
- Trade Frequency: Weekly evaluation (every 7 calendar days).
2️⃣ Trend Filter (Augmented Dickey–Fuller Test)
- Reject assets exhibiting mean reversion (ADF p‑value < 0.05), ensuring trades occur only in trending environments.
3️⃣ Range Definition
- Support: Minimum close over the lookback window.
- Resistance: Maximum close over the lookback window.
4️⃣ Volatility & Momentum Filters
- Average True Range (ATR, 14‑day): Ensures sufficient price movement.
- ADX (14‑day): Requires ADX > 20 and +DI > –DI to confirm trend strength.
5️⃣ Entry & Exit Rules
- Entry: Go long when price closes above resistance within the upcoming week.
- Exit: Close the position at the final trading-day close of that week.
Get PyQuantLab’s stories in your inbox
Join Medium for free to get updates from this writer.
Backtesting Python Script
import yfinance as yf
from statsmodels.tsa.stattools import adfuller
import pandas as pd
from datetime import timedelta
import ta
# Configuration
symbols = ["BTC-USD", "ETH-USD"]
start_date = "2024-10-01"
end_date = "2025-03-27"
# Weekly periods
weeks = pd.date_range(start=start_date, end=end_date, freq="7D")
breakout_trades = []
for sym in symbols:
print(f"Processing {sym}…")
for week_start in weeks:
hist_end = week_start - timedelta(days=1)
hist_start = hist_end - timedelta(days=30)
hist_df = yf.download(sym, hist_start.strftime("%Y-%m-%d"), hist_end.strftime("%Y-%m-%d")).dropna()
hist_df.columns = hist_df.columns.droplevel(1)
if len(hist_df) < 30:
continue
# ADF filter (trend)
p_val = adfuller(hist_df["Close"])[1]
if p_val < 0.05:
continue
support = float(hist_df["Close"].min())
resistance = float(hist_df["Close"].max())
atr_indicator = ta.volatility.AverageTrueRange(hist_df["High"], hist_df["Low"], hist_df["Close"], window=14)
atr = atr_indicator.average_true_range().iloc[-1]
adx_ind = ta.trend.ADXIndicator(hist_df["High"], hist_df["Low"], hist_df["Close"], window=14)
adx = adx_ind.adx().iloc[-1]
adx_pos = adx_ind.adx_pos().iloc[-1]
adx_neg = adx_ind.adx_neg().iloc[-1]
# Next week prices
week_end = week_start + timedelta(days=6)
future = yf.download(sym, week_start.strftime("%Y-%m-%d"), week_end.strftime("%Y-%m-%d"))["Close"].dropna()
if future.empty:
continue
# Breakout entry
breakout = future[future >= resistance].dropna()
if breakout.empty or adx <= 20 or adx_pos <= adx_neg:
continue
entry_date = breakout.index[0]
entry_price = float(breakout.iloc[0])
exit_date = future.index[-1]
exit_price = float(future.iloc[-1])
pnl = exit_price / entry_price - 1
breakout_trades.append({
"symbol": sym,
"week_start": week_start.date(),
"p_value": round(p_val,4),
"support": support,
"resistance": resistance,
"atr": round(atr,4),
"adx": round(adx,2),
"entry_date": entry_date.date(),
"entry_price": entry_price,
"exit_date": exit_date.date(),
"exit_price": exit_price,
"pnl": round(pnl,4)
})
# Results
if len(breakout_trades) > 0:
breakout_results = pd.DataFrame(breakout_trades)
print("\n=== BREAKOUT BACKTEST RESULTS ===")
print(breakout_results)
print("\n=== Aggregate PnL by Symbol ===")
print(breakout_results.groupby("symbol")["pnl"].agg(["count","sum","mean"]))
else:
print('no breakouts')Sample Backtest Results (Oct ’24 — Mar ’25)
= BREAKOUT BACKTEST RESULTS =
symbol week_start p_value … exit_date exit_price pnl
0 BTC-USD 2024–11–05 0.5984 … 2024–11–10 80474.187500 0.0639
1 BTC-USD 2024–11–12 0.9921 … 2024–11–17 89845.851562 0.0215
2 BTC-USD 2024–11–19 0.9631 … 2024–11–24 98013.820312 0.0614
3 BTC-USD 2024–12–03 0.4949 … 2024–12–08 101236.015625 0.0132
4 BTC-USD 2024–12–17 0.9811 … 2024–12–22 95104.937500 -0.1040
5 ETH-USD 2024–11–12 0.8458 … 2024–11–17 3075.661621 -0.0526
6 ETH-USD 2024–11–19 0.7514 … 2024–11–24 3363.659912 -0.0096
7 ETH-USD 2024–11–26 0.8003 … 2024–12–01 3711.165039 0.0147
8 ETH-USD 2024–12–03 0.9991 … 2024–12–08 4005.704590 0.0428
Further Considerations
- Risk Management: Cap risk per trade (e.g., 1–2% of portfolio) and use ATR‑based stop‑losses.
- Position Sizing: Scale exposure by volatility to equalize risk across assets.
- Out‑of‑Sample Testing: Validate on different timeframes and asset classes.
- Enhancements: Incorporate multi‑timeframe confirmation or a market regime filter (e.g., volatility regime, macro indicators).
What the ADF test actually does
- Null hypothesis: The time series has a unit root (i.e., is non‑stationary).
- Alternative hypothesis: The series is stationary (mean‑reverting).
A failing ADF test (p‑value ≥ 0.05) tells you the series is non‑stationary — but non‑stationarity can mean many things: a pure random walk, a structural break, or a deterministic trend. It doesn’t distinguish between sideways noise, random drift, or a sustained directional trend.
Better ways to filter for trending regimes
Conclusion
The ADF‑Filtered Weekly Breakout Strategy is meant to capture directional momentum while filtering out range‑bound noise. It is simple and intuitive and an ideal starting point for systematic traders to use alongside other strategies or combine and extend and make improvements for their trading needs.









