Skip to content

超卖按钮美股25年有效,A股33年只剩年化1.3%

作 者:老余捞鱼

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

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

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

· · ·

先看一组数字。

同一套规则,在美股跑了 25 年,223 笔,胜率 67.3%,最大回撤 27.54%。

同一套规则,在 A 股跑了 33 年,351 笔,年化 1.31%,33 年总共攒了 55%。

再同一套规则,在沪深300ETF 上跑了 14 年,142 笔,年化负 1.02%。

一个指标,两条规则,三个市场,三张成绩单。跑完这组对照,我把三张表翻来覆去看了很久。它回答了一个我惦记很久的问题,那些在别人手里灵光的超卖策略,搬到 A 股还剩几成功力。

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

一、按钮是个什么零件

这个按钮叫 DeMarker,读音接近”德马克”,主流行情软件里都能翻到。

它干的活很简单。给市场近几天的状态量个体温,读数从 0 到 100。

算法只有两步。第一步,每天收盘后看两件事。今天的高点比昨天高出多少,这是上攻的力量。今天的低点比昨天砸低多少,这是让步的力量。第二步,两股力量各取 5 天平均,再做个除法。

上攻占上风,读数靠近 100。让步占上风,读数往 0 掉。低于 10 的意思很直白,最近 5 天这个市场几乎天天在让步,高点不再抬高,低点不断被砸穿。这种读数很少见,美股一年里只有 20 多天会掉到 10 以下。记住这个频率,后面有个数字会跟它对不上。

策略就在这种时刻进场,赌恐慌释放得差不多,价格要喘口气了。

它有个天然缺陷,只测量,不解释。低点为什么被砸穿,是资金撤离还是技术性回踩,它一概不知。我喜欢测这种笨指标,规则笨到没法作弊,结果是什么就是什么。

① DeMarker 指标,核心就六行

def demarker(high, low, window=5):
    demax = (high - high.shift(1)).clip(lower=0)   # 上攻力量
    demin = (low.shift(1) - low).clip(lower=0)     # 让步力量
    demax_sma = demax.rolling(window).mean()
    demin_sma = demin.rolling(window).mean()
    return 100 * demax_sma / (demax_sma + demin_sma).replace(0, np.nan)

二、规则就两条

进场,DeMarker(5) 低于 10 的那天,收盘记一笔信号。

离场,哪天收盘价重新站上前一日高点,说明卖压停了,第二天开盘离场。

没有目标价,没有止损线,不加码,单向做多。

成交口径单独交代一下。信号在收盘确认,成交放在下一根 K 线的开盘,中间隔一根 K 线。不少回测的坑就坑在看到信号立刻按收盘价成交,等于偷看了未来。这笔账我不偷。

费用按双边万分之一加滑点万分之二计,信号和结算都用未复权价。

② 信号状态机,决策与成交分离

def build_signals(df):
    n = len(df)
    close = df["close"].to_numpy()
    dm = demarker(df["high"], df["low"]).to_numpy()
    signal = dm < 10    # NaN 比较返回 False,天然安全

    decision_entries = np.zeros(n, dtype=bool)
    decision_exits = np.zeros(n, dtype=bool)
    in_position = False
    for i in range(n):
        if not in_position:
            if signal[i]:
                decision_entries[i] = True   # 收盘决策
                in_position = True
        elif i > 0 and close[i] > df["high"].iloc[i - 1]:
            decision_exits[i] = True         # 站上昨日高点
            in_position = False
    # 决策整体后移一根,成交落在下一根开盘
    entries = np.zeros(n, dtype=bool)
    exits = np.zeros(n, dtype=bool)
    entries[1:] = decision_entries[:-1]
    exits[1:] = decision_exits[:-1]
    return entries, exits

三、先看 SPY 25 年

美股这边我用 SPY,2001 年 1 月到今年 9 月,6465 根日线。表先上。

指标恐慌按钮一直持有
交易笔数223223
胜率67.3%无
盈亏比1.45无
年化收益2.27%7.16%
最大回撤27.54%56.47%
在场时间12.4%100%

单看收益,这策略不及格,年化 2.27% 连持有的一半都不到。

