Volatility Adjusted Signal Filter
Build a volatility-aware signal filter that dynamically adjusts entry and exit threshold sensitivity based on the prevailing volatility regime, preventing overtrading in low-volatility chop and undertrading during high-volatility trends.
Volatility-Adjusted Signal Filter Notebook
1. Introduction
Volatility-adjusted filtering suppresses trading signals during periods of extreme or insufficient volatility, targeting only favourable market conditions.
Volatility regimes (based on ATR percentile):
| ATR Percentile | Regime | Action |
|---|---|---|
| < 25th | Low volatility | Suppress signals (breakouts unlikely) |
| 25th – 75th | Normal volatility | Allow signals |
| > 75th | High volatility | Suppress signals (risk too high) |
Limitation: ATR percentile thresholds are dataset-specific; out-of-sample periods may have different volatility distributions.
2. Import Libraries
import pandas as pd
import numpy as np
import plotly.graph_objects as go
from plotly.subplots import make_subplots3. Data Generation
This section defines a function generate_data to create synthetic OHLCV (Open, High, Low, Close, Volume) price data using a geometric random walk. This simulated data will be used to test the volatility-adjusted signal filter.
def generate_data(periods: int) -> pd.DataFrame:
"""
Generate synthetic OHLCV price data using a geometric random walk.
Parameters
----------
periods : int
Number of 1-minute bars to generate.
Returns
-------
pd.DataFrame
DataFrame with columns: open, high, low, close, volume, datetime.
"""
start_date = pd.to_datetime("2024-01-01 00:00:00+00:00")
datetime_index = pd.date_range(start_date, periods=periods, freq="1min", tz="UTC")
price_data = []
last_close = 42000
for i in range(periods):
open_price = last_close + np.random.normal(0, last_close * 0.0005)
close_price = open_price + np.random.normal(0, last_close * 0.005)
body_high = max(open_price, close_price)
body_low = min(open_price, close_price)
high_price = max(body_high + abs(np.random.normal(0, last_close * 0.002)), open_price, close_price)
low_price = min(body_low - abs(np.random.normal(0, last_close * 0.002)), open_price, close_price)
if high_price < low_price:
high_price, low_price = low_price, high_price
price_data.append({
"open": max(1, int(open_price)),
"high": max(1, int(high_price)),
"low": max(1, int(low_price)),
"close": max(1, int(close_price)),
})
last_close = close_price
df = pd.DataFrame(price_data, index=datetime_index)
df.index.name = "datetime"
df["volume"] = np.random.uniform(100.0, 500.0, periods)
df["datetime"] = df.index.to_series()
return df.reset_index(drop=True)
df = generate_data(500)
display(df.head())| open | high | low | close | volume | datetime | |
|---|---|---|---|---|---|---|
| 0 | 42008 | 42040 | 41897 | 41990 | 252.253160 | 2024-01-01 00:00:00+00:00 |
| 1 | 41984 | 42097 | 41955 | 42072 | 225.056928 | 2024-01-01 00:01:00+00:00 |
| 2 | 42070 | 42097 | 41940 | 41942 | 136.756562 | 2024-01-01 00:02:00+00:00 |
| 3 | 41930 | 41933 | 41481 | 41626 | 365.950460 | 2024-01-01 00:03:00+00:00 |
| 4 | 41626 | 41672 | 41473 | 41665 | 181.969019 | 2024-01-01 00:04:00+00:00 |
4. Volatility-Adjusted Signal Filter Function
This function volatility_adjusted_signal_filter applies a volatility-based filtering mechanism to trading signals. It computes Average True Range (ATR) to gauge volatility and then suppresses signals during periods of extremely low or high volatility, as determined by ATR percentiles.
def volatility_adjusted_signal_filter(
df: pd.DataFrame,
atr_period: int = 14,
low_pct: float = 25.0,
high_pct: float = 75.0,
base_signal_window: int = 20,
) -> pd.DataFrame:
"""
Filter a Donchian breakout signal using an ATR-based volatility regime.
Core logic
----------
1. Compute ATR(atr_period) as the volatility measure.
2. Classify volatility regime by comparing the current ATR to its
rolling percentile (low_pct and high_pct thresholds).
3. Generate a base Donchian breakout signal.
4. Suppress the base signal whenever volatility is outside the normal regime.
Parameters
----------
df : pd.DataFrame OHLCV DataFrame.
atr_period : int ATR rolling window.
low_pct : float ATR percentile below which volatility is 'low'.
high_pct : float ATR percentile above which volatility is 'high'.
base_signal_window: int Donchian channel window for the base signal.
Returns
-------
pd.DataFrame with: atr, vol_regime, base_signal, signal.
"""
df = df.copy().sort_values("datetime", ignore_index=True)
# ── ATR ──────────────────────────────────────────────────────────────────
tr = pd.concat([
df["high"] - df["low"],
(df["high"] - df["close"].shift(1)).abs(),
(df["low"] - df["close"].shift(1)).abs(),
], axis=1).max(axis=1)
df["atr"] = tr.rolling(atr_period).mean()
# ── Volatility regime ─────────────────────────────────────────────────────
rolling_low = df["atr"].rolling(100).quantile(low_pct / 100)
rolling_high = df["atr"].rolling(100).quantile(high_pct / 100)
df["vol_regime"] = "normal"
df.loc[df["atr"] < rolling_low, "vol_regime"] = "low"
df.loc[df["atr"] > rolling_high, "vol_regime"] = "high"
# ── Base signal: Donchian breakout ────────────────────────────────────────
ch_high = df["high"].rolling(base_signal_window).max().shift(1)
ch_low = df["low"].rolling(base_signal_window).min().shift(1)
df["base_signal"] = np.where(df["close"] > ch_high, 1,
np.where(df["close"] < ch_low, -1, 0))
# ── Filtered signal: suppress outside normal regime ───────────────────────
df["signal"] = np.where(df["vol_regime"] == "normal", df["base_signal"], 0)
return df
df_signals = volatility_adjusted_signal_filter(df)
print(df_signals["vol_regime"].value_counts())
print(df_signals["signal"].value_counts())vol_regime normal 310 high 120 low 70 Name: count, dtype: int64 signal 0 458 -1 22 1 20 Name: count, dtype: int64
- Rolling percentile: Computed over a 100-bar window to adapt the volatility thresholds to changing market conditions rather than using a fixed absolute ATR level.
- Signal suppression: Setting
signal = 0outside the normal regime does not remove the base signal; it preserves it inbase_signalfor audit and comparison purposes.
5. Visualization of Results
This section visualizes the generated OHLCV data, the buy/sell signals generated by the filter, and the underlying ATR with corresponding volatility regimes. This helps in understanding how the filter operates in different market conditions.
buy_signals = df_signals[df_signals["signal"] == 1]
sell_signals = df_signals[df_signals["signal"] == -1]
regime_colors = {"normal": "rgba(0,200,0,0.08)", "low": "rgba(0,0,200,0.08)", "high": "rgba(200,0,0,0.08)"}
fig = make_subplots(rows=2, cols=1, shared_xaxes=True,
subplot_titles=["Price + Volatility-Filtered Signals", "ATR + Regime"],
row_heights=[0.65, 0.35])
fig.add_trace(go.Candlestick(x=df_signals["datetime"],
open=df_signals["open"], high=df_signals["high"],
low=df_signals["low"], close=df_signals["close"], name="Price"), row=1, col=1)
fig.add_trace(go.Scatter(x=buy_signals["datetime"], y=buy_signals["low"] * 0.999,
mode="markers", marker=dict(symbol="triangle-up", size=10, color="green"), name="Buy"), row=1, col=1)
fig.add_trace(go.Scatter(x=sell_signals["datetime"], y=sell_signals["high"] * 1.001,
mode="markers", marker=dict(symbol="triangle-down", size=10, color="red"), name="Sell"), row=1, col=1)
atr_colors = ["green" if r == "normal" else "blue" if r == "low" else "red" for r in df_signals["vol_regime"]]
fig.add_trace(go.Bar(x=df_signals["datetime"], y=df_signals["atr"],
marker_color=atr_colors, name="ATR"), row=2, col=1)
fig.update_layout(title_text="Volatility-Adjusted Signal Filter",
xaxis_rangeslider_visible=False, height=700, xaxis2_title="Datetime")
fig.show()Conclusion
This notebook demonstrated a volatility-adjusted signal filter that suppresses trading signals during periods of extreme or insufficient volatility. The filter uses the Average True Range (ATR) to classify market conditions into 'low', 'normal', and 'high' volatility regimes based on rolling percentiles. Signals are only allowed during 'normal' volatility periods, aiming to improve trading performance by avoiding unfavorable market conditions.