美股盈餘驚喜廣度擇時(槓桿 ETF)
近 20 個交易日內,流動性前 500 檔美股中 EPS 超出分析師預估 5% 以上且當日上漲的事件數超過 1%,同時 QQQ 趨勢向上時視為多頭:多頭月份在最強的防禦型 ETF 之外加上 TQQQ、TECL(約各 25%、防禦 50%),空頭月份只持有 IEF、GLD、SHY 中最強的一檔;月換股、8% 停損。2016 年 5 月至 2026 年 10 月回測年化報酬 32.0%、日夏普 1.47、最大回撤 -21.1%;同視窗 QQQ 含息年化 20.7%、日夏普 0.95、最大回撤 -35.1%。鄰近參數、前後兩段、每邊 0.1% 交易成本三項驗證皆通過。
投資警語:本文僅供教學參考,不構成投資建議。過去績效不代表未來表現,投資有風險。
回測績效
- 年化報酬率
- 32.0%
- 日夏普
- 1.47
- 最大回撤
- -21.1%
- 總報酬率
- 1740%
回測不計手續費與交易稅(finlab 套件 sim() 的美股預設成本),日夏普未扣無風險利率。策略淨值自資料可得日(2016-05-02)起算。
淨值曲線(對數座標)
美股盈餘驚喜廣度擇時(槓桿 ETF)QQQ 買進持有(含息)
與 QQQ 買進持有(含息)對照
| 對象 | 年化報酬率 | 日夏普 | 最大回撤 | 統計視窗 |
|---|---|---|---|---|
| 美股盈餘驚喜廣度擇時(槓桿 ETF) | 32.0% | 1.47 | -21.1% | 2016-05-02 〜 2026-10-26 |
| QQQ 買進持有(含息) | 20.7% | 0.95 | -35.1% | 2016-05-02 〜 2026-10-26 |
完整策略程式碼
安裝 finlab 套件後即可直接執行重現(finlab.login() 會自動引導登入,不需手動填 token)。想用 AI 輔助安裝與跑第一次回測,可以走安裝教學。
展開程式碼(strategy.py)
"""US earnings-surprise drift (PEAD) breadth strategy (FinLab).
Count how many liquid US stocks beat analyst EPS estimates by more than 5%
with a positive same-day price reaction over the last 20 trading days. When
that count exceeds 1% of the liquid universe AND QQQ is in an uptrend, hold
the two leveraged growth ETFs (TQQQ / TECL). Otherwise rotate into the
strongest defensive asset (IEF / GLD / SHY). When the two sleeves overlap on
a rebalance date the simulator normalizes the book (half defensive, a quarter
in each leveraged ETF). Monthly rebalance, 8% intramonth stop-loss.
Complete the AI-assisted setup flow at https://finlab.finance/en/setup before running this file.
python strategy.py # backtest
python strategy.py --validate # robustness checks from the article
"""
import sys
import finlab
from finlab import data
from finlab.backtest import sim
from finlab.dataframe import FinlabDataFrame
RISK_ASSETS = ["TQQQ", "TECL"]
DEFENSIVE_ASSETS = ["IEF", "GLD", "SHY"]
def build_position(breadth_threshold=0.01, trend_window=200):
data.set_market("us")
close = data.get("us_price:adj_close")
raw_close = data.get("us_price:close") # volume is not split-adjusted, so
volume = data.get("us_price:volume") # dollar volume uses the unadjusted close
eps_actual = data.get("us_earnings_surprises:eps_actual")
eps_estimated = data.get("us_earnings_surprises:eps_estimated")
fund_close = data.get("us_fund_price:adj_close")[["QQQ"] + RISK_ASSETS + DEFENSIVE_ASSETS]
# Earnings surprise in % of the estimate (values exist on announcement days).
surprise = (eps_actual - eps_estimated) / eps_estimated.abs()
# Liquid universe: top 500 by 60-day average dollar volume, price above $5,
# excluding names with a recent extreme (>50%) daily move.
dollar_volume = (raw_close * volume).rolling(60, min_periods=20).mean()
returns = close.pct_change()
had_extreme_move = (returns.abs() >= 0.50).rolling(252, min_periods=1).max().shift(1).fillna(False) > 0
universe = (
dollar_volume.is_largest(500)
& (dollar_volume > 8_000_000)
& (raw_close > 5)
& ~had_extreme_move.astype(bool)
)
# Signal: breadth of positive surprise events over the last 20 trading days.
reaction = close / close.shift(1) - 1
positive_events = universe & (surprise > 0.05) & (reaction > 0)
event_count = positive_events.rolling(20, min_periods=1).sum().sum(axis=1)
event_breadth = event_count / universe.sum(axis=1).replace(0, float("nan"))
# Market regime gate: QQQ above its 200-day average with positive 6-month momentum.
qqq_trend = fund_close["QQQ"] > fund_close["QQQ"].rolling(trend_window, min_periods=trend_window // 2).mean()
qqq_momentum = fund_close["QQQ"] / fund_close["QQQ"].shift(126) - 1
risk_on = (event_breadth > breadth_threshold) & (qqq_trend & (qqq_momentum > 0)).reindex(event_breadth.index)
# Execution sleeve: two strongest leveraged ETFs when risk-on,
# strongest defensive asset when risk-off.
risk_score = FinlabDataFrame(fund_close[RISK_ASSETS].pct_change(126))
defensive_score = FinlabDataFrame(
fund_close[DEFENSIVE_ASSETS].pct_change(63) - fund_close[DEFENSIVE_ASSETS].pct_change(21)
)
risk_on_daily = risk_on.reindex(fund_close.index).fillna(False)
risky_position = risk_score.is_largest(2) & risk_on_daily.to_frame().reindex(risk_score.index).iloc[:, 0]
risky_position = risky_position.astype(float).div(risky_position.astype(float).sum(axis=1), axis=0).fillna(0)
defensive_position = (
defensive_score.is_largest(1)
& (~risk_on_daily).to_frame().reindex(defensive_score.index).iloc[:, 0]
).astype(float)
position = (
risky_position.reindex(fund_close.index).fillna(0)
.join(defensive_position.reindex(fund_close.index).fillna(0), how="outer")
.fillna(0)
.T.groupby(level=0).max().T
)
return position.loc["2016-01-01":]
def run_backtest(position, fee_ratio=None):
data.set_market("us_fund")
return sim(
position,
resample="M",
name="us_earnings_surprise_drift",
upload=False,
fee_ratio=fee_ratio, # None = finlab's US default (0 commission, 0 tax)
trade_at_price="close",
position_limit=1,
stop_loss=0.08,
touched_exit=True,
)
# ---------------------------------------------------------------------------
# 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():
# 1. Neighbouring parameters: the original value and one step either side.
wins = 0
for breadth_threshold in (0.0075, 0.01, 0.0125):
for trend_window in (150, 200, 250):
report = run_backtest(build_position(breadth_threshold=breadth_threshold, trend_window=trend_window))
strategy, qqq = sharpe_vs_qqq(report.creturn)
wins += strategy > qqq
print(f"breadth>{breadth_threshold:.4f} QQQ>{trend_window}d MA: 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.
equity = run_backtest(build_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(build_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() # opens a browser prompt; no token needed in the script
if "--validate" in sys.argv:
validate()
return
report = run_backtest(build_position())
print(report.get_stats())
report.display()
if __name__ == "__main__":
main()
在 Studio 裡用同一套真實資料調整條件、重跑回測。