值钱的地方在右半边。回撤 27.54%,持有要扛 56.47%,直接砍掉一半。一年 87.6% 的日子是空仓的,钱大部分时间躺在场外,突发的黑天鹅大部分都不在场。

赢亏的形状也记一下。平均每笔赢 1.39%,平均每笔亏 1.97%,亏单比赢单重,靠 67.3% 的胜率把账做平。中位持仓只有 4 天,最久的一笔拿了 23 天,一年出手不到 9 次,谈不上什么长线功夫,更像高频短跑。

统计检验也做了。223 笔的单笔均值 0.29%,重抽 5000 次,95% 区间落在负 0.04% 与 0.61% 之间,均值低于零的概率 4.5%。踩着及格线过,不算漂亮,但确实站在零的右边。

夏普比一比更扎心,策略 0.28,持有 0.46,单位风险的回报也不占优。它的价值全压在回撤和空仓这两件事上,图收益的人看不上它,图睡得着的人才会多看两眼。

③ 回测主循环,净值就是单笔的连乘

def run_backtest(df):
    entries, exits = build_signals(df)
    n = len(df)
    open_ = df["open"].to_numpy()
    trades = []
    i = 1
    while i < n:
        if entries[i]:
            j = next((k for k in range(i + 1, n) if exits[k]), None)
            if j is None:
                break
            entry_eff = open_[i] * (1 + SLIPPAGE + FEES)
            exit_eff = open_[j] * (1 - SLIPPAGE - FEES)
            trades.append(exit_eff / entry_eff - 1)
            i = j
        i += 1
    tr = np.array(trades)
    eq = INIT_CASH
    for r in tr:
        eq *= 1 + r    # 空仓日净值冻结,终值就是单笔的连乘
    return tr, eq / INIT_CASH - 1

四、但真正该盯着看的在下面

把 223 笔按时间撒到图上,故事的另一面就露出来了。

2008 年 9 月到 2009 年 3 月那一段,最赚和最亏挤在一起。2008 年 11 月 21 日进场那笔赚了 12.64%,是 25 年里最大的一笔。两个多月后,2009 年 2 月 18 日进场那笔亏了 12.2%,成了 25 年里最惨的一笔。同一场危机,既是这策略的巅峰,也是它的深坑。

红盈绿亏,最赚与最亏的单笔挤在同一段恐慌里

危机后段的账单也得认。2009 年剩下的 7 笔合计亏 8.5%,2020 年 3 月熔断周前后进场 6 笔,最疼一笔负 8.38%,合计小亏 1.8%。再往前数,2001 年 9 月 4 日那笔亏了 11.56%,进场时 911 事件后的余震还没散完。

这就是接飞刀策略的诚实形状,接住的是黄金还是刀柄,按下去那一刻没人知道。

我又加了一道对照。同样 223 个进场名额,随机撒到 25 年的日历上,离场规则不变,跑 200 遍。结果有点吓人,随机组合的总收益中位数是负 70%,最惨的亏掉 83%。我这 223 笔排在 200 遍里的第一。

同一把离场的尺子,随机进场亏成这样,挑进场时机的价值一下就量出来了。这把尺子自己不赚钱,DeMarker 挑的位置才是全部。

④ 随机基线,同名额随机撒点

def random_baseline(df, n_entries, n_sims=200):
    rng = np.random.default_rng(42)
    totals = []
    for _ in range(n_sims):
        mask = np.zeros(len(df), dtype=bool)
        days = rng.choice(np.arange(1, len(df) - 1), size=n_entries, replace=False)
        mask[days] = True
        eq, shares, in_pos = INIT_CASH, 0.0, False
        i = 1
        while i < len(df):
            if not in_pos and mask[i - 1]:          # 前一日决策,当日开盘成交
                in_pos = True
                shares = eq / (df["open"].iloc[i] * (1 + SLIPPAGE + FEES))
                eq = shares * df["close"].iloc[i]
            elif in_pos:
                eq = shares * df["close"].iloc[i]
                if df["close"].iloc[i] > df["high"].iloc[i - 1]:
                    eq = shares * df["open"].iloc[i] * (1 - SLIPPAGE - FEES)
                    in_pos = False
            i += 1
        totals.append(eq / INIT_CASH - 1)
    return np.array(totals)   # 25 年里,200 次随机的中位数是 -70%

