← Back to Home
Multi-Asset Crypto Portfolio with AO Saucer, ATR Stops, and Backtrader

Multi-Asset Crypto Portfolio with AO Saucer, ATR Stops, and Backtrader

This project builds a fully automated multi-asset crypto portfolio strategy using Bill Williams’ Awesome Oscillator (AO) Saucer pattern. It runs on hourly data from Binance, sizes positions by risk, and manages exits with ATR-based trailing stops. Performance is evaluated against a simple BTC/USDC buy-and-hold benchmark.

On a recent three-month backtest, the AO Saucer portfolio grew the account from 100,000 to about 120,600 USDC with a maximum drawdown of roughly −12%, while buy-and-hold BTC lost value and experienced significantly deeper drawdowns.

Fetching and aligning OHLCV data from Binance

Binance limits each OHLCV request to 1000 candles, so the script uses pagination to walk forward through history until it reaches the current time. The function below fetches several months of hourly data and returns a clean, deduplicated DataFrame:

def fetch_ohlcv_binance(symbol, timeframe='1h', months=3):
    exchange = ccxt.binance()
    now = exchange.milliseconds()
    start_since = now - int(months * 30 * 24 * 60 * 60 * 1000)

    all_ohlcv = []
    current_since = start_since

    while True:
        ohlcv = exchange.fetch_ohlcv(symbol, timeframe=timeframe,
                                     since=current_since, limit=1000)
        if not ohlcv:
            break

        all_ohlcv.extend(ohlcv)
        last_timestamp = ohlcv[-1][0]

        if last_timestamp >= now:
            break

        current_since = last_timestamp + 1
        time.sleep(exchange.rateLimit / 1000)

    if not all_ohlcv:
        return pd.DataFrame()

    df = pd.DataFrame(
        all_ohlcv,
        columns=['datetime', 'open', 'high', 'low', 'close', 'volume']
    )
    df['datetime'] = pd.to_datetime(df['datetime'], unit='ms')
    df.set_index('datetime', inplace=True)

    df = df[~df.index.duplicated(keep='first')]
    df.sort_index(inplace=True)
    return df

The script downloads data for a basket of large-cap coins quoted in USDC (BTC, ETH, SOL, BNB, XRP, etc.), then intersects their timestamps to keep only bars that exist for all symbols, ensuring a clean multi-asset backtest.

AO Saucer portfolio strategy in Backtrader

The portfolio strategy trades every data feed in the Cerebro engine. For each asset it:

Initialization builds indicator dictionaries keyed by data feed:

class PortfolioAOSaucer(bt.Strategy):
    params = dict(
        ao_fast=24*1,
        ao_slow=24*3,
        trend_period=24*7,
        atr_period=24,
        atr_stop_multiplier=2.0,
        risk_per_trade=0.005,    # 0.5%
        max_portfolio_risk=0.04, # 4%
    )

    def __init__(self):
        self.ao, self.trend_ema, self.atr = {}, {}, {}
        self.stop_price, self.highest, self.lowest = {}, {}, {}
        self.entry_risk = {}

        for d in self.datas:
            self.ao[d] = bt.indicators.AwesomeOscillator(
                d, fast=self.p.ao_fast, slow=self.p.ao_slow
            )
            self.trend_ema[d] = bt.indicators.ExponentialMovingAverage(
                d, period=self.p.trend_period
            )
            self.atr[d] = bt.indicators.AverageTrueRange(
                d, period=self.p.atr_period
            )
            self.stop_price[d] = None
            self.highest[d] = None
            self.lowest[d] = None
            self.entry_risk[d] = 0.0

When a new order completes, the strategy initializes the trailing stop and estimates how much equity is at risk:

def notify_order(self, order):
    if order.status in [order.Submitted, order.Accepted]:
        return

    data = order.data
    if order.status == order.Completed:
        pos = self.getposition(data)
        if pos.size != 0:
            atr_val = self.atr[data][0]

            if order.isbuy():
                self.highest[data] = data.high[0]
                stop = self.highest[data] - atr_val * self.p.atr_stop_multiplier
            else:
                self.lowest[data] = data.low[0]
                stop = self.lowest[data] + atr_val * self.p.atr_stop_multiplier

            self.stop_price[data] = stop

            risk_per_unit = abs(data.close[0] - stop)
            equity = self.broker.getvalue()
            risk_value = risk_per_unit * abs(pos.size)
            self.entry_risk[data] = risk_value / equity if equity > 0 else 0.0
        else:
            self.stop_price[data] = None
            self.highest[data] = None
            self.lowest[data] = None
            self.entry_risk[data] = 0.0

On every new bar, the strategy first manages open trades via ATR trailing stops, then looks for new AO saucer entries while respecting the portfolio risk cap:

