← Back to Home
Butterworth Digital Filter for Trading Backtesting with Python and Backtrader

Butterworth Digital Filter for Trading Backtesting with Python and Backtrader

In financial markets, price data is inherently noisy, often obscuring true trends and making accurate signal generation challenging. Traditional moving averages can smooth this noise, but they introduce lag. Digital filters, particularly the Butterworth filter, offer a sophisticated alternative, allowing for precise control over the trade-off between smoothing and lag. This article explores the theory, method, and practical application of a Butterworth low-pass filter in a simple crossover trading strategy.

Butterworth Filter

The Butterworth filter is a type of signal filter designed to have as flat a frequency response as possible in the passband. The digital version applies a difference equation to smooth price data.

The basic recursive filter equation is:

y[n] = b_0 x[n] + b_1 x[n-1] + \dots - a_1 y[n-1] - a_2 y[n-2] \dots

Where:

The filter is applied using its transfer function coefficients, denoted as b (numerator) and a (denominator), which are derived from the filter’s design. The scipy.signal.butter function computes these coefficients. The filtering itself is performed using scipy.signal.lfilter, which applies a linear filter to data. For real-time applications like trading, it’s crucial to manage the filter’s internal state (zi) to ensure continuous, accurate filtering of new data points without re-filtering the entire historical series.

The Indicator: ButterworthFilter

Our ButterworthFilter indicator wraps this digital signal processing logic within Backtrader:

class ButterworthFilter(bt.Indicator):
    lines = ('filtered',)
    params = (
        ('cutoff_freq', 0.1),  # Normalized cutoff frequency (0 to 0.5)
        ('order', 2),          # Filter order
        ('lookback', 100),     # Data points needed for initial stable filter
    )

    def __init__(self):
        self.addminperiod(self.params.lookback)
        # Design the filter (coefficients b, a)
        self.b, self.a = signal.butter(
            N=self.params.order,
            Wn=self.params.cutoff_freq,
            btype='low',       # Low-pass filter
            analog=False       # Digital filter
        )
        self.data_buffer = deque(maxlen=self.params.lookback)
        self.zi = signal.lfilter_zi(self.b, self.a) # Initial filter state

    def next(self):
        current_price = self.data.close[0]
        self.data_buffer.append(current_price)

        if len(self.data_buffer) < self.params.lookback:
            self.lines.filtered[0] = current_price # Not enough data, return raw price
            return
        
        # Apply filter using the 'zi' (initial conditions) for continuity
        filtered_point, self.zi = signal.lfilter(
            self.b, self.a, [current_price], zi=self.zi
        )
        self.lines.filtered[0] = filtered_point[0]

The lookback parameter ensures that the filter has enough initial data to stabilize its output, preventing artifacts at the beginning of the series. The zi (initial conditions) array is crucial for lfilter to process new data points sequentially, maintaining the filter’s state from the previous calculation.

The Strategy: ButterworthCrossoverStrategy

The trading strategy employs two Butterworth filters: a “fast” filter with a higher cutoff_freq (less smoothing, less lag) and a “slow” filter with a lower cutoff_freq (more smoothing, more lag). Signals are generated based on the crossover of these two filtered lines, analogous to traditional moving average crossovers.

Additionally, the strategy incorporates:

class ButterworthCrossoverStrategy(bt.Strategy):
    params = (
        ('fast_cutoff', 0.15),
        ('slow_cutoff', 0.05),
        ('filter_order', 2),
        ('lookback', 50),
        ('trailing_stop_pct', 0.04),
        ('stop_loss_pct', 0.08),
        ('trend_threshold', 0.001), # Minimum relative difference for entry
        ('printlog', False),
    )

    def __init__(self):
        # Initialize fast and slow Butterworth filters
        self.fast_filter = ButterworthFilter(self.data.close, cutoff_freq=self.params.fast_cutoff, order=self.params.filter_order, lookback=self.params.lookback)
        self.slow_filter = ButterworthFilter(self.data.close, cutoff_freq=self.params.slow_cutoff, order=self.params.filter_order, lookback=self.params.lookback)
        
        # Crossover indicator for signals
        self.crossover = bt.indicators.CrossOver(self.fast_filter, self.slow_filter)
        self.filter_diff = self.fast_filter - self.slow_filter # For trend strength

        self.order = None
        self.trailing_stop_price = None
        self.entry_price = None
        self.last_signal = 0 # To prevent re-entry on same signal

    def next(self):
        if self.order: return # Only one order at a time
        current_price = self.data.close[0]
        fast_val = self.fast_filter[0]
        slow_val = self.slow_filter[0]

        # Ensure filters have enough data to be valid
        if len(self) < self.params.lookback * 2 or np.isnan(fast_val) or np.isnan(slow_val):
            return

        # Entry Logic
        if not self.position:
            filter_diff_ratio = (fast_val - slow_val) / abs(slow_val) # Relative difference
            
            # Long signal: Fast filter crosses above slow with sufficient trend strength
            if self.crossover[0] > 0 and filter_diff_ratio > self.params.trend_threshold and self.last_signal != 1:
                self.log(f'BUY CREATE: {current_price:.2f}')
                self.order = self.buy()
                self.last_signal = 1
            # Short signal: Fast filter crosses below slow with sufficient trend strength
            elif self.crossover[0] < 0 and filter_diff_ratio < -self.params.trend_threshold and self.last_signal != -1:
                self.log(f'SELL CREATE: {current_price:.2f}')
                self.order = self.sell()
                self.last_signal = -1
        
        # Exit and Stop-Loss/Trailing-Stop Logic (simplified for conciseness)
        else: # If in a position
            # ... (Full code includes logic for updating trailing_stop_price, and checking
            #     exit conditions based on crossover reversal, trailing stop, and fixed stop loss.)
            pass

The trend_threshold adds an interesting dimension, requiring not just a crossover but a sustained difference between the filters, potentially reducing false signals.

Backtesting and Initial Observations

The strategy is evaluated using a Backtrader environment with historical BTC-USD data. The run_butterworth_strategy() function sets up the Cerebro engine, adds the strategy with customizable parameters, fetches data from yfinance (respecting the auto_adjust=False and droplevel instruction), and includes standard financial analyzers.

def run_butterworth_strategy():
    print("Downloading BTC-USD data...")
    # Using saved instruction: yfinance download with auto_adjust=False and droplevel(axis=1, level=1).
    data = yf.download('BTC-USD', period='3y', auto_adjust=False).droplevel(1, axis=1)
    
    cerebro = bt.Cerebro()
    cerebro.addstrategy(ButterworthCrossoverStrategy,
                        fast_cutoff=0.1, slow_cutoff=0.02, filter_order=3, lookback=30,
                        trailing_stop_pct=0.05, stop_loss_pct=0.1, trend_threshold=0.001, printlog=True)
    cerebro.adddata(bt.feeds.PandasData(dataname=data))
    cerebro.broker.setcash(10000.0)
    cerebro.broker.setcommission(commission=0.001)
    cerebro.addsizer(bt.sizers.PercentSizer, percents=95)
    
    # Add analyzers for performance evaluation
    cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name='sharpe')
    cerebro.addanalyzer(bt.analyzers.DrawDown, _name='drawdown')
    cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name='trades')
    cerebro.addanalyzer(bt.analyzers.Returns, _name='returns')
    
    print(f'Starting Portfolio Value: {cerebro.broker.getvalue():.2f}')
    results = cerebro.run()
    strat = results[0]
    print(f'Final Portfolio Value: {cerebro.broker.getvalue():.2f}')
    # ... (code to print detailed analysis results and plot)

The backtest provides valuable metrics like Sharpe Ratio and Max Drawdown, alongside a visual representation of the equity curve. These initial results are crucial for understanding the strategy’s behavior in specific historical contexts.

Pasted image 20250617153103.png

Conclusion: A Promising Avenue for Noise Reduction

The Butterworth digital filter offers a powerful, mathematically grounded approach to smoothing financial data, providing a compelling alternative to traditional moving averages. By precisely controlling the cutoff frequency and filter order, traders can fine-tune the balance between noise reduction and lag. The ButterworthCrossoverStrategy demonstrates how these filters can be integrated into a complete trading system with robust risk management. While this initial exploration shows the potential of such an approach in filtering market noise and identifying trends, comprehensive optimization and extensive out-of-sample testing are essential next steps to validate its robustness and profitability across diverse market conditions.