五、搬进A股,按钮哑火了

然后我把同一套代码原封不动指向 A 股,先跑上证指数,从 1993 年开始,和美股那个经典窗口一样长,33.7 年。三个市场的净值放在一起,差距不用我多说。

橙红线是策略,灰虚线是持有,同一按钮的两种命运
指标SPY上证指数沪深300ETF
笔数223351142
胜率67.3%63.5%63.4%
盈亏比1.451.180.94
单笔均值+0.29%+0.18%-0.06%
年化收益2.27%1.31%-1.02%
最大回撤27.54%34.19%37.02%
持有年化7.16%4.77%4.03%

成本两边一个样,费用和滑点用的是同一套参数,这个差距赖不到成本头上。

上证 351 笔,胜率还有 63.5%。可年化只剩 1.31%,33 年攒了 55%,同期指数本身年化 4.77%。统计检验也过不去了。单笔均值的 95% 区间,左端探到负 0.16%,右端也只够到 0.52%,均值低于零的概率 14.8%。换个说法,没法排除白忙一场。

沪深300ETF 更干脆。盈亏比 0.94,跌破 1,平均每笔亏 0.06%,14 年下来负 13.7%。2015 年秋天那 9 笔合计 -17.4%,2018 年 12 笔又搭进去 7.7%。63.4% 的胜率看着不差,输在赢的太小、亏的太重。

最疼的一笔在 1994 年春天。进场之后连续 28 天没等到收盘站上昨日高点这个离场条件,一笔 -19.95%。那是没有涨跌停限制的年代,指数可以不带喘地连跌一个月。

有个细节跟直觉相反,崩盘年反而算不上最大的坑。上证 2008 年进场 17 笔,合计还赚 2.2%,2015 年 10 笔基本打平,2016 年 13 笔赚 5.3%。真正放血的是阴跌磨人的年份。2018 年的 ETF 进场 12 笔,亏掉 7.7%。2015 年秋天 9 笔,亏 17.4%。急跌它接得住,阴跌它接不住。美股那边危机过后一两年就翻篇,A 股的熊市拖着长尾,磨底期的小亏一年一年叠上来,这才是差距拉开的地方。

六、为什么同一个按钮,两个市场不一个声

对着三张表看了一晚上,我的解释有四条,按可信度排。

第一条,A 股的恐慌不尖。信号日 SPY 平均每年出现 22 天,上证 31.8 天,ETF 30.3 天。同样的刻度,A 股更容易跌进恐慌区,这么容易触发的恐慌谈不上恐慌,更像常态。美股的深跌是插下去就弹的 V 型,A 股的深跌常是趴着磨的 L 型。这个按钮想抓的是前一种。

蓝柱(上证)几乎全线高过红柱(SPY)

第二条,离场的尺子在 A 股反应慢。收盘站上昨日高点这个条件,在阴跌里能连续几周等不到。等不到,就只能扛着。两边的中位持仓都是 4 天,但 A 股最长被拖到 28 天,SPY 只有 23 天。多扛的每一天都在放大亏损。平均每笔亏损,A 股 2.72%,SPY 只有 1.97%。

第三条,赔率倒挂。上证平均每笔赢 1.85%,亏 2.72%,赢小亏大,63.5% 的胜率填不平这个坑。SPY 那边是赢 1.39% 亏 1.97%,靠更高的胜率把账做平。

第四条带推测成分。美股下跌时,长期定投资金真的在承接。恐慌读数见底的位置,常有钱等着。A 股下跌时大家都在等更便宜的筹码,承接来得晚,底就磨得久。这条我没有直接证据,是拿行为逻辑倒推的,听个方向就好。

顺手补一句随机基线的事。随机进场在 A 股更惨,上证的 200 个随机组合总收益中位数亏 98.6%,等于几乎归零。也就是说就算在 A 股,DeMarker 挑的位置也比瞎选强得多,只是这份强,不够把 1.31% 的年化抬到能看。

四条凑在一起,结论很收敛。按钮没坏,是墙的材质变了,美股那面墙里埋着回弹的钢丝,A 股这面墙是实心的。

