The Spectral Slope Adaptive Filter Strategy introduces an advanced approach to dynamic indicator adaptation by leveraging spectral analysis to infer the “color” or underlying statistical properties of price movements. Instead of relying on traditional volatility measures, this strategy estimates the spectral slope of the price series to determine whether the market is in a trending (more predictable) or noisy (more random/mean-reverting) regime. This spectral slope then directly influences the smoothing period of an Exponential Moving Average (EMA), making the filter intelligently responsive to current market conditions. The strategy uses this adaptive EMA for trade signals and employs an ATR-based trailing stop for risk management.
The Power Spectral Density (PSD) describes how the power (variance) of a time series is distributed across different frequencies. It helps identify dominant cycles, noise, or trends in the signal.
For a discrete-time signal \(x[n]\):
\[ \text{PSD}(f) = \lim_{T \to \infty} \frac{1}{T} \left| \sum_{n=0}^{T-1} x[n] \, e^{-j2\pi fn} \right|^2 \]
This is the squared magnitude of the Fourier Transform, normalized by time.
Used for noisy time series (like price):
\[ \text{PSD}(f) = \frac{1}{K} \sum_{k=1}^{K} \frac{|\text{FFT}(w_k x_k)[f]|^2}{U} \]
Where:
From PSD output:
\[ \log_{10}(\text{PSD}_i) = a + b \cdot \log_{10}(f_i) \]
The slope \(b\) quantifies trend/noise:
The SpectralSlopeAdaptiveFilter
operates on a
sophisticated adaptive mechanism and manages trades as follows:
Spectral Slope Analysis for Adaptation: The core innovation is the use of the spectral slope of the log-log power spectral density (PSD) of the price series.
Adaptive EMA Calculation: The EMA’s smoothing period is dynamically determined by the calculated spectral slope:
spectral_slope
is mapped to an EMA period between
period_filter_min
(for noisier markets, where a faster
filter is desired) and period_filter_max
(for trending
markets, where a slower filter is desired).slope_map_noise
results in a faster
EMA, and a slope closer to slope_map_trend
results in a
slower EMA.adaptive_ema
) is then updated using
the calculated period.Entry Logic: The strategy uses a simple crossover for entry signals:
adaptive_ema
.adaptive_ema
.Exit Logic (ATR Trailing Stop): All open positions are managed with an ATR-based trailing stop:
atr_multiplier_sl
) of the Average True Range (ATR) away
from the entry price.Here’s the SpectralSlopeAdaptiveFilter
class, including
its helper methods:
import backtrader as bt
import numpy as np
from scipy import signal, stats
from collections import deque
import warnings
"ignore", category=stats.ConstantInputWarning)
warnings.filterwarnings(
class SpectralSlopeAdaptiveFilter(bt.Strategy):
= (
params 'spectrum_window', 30), # Window size for spectral analysis (price_buffer)
('spectrum_nperseg', 10), # Length of segments used in Welch's method (nperseg must be <= spectrum_window)
('slope_map_trend', -2.8), # Spectral slope value representing strong trend (steeper negative)
('slope_map_noise', -1.2), # Spectral slope value representing noise/mean-reversion (less steep negative)
('period_filter_min', 15), # Minimum EMA period (for noisy/mean-reverting markets)
('period_filter_max', 150), # Maximum EMA period (for trending markets)
('atr_window_sl', 14), # Window for ATR calculation for stop loss
('atr_multiplier_sl', 2.0), # Multiplier for ATR to set trailing stop distance
(
)
def __init__(self):
# ATR for stop loss
self.atr = bt.indicators.AverageTrueRange(self.data, period=self.params.atr_window_sl)
# State variables
self.price_buffer = deque(maxlen=self.params.spectrum_window) # Buffer to hold price data for spectral analysis
self.adaptive_ema = None
self.spectral_slope = -2.0 # Default to Brownian motion slope, typical for financial data
self.trailing_stop = None
self.entry_price = None # Stores entry price for initial stop calculation
def _calculate_spectral_slope(self, price_segment):
"""Calculate log-log slope of price spectrum using Welch's method"""
# Ensure sufficient data for nperseg and detrending
if len(price_segment) < self.params.spectrum_nperseg // 2 or len(price_segment) < 2:
return -2.0 # Default Brownian slope if data is insufficient
try:
# Detrend the price segment to focus on fluctuations, not overall trend
= signal.detrend(price_segment)
detrended
# If detrended series is almost constant, return default slope
if np.std(detrended) < 1e-9: # Check for near-zero standard deviation
return -2.0
# Calculate power spectral density (PSD) using Welch's method
# fs=1.0 for daily data, nperseg is segment length, scaling='density' gives PSD
= signal.welch(
freqs, psd
detrended, =1.0, # Sampling frequency (e.g., 1.0 for daily data)
fs=min(len(detrended), self.params.spectrum_nperseg), # Ensure nperseg <= data length
nperseg='density',
scaling=max(self.params.spectrum_nperseg, len(detrended)) # NFFT should be at least nperseg
nfft
)
# Filter out zero frequencies (log10(0) is undefined) and very small PSD values
= np.where((freqs > 1e-6) & (psd > 1e-9))[0]
valid_indices
# Need at least 2 points for a linear regression slope, but 3 is safer for stats.linregress
if len(valid_indices) < 3:
return -2.0
= np.log10(freqs[valid_indices])
log_freqs = np.log10(psd[valid_indices])
log_psd
# Check for sufficient variation in log_freqs or log_psd to avoid errors in linregress
if np.std(log_freqs) < 1e-6 or np.std(log_psd) < 1e-6:
return -2.0
# Perform linear regression on log-log plot (log(PSD) vs log(freq))
= stats.linregress(log_freqs, log_psd)
slope, _, _, _, _
return slope
except (ValueError, FloatingPointError):
# Handle cases where inputs might be problematic for log or linregress
return -2.0
def _map_slope_to_period(self, slope):
"""Map spectral slope to EMA period"""
# Clip slope to mapping range [slope_map_trend, slope_map_noise]
= np.clip(slope, self.params.slope_map_trend, self.params.slope_map_noise)
clipped_slope
# Normalize the clipped slope to a 0-1 range
# 0 corresponds to slope_map_trend (strong trend, max period)
# 1 corresponds to slope_map_noise (noise/mean-reversion, min period)
= self.params.slope_map_noise - self.params.slope_map_trend
slope_range if slope_range == 0: # Avoid division by zero if thresholds are identical
return (self.params.period_filter_min + self.params.period_filter_max) // 2
= (clipped_slope - self.params.slope_map_trend) / slope_range
norm_slope
# Map normalized slope to the desired EMA period range
# A smaller (more negative) slope -> closer to slope_map_trend (norm_slope close to 0) -> period_filter_max (slower EMA for trending)
# A larger (less negative) slope -> closer to slope_map_noise (norm_slope close to 1) -> period_filter_min (faster EMA for noisy)
= self.params.period_filter_min + (1 - norm_slope) * (self.params.period_filter_max - self.params.period_filter_min)
period
return int(np.clip(np.round(period), self.params.period_filter_min, self.params.period_filter_max))
def _update_adaptive_ema(self):
"""Update the spectral-slope adaptive EMA"""
= self.data.close[0]
current_price
# Calculate spectral slope only if we have enough data in the buffer
if len(self.price_buffer) == self.params.spectrum_window:
self.spectral_slope = self._calculate_spectral_slope(list(self.price_buffer))
# Map the current spectral slope to an adaptive EMA period
= self._map_slope_to_period(self.spectral_slope)
adaptive_period
# Calculate alpha (smoothing factor) for the EMA based on the adaptive period
= 2 / (adaptive_period + 1)
alpha
# Update the adaptive EMA
if self.adaptive_ema is None:
self.adaptive_ema = current_price # Initialize with current price on first valid bar
else:
self.adaptive_ema = alpha * current_price + (1 - alpha) * self.adaptive_ema
def next(self):
# Ensure sufficient data for initial calculations (buffers, indicators)
if len(self.data) < max(self.params.spectrum_window, self.params.atr_window_sl):
return
= self.data.close[0]
current_close = self.atr[0]
current_atr
# Update price buffer with current close price
self.price_buffer.append(current_close)
# Update the adaptive EMA for the current bar
self._update_adaptive_ema()
# Ensure adaptive EMA has been initialized before using it for signals
if self.adaptive_ema is None:
return
= self.position.size # Current position size
position
# --- Handle existing positions: Check Trailing Stops First ---
# For long positions
if position > 0:
if self.trailing_stop is None: # Initialize stop on the first bar after entry
self.trailing_stop = self.entry_price - self.params.atr_multiplier_sl * current_atr
elif self.data.low[0] <= self.trailing_stop: # Check if price hit stop loss
self.close() # Close position
self.trailing_stop = None # Reset stop for next trade
self.entry_price = None # Reset entry price
return # Exit next() after closing
else: # Update trailing stop if price moved favorably
= current_close - self.params.atr_multiplier_sl * current_atr
new_stop if new_stop > self.trailing_stop: # Stop only moves up
self.trailing_stop = new_stop
# For short positions
elif position < 0:
if self.trailing_stop is None: # Initialize stop on the first bar after entry
self.trailing_stop = self.entry_price + self.params.atr_multiplier_sl * current_atr
elif self.data.high[0] >= self.trailing_stop: # Check if price hit stop loss
self.close() # Close position
self.trailing_stop = None # Reset stop for next trade
self.entry_price = None # Reset entry price
return # Exit next() after closing
else: # Update trailing stop if price moved favorably
= current_close + self.params.atr_multiplier_sl * current_atr
new_stop if new_stop < self.trailing_stop: # Stop only moves down
self.trailing_stop = new_stop
# --- Entry signals based on adaptive EMA crossover - only if no position open ---
if position == 0:
= self.data.close[-1]
prev_close
# Long signal: previous close crosses above adaptive EMA
if prev_close > self.adaptive_ema and self.data.close[-2] <= self.adaptive_ema: # Added crossover condition
self.buy() # Place buy order
self.entry_price = self.data.open[0] # Record entry price for initial stop
# Initial trailing stop will be set on the next bar in the next() call or through notify_order.
# It's better to explicitly set it here for immediate effect or in notify_order.
# For this strategy, setting it here after order is placed is safer as notify_order runs after next().
self.trailing_stop = self.entry_price - self.params.atr_multiplier_sl * current_atr
# Short signal: previous close crosses below adaptive EMA
elif prev_close < self.adaptive_ema and self.data.close[-2] >= self.adaptive_ema: # Added crossover condition
self.sell() # Place sell order
self.entry_price = self.data.open[0] # Record entry price for initial stop
# Initial trailing stop set here
self.trailing_stop = self.entry_price + self.params.atr_multiplier_sl * current_atr
params
):spectrum_window
: The lookback window (number of bars)
used to collect price data for the spectral analysis. This determines
the segment length for the PSD calculation.spectrum_nperseg
: The length of segments (in bars) to
use for the Welch’s method PSD estimation. It must be less than or equal
to spectrum_window
.slope_map_trend
: A spectral slope value (e.g., -2.8)
that is mapped to the period_filter_max
(slower EMA).
Slopes more negative than this suggest strong trending.slope_map_noise
: A spectral slope value (e.g., -1.2)
that is mapped to the period_filter_min
(faster EMA).
Slopes less negative (closer to 0) suggest noisier/mean-reverting
markets.period_filter_min
: The minimum period for the adaptive
EMA, used when the market is identified as noisy/mean-reverting.period_filter_max
: The maximum period for the adaptive
EMA, used when the market is identified as strongly trending.atr_window_sl
: The lookback window for the Average True
Range (ATR) indicator, used to calculate the trailing stop
distance.atr_multiplier_sl
: The multiplier for the ATR,
determining how far the trailing stop is placed from the price.__init__
):self.atr
: Initializes the Average True Range indicator,
used for calculating the dynamic trailing stop.self.price_buffer
: A deque
(double-ended
queue) of fixed size (spectrum_window
) to store the recent
closing prices. This buffer serves as the input data segment for the
spectral analysis.self.adaptive_ema
: Stores the current value of the
dynamically adjusted Exponential Moving Average.self.spectral_slope
: Stores the latest calculated
spectral slope, defaulting to -2.0 (representing Brownian motion/random
walk).self.trailing_stop
: Stores the current price level of
the ATR-based trailing stop.self.entry_price
: Stores the entry price of the current
position, used for setting the initial trailing stop._calculate_spectral_slope(self, price_segment)
:
price_segment
. The slope indicates the underlying “color”
of the price movement.price_segment
using scipy.signal.detrend
.
This focuses the analysis on fluctuations rather than the overall
directional bias, which is handled by the EMA itself.scipy.signal.welch
to compute the PSD of the detrended
price data. welch
breaks the signal into overlapping
segments, computes a modified periodogram for each, and averages them to
get the PSD.log_freqs
vs log_psd
). The slope of this
regression line is the spectral slope._map_slope_to_period(self, slope)
:
spectral_slope
into a suitable period for the adaptive
EMA.slope
is clipped
between slope_map_trend
(more negative, indicating strong
trend) and slope_map_noise
(less negative, indicating more
noise/mean-reversion).slope_map_trend
(e.g., -2.8)
results in a normalized value close to 0, which maps to
period_filter_max
(a slower EMA for trending markets). A
slope closer to slope_map_noise
(e.g., -1.2) results in a
normalized value closer to 1, which maps to
period_filter_min
(a faster EMA for noisy markets).period_filter_min
to
period_filter_max
). The mapping ensures that trending
markets (steeper negative slope) get a slower EMA, and noisy markets
(less steep negative slope) get a faster EMA._update_adaptive_ema()
:
self.adaptive_ema
for the current bar using the dynamically
determined period._calculate_spectral_slope
(if
price_buffer
is full) to get the
spectral_slope
._map_slope_to_period
to convert this
slope into an adaptive_period
.alpha = 2 / (period + 1)
is calculated using this
adaptive_period
.self.adaptive_ema
is updated using the
current price and the new alpha
.next
):The next
method is executed on each new bar of data and
orchestrates the strategy’s operations:
self.price_buffer
.self._update_adaptive_ema()
is called to compute the
current value of the adaptive EMA.self.trailing_stop
is None
), the initial stop
price is calculated using the entry_price
(recorded upon
order placement) and the current ATR.data.low[0]
falls below or equals
self.trailing_stop
, the position is closed. For short
positions, if data.high[0]
rises above or equals
self.trailing_stop
, the position is closed. The
trailing_stop
and entry_price
are reset, and
the method returns.trailing_stop
is updated. For long positions, it only moves
up if the price moves favorably; for short positions, it only moves
down.position == 0
), the
strategy looks for a crossover of the previous bar’s close price and the
adaptive_ema
:
prev_close
crosses
above self.adaptive_ema
(i.e.,
prev_close > self.adaptive_ema
and
data.close[-2] <= self.adaptive_ema
), a buy
order is placed. The entry_price
is recorded for the
subsequent trailing stop initialization.prev_close
crosses
below self.adaptive_ema
(i.e.,
prev_close < self.adaptive_ema
and
data.close[-2] >= self.adaptive_ema
), a
sell
order is placed. The entry_price
is
recorded.trailing_stop
for the new position is set
immediately after the order is placed, using the recorded
entry_price
and current ATR
.
Running the strategy over multiple, sequential time windows (e.g., yearly) across a broad historical dataset. This helps assess the strategy’s robustness and consistency under different market conditions.