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.