← Back to Home
Hull MA Slope Rider BNB-USD Hourly Tutorial 29.33% Return

Hull MA Slope Rider BNB-USD Hourly Tutorial 29.33% Return

This tutorial uses HullMaSlopeRiderStrategy from the Mega Backtrader Strategy Pack. The package includes the complete strategy source and the backtest runner used to produce these results.

The Hull MA Slope Rider strategy returned 29.33% on BNB-USD while BNB buy-and-hold lost 27.23%. Its Sharpe ratio was 1.62 and maximum drawdown was 14.39%.

The Result First

HullMaSlopeRiderStrategy hourly backtest results

Each backtest started with $10,000 and used 1-hour bars from August 14, 2025 at 15:00 UTC through August 14, 2026 at 15:00 UTC, 0.10% commission per execution, 95% position sizing, and long-only execution. Every run ended with zero open trades.

What We Are Building

The strategy uses the faster response of a Hull Moving Average to identify slope direction, confirms the broader trend with a 50-hour SMA, and protects the position with a three-ATR trailing stop.

Step 0: Install and Download Hourly Data

Install the minimal dependencies:

pip install backtrader yfinance pandas matplotlib numpy

Then fetch and normalize the hourly OHLCV frame:

from datetime import datetime, timezone

import backtrader as bt
import pandas as pd
import yfinance as yf

symbol = "BNB-USD"
data = yf.download(
    symbol,
    start=datetime(2025, 8, 14, 15, tzinfo=timezone.utc),
    end=datetime(2026, 8, 14, 16, tzinfo=timezone.utc),
    interval="1h",
    auto_adjust=False,
    progress=False,
)

if isinstance(data.columns, pd.MultiIndex):
    data.columns = data.columns.get_level_values(0)
data = data[["Open", "High", "Low", "Close", "Volume"]].dropna()

Step 1: build the Hull Moving Average

Hull MA combines weighted averages to reduce lag while retaining smoothing.

