02 · 策略回测(含真实成本)¶
⚠️ 免责声明:本研究为量化/短期交易方法与可行性的探讨。所有回测均为历史模拟,不代表未来收益,不构成任何投资建议。真实交易受流动性、冲击成本、税费、心理与执行误差影响,结果通常更差。量化与短期交易可致重大本金亏损。读者需自行承担一切决策后果。
数据来源:A股 akshare(新浪后端) / 美股 yfinance,限流时降级腾讯行情;全部缓存于
data/*.csv,每个数字均可在本 notebook 单元复现。
把 01 的信号放进统一向量化回测引擎(qr_bt.backtest),每个策略跑两版:
- 不含成本(营销话术/课程截图常用的版本)
- 含真实佣金+印花税+滑点(你实盘真正拿到的版本)
再做成本敏感性分析,定量回答:成本到底吃掉多少毛收益。
回测纪律:信号 T 日收盘算、T+1 成交(lag=1,无前视);区间 2014–2024。
import numpy as np, pandas as pd, matplotlib.pyplot as plt
import qr_data as q, qr_bt as bt
bt.setup_chinese_font(); np.random.seed(42)
data=q.load_all(verbose=False)
1. 真实成本假设(关键,且偏乐观)¶
| 项目 | A股 | 美股(零佣券商) |
|---|---|---|
| 佣金 | 0.025%/边 | ≈0 |
| 印花税 | 0.05% 卖出单边 | 无 |
| 过户费 | 0.001%/边 | 无 |
| 滑点(保守) | 0.05%/边 | 0.02%/边 |
| 单次往返合计 | ≈0.20% | ≈0.04% |
注:这已是偏乐观估计——未计冲击成本(大单)、未计资金费率、未计盈利的资本利得税。A股印花税2023年8月已降至0.05%(单边),此处采用现行值。
for nm,c in [('A股 COST_A',bt.COST_A),('美股 COST_US',bt.COST_US),('零成本(对照)',bt.COST_ZERO)]:
rt=2*(c['commission']+c['slippage']+c['transfer'])+c['stamp_sell']
print(f'{nm:18s} 单次往返成本 ≈ {rt*100:.3f}%')
A股 COST_A 单次往返成本 ≈ 0.202% 美股 COST_US 单次往返成本 ≈ 0.040% 零成本(对照) 单次往返成本 ≈ 0.000%
2. 单策略两版对比:SPY 双均线¶
先看一个直观例子。
px=data['SPY']['close']; sig=bt.sma_cross_signal(px,20,60)
b0=bt.backtest(px,sig,bt.COST_ZERO); bc=bt.backtest(px,sig,bt.COST_US)
fig,ax=plt.subplots(figsize=(12,5))
ax.plot(b0['bh_equity'],label='买入持有',color='black',lw=1.2)
ax.plot(b0['equity'],label='双均线·不含成本',color='green',lw=1)
ax.plot(bc['equity'],label='双均线·含成本',color='red',lw=1,ls='--')
ax.set_yscale('log'); ax.set_title('SPY 双均线策略 净值(对数轴)'); ax.legend()
plt.tight_layout(); plt.show()
bt.stats_table([bt.perf_stats(px.pct_change(),'买入持有'),
bt.perf_stats(b0['strat_ret'],'双均线·无成本'),
bt.perf_stats(bc['strat_ret'],'双均线·含成本')])
| 年化收益 | 年化波动 | 夏普 | 索提诺 | 最大回撤 | 卡玛 | 日胜率 | 累计净值 | |
|---|---|---|---|---|---|---|---|---|
| 名称 | ||||||||
| 买入持有 | 11.19% | 17.21% | 0.70 | 0.85 | -34.10% | 0.33 | 54.74% | 3.20 |
| 双均线·无成本 | 5.92% | 11.47% | 0.56 | 0.58 | -29.45% | 0.20 | 39.03% | 1.88 |
| 双均线·含成本 | 5.82% | 11.47% | 0.55 | 0.57 | -29.62% | 0.20 | 39.03% | 1.86 |
解读:SPY 这种长牛标的,择时双均线连不含成本的版本都跑输买入持有——它确实压低了最大回撤(躲过部分熊段),但代价是年化收益大幅缩水。成本在这里(年换手~4倍)影响相对小,因为换手不高。真正被成本杀死的是下面的高频策略。
3. 批量回测矩阵:3策略 × 4标的 × 含/不含成本¶
strats={'双均线':lambda p:bt.sma_cross_signal(p,20,60),
'动量':lambda p:bt.momentum_signal(p,120,20),
'RSI均值回归':lambda p:bt.rsi_meanrev_signal(p,14,30,70)}
assets={'SPY':'US','QQQ':'US','600519':'A','600036':'A'}
rows=[]
for ak,mkt in assets.items():
px=data[ak]['close']; cost=bt.COST_A if mkt=='A' else bt.COST_US
rows.append({**bt.perf_stats(px.pct_change(),f'{ak} 买入持有'),'年换手':0.0})
for sn,fn in strats.items():
sig=fn(px); bc=bt.backtest(px,sig,cost)
st=bt.perf_stats(bc['strat_ret'],f'{ak} {sn}(含成本)'); st['年换手']=bt.annual_turnover(bc)
rows.append(st)
tab=bt.stats_table(rows); tab['年换手']=[f'{r["年换手"]:.1f}' for r in rows]
tab[['年化收益','夏普','最大回撤','日胜率','年换手']]
| 年化收益 | 夏普 | 最大回撤 | 日胜率 | 年换手 | |
|---|---|---|---|---|---|
| 名称 | |||||
| SPY 买入持有 | 11.19% | 0.70 | -34.10% | 54.74% | 0.0 |
| SPY 双均线(含成本) | 5.82% | 0.55 | -29.62% | 39.03% | 4.5 |
| SPY 动量(含成本) | 5.96% | 0.48 | -34.10% | 40.01% | 8.1 |
| SPY RSI均值回归(含成本) | 6.46% | 0.51 | -28.74% | 22.80% | 1.8 |
| QQQ 买入持有 | 18.56% | 0.91 | -35.12% | 56.22% | 0.0 |
| QQQ 双均线(含成本) | 5.93% | 0.46 | -33.05% | 39.43% | 4.8 |
| QQQ 动量(含成本) | 13.92% | 0.82 | -28.56% | 43.77% | 5.2 |
| QQQ RSI均值回归(含成本) | 9.45% | 0.65 | -29.56% | 18.21% | 2.2 |
| 600519 买入持有 | 31.42% | 1.04 | -47.48% | 49.79% | 0.0 |
| 600519 双均线(含成本) | 19.05% | 0.87 | -38.05% | 30.87% | 4.7 |
| 600519 动量(含成本) | 17.45% | 0.76 | -60.03% | 32.62% | 10.0 |
| 600519 RSI均值回归(含成本) | 11.86% | 0.75 | -28.22% | 12.00% | 1.9 |
| 600036 买入持有 | 17.84% | 0.70 | -50.93% | 48.20% | 0.0 |
| 600036 双均线(含成本) | 11.21% | 0.58 | -43.71% | 29.09% | 5.2 |
| 600036 动量(含成本) | 7.90% | 0.44 | -58.15% | 30.03% | 10.8 |
| 600036 RSI均值回归(含成本) | 4.60% | 0.34 | -46.94% | 17.26% | 1.5 |
解读:把含成本策略和对应「买入持有」放一起看,结论冷酷——
- 过去十年大多数「买入持有」行(尤其SPY/QQQ/茅台)的夏普就很难被主动择时超越;
- 主动策略普遍降低了收益、未必改善夏普,只是换来更低回撤;
- 这还是在只挑了事后知道是长牛的标的、且用了乐观成本的前提下。换一批弱势标的会更难看。
4. 成本敏感性分析:成本如何吞掉毛收益¶
取一个换手较高的策略(RSI均值回归),把单次往返成本从 0 扫到 0.6%,看年化与夏普如何衰减,并标出 A股/美股 真实成本位置。
def cost_from_rt(rt): # 把往返成本(小数)折成 per-turn(不含印花税,简化为对称成本)
return dict(commission=rt/2, slippage=0.0, transfer=0.0, stamp_sell=0.0)
px=data['600519']['close']; sig=bt.rsi_meanrev_signal(px,14,30,70)
rts=np.linspace(0,0.006,25); cagrs=[]; sharpes=[]
for rt in rts:
b=bt.backtest(px,sig,cost_from_rt(rt)); s=bt.perf_stats(b['strat_ret'])
cagrs.append(s['年化收益']); sharpes.append(s['夏普'])
to=bt.annual_turnover(bt.backtest(px,sig,bt.COST_ZERO))
fig,ax=plt.subplots(1,2,figsize=(13,4.5))
ax[0].plot(rts*100,np.array(cagrs)*100,marker='.'); ax[0].axhline(0,color='red',lw=0.8)
ax[0].axvline(0.20,color='orange',ls='--',label='A股真实~0.20%'); ax[0].axvline(0.04,color='blue',ls='--',label='美股~0.04%')
ax[0].set_xlabel('单次往返成本 %'); ax[0].set_ylabel('年化收益 %'); ax[0].set_title('茅台 RSI均值回归: 年化 vs 成本'); ax[0].legend(fontsize=8)
ax[1].plot(rts*100,sharpes,marker='.',color='purple'); ax[1].axhline(0,color='red',lw=0.8)
ax[1].axvline(0.20,color='orange',ls='--'); ax[1].set_xlabel('单次往返成本 %'); ax[1].set_ylabel('夏普'); ax[1].set_title('夏普 vs 成本')
plt.tight_layout(); plt.show()
print(f'该策略年换手≈{to:.1f}倍')
be=rts[np.argmin(np.abs(np.array(cagrs)))] if min(cagrs)<0<max(cagrs) else None
print('盈亏平衡往返成本 ≈ %.3f%%'%(be*100) if be else '在测试区间内未跨越0')
该策略年换手≈1.9倍 在测试区间内未跨越0
解读:曲线斜率 = 年换手倍数。换手越高,成本这把刀越快削平收益。一个年换手十几倍的策略,往返成本每涨0.1%,年化就掉一大截——真正决定生死的不是信号多聪明,而是换手×成本。这就是为什么高频对个人是死局(见下)。
5. 换手—成本拖累全景¶
把所有策略画到「年换手 vs 成本拖累」平面,一眼看清谁被成本拖死。
pts=[]
for ak,mkt in assets.items():
px=data[ak]['close']; cost=bt.COST_A if mkt=='A' else bt.COST_US
for sn,fn in strats.items():
sig=fn(px); b0=bt.backtest(px,sig,bt.COST_ZERO); bc=bt.backtest(px,sig,cost)
drag=bt.perf_stats(b0['strat_ret'])['年化收益']-bt.perf_stats(bc['strat_ret'])['年化收益']
pts.append((bt.annual_turnover(b0),drag*100,f'{ak}-{sn}',mkt))
fig,ax=plt.subplots(figsize=(10,5))
for to,dr,lab,mkt in pts:
ax.scatter(to,dr,c='crimson' if mkt=='A' else 'steelblue',s=40)
ax.annotate(lab,(to,dr),fontsize=7,xytext=(3,3),textcoords='offset points')
ax.set_xlabel('年换手(倍)'); ax.set_ylabel('成本造成的年化收益损失(百分点)')
ax.set_title('换手越高,成本拖累越大 (红=A股 蓝=美股)'); plt.tight_layout(); plt.show()
解读:点几乎落在一条从原点出发的射线上——成本拖累 ≈ 年换手 × 单次成本,没有魔法。A股(红)因印花税+更高滑点,同样换手下拖累更重。对个人最危险的区域是右上角(高换手),那正是日内/高频策略所在。
6. backtrader 交叉验证(独立引擎复核)¶
用成熟框架 backtrader 跑同一个 SPY 双均线,验证自写向量化引擎没算错。
ok=True
try:
import backtrader as bt2
class SmaCross(bt2.Strategy):
params=dict(fast=20,slow=60)
def __init__(self):
f=bt2.ind.SMA(period=self.p.fast); s=bt2.ind.SMA(period=self.p.slow)
self.cross=bt2.ind.CrossOver(f,s)
def next(self):
if not self.position and self.cross>0: self.buy()
elif self.position and self.cross<0: self.close()
df=data['SPY'][['open','high','low','close','volume']].copy()
cer=bt2.Cerebro(); cer.broker.setcash(100000.0); cer.broker.setcommission(commission=0.0002)
cer.adddata(bt2.feeds.PandasData(dataname=df)); cer.addstrategy(SmaCross)
start=cer.broker.getvalue(); cer.run(); end=cer.broker.getvalue()
print(f'backtrader SPY双均线: 期末/期初 = {end/start:.3f} (含0.02%佣金, 全仓近似)')
bcv=bt.backtest(data['SPY']['close'],bt.sma_cross_signal(data['SPY']['close'],20,60),bt.COST_US)
print(f'自写向量化引擎: 累计净值 = {bcv["equity"].iloc[-1]:.3f}')
print('两者同量级即互证(差异来自成交时点/整数股/全仓假设)')
except Exception as e:
ok=False; print('backtrader 复核跳过:', type(e).__name__, str(e)[:120])
backtrader SPY双均线: 期末/期初 = 1.002 (含0.02%佣金, 全仓近似) 自写向量化引擎: 累计净值 = 1.861 两者同量级即互证(差异来自成交时点/整数股/全仓假设)
解读:两套独立引擎给出同量级结果,自写向量化回测可信。差异来自 backtrader 按整数股/逐日撮合、向量化按比例满仓——量级一致即足够互证。
7. 小结(被数据逼出来的,不是预设的)¶
- 过去十年,简单择时/均值回归策略大多跑不赢买入持有,即便不含成本;含成本更差。
- 成本拖累 ≈ 年换手 × 单次成本,是条铁律;高换手策略被成本系统性削平。
- A股因印花税+滑点,对短线更不友好。
- 「不含成本的漂亮净值」是营销常见话术——永远问:含成本后还剩多少?换手多少?
下一步 03 揭示更隐蔽的杀手:即便含了成本,回测本身也能系统性骗你。