作 者:老余捞鱼
原创不易,转载请标明出处及原作者。

文中示例仅用于技术讨论,不构成任何操作建议。
量化策略开发应以学习和技术交流为目的。
本号不荐股、不卖课、不承诺收益。
市场有风险,请合法合规投资。
本文约 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 根日线。表先上。
| 指标 | 恐慌按钮 | 一直持有 |
|---|---|---|
| 交易笔数 | 223 | 223 |
| 胜率 | 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 |
|---|---|---|---|
| 笔数 | 223 | 351 | 142 |
| 胜率 | 67.3% | 63.5% | 63.4% |
| 盈亏比 | 1.45 | 1.18 | 0.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 型。这个按钮想抓的是前一种。

第二条,离场的尺子在 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 未含股息。本文不构成任何投资建议。
Be First to Comment