Skip to content

策略年化1.66%,跑输SPY九个点,我却没删它(附完整源码)

作 者:老余捞鱼

原创不易,转载请标明出处及原作者。

文中示例仅用于技术讨论,不构成任何操作建议。
量化策略开发应以学习和技术交流为目的。
本号不荐股、不卖课、不承诺收益。
市场有风险,请合法合规投资。

本文约 7000 字 | 预计阅读 15-18 分钟,文后有完整代码,建议先收藏。

· · ·

我在某视频平台刷到一个叫 Rubber Band 的均值回归系统,主讲人报了三个数:策略在SPY 上跑,每笔平均赚 0.66%,年化 6.4%,在市时间只有 14%,然后话锋一转,死活不肯给规则,只说”比第一个系统稍微复杂一点”。

我感觉这个里面有问题,照着把它重建了出来,跑完 SPY 三十三年半的日线,年化只有 1.66%,而同期买入持有是 10.84%,按最常见的标准,这个策略是该进回收站的失败品。

但我在测试中发现,这个策略的市场敞口只有 2%,33 年里只做了 79 笔交易、胜率却有 83.5%,按实际部署的资本年化约 128%,它每年输给买入持有 9 个点,仅仅是因为 98% 的时间根本不在场。

这篇我想讲三件事:怎么从一组汇总统计反推出一个策略,又为什么反推出来的只是”一个”策略;怎么去读 CAGR 之外的数字;以及一个每年只出手 2.3 次的系统,到底该怎么用。

代码全在下面,按章节拆成五段,文末有拼好的完整脚本。数据 SPY 日线取自 Yahoo Finance,回测用 vectorbt,成本 1bp 手续费加 2bp 滑点。

事情的起头:一个不肯给规则的系统

视频讲得头头是道,统计也漂亮,可主讲人从头到尾没给入场条件,只有一个名字和三个数字。名字起得挺妙,核心思想全藏在里头:拉伸橡皮筋,等它回弹,跌得越狠,弹得越高,均值回归这一套的说服力从来不靠逻辑,靠的就是”跌多了总会回来”这个直觉。

可直觉毕竟不能当策略用,我手头只有三样东西:标的 SPY、单笔 0.66%、在市 14%,其余规则,全得自己搭。

规则:四道闸门

重建版的核心结构:三道入场闸门串联,出场三选一先到先走。橡皮筋挂在上升趋势的轨道上,拉得够开才动手。

入场是三个条件串联,全部满足才进。

  • 趋势过滤。收盘在 200 日均线上方。只在上升趋势里接回调,不做下跌趋势里的便宜货。
  • 拉伸。最近 5 天累计跌幅 ≤ -3%。橡皮筋得拉得够开,小打小闹不碰。
  • 确认。IBS < 0.25,且收盘是最近 5 天的最低收盘。

IBS 是 Internal Bar Strength 的缩写,算法是(收盘−最低)÷(最高−最低)。它把一天的强弱压到 0 到 1 之间:0.5 是中间,小于 0.25 意味着收在当日区间的下四分之一,全天卖压主导、收得非常难看。这种”丑收”是经典的短期反转信号。

出场三选一,谁先到就先走:收盘站上昨天最高点;IBS > 0.75,收得漂亮就落袋;或者拿满 10 根 K 线,强平。

① 参数与数据

import pandas as pd
import numpy as np
import vectorbt as vbt
import yfinance as yf

SYMBOL           = "SPY"
START_DATE       = "1993-01-01"
END_DATE         = "2030-01-01"
TREND_MA         = 200      # 趋势过滤均线,设 0 可关闭
STRETCH_LOOKBACK = 5        # 橡皮筋回看窗口(天)
STRETCH_PCT      = -0.03    # 5 日累计收益 ≤ -3%
IBS_ENTRY        = 0.25     # 入场:收盘贴着当日低点
IBS_EXIT         = 0.75     # 离场:收盘贴着当日高点
LOWEST_N         = 5        # 收盘须为最近 5 根的最低收盘
MAX_HOLD         = 10       # 时间止损:最多拿 10 根 K 线
INIT_CASH        = 100_000
FEES             = 0.0001
SLIPPAGE         = 0.0002