七、想自己按一遍的人,从这里开始

三条建议,照着做能少走弯路。

第一,成交口径别偷懒。收盘决策、次日开盘成交、双边成本一万分之三,这三处任何一处放宽,数字都会变好看,变好看的那部分全是假的。

第二,先跑随机基线再看收益。同样名额随机撒点,你的策略跑不赢 200 个随机组合,后面的讨论都可以省了。

第三,胜率单独看没有意义。沪深300ETF 63.4% 的胜率配 0.94 的盈亏比,照样是亏。先看盈亏比能不能站上 1,再看胜率。

完整脚本在下面,三个标的、指标、Bootstrap、随机基线全在里面,换个代码就能跑你自己的标的。跑之前先装依赖,一行 pip install akshare pandas numpy。数据走新浪财经的免费接口,不用注册,不用 token。

以上是结论和用法,往下是可以直接抄的完整代码

⑤ 完整脚本(可整体复制运行)

# -*- coding: utf-8 -*-
"""
恐慌按钮策略实测(最小可跑版)
规则:DeMarker(5) < 10 收盘决策进场,收盘重新站上前一日高点后次日开盘离场
口径:决策在收盘、成交在下一根开盘,双边费用 0.01% + 滑点 0.02%,全程未复权
标的:SPY(2001 起)、上证指数(1993 起)、沪深300ETF 510300(2012 起)
依赖:pip install akshare pandas numpy
"""
import warnings

import akshare as ak
import numpy as np
import pandas as pd

warnings.filterwarnings("ignore")

DEMARKER_WINDOW = 5
DEMARKER_ENTRY_MAX = 10
INIT_CASH = 100_000.0
FEES = 0.0001
SLIPPAGE = 0.0002
N_BOOTSTRAP = 5_000
N_SIMS = 200
SEED = 42

MARKETS = {
    "SPY": ("us", "2001-01-01", "2026-09-18"),
    "上证指数": ("index", "1993-01-01", "2026-09-18"),
    "沪深300ETF": ("etf", "2012-05-28", "2026-09-18"),
}


def fetch(kind):
    if kind == "us":
        df = ak.stock_us_daily(symbol="SPY", adjust="")
    elif kind == "index":
        df = ak.stock_zh_index_daily(symbol="sh000001")
    else:
        df = ak.fund_etf_hist_sina(symbol="sh510300")
    df["date"] = pd.to_datetime(df["date"])
    df = df.sort_values("date").drop_duplicates("date").set_index("date")
    return df[["open", "high", "low", "close"]].dropna()


def demarker(high, low, window=DEMARKER_WINDOW):
    demax = (high - high.shift(1)).clip(lower=0)
    demin = (low.shift(1) - low).clip(lower=0)
    demax_sma = demax.rolling(window).mean()
    demin_sma = demin.rolling(window).mean()
    return 100 * demax_sma / (demax_sma + demin_sma).replace(0, np.nan)


def build_signals(df):
    n = len(df)
    close = df["close"].to_numpy()
    dm = demarker(df["high"], df["low"]).to_numpy()
    signal = dm < DEMARKER_ENTRY_MAX
    decision_entries = np.zeros(n, dtype=bool)
    decision_exits = np.zeros(n, dtype=bool)
    in_position = False
    for i in range(n):
        if not in_position:
            if signal[i]:
                decision_entries[i] = True
                in_position = True
        elif i > 0 and close[i] > df["high"].iloc[i - 1]:
            decision_exits[i] = True
            in_position = False
    entries = np.zeros(n, dtype=bool)
    exits = np.zeros(n, dtype=bool)
    entries[1:] = decision_entries[:-1]
    exits[1:] = decision_exits[:-1]
    return entries, exits


