← Back to Home
Trading the Awesome Oscillator Saucer with Trend Confirmation

Trading the Awesome Oscillator Saucer with Trend Confirmation

The Awesome Oscillator (AO), developed by Bill Williams, is a momentum indicator that reflects changes in market driving force. One of its key patterns is the “Saucer,” which signals a short-term shift in momentum within an established trend, suggesting a potential continuation of that trend. This article will explain the AOSaucerStrategy, which aims to capitalize on these patterns, filtered by a long-term Exponential Moving Average (EMA) and protected by a dynamic ATR-based trailing stop.

Strategy Overview

The AOSaucerStrategy identifies entries based on specific Awesome Oscillator configurations that resemble a “saucer” shape, signaling a brief pause or reversal in short-term momentum before the dominant trend resumes. To ensure trades align with the larger market direction, an Exponential Moving Average (EMA) acts as a trend filter. Finally, an Average True Range (ATR) based trailing stop is implemented for robust risk management.

Buy Signal (Bullish Saucer)

A bullish saucer pattern indicates a potential buying opportunity in an uptrend. It’s identified when:

  1. The price is above the long-term EMA, confirming an uptrend.
  2. The Awesome Oscillator is positive (above zero).
  3. The current AO bar is green (higher than the previous bar).
  4. The previous AO bar was red (lower than the bar before it) and smaller than the bar before that, indicating a minor dip in momentum.

Essentially, it looks for two consecutive red bars in the AO histogram followed by a green bar, all while the AO remains above zero, and price is in an uptrend.

Sell Signal (Bearish Saucer)

A bearish saucer pattern suggests a potential selling opportunity in a downtrend. It’s identified when:

  1. The price is below the long-term EMA, confirming a downtrend.
  2. The Awesome Oscillator is negative (below zero).
  3. The current AO bar is red (lower than the previous bar).
  4. The previous AO bar was green (higher than the bar before it) and smaller than the bar before that, indicating a minor bounce in momentum.

This involves two consecutive green bars in the AO histogram followed by a red bar, all while the AO remains below zero, and price is in a downtrend.

Backtrader Implementation

The strategy is implemented in backtrader as follows:

import backtrader as bt

class AOSaucerStrategy(bt.Strategy):
    """
    A trend-continuation strategy that enters on a Bill Williams 'Saucer'
    pattern in the Awesome Oscillator, filtered by a long-term EMA.
    """
    params = (
        # Awesome Oscillator
        ('ao_fast', 7),
        ('ao_slow', 30),
        # Trend Filter
        ('trend_period', 30),
        # Risk Management
        ('atr_period', 14),
        ('atr_stop_multiplier', 3.0),
    )

    def __init__(self):
        self.order = None

        # --- Indicators ---
        self.ao = bt.indicators.AwesomeOscillator(self.data, fast=self.p.ao_fast, slow=self.p.ao_slow)
        self.trend_ema = bt.indicators.ExponentialMovingAverage(self.data, period=self.p.trend_period)
        self.atr = bt.indicators.AverageTrueRange(self.data, period=self.p.atr_period)

        # --- Trailing Stop State ---
        self.stop_price = None
        self.highest_price_since_entry = None
        self.lowest_price_since_entry = None

    def notify_order(self, order):
        if order.status in [order.Submitted, order.Accepted]: return
        if order.status in [order.Completed]:
            if self.position and self.stop_price is None:
                if order.isbuy():
                    self.highest_price_since_entry = self.data.high[0]
                    self.stop_price = self.highest_price_since_entry - (self.atr[0] * self.p.atr_stop_multiplier)
                elif order.issell():
                    self.lowest_price_since_entry = self.data.low[0]
                    self.stop_price = self.lowest_price_since_entry + (self.atr[0] * self.p.atr_stop_multiplier)
            elif not self.position:
                self.stop_price = None; self.highest_price_since_entry = None; self.lowest_price_since_entry = None
        self.order = None

    def next(self):
        if self.order: return

        if not self.position:
            # --- 1. Check Macro Trend Filter ---
            is_uptrend = self.data.close[0] > self.trend_ema[0]
            is_downtrend = self.data.close[0] < self.trend_ema[0]

            # --- 2. Check for Saucer Patterns ---
            # Bullish Saucer requires AO > 0
            if is_uptrend and self.ao[0] > 0:
                is_green_bar = self.ao[0] > self.ao[-1]
                is_second_red_bar_smaller = self.ao[-1] < 0 and self.ao[-1] > self.ao[-2]
                
                if is_green_bar and is_second_red_bar_smaller:
                    self.order = self.buy()
            
            # Bearish Saucer requires AO < 0
            elif is_downtrend and self.ao[0] < 0:
                is_red_bar = self.ao[0] < self.ao[-1]
                is_second_green_bar_smaller = self.ao[-1] > 0 and self.ao[-1] < self.ao[-2]
                
                if is_red_bar and is_second_green_bar_smaller:
                    self.order = self.sell()

        elif self.position:
            # --- Manual ATR Trailing Stop Logic ---
            if self.position.size > 0: # Long
                self.highest_price_since_entry = max(self.highest_price_since_entry, self.data.high[0])
                new_stop = self.highest_price_since_entry - (self.atr[0] * self.p.atr_stop_multiplier)
                self.stop_price = max(self.stop_price, new_stop)
                if self.data.close[0] < self.stop_price: self.order = self.close()
            elif self.position.size < 0: # Short
                self.lowest_price_since_entry = min(self.lowest_price_since_entry, self.data.low[0])
                new_stop = self.lowest_price_since_entry + (self.atr[0] * self.p.atr_stop_multiplier)
                self.stop_price = min(self.stop_price, new_stop)
                if self.data.close[0] > self.stop_price: self.order = self.close()