def hull_ma(data, period):
    half = max(2, period // 2)
    root = max(2, int(round(np.sqrt(period))))
    fast_wma = bt.indicators.WMA(data, period=half)
    slow_wma = bt.indicators.WMA(data, period=period)
    raw = fast_wma * 2 - slow_wma
    return bt.indicators.WMA(raw, period=root)

Step 2: initialize trend and risk indicators

The Hull MA supplies the fast signal, the SMA supplies context, and ATR determines the trailing distance.

def __init__(self):
    self.hull = hull_ma(self.data.close, self.p.hull_period)
    self.sma = bt.indicators.SMA(
        self.data.close, period=self.p.sma_filter
    )
    self.atr = bt.indicators.ATR(self.data, period=self.p.atr_period)
    self.order = None
    self.trail = None

Step 3: measure the Hull MA slope

A three-bar difference turns the smoothed average into a directional signal.

def _slope(self):
    if len(self.hull) <= self.p.slope_lookback:
        return 0.0
    return self.hull[0] - self.hull[-self.p.slope_lookback]

Step 4: combine slope with the SMA filter

The long entry needs both a rising Hull MA and price above the slower SMA.

slope = self._slope()
long_signal = slope > 0 and self.data.close[0] > self.sma[0]

if not self.position and long_signal:
    self.order = self.buy()

Step 5: trail by three ATR and respect slope reversals

The trade can exit through the stop or when the Hull MA slope turns negative.

trail_amount = self.atr[0] * self.p.stop_atr_mult
self.trail = self.sell(
    exectype=bt.Order.StopTrail,
    trailamount=trail_amount,
)

if self.position.size > 0 and self._slope() < 0:
    self.cancel(self.trail)
    self.order = self.close()

Connect the Strategy to Backtrader

Replace StrategyClass with the class built above. This is the minimal engine configuration: $10,000 starting cash, 0.10% commission, and 95% position sizing.

cerebro = bt.Cerebro()
cerebro.broker.setcash(10_000)
cerebro.broker.setcommission(commission=0.001)  # 0.10% per execution
cerebro.addsizer(bt.sizers.PercentSizer, percents=95)
cerebro.adddata(bt.feeds.PandasData(dataname=data))
cerebro.addstrategy(StrategyClass)

result = cerebro.run()[0]
print(f"Final value: ${cerebro.broker.getvalue():,.2f}")

Complete Copy-and-Run Script

The following script includes the complete strategy, data download, long-only execution rule, analyzers, and printed results. Save it as a .py file and run it directly.

import math
from datetime import datetime, timezone

import backtrader as bt
import backtrader.indicators as btind
import numpy as np
import pandas as pd

import yfinance as yf


# Complete strategy implementation

def hull_ma(data, period):
    """WMA(2*WMA(n/2) - WMA(n)), sqrt(n))."""
    half = max(2, period // 2)
    sqrtn = max(2, int(round(np.sqrt(period))))
    wma_half = bt.indicators.WeightedMovingAverage(data, period=half)
    wma_full = bt.indicators.WeightedMovingAverage(data, period=period)
    raw = wma_half * 2.0 - wma_full
    return bt.indicators.WeightedMovingAverage(raw, period=sqrtn)


class HullMaSlopeRiderStrategy(bt.Strategy):
    """Ride the slope of HullMA filtered by a slow SMA.

    Entry (long):  HullMA slope > 0 AND close > sma_filter.
    Entry (short): HullMA slope < 0 AND close < sma_filter.
    Exit:          opposite slope.
    Risk:          Chandelier-style ATR trail (3 * ATR).
    """
    params = (
        ('hull_period', 20),
        ('sma_filter', 50),
        ('slope_lookback', 3),
        ('atr_period', 14),
        ('stop_atr_mult', 3.0),
        ('printlog', False),
    )

    def __init__(self):
        d = self.datas[0]
        self.hull = hull_ma(d.close, self.p.hull_period)
        self.sma = bt.indicators.SMA(d.close, period=self.p.sma_filter)
        self.atr = bt.indicators.ATR(d, period=self.p.atr_period)
        self.order = None
        self.trail = None

    def _slope(self):
        if len(self.hull) <= self.p.slope_lookback:
            return 0.0
        return self.hull[0] - self.hull[-self.p.slope_lookback]

    def notify_order(self, order):
        if order.status in [order.Submitted, order.Accepted]:
            return
        if order.status == order.Completed:
            self._place_trail(self.position.size > 0)
        if order is self.order:
            self.order = None
        elif order is self.trail:
            self.trail = None

    def _place_trail(self, is_long):
        self._cancel_trail()
        amt = self.atr[0] * self.p.stop_atr_mult
        side = self.sell if is_long else self.buy
        self.trail = side(exectype=bt.Order.StopTrail, trailamount=amt)

    def _cancel_trail(self):
        if self.trail:
            self.cancel(self.trail)
            self.trail = None

    def next(self):
        if self.order:
            return
        slope = self._slope()
        up_trend = self.data.close[0] > self.sma[0]
        dn_trend = self.data.close[0] < self.sma[0]
        if not self.position:
            if slope > 0 and up_trend:
                self.order = self.buy()
            elif slope < 0 and dn_trend:
                self.order = self.sell()
            return
        if self.position.size > 0 and slope < 0:
            self._cancel_trail(); self.order = self.close()
        elif self.position.size < 0 and slope > 0:
            self._cancel_trail(); self.order = self.close()


# Keep this tutorial backtest long-only.
original_sell = bt.Strategy.sell

def long_only_sell(self, *args, **kwargs):
    if self.position.size <= 0:
        return None
    size = kwargs.get("size")
    if size is None or size > self.position.size:
        kwargs["size"] = self.position.size
    return original_sell(self, *args, **kwargs)

bt.Strategy.sell = long_only_sell


# Download the exact hourly test window.
symbol = "BNB-USD"
data = yf.download(
    symbol,
    start=datetime(2025, 8, 14, 15, tzinfo=timezone.utc),
    end=datetime(2026, 8, 14, 16, tzinfo=timezone.utc),
    interval="1h",
    auto_adjust=False,
    progress=False,
)

if isinstance(data.columns, pd.MultiIndex):
    data.columns = data.columns.get_level_values(0)
data = data[["Open", "High", "Low", "Close", "Volume"]].dropna()
if getattr(data.index, "tz", None) is not None:
    data.index = data.index.tz_localize(None)


# Configure and run Backtrader.
cerebro = bt.Cerebro()
cerebro.broker.setcash(10_000)
cerebro.broker.setcommission(commission=0.001)
cerebro.addsizer(bt.sizers.PercentSizer, percents=95)
cerebro.adddata(bt.feeds.PandasData(dataname=data))
cerebro.addstrategy(HullMaSlopeRiderStrategy)
cerebro.addanalyzer(bt.analyzers.DrawDown, _name="drawdown")
cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name="trades")

result = cerebro.run()[0]
final_value = cerebro.broker.getvalue()
total_return = (final_value / 10_000 - 1) * 100
buy_hold_prices = data.loc[data.index.hour == 0, "Close"]
buy_hold_return = (buy_hold_prices.iloc[-1] / buy_hold_prices.iloc[0] - 1) * 100
excess_return = total_return - buy_hold_return
drawdown = result.analyzers.drawdown.get_analysis()
trades = result.analyzers.trades.get_analysis()

print(f"Final value: ${final_value:,.2f}")
print(f"Strategy return: {total_return:.2f}%")
print(f"Buy-and-hold return: {buy_hold_return:.2f}%")
print(f"Excess return: {excess_return:.2f} percentage points")
print(f"Maximum drawdown: {drawdown['max']['drawdown']:.2f}%")
print(f"Closed trades: {trades.get('total', {}).get('closed', 0)}")

Run the Packaged Backtest

If you have the Mega Backtrader Strategy Pack, this command runs the full reporting pipeline for BNB-USD and automatically creates the CSV metrics and charts used below:

python run_backtest.py --symbol BNB-USD --period 1y --interval 1h --benchmark BNB-USD --strategy-filter HullMaSlopeRiderStrategy.py --out hourly_crypto_results

Read the Charts

BNB-USD: strategy versus buy-and-hold

HullMaSlopeRiderStrategy BNB-USD versus buy-and-hold

BNB-USD: drawdown versus buy-and-hold

HullMaSlopeRiderStrategy BNB-USD drawdown versus buy-and-hold

BNB-USD: strategy return distribution

HullMaSlopeRiderStrategy BNB-USD return distribution

How to Interpret This Result

A Better Research Workflow

  1. Save the downloaded dataset and record its checksum so every future run uses the same input bars.
  2. Reserve untouched validation months, then run walk-forward rather than selecting on the entire year.
  3. Test nearby parameters. A robust idea should not collapse when a period changes by one or two bars.
  4. Add spread, slippage, funding, and exchange-specific order rules—especially for high-turnover strategies.
  5. Inspect every order lifecycle and test the short-enabled branch separately with --allow-short.
  6. Compare the one-hour configuration with time-equivalent settings on four-hour and daily bars.

Continue with the Full Package

HullMaSlopeRiderStrategy comes from the Mega Backtrader Strategy Pack. The package includes the complete HullMaSlopeRiderStrategy.py source file and the backtest runner used in this tutorial, so you can run the strategy, change its parameters, and generate the same result files yourself.

Disclaimer

This material is for research and education only. Backtests are hypothetical, sensitive to data and assumptions, and do not guarantee future performance.