← Back to Home
Spectral Slope Adaptive Filter Strategy - Self-Adjusting Averages for Trend Following with Trailing Stops

Spectral Slope Adaptive Filter Strategy - Self-Adjusting Averages for Trend Following with Trailing Stops

Traditional moving averages are static, using fixed lookback periods that may not optimally capture trend dynamics across diverse market conditions. This article introduces the SpectralSlopeAdaptiveFilterTrailingStopStrategy, a novel approach that dynamically adjusts its moving average period based on the spectral properties of price data. By analyzing the “color” of market noise, this strategy adapts its responsiveness, aiming to better identify trends, and always ensures disciplined exits through an Average True Range (ATR)-based trailing stop.

SpectralSlopeAdaptiveFilter.png

The Adaptive Core: Spectral Slope Analysis

The strategy’s innovation lies in its use of the spectral slope, a concept derived from signal processing that describes the fractal nature of a time series. Different spectral slopes correspond to different types of noise:

The _calculate_spectral_slope method computes this slope using Welch’s method for Power Spectral Density (PSD) estimation on a detrended price series. This slope is then mapped to an adaptive Exponential Moving Average (EMA) period:

This allows the strategy’s core trend-following indicator to dynamically adjust its sensitivity to market conditions.

import backtrader as bt
import numpy as np
from scipy import signal, stats
from collections import deque
import warnings

warnings.filterwarnings("ignore", category=DeprecationWarning)
warnings.filterwarnings("ignore", category=RuntimeWarning)

class SpectralSlopeAdaptiveFilterTrailingStopStrategy(bt.Strategy):
    params = (
        ('spectrum_window', 128),     # Window size for spectral analysis
        ('spectrum_nperseg', 64),     # Segment length for Welch's method
        ('slope_map_trend', -2.5),    # Spectral slope considered strongly trending
        ('slope_map_noise', -1.0),    # Spectral slope considered noisy/ranging
        ('period_filter_min', 10),    # Minimum EMA period (for trending)
        ('period_filter_max', 100),   # Maximum EMA period (for noisy)
        ('atr_window_sl', 14),        # ATR window for trailing stop calculation
        ('atr_multiplier_sl', 2.0),   # Multiplier for ATR in trailing stop
    )
    
    def __init__(self):
        self.atr = bt.indicators.AverageTrueRange(self.data, period=self.params.atr_window_sl)
        
        self.price_buffer = deque(maxlen=self.params.spectrum_window)
        self.spectral_slope = -2.0 # Initial default to Brownian (trending)
        self.adaptive_ema = None   # The dynamically smoothed moving average
        self.trailing_stop = None  # Current trailing stop price
        self.entry_price = None    # Price at which position was entered

    def _calculate_spectral_slope(self, price_values):
        """Calculate log-log spectral slope using Welch's method."""
        if len(price_values) < self.params.spectrum_nperseg // 2 or np.std(price_values) < 1e-9:
            return np.nan
        try:
            detrended = signal.detrend(price_values)
            if np.std(detrended) < 1e-9: return np.nan
            
            freqs, psd = signal.welch(
                detrended, fs=1.0, nperseg=min(len(detrended), self.params.spectrum_nperseg),
                scaling='density', nfft=max(self.params.spectrum_nperseg, len(detrended))
            )
            valid_indices = np.where((freqs > 1e-6) & (psd > 1e-9))[0]
            if len(valid_indices) < 2: return np.nan
            
            log_freqs = np.log10(freqs[valid_indices])
            log_psd = np.log10(psd[valid_indices])
            
            if np.std(log_freqs) < 1e-6 or np.std(log_psd) < 1e-6: return np.nan
            
            slope, _, _, _, _ = stats.linregress(log_freqs, log_psd)
            return slope
        except (ValueError, FloatingPointError):
            return np.nan
    
    def _normalize_slope_to_period(self, slope):
        """Map spectral slope to EMA period based on defined ranges."""
        clipped_slope = np.clip(slope, self.params.slope_map_trend, self.params.slope_map_noise)
        range_val = self.params.slope_map_noise - self.params.slope_map_trend
        if range_val == 0: return (self.params.period_filter_min + self.params.period_filter_max) // 2
            
        normalized = (clipped_slope - self.params.slope_map_trend) / range_val
        # Map: trendier (more negative slope) -> shorter period, noisier -> longer period
        period = self.params.period_filter_min + (1 - normalized) * (self.params.period_filter_max - self.params.period_filter_min)
        return int(np.clip(np.round(period), self.params.period_filter_min, self.params.period_filter_max))
        
    def _update_adaptive_ema(self):
        """Update the dynamically adjusted EMA."""
        current_price = self.data.close[0]
        
        if len(self.price_buffer) == self.params.spectrum_window:
            # Calculate slope on the full buffer
            slope = self._calculate_spectral_slope(list(self.price_buffer))
            if not np.isnan(slope):
                self.spectral_slope = slope
        
        # Use the latest spectral slope (from previous bar's calculation) for current EMA period
        adaptive_period = self._normalize_slope_to_period(self.spectral_slope)
        alpha = 2 / (adaptive_period + 1)
        
        if self.adaptive_ema is None:
            self.adaptive_ema = current_price
        else:
            self.adaptive_ema = alpha * current_price + (1 - alpha) * self.adaptive_ema

