← Back to Home
Is the Hilbert Sine Wave Effective for Timing Trend Pullbacks

Is the Hilbert Sine Wave Effective for Timing Trend Pullbacks

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.

Strategy Overview

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:

  1. Trend Identification: Average Directional Index (ADX) and Directional Movement Index (+DI, -DI).
  2. Entry Timing (Pullback Signal): Hilbert Transform Sine Wave Crossover.
  3. Exit Management: Percentage-Based Trailing Stop Loss.

Implementation Details

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 ...

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

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.

Running the Backtest

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)
Pasted image 20250503040107.png
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%

Discussion & Potential Improvements

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:

  1. Low Trade Frequency: The combination of ADX trend confirmation and specific Hilbert Sine timing signals might still result in relatively few trades over long periods.
  2. Low Sharpe Ratio: Despite being profitable, the risk-adjusted return (Sharpe Ratio) was often very low, suggesting high volatility relative to returns.
  3. Parameter Sensitivity: Performance likely depends heavily on the chosen adx_period, adx_threshold, and trail_percent.

Further Improvements to Consider:

Conclusion

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.