Algorithmic trading often involves combining different technical
analysis concepts to create robust strategies. This article explores a
trend-following strategy implemented using the Python
backtrader library. It aims to identify the dominant market
trend using the ADX/DMI indicators and time entries during pullbacks
using the Hilbert Transform Sine wave, while managing exits with a
trailing stop loss. We’ll apply this to Bitcoin (BTC-USD) data obtained
via yfinance.
The core idea is to only enter trades in the direction of a confirmed, strong trend. Instead of entering immediately when a trend is detected, we wait for a potential pause or pullback, identified by the Hilbert Transform Sine wave cycle indicator, before entering. This aims to provide a potentially better entry price within the established trend.
Key Components:
Let’s break down how these components are implemented in the
backtrader strategy class.
1. Strategy Class and Parameters:
We define a strategy class inheriting from bt.Strategy
and set up default parameters.
Python
import backtrader as bt
import yfinance as yf
import pandas as pd
import talib # Required for bt.talib usage
class HilbertTrendStrategy(bt.Strategy):
"""
Trend Following Strategy using ADX/DMI for trend direction
and Hilbert Sine Wave crossover for pullback entry timing.
Exits via a percentage-based Trailing Stop Loss.
"""
params = dict(
adx_period=14, # Period for ADX/DMI calculation
adx_threshold=25, # ADX level above which a trend is active
trail_percent=0.05, # Trailing stop percentage (e.g., 0.05 = 5%)
)
# ... rest of the class methods ...
adx_period: Lookback period for ADX/DMI (default
14).adx_threshold: Minimum ADX value to consider a trend
strong enough (default 25).trail_percent: The percentage the price must reverse
from its peak/trough to trigger the trailing stop (default 5%).2. Indicator Setup (__init__)
In the initialization method, we set up the required indicators:
Python
def __init__(self):
# 1) Hilbert Transform SineWave via Backtrader talib integration
# Provides cycle timing signals
ht = bt.talib.HT_SINE(self.data.close)
self.ht_sine = ht.sine # Grab the 'sine' output line
self.ht_leadsine = ht.leadsine # Grab the 'leadsine' output line
# Create a crossover signal line for convenience
self.sine_cross = bt.indicators.CrossOver(
self.ht_sine, self.ht_leadsine
)
# 2) Directional Movement Index (ADX, +DI, -DI)
# Used for trend strength and direction
dmi = bt.indicators.DirectionalMovementIndex(
period=self.p.adx_period # Use parameter notation self.p
)
# Assign lines to instance variables for easy access
self.adx = dmi.adx
self.plus_di = dmi.plusDI # Note: Accessing directly, implies this works
self.minus_di = dmi.minusDI # Note: Accessing directly, implies this works
# 3) Order tracking
self.entry_order = None # Tracks pending entry order
self.stop_order = None # Tracks active stop order
bt.talib.HT_SINE to get the Hilbert Sine and
Lead Sine lines and create a CrossOver indicator for easy
signal detection.bt.indicators.DirectionalMovementIndex to
calculate ADX, +DI, and -DI. Note the code accesses
dmi.plusDI and dmi.minusDI directly,
suggesting this attribute access method worked in the final user
version.3. Entry Logic (next)
The next method contains the core logic executed on each
bar:
Python
def next(self):
# Ignore bar if any orders are pending
if self.entry_order or self.stop_order:
return
# Determine trend state using ADX and DMI
trending = self.adx[0] > self.p.adx_threshold
uptrend = trending and (self.plus_di[0] > self.minus_di[0])
downtrend = trending and (self.minus_di[0] > self.plus_di[0])
# Determine Hilbert Sine crossover state
cross_up = (self.sine_cross[0] == 1) # Sine crossed above LeadSine
cross_down = (self.sine_cross[0] == -1) # Sine crossed below LeadSine
# Entry Logic: Only enter if not already in a position
if not self.position:
if uptrend and cross_up:
# Enter LONG: Strong uptrend confirmed + HT cycle bottom signal (pullback likely ending)
self.entry_order = self.buy()
elif downtrend and cross_down:
# Enter SHORT: Strong downtrend confirmed + HT cycle top signal (rally likely ending)
self.entry_order = self.sell()
The logic is clear:
4. Exit Logic (notify_order)
Exits are handled solely by the trailing stop loss, which is placed immediately after an entry order is filled.
Python
def notify_order(self, order):
# Ignore submitted/accepted orders
if order.status in (order.Submitted, order.Accepted):
return
if order.status == order.Completed:
# Check if it was the entry order that completed
if order == self.entry_order:
self.entry_order = None # Clear pending entry order tracker
# Place the appropriate trailing stop order
if order.isbuy():
self.stop_order = self.sell(
exectype=bt.Order.StopTrail,
trailpercent=self.p.trail_percent
)
else: # Entry was a sell
self.stop_order = self.buy(
exectype=bt.Order.StopTrail,
trailpercent=self.p.trail_percent
)
# Check if it was the stop order that completed
elif order == self.stop_order:
self.stop_order = None # Clear active stop order tracker
elif order.status in (order.Canceled, order.Margin, order.Rejected):
# Clear relevant order tracker if order failed
if order == self.entry_order:
self.entry_order = None
elif order == self.stop_order:
self.stop_order = None
This ensures that once a position is entered, the exit is managed
dynamically by the broker simulation based on the
trail_percent.
The script includes helper functions and a main execution block to run the backtest.
Data Fetching:
A simple function fetches data using yfinance.
Python
def fetch_data(symbol: str, start: str, end: str) -> pd.DataFrame:
df = yf.download(symbol, start=start, end=end, progress=False)
if df.empty:
raise ValueError(f"No data for {symbol} from {start} to {end}")
# Drop the second level if columns come in as a MultiIndex
# (Common with older yfinance or specific parameters)
if isinstance(df.columns, pd.MultiIndex):
df.columns = df.columns.droplevel(1)
df.index = pd.to_datetime(df.index)
return df
Analysis Printing:
A helper function formats the output from Backtrader’s analyzers.
Python
def print_analysis(strat, data):
an = strat.analyzers
# 1) Sharpe
sharp = an.sharpe_ratio.get_analysis().get('sharperatio', None)
print(f"Sharpe Ratio: {sharp:.3f}" if sharp else "Sharpe Ratio: N/A")
# 2) Drawdown
dd = an.drawdown.get_analysis().max
print(f"Max Drawdown: {dd.drawdown:.2f}% (${dd.moneydown:.2f})")
# 3) Trades
tr = an.trades.get_analysis()
total = getattr(tr.total, 'total', 0)
print(f"Total Trades: {total}")
if total:
win = tr.won.total
loss = tr.lost.total
pf = (abs(tr.won.pnl.total / tr.lost.pnl.total)
if tr.lost.pnl.total else float('inf'))
print(f"Win Rate: {win/total*100:.2f}% | Profit Factor: {pf:.2f}")
# 4) Returns & CAGR
rtn = an.returns.get_analysis()
total_ret = rtn['rtot'] # e.g. 0.2278 → 22.78%
years = (data.index[-1] - data.index[0]).days / 365.25
cagr = (1 + total_ret)**(1/years) - 1
print(f"Total Return: {total_ret*100:.2f}%")
print(f"Annualized (CAGR):{cagr*100:.2f}%")
Main Execution Block:
This sets up Cerebro, adds the data and strategy,
configures the broker and sizer, adds analyzers, runs the test, prints
results, and plots.
Python
if __name__ == "__main__":
cerebro = bt.Cerebro()
cerebro.addstrategy(HilbertTrendStrategy)
data = fetch_data('BTC-USD', '2020-01-01', '2023-12-31')
cerebro.adddata(bt.feeds.PandasData(dataname=data))
cerebro.broker.setcash(10_000)
cerebro.broker.setcommission(commission=0.001)
cerebro.addsizer(bt.sizers.PercentSizer, percents=95)
cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name='sharpe_ratio',
timeframe=bt.TimeFrame.Days, riskfreerate=0.0)
cerebro.addanalyzer(bt.analyzers.DrawDown, _name='drawdown')
cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name='trades')
cerebro.addanalyzer(bt.analyzers.Returns, _name='returns')
print("Starting Portfolio Value:", cerebro.broker.getvalue())
results = cerebro.run()
strat = results[0]
print("Final Portfolio Value: ", cerebro.broker.getvalue())
print("\n--- Strategy Analysis ---")
print_analysis(strat, data)
cerebro.plot(iplot=False)
Starting Portfolio Value: 10000
Final Portfolio Value: 12557.804185738676
--- Strategy Analysis ---
Sharpe Ratio: 0.022
Max Drawdown: 14.82% ($1882.04)
Total Trades: 20
Win Rate: 55.00% | Profit Factor: 1.87
Total Return: 22.78%
Annualized (CAGR):5.27%
Based on previous iterations of testing this strategy (results not shown here, but referenced from prior context), this trend-following approach showed potential profitability on Bitcoin data, outperforming earlier mean-reversion attempts. It successfully captured some trend moves, resulting in a positive total return, a profit factor above 1.5, and a win rate over 50%.
However, potential weaknesses included:
adx_period,
adx_threshold, and trail_percent.Further Improvements to Consider:
trail_percent with an ATR-based trailing stop to adapt
better to changing market volatility.This backtrader strategy demonstrates a viable approach
to trend following by combining ADX/DMI for trend context with the
Hilbert Transform Sine wave for timing pullback entries. While showing
promise, particularly compared to pure mean-reversion on trending assets
like Bitcoin, its low trade frequency and potentially poor risk-adjusted
returns highlight the need for further refinement, optimization, and
robust risk management before considering live deployment. As always,
past performance is not indicative of future results.