Parameters (params)

Initialization (__init__)

The __init__ method sets up the necessary indicators:

Order Notification (notify_order)

This method is crucial for handling order completions and managing the trailing stop. When a position is entered, it sets the initial stop_price based on the entry price and current ATR. When a position is closed, it resets the trailing stop variables, preparing for the next trade.

Main Logic (next)

The next method is executed bar by bar and contains the core trading logic:

  1. Trend Filter: It first checks if the current close price is above (is_uptrend) or below (is_downtrend) the trend_ema. This ensures trades are only taken in the direction of the macro trend.
  2. Saucer Pattern Detection:
    • Bullish Saucer: If in an uptrend and AO is positive, it looks for the specific pattern: current AO bar is green, and the previous AO bar was red and smaller than the one before it. If this saucer formation is found, a buy order is placed.
    • Bearish Saucer: If in a downtrend and AO is negative, it looks for the inverse pattern: current AO bar is red, and the previous AO bar was green and smaller than the one before it. If this saucer formation is found, a sell order is placed.
  3. Manual ATR Trailing Stop: Once a position is open, this logic dynamically adjusts the stop_price.
    • For long positions, the stop_price moves up as the price moves higher, locking in profits. If the price falls below the trailing stop, the position is closed.
    • For short positions, the stop_price moves down as the price moves lower. If the price rises above the trailing stop, the position is closed. This manual implementation ensures trailing stops are always active and adjust dynamically.

Rolling Backtesting Setup

To evaluate the strategy’s performance, we’ll employ a rolling backtest. This method assesses the strategy over multiple, successive time windows, providing a more robust view of its consistency compared to a single, fixed backtest.

from collections import deque
import backtrader as bt
import numpy as np
import pandas as pd
import yfinance as yf
import dateutil.relativedelta as rd

def run_rolling_backtest(
    ticker,
    start,
    end,
    window_months,
    strategy_class,
    strategy_params=None
):
    strategy_params = strategy_params or {}
    all_results = []
    start_dt = pd.to_datetime(start)
    end_dt = pd.to_datetime(end)
    current_start = start_dt

    while True:
        current_end = current_start + rd.relativedelta(months=window_months)
        # Ensure the current_end does not exceed the overall end date
        if current_end > end_dt:
            current_end = end_dt # Adjust current_end to the overall end_dt if it overshoots
            if current_start >= current_end: # If the window becomes invalid, break
                break

        print(f"\nROLLING BACKTEST: {current_start.date()} to {current_end.date()}")

        # Fetch data using yfinance
        data = yf.download(ticker, start=current_start, end=current_end, auto_adjust=False, progress=False)

        if data.empty or len(data) < 90:
            print("Not enough data for this period. Skipping.")
            # Advance start date to the next window for the next iteration
            current_start += rd.relativedelta(months=window_months)
            continue

        if isinstance(data.columns, pd.MultiIndex):
            data = data.droplevel(1, 1)

        # Calculate Buy & Hold return for the period
        start_price = data['Close'].iloc[0]
        end_price = data['Close'].iloc[-1]
        benchmark_ret = (end_price - start_price) / start_price * 100

        feed = bt.feeds.PandasData(dataname=data)
        cerebro = bt.Cerebro()
        
        cerebro.addstrategy(strategy_class, **strategy_params)
        cerebro.adddata(feed)
        cerebro.broker.setcash(100000)
        cerebro.broker.setcommission(commission=0.001)
        cerebro.addsizer(bt.sizers.PercentSizer, percents=95)

        start_val = cerebro.broker.getvalue()
        try:
            cerebro.run()
        except Exception as e:
            print(f"Error running backtest for {current_start.date()} to {current_end.date()}: {e}")
            current_start += rd.relativedelta(months=window_months)
            continue

        final_val = cerebro.broker.getvalue()
        strategy_ret = (final_val - start_val) / start_val * 100

        all_results.append({
            'start': current_start.date(),
            'end': current_end.date(),
            'strategy_return_pct': strategy_ret,
            'benchmark_return_pct': benchmark_ret,
            'final_value': final_val,
        })

        print(f"Strategy Return: {strategy_ret:.2f}% | Buy & Hold Return: {benchmark_ret:.2f}%")

        # Advance start date for the next window
        current_start += rd.relativedelta(months=window_months)

        # Break if the next window's start date goes beyond the overall end date
        if current_start > end_dt:
            break

    return pd.DataFrame(all_results)

How the Rolling Backtest Works:

Conclusion

The AOSaucerStrategy offers a structured approach to trend-continuation trading using Bill Williams’ Awesome Oscillator patterns. By combining the “Saucer” entry pattern with a long-term EMA trend filter and a dynamic ATR trailing stop, it aims to capture momentum shifts within established trends while managing risk effectively. The use of a rolling backtest provides a more robust and realistic assessment of its potential performance across various market cycles. As always, thorough analysis of backtesting results and further optimization are recommended before considering any strategy for live trading.