Keltner ADX Volatility Breakout Strategy

Python / VectorBT Research Notebook

Author

quantstr.at Research

Published

October 11, 2026

0.1 Overview

This notebook is a complete, executable Python replication of the strategy published at quantstr.at, built on VectorBT. Every number, table and chart below is produced by the Python code in this document at render time.

The strategy, as specified:

Trade QQQ, NVDA, AAPL, MSFT, AMD, and AMZN using a Keltner Channel Volatility Breakout strategy. The channel is a 20-period EMA centre line with upper and lower bands set 2.0 x the 10-period ATR away from it. Enter long when the close breaks above the upper band and the 14-period ADX is above 25 (confirming trend strength). Exit when the close falls below the 20-period EMA centre line.

Note

Differences from the R report. The two engines are independent implementations. Small differences in results are expected: VectorBT sizes orders in fractional shares where the R report uses whole shares, and the two data libraries apply price adjustments separately.

1 Backtest

1.1 Environment

Code
import numpy as np
import pandas as pd
import vectorbt as vbt

print(f"vectorbt {vbt.__version__} | pandas {pd.__version__} | numpy {np.__version__}")
vectorbt 1.1.1 | pandas 3.0.6 | numpy 2.4.6

1.2 Universe & Parameters

Same universe, window and sizing as the R report: one equal dollar slot per symbol, long-only, no leverage, no adding to an open position.

Code
symbols = ["QQQ", "NVDA", "AAPL", "MSFT", "AMD", "AMZN"]
start_date = "2023-08-29"
end_date = "2026-08-27"
init_equity = 100_000

ema_n = 20       # Keltner centre line: EMA of the close
atr_n = 10       # Keltner band width: ATR period
atr_mult = 2.0   # Keltner band width: ATR multiplier
adx_n = 14       # trend-strength filter period
adx_min = 25     # entries require ADX above this level
slot_usd = init_equity / len(symbols)
cost_rate = 0.0005  # 5 bps of traded value on every fill

1.3 Data

Daily bars from Yahoo Finance via yfinance, with OHLC adjusted for splits and dividends.

Code
data = vbt.YFData.download(symbols, start=start_date, end=end_date, missing_columns="nan")
close = data.get("Close")[symbols]
open_ = data.get("Open")[symbols]
high = data.get("High")[symbols]
low = data.get("Low")[symbols]

# Keep the same trading-day range as the R report (calendar-date filter).
in_range = close.index.date >= pd.Timestamp(start_date).date()
close, open_, high, low = close[in_range], open_[in_range], high[in_range], low[in_range]

worst = close.pct_change().abs().max()
assert (worst < 0.40).all(), f"suspect unadjusted split: {worst[worst >= 0.40].to_dict()}"
print("Bars downloaded per symbol:")
print(close.count())
Bars downloaded per symbol:
symbol
QQQ     751
NVDA    751
AAPL    751
MSFT    751
AMD     751
AMZN    751
dtype: int64

1.4 Indicators

EMA uses alpha = 2/(n+1) and Wilder smoothing uses alpha = 1/n; both are SMA-seeded, matching the R report’s TTR::EMA(), TTR::ATR() and TTR::ADX().

Code
def sma_seeded(df: pd.DataFrame, n: int, alpha: float) -> pd.DataFrame:
    x = df.to_numpy(dtype=float)
    out = np.full(x.shape, np.nan)
    for j in range(x.shape[1]):
        col = x[:, j]
        valid = np.flatnonzero(~np.isnan(col))
        if len(valid) < n:
            continue
        s = valid[0] + n - 1
        out[s, j] = col[valid[0]:s + 1].mean()
        for i in range(s + 1, len(col)):
            out[i, j] = alpha * col[i] + (1 - alpha) * out[i - 1, j]
    return pd.DataFrame(out, index=df.index, columns=df.columns)

ema = lambda df, n: sma_seeded(df, n, 2.0 / (n + 1))
wilder = lambda df, n: sma_seeded(df, n, 1.0 / n)

prev_close = close.shift(1)
true_range = pd.DataFrame(
    np.maximum.reduce([(high - low).to_numpy(), (high - prev_close).abs().to_numpy(), (low - prev_close).abs().to_numpy()]),
    index=close.index, columns=close.columns)
true_range.iloc[0] = (high - low).iloc[0]

mid = ema(close, ema_n)
atr = wilder(true_range, atr_n)
upper = mid + atr_mult * atr
lower = mid - atr_mult * atr

up_move = high.diff()
down_move = -low.diff()
plus_dm = up_move.where((up_move > down_move) & (up_move > 0), 0.0)
minus_dm = down_move.where((down_move > up_move) & (down_move > 0), 0.0)
tr_adx = wilder(true_range, adx_n)
di_pos = 100 * wilder(plus_dm, adx_n) / tr_adx
di_neg = 100 * wilder(minus_dm, adx_n) / tr_adx
dx = 100 * (di_pos - di_neg).abs() / (di_pos + di_neg)
adx = wilder(dx.dropna(how="all"), adx_n).reindex(close.index)

1.5 Signals

Entry fires on the first bar where the close is above the upper band AND ADX is above its threshold. Exit fires when the close crosses below the EMA centre line. A signal evaluated on day T is executed at day T+1’s open.

Code
entry_state = ((close > upper) & (adx > adx_min)).fillna(False).astype(bool)
raw_entries = entry_state & ~entry_state.shift(1, fill_value=False)
raw_exits = ((close < mid) & (close.shift(1) >= mid.shift(1))).fillna(False).astype(bool)

entries = raw_entries.shift(1, fill_value=False).astype(bool)
exits = raw_exits.shift(1, fill_value=False).astype(bool)

1.6 Run the Backtest

