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.
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 dfThe 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.
The portfolio strategy trades every data feed in the
Cerebro engine. For each asset it:
Applies an AO indicator with custom fast/slow periods
Uses a long-term EMA as a trend filter
Detects the AO Saucer pattern:
Bullish saucer: AO above zero, a down bar followed by an up bar
Bearish saucer: AO below zero, an up bar followed by a down bar
Sets an initial stop at a multiple of ATR and then trails it over time
Sizes each trade so that a stop-out costs about 0.5% of equity
Caps the approximate total portfolio risk at 4% of equity
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.0When 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.0On 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_tradeThis combination gives you a trend-aligned, volatility-aware, and risk-controlled multi-asset system.
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.
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.")===== 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.