df = yf.download(SYMBOL, start=START_DATE, end=END_DATE,
                 interval="1d", multi_level_index=False)
df = df.dropna(subset=["Open", "High", "Low", "Close"])

把信号交给回测引擎

② 信号构造

def build_signals(df, trend_ma=TREND_MA, lookback=STRETCH_LOOKBACK,
                  stretch=STRETCH_PCT, ibs_in=IBS_ENTRY,
                  ibs_out=IBS_EXIT, lowest_n=LOWEST_N):
    out = df.copy()

    # 除零保护:High == Low 的极端 K 线
    rng = (out["High"] - out["Low"]).replace(0, np.nan)
    out["IBS"] = (out["Close"] - out["Low"]) / rng

    # 闸门一:只在上升趋势里接回调(trend_ma=0 可关闭)
    if trend_ma and trend_ma > 0:
        out["Trend"] = out["Close"] > out["Close"].rolling(trend_ma).mean()
    else:
        out["Trend"] = True

    # 闸门二:橡皮筋被拉开
    out["Stretch"] = out["Close"].pct_change(lookback) <= stretch

    # 闸门三:丑收确认,收盘贴着低点且是 5 日最低
    out["Confirm"] = (out["IBS"] < ibs_in) & \
                     (out["Close"] <= out["Close"].rolling(lowest_n).min())

    out["Entry"] = out["Trend"] & out["Stretch"] & out["Confirm"]
    out["Exit"] = (out["Close"] > out["High"].shift(1)) | (out["IBS"] > ibs_out)

    return out

确认条件里有个容易被忽略的细节:rolling(5).min() 包含当天。所以”收盘 ≤ 5 日最低收盘”翻译过来是”今天的收盘就是最近五天的最低”——下跌末端、恐慌最重的那一天才触发,这正是均值回归要接的位置。

③ 时间止损与下单

def apply_time_stop(entries, exits, max_hold=MAX_HOLD):
    """入场后数 K 线,拿满 max_hold 根强制离场"""
    n = len(entries)
    bars_held = np.full(n, 0)
    out_exits = exits.copy()
    in_pos = False

    for i in range(n):
        if not in_pos and entries[i]:
            in_pos = True
            bars_held[i] = 0
        elif in_pos:
            bars_held[i] = bars_held[i - 1] + 1
            if bars_held[i] >= max_hold:
                out_exits[i] = True

        if in_pos and out_exits[i]:
            in_pos = False
            bars_held[i] = 0

    return out_exits


# ----- 主流程 -----
sig = build_signals(df)

# 信号延后一根 K 线,用开盘价成交,避免未来函数。
# 注意顺序:先 fillna 再 astype,shift 出来的 NaN 会被 bool() 判成 True
shift_entries = sig["Entry"].shift(1).fillna(False).astype(bool).to_numpy()
shift_exits = sig["Exit"].shift(1).fillna(False).astype(bool).to_numpy()
shift_exits = apply_time_stop(shift_entries, shift_exits)

pf = vbt.Portfolio.from_signals(
    close=df["Open"],
    entries=shift_entries,
    exits=shift_exits,
    init_cash=INIT_CASH,
    fees=FEES,
    slippage=SLIPPAGE,
    direction="longonly",
    freq="1D",
)

vectorbt 的 from_signals 没有现成的”N 根强平”参数,时间止损得自己写。逐根扫描是笨办法,但逻辑清楚:入场日起数 K 线,数满 10 根就在当根置一个出场标记,先于价格出场条件也没关系,反正先到先走。

表面结果:一个标准的失败品

SPY 三十三年半的完整回测。Underwater 子图里那条最深的坑,就是后面要讲的 3,046 天。

指标橡皮筋策略买入持有 SPY
年化收益 CAGR1.66%10.84%
总回报73.5%3,062%
最大回撤10.21%约 55%
夏普比率0.596约 0.50
胜率83.5%
盈亏比2.51
交易次数791
最长回撤持续3,046 天

区间 1993-01 至 2026-08,共 33.6 年。买入持有的胜率和盈亏比没有意义,它只有一笔交易。

每年落后 9 个百分点,复利三十三年,就是 73.5% 对 3,062% 的差距。