Trading Logic and Risk Management

The strategy’s next method handles entries and exits. Trading signals are generated by a simple crossover of the current price relative to the adaptive_ema.

    def next(self):
        # Ensure sufficient data for all calculations
        if len(self.data) < max(self.params.spectrum_window, self.params.atr_window_sl) + 1: # +1 for prev_close
            return
            
        current_close = self.data.close[0]
        prev_close = self.data.close[-1] # Used for EMA crossover check
        current_atr = self.atr[0]
        
        # Update price buffer for spectral analysis
        self.price_buffer.append(current_close)
        
        # Update the adaptive EMA
        self._update_adaptive_ema()
        
        if self.adaptive_ema is None or np.isnan(current_atr) or np.isnan(self.adaptive_ema):
            return # Ensure indicators are ready

        position = self.position.size
        
        # --- Exit Logic: ATR-based Trailing Stop ---
        # This implementation uses a manual trailing stop for demonstration.
        # For production, backtrader's bt.Order.StopTrail is often preferred
        # (as seen in previous examples) for robustness in event handling.
        
        if position > 0: # Currently Long
            # Re-calculate trailing stop: Price - ATR_Multiplier * ATR
            new_stop_level = current_close - self.params.atr_multiplier_sl * current_atr
            # Trailing stop only moves up (for long positions)
            if self.trailing_stop is None or new_stop_level > self.trailing_stop:
                self.trailing_stop = new_stop_level
            
            # Check if price has hit the trailing stop
            if current_close < self.trailing_stop: # Use current_close instead of low for strictness
                self.close()
                self.trailing_stop = None
                self.entry_price = None # Reset
        
        elif position < 0: # Currently Short
            # Re-calculate trailing stop: Price + ATR_Multiplier * ATR
            new_stop_level = current_close + self.params.atr_multiplier_sl * current_atr
            # Trailing stop only moves down (for short positions)
            if self.trailing_stop is None or new_stop_level < self.trailing_stop:
                self.trailing_stop = new_stop_level
            
            # Check if price has hit the trailing stop
            if current_close > self.trailing_stop: # Use current_close instead of high for strictness
                self.close()
                self.trailing_stop = None
                self.entry_price = None # Reset
                
        # --- Entry Logic (only if not in a position) ---
        if position == 0:
            # Long signal: previous close crosses ABOVE adaptive EMA
            if prev_close < self.adaptive_ema and current_close > self.adaptive_ema:
                self.buy()
                self.entry_price = current_close # Use current close for initial stop reference
                # Initialize trailing stop upon entry
                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 current_close < self.adaptive_ema:
                self.sell()
                self.entry_price = current_close
                # Initialize trailing stop upon entry
                self.trailing_stop = self.entry_price + self.params.atr_multiplier_sl * current_atr

