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.
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:
Where:
signal.butter(...)
The filter is applied using its transfer function coefficients,
denoted as (numerator) and
(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.
ButterworthFilter
Our ButterworthFilter
indicator wraps this digital
signal processing logic within Backtrader:
class ButterworthFilter(bt.Indicator):
= ('filtered',)
lines = (
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(
=self.params.order,
N=self.params.cutoff_freq,
Wn='low', # Low-pass filter
btype=False # Digital filter
analog
)self.data_buffer = deque(maxlen=self.params.lookback)
self.zi = signal.lfilter_zi(self.b, self.a) # Initial filter state
def next(self):
= self.data.close[0]
current_price 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
self.zi = signal.lfilter(
filtered_point, 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.
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:
trend_threshold
parameter demands a minimum difference between the fast and slow filters
for a signal to be considered valid, aiming to filter out weak
crossovers.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
= self.data.close[0]
current_price = self.fast_filter[0]
fast_val = self.slow_filter[0]
slow_val
# 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:
= (fast_val - slow_val) / abs(slow_val) # Relative difference
filter_diff_ratio
# 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.
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).
= yf.download('BTC-USD', period='3y', auto_adjust=False).droplevel(1, axis=1)
data
= bt.Cerebro()
cerebro
cerebro.addstrategy(ButterworthCrossoverStrategy,=0.1, slow_cutoff=0.02, filter_order=3, lookback=30,
fast_cutoff=0.05, stop_loss_pct=0.1, trend_threshold=0.001, printlog=True)
trailing_stop_pct=data))
cerebro.adddata(bt.feeds.PandasData(dataname10000.0)
cerebro.broker.setcash(=0.001)
cerebro.broker.setcommission(commission=95)
cerebro.addsizer(bt.sizers.PercentSizer, percents
# Add analyzers for performance evaluation
='sharpe')
cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name='drawdown')
cerebro.addanalyzer(bt.analyzers.DrawDown, _name='trades')
cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name='returns')
cerebro.addanalyzer(bt.analyzers.Returns, _name
print(f'Starting Portfolio Value: {cerebro.broker.getvalue():.2f}')
= cerebro.run()
results = results[0]
strat 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.
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.