Each entry buys one dollar slot at the next open; accumulate=False ignores a second entry while a position is open. Every fill pays 5 bps of traded value.

Code
portfolio = vbt.Portfolio.from_signals(
    close,
    entries=entries,
    exits=exits,
    price=open_,
    size=slot_usd,
    size_type="value",
    fees=cost_rate,
    init_cash=init_equity,
    cash_sharing=True,
    group_by=True,
    accumulate=False,
    freq="1D",
)

1.7 Headline Performance

Code
equity = portfolio.value()
returns = equity.pct_change().dropna()
final_val = float(equity.iloc[-1])
total_ret = (final_val / init_equity - 1) * 100
sharpe = returns.mean() / returns.std() * np.sqrt(252)
max_dd = -((equity / equity.cummax()) - 1).min() * 100

trades = portfolio.trades.records_readable
closed = trades[trades["Status"] == "Closed"]
win_rate = (closed["PnL"] > 0).mean() * 100
profit_factor = closed.loc[closed["PnL"] > 0, "PnL"].sum() / abs(closed.loc[closed["PnL"] < 0, "PnL"].sum())

print(pd.Series({
    "Final Equity": f"${final_val:,.0f}",
    "Total Return": f"{total_ret:.2f}%",
    "Annualized Sharpe": f"{sharpe:.2f}",
    "Max Drawdown": f"{max_dd:.2f}%",
    "Closed Trades": int(len(closed)),
    "Win Rate": f"{win_rate:.1f}%",
    "Profit Factor": f"{profit_factor:.2f}",
}).to_string())
Final Equity         $128,657
Total Return           28.66%
Annualized Sharpe        0.99
Max Drawdown            7.11%
Closed Trades              48
Win Rate                47.9%
Profit Factor            2.61

1.8 Trade Statistics

One row per symbol: closed-trade count, net P&L, win rate and profit factor.

Code
per_symbol = closed.groupby("Column").agg(
    Num_Trades=("PnL", "count"),
    Net_PnL=("PnL", "sum"),
    Percent_Positive=("PnL", lambda s: (s > 0).mean() * 100),
    Avg_PnL=("PnL", "mean"),
).reset_index().rename(columns={"Column": "Symbol"})
print(per_symbol.round(2).to_string(index=False))
Symbol  Num_Trades  Net_PnL  Percent_Positive  Avg_PnL
  AAPL          10  -736.74             40.00   -73.67
   AMD           8 17923.55             75.00  2240.44
  AMZN           9 -4143.77             22.22  -460.42
  MSFT           6  1745.26             50.00   290.88
  NVDA           7 11901.15             42.86  1700.16
   QQQ           8  2039.82             62.50   254.98

2 Benchmarks & Risk

This section continues in the same Python session, reusing the data and portfolio built above.

2.1 Strategy vs. Benchmarks

The strategy against two passive benchmarks over the same window: SPY buy-and-hold and an equal-weight buy-and-hold basket of the traded symbols.

Code
import matplotlib.pyplot as plt

spy_data = vbt.YFData.download("SPY", start=start_date, end=end_date, missing_columns="nan")
spy = spy_data.get("Close")
spy = spy[spy.index.date >= pd.Timestamp(start_date).date()]
spy_eq = init_equity * spy / spy.iloc[0]
basket_eq = (init_equity / len(symbols)) * (close / close.iloc[0]).sum(axis=1)
comparison = pd.concat([equity.rename("Strategy"), spy_eq.rename("SPY"), basket_eq.rename("Equal-weight basket")], axis=1).dropna()

fig, ax = plt.subplots(figsize=(9, 4.5))
for col, color in zip(comparison.columns, ["#00E08F", "#8B5CF6", "#2563EB"]):
    ax.plot(comparison.index, comparison[col], label=col, color=color, lw=2)
ax.set_ylabel("Equity ($)"); ax.set_title("Strategy vs. Benchmark Equity Curves"); ax.legend(loc="upper left", frameon=False)
plt.show()
print(pd.DataFrame({"Final Equity": comparison.iloc[-1].round(0), "Total Return %": ((comparison.iloc[-1] / init_equity - 1) * 100).round(2)}))

                     Final Equity  Total Return %
Strategy                 128657.0           28.66
SPY                      177038.0           77.04
Equal-weight basket      266324.0          166.32

2.2 Drawdown

Code
dd = (equity / equity.cummax() - 1) * 100
fig, ax = plt.subplots(figsize=(9, 3.5))
ax.fill_between(dd.index, dd.values, 0, color="#FF4D6A", alpha=0.35); ax.plot(dd.index, dd.values, color="#FF4D6A", lw=1.2)
ax.set_ylabel("Drawdown (%)"); ax.set_title("Strategy Drawdown")
plt.show()

2.3 Portfolio Exposure Over Time

Mark-to-market share of equity held in positions.

Code
exposure_pct = 100 * portfolio.asset_value(group_by=True) / equity
fig, ax = plt.subplots(figsize=(9, 3.5))
ax.plot(exposure_pct.index, exposure_pct.values, color="#2563EB", lw=1.4)
ax.set_ylabel("% of Equity Invested"); ax.set_title("Portfolio Exposure Over Time")
plt.show()

2.4 Rolling 3-Month Sharpe

Code
roll = returns.rolling(63).apply(lambda r: r.mean() / r.std() * np.sqrt(252), raw=True)
fig, ax = plt.subplots(figsize=(9, 3.5))
ax.plot(roll.index, roll.values, color="#8B5CF6", lw=1.4); ax.axhline(0, color="gray", ls="--", lw=1)
ax.set_ylabel("Annualized Sharpe"); ax.set_title("Rolling 3-Month Sharpe")
plt.show()