def run_backtest(df, n_boot=N_BOOTSTRAP):
    entries, exits = build_signals(df)
    n = len(df)
    open_ = df["open"].to_numpy()
    trades = []
    i = 1
    while i < n:
        if entries[i]:
            j = next((k for k in range(i + 1, n) if exits[k]), None)
            if j is None:
                break
            entry_eff = open_[i] * (1 + SLIPPAGE + FEES)
            exit_eff = open_[j] * (1 - SLIPPAGE - FEES)
            trades.append(dict(entry=df.index[i], exit=df.index[j],
                               ret=exit_eff / entry_eff - 1))
            i = j
        i += 1
    tr = np.array([t["ret"] for t in trades])

    eq = INIT_CASH
    for r in tr:
        eq *= 1 + r
    n_years = (df.index[-1] - df.index[0]).days / 365.25
    total = eq / INIT_CASH - 1
    bh = INIT_CASH * df["close"] / df["close"].iloc[0]
    bh_total = bh.iloc[-1] / INIT_CASH - 1

    rng = np.random.default_rng(SEED)
    boot = rng.choice(tr, size=(n_boot, len(tr)), replace=True).mean(axis=1)
    lo_ci, hi_ci = np.percentile(boot, [2.5, 97.5])
    return dict(trades=len(tr),
                win_rate=(tr > 0).mean() * 100,
                profit_factor=tr[tr > 0].sum() / abs(tr[tr < 0].sum()),
                avg_trade=tr.mean() * 100,
                cagr=((1 + total) ** (1 / n_years) - 1) * 100,
                total=total * 100,
                bh_cagr=((1 + bh_total) ** (1 / n_years) - 1) * 100,
                boot_mean=tr.mean() * 100,
                boot_lo=lo_ci * 100, boot_hi=hi_ci * 100,
                boot_p_le0=(boot <= 0).mean())


def random_baseline(df, n_entries, n_sims=N_SIMS):
    n = len(df)
    open_ = df["open"].to_numpy()
    close = df["close"].to_numpy()
    high_prev = df["high"].shift(1).to_numpy()
    rng = np.random.default_rng(SEED)
    totals = []
    for _ in range(n_sims):
        mask = np.zeros(n, dtype=bool)
        mask[rng.choice(np.arange(1, n - 1), size=n_entries, replace=False)] = True
        eq, shares, in_pos = INIT_CASH, 0.0, False
        i = 1
        while i < n:
            if not in_pos and mask[i - 1]:
                in_pos = True
                shares = eq / (open_[i] * (1 + SLIPPAGE + FEES))
                eq = shares * close[i]
            elif in_pos:
                eq = shares * close[i]
                if close[i] > high_prev[i]:
                    eq = shares * open_[i] * (1 - SLIPPAGE - FEES)
                    in_pos = False
            i += 1
        totals.append(eq / INIT_CASH - 1)
    return np.array(totals)


def main():
    for name, (kind, start, end) in MARKETS.items():
        df = fetch(kind).loc[start:end]
        m = run_backtest(df)
        rand = random_baseline(df, m["trades"])
        print(f"===== {name}  {df.index[0].date()} ~ {df.index[-1].date()} =====")
        print(f"  交易笔数 {m['trades']}   胜率 {m['win_rate']:.1f}%   盈亏比 {m['profit_factor']:.2f}")
        print(f"  单笔均值 {m['avg_trade']:.3f}%   年化 {m['cagr']:.2f}%   区间总收益 {m['total']:.1f}%")
        print(f"  一直持有,年化 {m['bh_cagr']:.2f}%")
        print(f"  Bootstrap 95% 区间 [{m['boot_lo']:.3f}%, {m['boot_hi']:.3f}%],P(均值<=0)={m['boot_p_le0']:.3f}")
        print(f"  随机基线中位 {np.median(rand) * 100:.1f}%,策略跑赢 {(rand < m['total'] / 100).mean() * 100:.0f}% 的随机组合")


if __name__ == "__main__":
    main()

最后留个问题。你盯着的市场,恐慌是尖底还是磨底?一句话说说,我挑几个标的把同样的对照跑给你们看。

觉得有用的话,把这篇转给同样在搞回测的朋友。

#量化回测 #DeMarker #超卖反弹 #策略对照 #上证指数 #沪深300ETF #SPY #交易系统 #随机基线 #回撤控制


本文全部数据为作者自行实测,数据源为新浪财经公开接口(akshare),区间截至 2026-09-18,口径与成本设定见文内说明。指数与 ETF 未含分红,SPY 未含股息。本文不构成任何投资建议。

Published inAI&Invest专栏

Be First to Comment

    发表回复