def next(self):
    equity = self.broker.getvalue()

    # Manage open positions with ATR trailing stops
    for data in self.datas:
        pos = self.getposition(data)
        if pos.size == 0:
            continue

        atr_val = self.atr[data][0]

        if pos.size > 0:
            current_high = data.high[0]
            if self.highest[data] is None or current_high > self.highest[data]:
                self.highest[data] = current_high

            new_stop = self.highest[data] - atr_val * self.p.atr_stop_multiplier
            self.stop_price[data] = (
                new_stop if self.stop_price[data] is None
                else max(self.stop_price[data], new_stop)
            )

            if data.close[0] < self.stop_price[data]:
                self.close(data=data)

        elif pos.size < 0:
            current_low = data.low[0]
            if self.lowest[data] is None or current_low < self.lowest[data]:
                self.lowest[data] = current_low

            new_stop = self.lowest[data] + atr_val * self.p.atr_stop_multiplier
            self.stop_price[data] = (
                new_stop if self.stop_price[data] is None
                else min(self.stop_price[data], new_stop)
            )

            if data.close[0] > self.stop_price[data]:
                self.close(data=data)

    # Entry logic
    total_open_risk = sum(self.entry_risk.values())

    for data in self.datas:
        pos = self.getposition(data)
        if pos.size != 0:
            continue

        if total_open_risk >= self.p.max_portfolio_risk:
            break

        ao = self.ao[data]
        ema = self.trend_ema[data]
        atr_val = self.atr[data][0]

        if atr_val <= 0 or len(ao) < 3:
            continue

        is_uptrend = data.close[0] > ema[0]
        is_downtrend = data.close[0] < ema[0]

        signal_buy = False
        signal_sell = False

        if is_uptrend and ao[0] > 0 and ao[-1] > 0 and ao[-2] > 0:
            if ao[-2] > ao[-1] and ao[-1] < ao[0]:
                signal_buy = True

        elif is_downtrend and ao[0] < 0 and ao[-1] < 0 and ao[-2] < 0:
            if ao[-2] < ao[-1] and ao[-1] > ao[0]:
                signal_sell = True

        if signal_buy or signal_sell:
            stop_distance = atr_val * self.p.atr_stop_multiplier
            risk_value = self.p.risk_per_trade * equity
            size = risk_value / stop_distance if stop_distance > 0 else 0

            if size > 0.00001 and \
               (total_open_risk + self.p.risk_per_trade) <= self.p.max_portfolio_risk:
                if signal_buy:
                    self.buy(data=data, size=size)
                else:
                    self.sell(data=data, size=size)
                total_open_risk += self.p.risk_per_trade

This combination gives you a trend-aligned, volatility-aware, and risk-controlled multi-asset system.

Benchmark and performance reporting

The benchmark is intentionally simple: buy BTC/USDC once and hold it. The strategy is:

class BuyHold(bt.Strategy):
    def next(self):
        if not self.position:
            size = (self.broker.getcash() * 0.99) / self.data.close[0]
            self.buy(size=size)

Both the AO Saucer portfolio and the benchmark are run through Backtrader with the TimeReturn analyzer. The returns are converted into equity curves and passed into a small reporting helper that prints total return, CAGR, Sharpe ratio, and maximum drawdown:

def returns_to_equity(returns_dict, start_value=100000.0):
    rets = pd.Series(returns_dict)
    rets.sort_index(inplace=True)
    equity = (1 + rets).cumprod() * start_value
    return rets, equity

def performance_report(rets, equity, label, bar_per_year):
    if equity.empty:
        print(f"No trades or data for {label}")
        return

    total_return = equity.iloc[-1] / equity.iloc[0] - 1
    mean_ret = rets.mean()
    vol_ret = rets.std()
    sharpe = (mean_ret / vol_ret) * np.sqrt(bar_per_year) if vol_ret > 0 else np.nan

    running_max = equity.cummax()
    drawdown = (equity / running_max - 1).min()

    days = (equity.index[-1] - equity.index[0]).days
    cagr = (equity.iloc[-1] / equity.iloc[0]) ** (365.0 / days) - 1 if days > 0 else np.nan

    print(f"\n===== {label} Performance =====")
    print(f"Total Return: {total_return:6.2%}")
    print(f"CAGR:         {cagr:6.2%}")
    print(f"Sharpe:       {sharpe:6.2f}")
    print(f"Max Drawdown: {drawdown:6.2%}")

For hourly crypto data, bar_per_year is set to 24 * 365.

Putting everything together

The main block runs both backtests, prints the performance table, and plots the equity curves for a visual comparison:

if __name__ == "__main__":
    if data_dfs:
        tr_port, dd_port = run_cerebro_portfolio(data_dfs)
        tr_bench, dd_bench = run_cerebro_benchmark(data_dfs)

        bars_per_year = 24 * 365
        rets_port, eq_port = returns_to_equity(tr_port)
        rets_bench, eq_bench = returns_to_equity(tr_bench)

        performance_report(rets_port, eq_port, "AO Saucer Portfolio", bars_per_year)
        performance_report(rets_bench, eq_bench, "Buy & Hold Benchmark", bars_per_year)

        plt.figure(figsize=(10, 5))
        plt.plot(eq_port.index, eq_port.values, label='AO Saucer Portfolio')
        plt.plot(eq_bench.index, eq_bench.values, label='Buy & Hold Benchmark', alpha=0.6)
        plt.title('Equity Curve: Portfolio vs Benchmark')
        plt.xlabel('Time')
        plt.ylabel('Equity (USDC)')
        plt.legend()
        plt.grid(True, alpha=0.3)
        plt.tight_layout()
        plt.show()
    else:
        print("No data available to run backtest.")
Pasted image 20251204215718.png
===== AO Saucer Portfolio Performance =====
Total Return: 20.60%
CAGR:         113.74%
Sharpe:         8.46
Max Drawdown: -12.33%

===== Buy & Hold Benchmark Performance =====
Total Return: -16.61%
CAGR:         -52.12%
Sharpe:        -7.69
Max Drawdown: -31.84%

The result is a realistic, multi-asset crypto trading experiment using AO Saucer entries, ATR trailing stops, and explicit portfolio risk management. It not only demonstrates the AO pattern on live market data but also shows how risk-based sizing and diversification can transform the equity curve compared to simply holding one coin.