Description:
This strategy rotates across major crypto assets using cycle
timing + trend confirmation.
The Hilbert Transform SineWave detects short-term cycle turns:
Sine crosses above LeadSine → bullish timing signal
Sine crosses below LeadSine → bearish timing signal
Trend regime is confirmed with ADX:
ADX > 20 = tradable trend
Direction is confirmed by DI dominance:
+DI > -DI → long only
-DI > +DI → short only
Active long and short signals are sized by inverse volatility:
\[w_i=\frac{1/\sigma_i}{\sum_{j=1}^{n}1/\sigma_j}\]
Risk is controlled with a 5% trailing stop:
\[Stop_t=Peak_t\times(1-0.05)\]
Python Bakctesting Code:
import backtrader as bt
import yfinance as yf
import pandas as pd
import matplotlib.pyplot as plt
import numpy as np
class MultiAssetHilbertTrendInvVolTrail(bt.Strategy):
params = dict(
adx_period=30,
adx_threshold=20,
trail_percent=0.05,
vol_lookback=30,
rebalance_days=10,
max_gross_exposure=0.95,
)
def __init__(self):
self.ind = {}
self.weights = {}
self.directions = {}
self.order_refs = {}
self.stop_orders = {}
self.last_rebalance = -999
self.date_history = []
self.value_history = []
self.asset_value_history = {d._name: [] for d in self.datas}
for d in self.datas:
ht = bt.talib.HT_SINE(d.close)
sine_cross = bt.ind.CrossOver(ht.sine, ht.leadsine)
adx = bt.ind.ADX(d, period=self.p.adx_period)
plus_di = bt.ind.PlusDI(d, period=self.p.adx_period)
minus_di = bt.ind.MinusDI(d, period=self.p.adx_period)
self.ind[d] = dict(
sine_cross=sine_cross,
adx=adx,
plus_di=plus_di,
minus_di=minus_di,
)
self.weights[d] = 0.0
self.directions[d] = 0
def next(self):
need_rebalance = False
for d in self.datas:
if d in self.order_refs:
continue
if len(d) < max(self.p.adx_period, self.p.vol_lookback) + 50:
continue
pos = self.getposition(d)
ind = self.ind[d]
trending = ind["adx"][0] > self.p.adx_threshold
uptrend = trending and ind["plus_di"][0] > ind["minus_di"][0]
downtrend = trending and ind["minus_di"][0] > ind["plus_di"][0]
cross_up = ind["sine_cross"][0] > 0
cross_down = ind["sine_cross"][0] < 0
if pos.size == 0:
if uptrend and cross_up:
self.directions[d] = 1
need_rebalance = True
elif downtrend and cross_down:
self.directions[d] = -1
need_rebalance = True
if len(self) - self.last_rebalance >= self.p.rebalance_days:
need_rebalance = True
if need_rebalance:
self.last_rebalance = len(self)
active = [d for d in self.datas if self.directions[d] != 0]
vols = {}
for d in active:
rets = []
for i in range(1, self.p.vol_lookback + 1):
if d.close[-i] == 0:
continue
r = d.close[-i + 1] / d.close[-i] - 1
rets.append(r)
if len(rets) >= 10:
vol = np.std(rets)
if vol > 0 and np.isfinite(vol):
vols[d] = vol
for d in self.datas:
self.weights[d] = 0.0
inv_sum = sum(1 / v for v in vols.values())
if inv_sum > 0:
for d, vol in vols.items():
self.weights[d] = (1 / vol) / inv_sum
equity = self.broker.getvalue()
for d in self.datas:
if d in self.order_refs:
continue
price = d.close[0]
if price <= 0:
continue
target_value = equity * self.weights[d] * self.p.max_gross_exposure * self.directions[d]
target_size = target_value / price
current_size = self.getposition(d).size
delta = target_size - current_size
if abs(delta * price) < 10:
continue
if delta > 0:
self.order_refs[d] = self.buy(data=d, size=delta)
elif delta < 0:
self.order_refs[d] = self.sell(data=d, size=abs(delta))
for d in self.datas:
pos = self.getposition(d)
if pos.size > 0 and d not in self.stop_orders:
self.stop_orders[d] = self.sell(
data=d,
size=pos.size,
exectype=bt.Order.StopTrail,
trailpercent=self.p.trail_percent,
)
elif pos.size < 0 and d not in self.stop_orders:
self.stop_orders[d] = self.buy(
data=d,
size=abs(pos.size),
exectype=bt.Order.StopTrail,
trailpercent=self.p.trail_percent,
)
self.date_history.append(self.datas[0].datetime.datetime(0))
self.value_history.append(self.broker.getvalue())
for d in self.datas:
pos = self.getposition(d)
self.asset_value_history[d._name].append(abs(pos.size * d.close[0]))
def notify_order(self, order):
d = order.data
if order.status in [order.Submitted, order.Accepted]:
return
if d in self.order_refs and order.ref == self.order_refs[d].ref:
del self.order_refs[d]
if d in self.stop_orders and order.ref == self.stop_orders[d].ref:
del self.stop_orders[d]
if order.status == order.Completed:
self.directions[d] = 0
self.weights[d] = 0.0
cerebro = bt.Cerebro()
assets = [
"BTC-USD", "ETH-USD", "BNB-USD", "SOL-USD",
"XRP-USD", "ADA-USD", "DOGE-USD", "AVAX-USD",
"LINK-USD", "DOT-USD", "LTC-USD", "BCH-USD",
]
START_DATE = "2025-01-01"
END_DATE = None
INIT_CASH = 100_000
raw_data = {}
for t in assets:
df = yf.download(
t,
start=START_DATE,
end=END_DATE,
interval="1d",
progress=False,
auto_adjust=False,
).droplevel(1, 1)
df.dropna(inplace=True)
if df.empty:
print(f"Skipping {t}: no data.")
continue
raw_data[t] = df.copy()
data = bt.feeds.PandasData(dataname=df, name=t)
cerebro.adddata(data)
cerebro.addstrategy(MultiAssetHilbertTrendInvVolTrail)
cerebro.broker.setcash(INIT_CASH)
cerebro.broker.setcommission(commission=0.001)
cerebro.broker.set_shortcash(True)
cerebro.broker.set_slippage_perc(perc=0.0005)
cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name="sharpe", timeframe=bt.TimeFrame.Days)
cerebro.addanalyzer(bt.analyzers.DrawDown, _name="dd")
cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name="trades")
start_value = cerebro.broker.getvalue()
results = cerebro.run()
strat = results[0]
end_value = cerebro.broker.getvalue()
print(f"Start Value: {start_value:,.2f}")
print(f"End Value: {end_value:,.2f}")
print(f"Return: {(end_value / start_value - 1) * 100:.2f}%")
print(f"Sharpe: {strat.analyzers.sharpe.get_analysis()}")
print(f"DrawDown: {strat.analyzers.dd.get_analysis()}")
print(f"Trades: {strat.analyzers.trades.get_analysis()}")
eq = pd.Series(strat.value_history, index=pd.to_datetime(strat.date_history), name="Strategy")
asset_values = pd.DataFrame(strat.asset_value_history, index=eq.index)
asset_values = asset_values.clip(lower=0)
cash_plot = (eq - asset_values.sum(axis=1)).clip(lower=0)
bench_close = pd.DataFrame(index=eq.index)
for t, df in raw_data.items():
bench_close[t] = df["Close"].reindex(eq.index, method="ffill")
bench_ret = bench_close.pct_change().fillna(0.0)
equal_bh = INIT_CASH * (1 + bench_ret.mean(axis=1)).cumprod()
btc_close = bench_close["BTC-USD"].dropna()
btc_bh = INIT_CASH * (btc_close / btc_close.iloc[0])
btc_bh = btc_bh.reindex(eq.index, method="ffill")
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 9), sharex=True)
ax1.plot(eq.index, eq.values, color="black", linewidth=2, label="Hilbert ADX InvVol Trail")
ax1.plot(equal_bh.index, equal_bh.values, linestyle="--", label="Equal-Weight Buy & Hold")
ax1.plot(btc_bh.index, btc_bh.values, linestyle="--", label="BTC Buy & Hold")
pf_ret = eq.iloc[-1] / eq.iloc[0] - 1
ew_ret = equal_bh.iloc[-1] / equal_bh.iloc[0] - 1
btc_ret = btc_bh.iloc[-1] / btc_bh.iloc[0] - 1
x_last = eq.index[-1]
ax1.annotate(f"PF: {pf_ret * 100:.2f}%", xy=(x_last, eq.iloc[-1]), xytext=(8, 0),
textcoords="offset points", va="center", ha="left", color="black", clip_on=False)
ax1.annotate(f"EW: {ew_ret * 100:.2f}%", xy=(x_last, equal_bh.iloc[-1]), xytext=(8, 0),
textcoords="offset points", va="center", ha="left", clip_on=False)
ax1.annotate(f"BTC: {btc_ret * 100:.2f}%", xy=(x_last, btc_bh.iloc[-1]), xytext=(8, 0),
textcoords="offset points", va="center", ha="left", clip_on=False)
ax1.grid(True, alpha=0.3)
ax1.legend()
labels = ["Cash"] + list(asset_values.columns)
colors = ["#d3d3d3"] + list(plt.cm.tab10.colors)[:len(asset_values.columns)]
ax2.stackplot(
eq.index,
cash_plot,
*[asset_values[col] for col in asset_values.columns],
labels=labels,
colors=colors,
alpha=0.8,
)
ax2.set_title("Portfolio Allocation Breakdown")
ax2.set_ylabel("Value ($)")
ax2.grid(True, alpha=0.3)
ax2.legend(loc="upper left", bbox_to_anchor=(1.0, 1), frameon=True)
plt.tight_layout()
plt.show()