更难受的是:最长回撤持续 3,046 天,八年四个月没创新高。对 standalone 策略来说这是死刑判决,没人扛得住八年不创新高还面不改色。

写到这里,大多数分析的剧情是删掉策略、改名 failed_ideas、另起炉灶。我差一点也这么干了。

改变一切的数字:2.0%

市场敞口:约 8,447 个交易日里,只持仓约 169 天

这张汇总图把79个入场点撒在三十三年的时间轴上,密密麻麻却只占2%。五个关键数都列在下面。

1.66% 的年化,不是”天天在场但表现差”,而是”98% 的时间拿着现金,出手一次赚两天”。79 笔交易平均持仓 2.1 天,一年 2.3 笔。

把分母换成实际部署的资本,账完全是另一本:73.5% 的总回报是在那 169 天里赚到的,按部署期间年化约 128%。

而128% 是比率,不是预测。你没法按需制造更多信号去收割,SPY 一年就给你两三次机会。但它告诉你信号的单位风险价值,这是比较一个 2% 敞口系统和一个 100% 敞口系统时,唯一诚实的尺子。

79 笔,均值 0.717%,单笔标准差约 1.40%,t 统计量约 4.6。就算按扫过 36 组参数做 Bonferroni 式校正,也稳稳超过 3.2 的阈值。样本不大,但效应量大、方向一致,不太像噪音。

三十三年的日历横带,亮起的格子不到十分之一。现金仓在绝大部分时间是闲置的。

④ 敞口与逐笔统计

in_position = pf.position_mask().to_numpy()

exposure = in_position.mean() * 100
print(f"Market exposure: {exposure:.1f}%  "
      f"({int(in_position.sum())} of {len(df)} bars)")

trades = pf.trades.records_readable
print(f"Trades: {len(trades)}  "
      f"Win rate: {(trades['PnL'] > 0).mean() * 100:.1f}%  "
      f"Avg per trade: {trades['Return'].mean() * 100:.3f}%")

# 按部署资本年化:总回报发生在持仓日里,分母要用持仓年数
days_held = ((pd.to_datetime(trades["Exit Timestamp"]) -
              pd.to_datetime(trades["Entry Timestamp"])).dt.days + 1)
held_years = days_held.sum() / 252
total_ret = (1 + trades["Return"]).prod() - 1
print(f"Return on deployed capital (annualized): "
      f"{(1 + total_ret) ** (1 / held_years) - 1:.0%}")

# 逐笔优势的显著性:均值是不是噪音
t_stat = trades["Return"].mean() / (trades["Return"].std() / np.sqrt(len(trades)))
print(f"Trade t-stat: {t_stat:.2f}")

我重建的不是那个策略

按部署资本年化 128% 听着很爽,但有一笔账我必须自己先戳破。

我的版本敞口 2.0%,视频宣称 14%。差了 7 倍,而且这不是调参能填平的——36 组参数扫下来,最活跃的组合也只有 5.4% 敞口,没有一个接近 14%。

所以直说:这不是视频里那个策略。它只是碰巧在另一种交易节奏上,长出了相似的单笔平均收益(我的 0.717% 对他的 0.66%)。原始策略可能更松:更浅的回调阈值、没有确认条件、也许压根没有趋势过滤,也许不止跑 SPY。

匹配一个统计量、却在另一个上差 7 倍,这不叫复现成功。从汇总统计反推,你得到的是”一个”策略,不是”那个”策略。

这一段是我写给自己看的。反推是好的练习,但反推的产物要按”新策略”走全套验证流程,不能因为”对上了视频的数字”就跳过。

参数扫描:网格修正了我的审美

选参数最怕拍脑袋。我把拉伸阈值、IBS 入场线、时间止损各取三档,扫了 36 组组合,让数据替我做这个决定。

36 格扫出来的地形是高原不是尖峰:-2% 那格在高原顶上,往四周走数字平缓下滑,不塌方。

回调阈值交易次数单笔均值夏普敞口
-2%1870.528%0.7695.4%
-3%(基准)790.717%0.5962.0%
-4%441.032%0.5780.9%
-5%131.831%0.4450.2%

