Phase 5: Hands-on Labs

Lab 03: Custom Indicators & StrategiesHands-on: build and test your own custom indicators

Objectives

  1. Learn how to inherit from the Indicator base class.
  2. Implement a Z-Score indicator (commonly used in mean-reversion models).
  3. Integrate custom indicators into a Strategy.
  4. Develop Bollinger Band-style mean-reversion execution logic based on the Z-Score.

Step 1: Implementing the Z-Score Indicator

The Z-score is defined as (current price - moving average) / standard deviation.
It measures how far price has moved away from its recent mean. Values above +2.0 or below -2.0 are often treated as overbought or oversold conditions.

from nautilus_trader.indicators.base.indicator import Indicator
from nautilus_trader.indicators.average.sma import SimpleMovingAverage
from nautilus_trader.indicators.volatility.std_dev import StandardDeviation

class ZScore(Indicator):
    def __init__(self, period: int):
        super().__init__()  # Must call the parent class constructor
        self.period = period

        # Compose existing indicators
        self.sma = SimpleMovingAverage(period)
        self.std = StandardDeviation(period)

        self._value = 0.0

    @property
    def value(self) -> float:
        return self._value

    @property
    def initialized(self) -> bool:
        return self.sma.initialized and self.std.initialized

    def update(self, price: float):
        # 1. Update internal indicators
        self.sma.update(price)
        self.std.update(price)

        # 2. Return immediately if internal indicators are not initialized
        if not self.initialized:
            return

        # 3. Calculate Z-Score
        # Prevent division by zero
        if self.std.value == 0:
            self._value = 0.0
        else:
            self._value = (price - self.sma.value) / self.std.value

        # 4. Trigger update event (optional, if other components subscribe to this indicator)
        self._set_value(self._value)
💡 Composition vs. Inheritance

This is a good example of composition. Instead of reimplementing the moving average and standard deviation yourself, you reuse the existing SMA and StdDev indicators. The result is cleaner, safer, and easier to maintain.

Step 2: Developing a Mean-Reversion Strategy

Strategy Logic:

  • Z-Score > 2.0: Price is overextended on the upside, initiate a short position.
  • Z-Score < -2.0: Price is overextended on the downside, initiate a long position.
  • Z-Score reverts near 0 (e.g., -0.5 to 0.5): Close positions.
from nautilus_trader.trading.strategy import Strategy
from nautilus_trader.config import StrategyConfig
from nautilus_trader.model.enums import OrderSide

class MeanReversionConfig(StrategyConfig):
    instrument_id: str
    period: int = 20
    entry_z: float = 2.0
    exit_z: float = 0.5
    trade_size: str = "0.01"

class MeanReversionStrategy(Strategy):
    def __init__(self, config: MeanReversionConfig):
        super().__init__(config)

        # Instantiate our custom indicator
        self.z_score = ZScore(config.period)

    def on_bar(self, bar):
        # Update the indicator
        self.z_score.update(bar.close)

        if not self.z_score.initialized:
            return

        z = self.z_score.value
        self.log.info(f"Z-Score: {z:.2f}")

        # Retrieve current position
        pos = self.portfolio.position(self.config.instrument_id)

        # --- Entry Logic ---
        if not pos:
            if z < -self.config.entry_z:
                # Undervalued -> Go Long
                self.buy()
            elif z > self.config.entry_z:
                # Overvalued -> Go Short
                self.sell()

        # --- Exit Logic ---
        else:
            if pos.is_long and z > -self.config.exit_z:
                # Long position: Reverting near the moving average -> Close position
                self.close_all_positions(self.config.instrument_id)
            elif pos.is_short and z < self.config.exit_z:
                # Short position: Reverting near the moving average -> Close position
                self.close_all_positions(self.config.instrument_id)

    def buy(self):
        order = self.order_factory.market(
            instrument_id=self.config.instrument_id,
            order_side=OrderSide.BUY,
            quantity=self.config.trade_size,
        )
        self.submit_order(order)

    def sell(self):
        order = self.order_factory.market(
            instrument_id=self.config.instrument_id,
            order_side=OrderSide.SELL,
            quantity=self.config.trade_size,
        )
        self.submit_order(order)

Step 3: Backtest Validation

You can now deploy this strategy within the run_backtest.py script from Lab 01 to analyze the backtest performance of this mean-reversion strategy on the ETHUSDT instrument.

Exercises

📝 Challenge Lab

1. Try modifying the strategy to include a momentum filter: Only emit long signals (Z < -2) when the RSI is concurrently in the oversold regime (< 30).

2. Deploy this strategy into the Lab 02 sandbox environment and observe its live execution performance.

3. Advanced Challenge: Implement a SuperTrend indicator (requires calculating the Average True Range, ATR).