US ETF rotation, conservative (QLD/SSO)
When QQQ is above its 200-day average with a positive 126-day return, holds QLD and SSO 50/50; otherwise holds the strongest of IEF, GLD and SHY. Monthly rebalance, 6% stop. May 2016 to October 2026: 24.2% CAGR, daily Sharpe 1.21, max drawdown -19.3%, vs QQQ total return 20.7%, 0.95 and -35.1% over the same dates. Passes all three robustness checks: neighbouring parameters, both halves of the backtest, and 0.1% per-side trading costs.
Disclaimer: for education only, not investment advice. Past performance does not guarantee future results, and investing involves risk.
Backtest performance
- CAGR
- 24.3%
- Daily Sharpe
- 1.21
- Max drawdown
- -19.3%
- Total return
- 874%
The backtest charges no commissions or taxes (the finlab sim() default for U.S. stocks), and the daily Sharpe ratio does not subtract a risk-free rate. The equity curve starts on the first date the strategy's data is available (2016-05-02).
Equity curve (log scale)
Compared with QQQ buy-and-hold (total return)
| Portfolio | CAGR | Daily Sharpe | Max drawdown | Window |
|---|---|---|---|---|
| US ETF rotation, conservative (QLD/SSO) | 24.3% | 1.21 | -19.3% | 2016-05-02 to 2026-10-26 |
| QQQ buy-and-hold (total return) | 20.7% | 0.95 | -35.1% | 2016-05-02 to 2026-10-26 |
Full strategy code
Install the finlab package and run it as is (finlab.login() walks you through sign-in, no token needed). For AI-assisted setup and a first backtest, follow the setup guide.
Show code (strategy.py)
"""US ETF rotation: one regime signal, two risk levels (FinLab).
Both systems read the same risk-on/risk-off signal from QQQ (price above its
200-day moving average AND a positive 126-day return) and rebalance monthly:
* "conservative" — rotates between the two strongest 2x leveraged equity
ETFs (QLD / SSO, held 50/50) in risk-on, with a 6% stop.
* "aggressive" — holds the single strongest 3x leveraged growth ETF
(TQQQ or TECL) in risk-on, with a 10% stop.
In risk-off, both park in the single strongest defensive ETF among
treasuries (IEF), gold (GLD), and cash-like short treasuries (SHY).
Complete the AI-assisted setup flow at https://finlab.finance/en/setup before running this file.
python strategy.py # backtest the profile chosen below
python strategy.py --validate # robustness checks from the article (both profiles)
"""
import sys
import finlab
from finlab import data
from finlab.backtest import sim
from finlab.dataframe import FinlabDataFrame
# Pick which version to run: "conservative" (QLD/SSO) or "aggressive" (TQQQ/TECL).
STRATEGY = "conservative"
PROFILES = {
"conservative": {
"risk_assets": ["QLD", "SSO"], # 2x leveraged NASDAQ-100 / S&P 500
"risk_holdings": 2, # hold both, 50% each
"risk_lookback": 126, # rank by 6-month return
"risk_skip": 0,
"stop_loss": 0.06, # 6% touched stop
},
"aggressive": {
"risk_assets": ["TQQQ", "TECL"], # 3x leveraged NASDAQ-100 / tech
"risk_holdings": 1, # hold only the strongest one
"risk_lookback": 63, # rank by 3-month minus 1-month return
"risk_skip": 21,
"stop_loss": 0.10, # 10% touched stop
},
}
DEFENSIVE_ASSETS = ["IEF", "GLD", "SHY"] # treasuries / gold / cash-like (risk-off)
def build_position(profile, trend_window=200, momentum_window=126):
data.set_market("us_fund")
tickers = ["QQQ"] + profile["risk_assets"] + DEFENSIVE_ASSETS
close = data.get("us_fund_price:adj_close")[tickers]
# 1. Regime signal: QQQ above its 200-day average AND positive 126-day return.
qqq_trend = close["QQQ"] > close["QQQ"].rolling(trend_window, min_periods=trend_window // 2).mean()
qqq_momentum = close["QQQ"] / close["QQQ"].shift(momentum_window) - 1
risk_on = qqq_trend & (qqq_momentum > 0)
# 2. Rank the risk ETFs by recent momentum (optionally skipping the last month).
risk_score = close[profile["risk_assets"]].pct_change(profile["risk_lookback"])
if profile["risk_skip"]:
risk_score = risk_score - close[profile["risk_assets"]].pct_change(profile["risk_skip"])
risk_score = FinlabDataFrame(risk_score)
# 3. Rank defensive ETFs by 3-month-minus-1-month momentum.
defensive_score = FinlabDataFrame(
close[DEFENSIVE_ASSETS].pct_change(63) - close[DEFENSIVE_ASSETS].pct_change(21)
)
# 4. Risk-on: equal-weight the strongest risk ETFs. Risk-off: 1 defensive ETF.
risky = risk_score.is_largest(profile["risk_holdings"]) & risk_on.to_frame().reindex(risk_score.index).iloc[:, 0]
risky = risky.astype(float).div(risky.astype(float).sum(axis=1), axis=0).fillna(0)
defensive = (
defensive_score.is_largest(1) & (~risk_on).to_frame().reindex(defensive_score.index).iloc[:, 0]
).astype(float)
position = (
risky.reindex(close.index).fillna(0)
.join(defensive.reindex(close.index).fillna(0), how="outer")
.fillna(0)
.T.groupby(level=0).max().T
)
return position.loc["2016-01-01":]
def run_backtest(profile, position, fee_ratio=None):
data.set_market("us_fund")
return sim(
position,
resample="M", # rebalance monthly
position_limit=1, # max weight per asset (number of holdings
# is set by is_largest(...) above)
stop_loss=profile["stop_loss"], # touched stop on the held ETF
touched_exit=True,
trade_at_price="close",
fee_ratio=fee_ratio, # None = finlab's US default (0 commission, 0 tax)
upload=False,
)
# ---------------------------------------------------------------------------
# Robustness checks behind the article's validation section:
# python strategy.py --validate
# "Beats QQQ" means a higher daily Sharpe than buying and holding QQQ
# (dividend-adjusted) over exactly the same dates.
# ---------------------------------------------------------------------------
COST_PER_SIDE = 0.001 # 0.1% per buy and per sell, for spread and slippage
def daily_sharpe(equity):
returns = equity.pct_change().dropna()
return returns.mean() / returns.std() * 252 ** 0.5
def sharpe_vs_qqq(equity):
qqq = data.get("us_fund_price:adj_close")["QQQ"].reindex(equity.index).ffill()
return daily_sharpe(equity), daily_sharpe(qqq)
def validate(name):
profile = PROFILES[name]
print(f"--- {name}")
# 1. Neighbouring parameters: the original value and one step either side.
wins = 0
for trend_window in (150, 200, 250):
for momentum_window in (63, 126, 189):
position = build_position(profile, trend_window, momentum_window)
strategy, qqq = sharpe_vs_qqq(run_backtest(profile, position).creturn)
wins += strategy > qqq
print(f"QQQ>{trend_window}d MA {momentum_window}d momentum: "
f"Sharpe {strategy:.2f} vs QQQ {qqq:.2f}")
print(f"{wins}/9 parameter sets beat QQQ")
# 2. First half and second half of the backtest, each on its own.
position = build_position(profile)
equity = run_backtest(profile, position).creturn
middle = equity.index[len(equity) // 2]
for label, part in (("first half", equity.loc[:middle]), ("second half", equity.loc[middle:])):
strategy, qqq = sharpe_vs_qqq(part)
print(f"{label} {part.index[0].date()} to {part.index[-1].date()}: "
f"Sharpe {strategy:.2f} vs QQQ {qqq:.2f}")
# 3. Trading costs: finlab's US default is zero, so charge 0.1% per side.
report = run_backtest(profile, position, fee_ratio=COST_PER_SIDE)
strategy, qqq = sharpe_vs_qqq(report.creturn)
print(f"with 0.1% per side: CAGR {report.get_stats()['cagr']:.1%}, "
f"Sharpe {strategy:.2f} vs QQQ {qqq:.2f}")
def main():
finlab.login() # finlab guides you through login automatically
if "--validate" in sys.argv:
for name in PROFILES:
validate(name)
return
profile = PROFILES[STRATEGY]
report = run_backtest(profile, build_position(profile))
print(report.get_stats())
report.display()
if __name__ == "__main__":
main()
Adjust the rules and rerun the backtest on the same real data in Studio.