单调的权衡线:阈值越深单笔越肥,证据越薄。-5% 那行 13 笔,是故事不是样本。

整个网格的中位夏普是 0.548,64% 的格子夏普 > 0.5。这是高原,不是尖峰,形态健康:参数挪一挪数字不会崩。

好消息在 -2% 那一行:夏普 0.769,187 笔,敞口 5.4%,最大回撤和基准基本持平。同样的回撤,更高的风险调整收益,2.4 倍的证据量。往下推进的版本应该是 -2%,不是我的 -3%。

⑤ 36 格参数扫描

grid = [(s, i, t)
        for s in [-0.02, -0.03, -0.04, -0.05]
        for i in [0.15, 0.25, 0.35]
        for t in [5, 10, 15]]

rows = []
for s, i, t in grid:
    g_sig = build_signals(df, stretch=s, ibs_in=i)
    e = g_sig["Entry"].shift(1).fillna(False).astype(bool).to_numpy()
    x = g_sig["Exit"].shift(1).fillna(False).astype(bool).to_numpy()
    x = apply_time_stop(e, x, max_hold=t)
    p = vbt.Portfolio.from_signals(close=df["Open"], entries=e, exits=x,
                                   init_cash=INIT_CASH, fees=FEES,
                                   slippage=SLIPPAGE, freq="1D")
    tr = p.trades.records_readable
    rows.append({
        "stretch": s, "ibs_in": i, "time_stop": t,
        "trades": len(tr),
        "avg_pct": tr["Return"].mean() * 100 if len(tr) else np.nan,
        "sharpe": p.sharpe_ratio(),
        "exposure": p.position_mask().to_numpy().mean() * 100,
    })

res = pd.DataFrame(rows).sort_values("sharpe", ascending=False)
print(res.to_string(index=False))

print(f"\n网格中位夏普: {res['sharpe'].median():.3f}  "
      f"夏普>0.5 占比: {(res['sharpe'] > 0.5).mean() * 100:.0f}%")

读扫描结果时别只找最高那格。要看的是:调参之后表现是平滑衰减,还是只在精确坐标上灵光。前者是效应,后者是过拟合的签名。

组合论的坑:两个系统都想在周二做多

最顺手的配对方案,恰恰撞车:两边都盯着”SPY 200 日线上方”这个前提要钱。

低敞口策略最常见的卖法是”免费叠加”:它 98% 的时间拿着现金,叠到任何核心策略上,等于让闲置资金多收一条流。听起来无本万利。

但这个论点有个洞。趋势过滤要求收盘站在 200 日线上方,而它需要资金的日子,恰好是任何多头趋势或动量系统已经满仓的日子。没有闲置资金可腾。两个系统不是互补,是抢钱:要么上杠杆,要么为了一笔两天的交易挤掉核心仓位。

这杀死了最顺手的配对,但没杀死想法,只是给合作方定了条件:资本需求要结构性错开,而不只是收益相关性低。

  • 不同资产类别。债券、黄金、外汇趋势系统有自己的时钟,TLT 不会在 SPY 回调 3% 的同一天要钱。
  • 市场中性或空头方向。对冲账簿的资金不绑定 SPY 的 200 日线状态。
  • 反向 regime 系统。在 200 日线下方部署的系统是天然对手方,两套系统拼接日历而不是重叠。
  • 有任务的现金仓。你本来就留着抄底的现金,正好让它在 2% 的时间里规则化干活。

叠加策略的正确顺序:先检查敞口窗口是否重叠,再看收益相关性。两个系统收益相关性 -0.1 也可以毫无用处,因为它们都想在同一个周二做多。

观点总结

每年 2.3 笔、每笔 0.717%、无杠杆,对组合的年贡献约 1.7%,这根本算不上一门生意:一年只有两笔交易,遇到其中一笔还亏着的年份,你几乎察觉不到它的存在。但有三件事是实的,值得拎出来说。

第一,它是资本效率组件,不是独立策略,放进策略栈、给小比例配置,用边际夏普贡献去评估,别拿独立 CAGR 说事。

第二,IBS 加拉伸加 regime 的组合通过了显著性检验,在 36 格网格里也活得好好的,优势是真的,只是小。