Key Elements:

  1. Adaptive EMA Crossover: The strategy generates a long signal when the current close price crosses above the adaptive_ema (from below), indicating a shift to an uptrend. A short signal is generated when the close crosses below the adaptive_ema (from above), indicating a downtrend.
  2. ATR-Based Trailing Stop: Crucially, upon entering a position, an ATR-based trailing stop is immediately set. For long positions, the stop trails below the price, moving up as the price rises. For short positions, it trails above, moving down as the price falls. The atr_multiplier_sl parameter controls the sensitivity of this stop. This mechanism ensures that profits are protected and losses are limited dynamically, fulfilling the consistent use of trailing stops.
  3. No notify_order or notify_trade: This specific implementation uses a manual trailing stop logic within the next method rather than backtrader’s bt.Order.StopTrail or notify_order. While functional, using bt.Order.StopTrail and handling its lifecycle in notify_order is generally more robust for complex order management in backtrader.

Parameter Optimization: Finding the Best Fit

Parameter optimization systematically tests various combinations of a strategy’s input parameters to find those that yield the best historical performance according to a chosen metric (e.g., Sharpe Ratio, total return). This process helps in identifying the most effective settings for a given strategy on a specific dataset.

import backtrader as bt
import numpy as np
import pandas as pd
import yfinance as yf # Assuming yfinance is used for data fetching

def optimize_parameters(strategy_class, opt_params, ticker, start_date, end_date):
    """Run optimization to find best parameters with diagnostics"""
    print("="*60)
    print(f"OPTIMIZING: {strategy_class.__name__} on {ticker}")
    print("="*60)

    # Fetch data for optimization
    print(f"Fetching data from {start_date} to {end_date}...")
    # User-specified: yfinance download with auto_adjust=False and droplevel(axis=1, level=1)
    df = yf.download(ticker, start=start_date, end=end_date, auto_adjust=False, progress=False)
    if isinstance(df.columns, pd.MultiIndex):
        df = df.droplevel(1, axis=1)

    if df.empty:
        print("No data fetched for optimization. Exiting.")
        return None

    print(f"Data shape: {df.shape}")
    print(f"Date range: {df.index[0].date()} to {df.index[-1].date()}")

    # Set up optimization
    cerebro = bt.Cerebro()
    data = bt.feeds.PandasData(dataname=df)
    cerebro.adddata(data)
    
    start_cash = 10000.0
    cerebro.broker.setcash(start_cash)
    cerebro.broker.setcommission(commission=0.001)
    
    # Add analyzers for performance metrics
    cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name='sharpe', timeframe=bt.TimeFrame.Days, annualize=True, riskfreerate=0.0)
    cerebro.addanalyzer(bt.analyzers.Returns, _name='returns')
    cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name='trades')

    print("Testing parameter combinations...")
    cerebro.optstrategy(strategy_class, **opt_params) # Run the optimization

    stratruns = cerebro.run()
    print(f"Optimization complete! Tested {len(stratruns)} combinations.")

    # Collect and analyze results
    results = []
    for i, run in enumerate(stratruns):
        strategy = run[0]
        sharpe_analysis = strategy.analyzers.sharpe.get_analysis()
        returns_analysis = strategy.analyzers.returns.get_analysis()
        trades_analysis = strategy.analyzers.trades.get_analysis()
        
        rtot = returns_analysis.get('rtot', 0.0)
        final_value = start_cash * (1 + rtot)
        sharpe_ratio = sharpe_analysis.get('sharperatio', -999.0) # Default to a low number
        total_trades = trades_analysis.get('total', {}).get('total', 0)

        if sharpe_ratio is None or np.isnan(sharpe_ratio):
            sharpe_ratio = -999.0

        result = {
            'sharpe_ratio': sharpe_ratio,
            'final_value': final_value,
            'return_pct': rtot * 100,
            'total_trades': total_trades,
        }
        
        # Dynamically add parameter values to the results
        param_values = {p: getattr(strategy.p, p) for p in opt_params.keys()}
        result.update(param_values)
        
        results.append(result)

    # Filter for valid results (at least one trade) and sort
    valid_results = [r for r in results if r['total_trades'] > 0]
    
    if not valid_results:
        print("\nNo combinations resulted in any trades. Cannot determine best parameters.")
        return None
        
    results_sorted = sorted(valid_results, key=lambda x: x['sharpe_ratio'], reverse=True)
    
    print(f"\n{'='*120}")
    print("TOP 5 PARAMETER COMBINATIONS BY SHARPE RATIO")
    print(f"{'='*120}")
    
    top_5_df = pd.DataFrame(results_sorted[:5])
    print(top_5_df.to_string())
    
    best_params = results_sorted[0]
    print(f"\nBest Parameters Found: {best_params}")
    
    return best_params