第三,调参问题已经解决。-2% 版本,187 笔,夏普 0.769,回撤不恶化,这才是值得往下带走的版本。

回看整个过程,当把 1.66% 和 10.84% 摆在一起的时候,我们可能会删掉文件。一个在市 2% 的策略,不是在和买入持有竞争。评价它的尺子不是 CAGR,是信号的单位风险价值、敞口窗口的形状,以及它跟组合里其他系统抢不抢钱。

下次看到一个难看的年化,先问一句:它的钱在场外待了多久?

完整代码

上面五段是拆开讲的,这份是拼好的完整脚本,可直接复制运行。装依赖:

pip install vectorbt yfinance pandas numpy
import pandas as pd
import numpy as np
import vectorbt as vbt
import yfinance as yf

# -------------------------
# 参数
# -------------------------
SYMBOL = "SPY"
START_DATE = "1993-01-01"
END_DATE = "2030-01-01"
TREND_MA = 200          # 趋势过滤均线,设 0 可关闭
STRETCH_LOOKBACK = 5    # 橡皮筋回看窗口(天)
STRETCH_PCT = -0.03     # 5 日累计收益阈值
IBS_ENTRY = 0.25        # 入场:收盘贴着当日低点
IBS_EXIT = 0.75         # 离场:收盘贴着当日高点
LOWEST_N = 5            # 收盘须为最近 N 根的最低收盘
MAX_HOLD = 10           # 时间止损(根)
INIT_CASH = 100_000
FEES = 0.0001
SLIPPAGE = 0.0002

# -------------------------
# 数据
# -------------------------
df = yf.download(SYMBOL, start=START_DATE, end=END_DATE,
                 interval="1d", multi_level_index=False)
df = df.dropna(subset=["Open", "High", "Low", "Close"])

# -------------------------
# 信号构造
# -------------------------
def build_signals(df, trend_ma=TREND_MA, lookback=STRETCH_LOOKBACK,
                  stretch=STRETCH_PCT, ibs_in=IBS_ENTRY,
                  ibs_out=IBS_EXIT, lowest_n=LOWEST_N):
    out = df.copy()
    rng = (out["High"] - out["Low"]).replace(0, np.nan)
    out["IBS"] = (out["Close"] - out["Low"]) / rng

    if trend_ma and trend_ma > 0:
        out["Trend"] = out["Close"] > out["Close"].rolling(trend_ma).mean()
    else:
        out["Trend"] = True

    out["Stretch"] = out["Close"].pct_change(lookback) <= stretch
    out["Confirm"] = (out["IBS"] < ibs_in) & \
                     (out["Close"] <= out["Close"].rolling(lowest_n).min())

    out["Entry"] = out["Trend"] & out["Stretch"] & out["Confirm"]
    out["Exit"] = (out["Close"] > out["High"].shift(1)) | (out["IBS"] > ibs_out)
    return out

# -------------------------
# 时间止损
# -------------------------
def apply_time_stop(entries, exits, max_hold=MAX_HOLD):
    n = len(entries)
    bars_held = np.full(n, 0)
    out_exits = exits.copy()
    in_pos = False

    for i in range(n):
        if not in_pos and entries[i]:
            in_pos = True
            bars_held[i] = 0
        elif in_pos:
            bars_held[i] = bars_held[i - 1] + 1
            if bars_held[i] >= max_hold:
                out_exits[i] = True

        if in_pos and out_exits[i]:
            in_pos = False
            bars_held[i] = 0

    return out_exits

# -------------------------
# 回测
# -------------------------
sig = build_signals(df)

shift_entries = sig["Entry"].shift(1).fillna(False).astype(bool).to_numpy()
shift_exits = sig["Exit"].shift(1).fillna(False).astype(bool).to_numpy()
shift_exits = apply_time_stop(shift_entries, shift_exits)

pf = vbt.Portfolio.from_signals(
    close=df["Open"],
    entries=shift_entries,
    exits=shift_exits,
    init_cash=INIT_CASH,
    fees=FEES,
    slippage=SLIPPAGE,
    direction="longonly",
    freq="1D",
)

# -------------------------
# 敞口与绩效
# -------------------------
in_position = pf.position_mask().to_numpy()