Key Features of optimize_parameters:

Generalized Rolling Backtesting: Assessing Out-of-Sample Performance

Once optimal parameters are identified from an in-sample optimization period, a rolling backtest (also known as walk-forward optimization) assesses the strategy’s stability and performance on unseen data. This method simulates how a strategy would perform in live trading by iteratively optimizing on one period and testing on a subsequent, out-of-sample period.

import dateutil.relativedelta as rd # Needed for date calculations in rolling backtest

def run_rolling_backtest(strategy_class, strategy_params, ticker, start, end, window_months):
    """Generalized rolling backtest function"""
    all_results = []
    start_dt = pd.to_datetime(start)
    end_dt = pd.to_datetime(end)
    current_start = start_dt
    
    while True:
        current_end = current_start + rd.relativedelta(months=window_months)
        if current_end > end_dt:
            break
            
        print(f"\nROLLING BACKTEST: {current_start.date()} to {current_end.date()}")
        
        # Fetch data for the current window
        # User-specified: yfinance download with auto_adjust=False and droplevel(axis=1, level=1)
        data = yf.download(ticker, start=current_start, end=current_end, auto_adjust=False, progress=False)
        
        if data.empty or len(data) < 30: # Need at least some data for indicators to warm up
            print("Not enough data for this period. Skipping window.")
            current_start += rd.relativedelta(months=window_months)
            continue
            
        if isinstance(data.columns, pd.MultiIndex):
            data = data.droplevel(1, 1)
        
        # Calculate Buy & Hold return for the period as a benchmark
        start_price = data['Close'].iloc[0]
        end_price = data['Close'].iloc[-1]
        benchmark_ret = (end_price - start_price) / start_price * 100
        
        # Setup and run Cerebro for the current window
        feed = bt.feeds.PandasData(dataname=data)
        cerebro = bt.Cerebro()
        
        cerebro.addstrategy(strategy_class, **strategy_params) # Use the optimized parameters
        cerebro.adddata(feed)
        cerebro.broker.setcash(100000) # Initial cash for the window
        cerebro.broker.setcommission(commission=0.001)
        cerebro.addsizer(bt.sizers.PercentSizer, percents=95) # Allocate 95% of capital per trade
        cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name='trades')
        
        start_val = cerebro.broker.getvalue()
        results_run = cerebro.run()
        final_val = cerebro.broker.getvalue()
        strategy_ret = (final_val - start_val) / start_val * 100
        
        # Get trade statistics
        trades_analysis = results_run[0].analyzers.trades.get_analysis()
        total_trades = trades_analysis.get('total', {}).get('total', 0)
        
        all_results.append({
            'start': current_start.date(),
            'end': current_end.date(),
            'return_pct': strategy_ret,
            'benchmark_pct': benchmark_ret,
            'trades': total_trades,
        })
        
        print(f"Strategy Return: {strategy_ret:.2f}% | Buy & Hold Return: {benchmark_ret:.2f}% | Trades: {total_trades}")
        current_start = current_end # Move to the next window
        
    return pd.DataFrame(all_results)

Key Features of run_rolling_backtest:

Conclusion

The SpectralSlopeAdaptiveFilterTrailingStopStrategy represents a sophisticated fusion of signal processing and quantitative trading. By automatically adjusting its core trend indicator based on the inherent “noise color” of the market and implementing a disciplined ATR-based trailing stop, this strategy offers a compelling adaptive approach to trend following and risk management in dynamic financial markets.