print(f"\nRubber Band Mean Reversion on {SYMBOL}")
print(f"Market exposure: {in_position.mean() * 100:.1f}%  "
      f"({int(in_position.sum())} of {len(df)} bars)")

bh = vbt.Portfolio.from_holding(close=df["Open"], init_cash=INIT_CASH, freq="1D")
print(f"Strategy CAGR : {pf.annualized_return() * 100:.2f}%")
print(f"Buy&Hold CAGR : {bh.annualized_return() * 100:.2f}%")
print(f"Strategy MaxDD: {pf.max_drawdown() * 100:.2f}%")
print(f"Buy&Hold MaxDD: {bh.max_drawdown() * 100:.2f}%")

trades = pf.trades.records_readable
print(f"Trades: {len(trades)}  "
      f"Win rate: {(trades['PnL'] > 0).mean() * 100:.1f}%  "
      f"Avg per trade: {trades['Return'].mean() * 100:.3f}%")

days_held = ((pd.to_datetime(trades["Exit Timestamp"]) -
              pd.to_datetime(trades["Entry Timestamp"])).dt.days + 1)
held_years = days_held.sum() / 252
total_ret = (1 + trades["Return"]).prod() - 1
print(f"Return on deployed capital (annualized): "
      f"{(1 + total_ret) ** (1 / held_years) - 1:.0%}")

t_stat = trades["Return"].mean() / (trades["Return"].std() / np.sqrt(len(trades)))
print(f"Trade t-stat: {t_stat:.2f}")

# -------------------------
# 绘图
# -------------------------
print(pf.stats())
fig = pf.plot(
    subplots=["value", "underwater", "orders", "trade_pnl"],
    title=f"{SYMBOL} — Rubber Band",
    make_subplots_kwargs=dict(
        row_heights=[0.30, 0.20, 0.20, 0.30],
        vertical_spacing=0.03,
    )
)
fig.update_layout(height=1200)
fig.show()

# -------------------------
# 36 格参数扫描(慢,默认关闭)
# -------------------------
RUN_GRID_SWEEP = False

if RUN_GRID_SWEEP:
    grid = [(s, i, t)
            for s in [-0.02, -0.03, -0.04, -0.05]
            for i in [0.15, 0.25, 0.35]
            for t in [5, 10, 15]]

    rows = []
    for s, i, t in grid:
        g_sig = build_signals(df, stretch=s, ibs_in=i)
        e = g_sig["Entry"].shift(1).fillna(False).astype(bool).to_numpy()
        x = g_sig["Exit"].shift(1).fillna(False).astype(bool).to_numpy()
        x = apply_time_stop(e, x, max_hold=t)
        g_pf = vbt.Portfolio.from_signals(close=df["Open"], entries=e, exits=x,
                                          init_cash=INIT_CASH, fees=FEES,
                                          slippage=SLIPPAGE, freq="1D")
        g_tr = g_pf.trades.records_readable
        rows.append({
            "stretch": s, "ibs_in": i, "time_stop": t,
            "trades": len(g_tr),
            "avg_pct": g_tr["Return"].mean() * 100 if len(g_tr) else np.nan,
            "sharpe": g_pf.sharpe_ratio(),
            "exposure": g_pf.position_mask().to_numpy().mean() * 100,
        })

    g_res = pd.DataFrame(rows).sort_values("sharpe", ascending=False)
    print(g_res.to_string(index=False))
    print(f"\n网格中位夏普: {g_res['sharpe'].median():.3f}  "
          f"夏普>0.5 占比: {(g_res['sharpe'] > 0.5).mean() * 100:.0f}%")

数据:SPY 日线,来源 Yahoo Finance,区间 1993-01 至 2026-08。回测框架 vectorbt,成本 1bp 手续费 + 2bp 滑点。文中反推策略与视频主讲人宣称的原始系统存在差异,详见正文。


#均值回归 #橡皮筋策略 #IBS #SPY #趋势过滤 #参数扫描 #市场敞口 #夏普比率 #策略叠加 #Python量化 #vectorbt #时间止损

Published inAI&Invest专栏

Be First to Comment

    发表回复