02 · 策略回测(含真实成本)¶

⚠️ 免责声明:本研究为量化/短期交易方法与可行性的探讨。所有回测均为历史模拟,不代表未来收益,不构成任何投资建议。真实交易受流动性、冲击成本、税费、心理与执行误差影响,结果通常更差。量化与短期交易可致重大本金亏损。读者需自行承担一切决策后果。

数据来源:A股 akshare(新浪后端) / 美股 yfinance,限流时降级腾讯行情;全部缓存于 data/*.csv,每个数字均可在本 notebook 单元复现。


把 01 的信号放进统一向量化回测引擎(qr_bt.backtest),每个策略跑两版:

  • 不含成本(营销话术/课程截图常用的版本)
  • 含真实佣金+印花税+滑点(你实盘真正拿到的版本)

再做成本敏感性分析,定量回答:成本到底吃掉多少毛收益。

回测纪律:信号 T 日收盘算、T+1 成交(lag=1,无前视);区间 2014–2024。

In [1]:
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%(单边),此处采用现行值。

In [2]:
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 双均线¶

先看一个直观例子。

In [3]:
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'],'双均线·含成本')])
No description has been provided for this image
Out[3]:
年化收益 年化波动 夏普 索提诺 最大回撤 卡玛 日胜率 累计净值
名称
买入持有 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标的 × 含/不含成本¶

In [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[['年化收益','夏普','最大回撤','日胜率','年换手']]
Out[4]:
年化收益 夏普 最大回撤 日胜率 年换手
名称
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股/美股 真实成本位置。

In [5]:
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')
No description has been provided for this image
该策略年换手≈1.9倍
在测试区间内未跨越0

解读:曲线斜率 = 年换手倍数。换手越高,成本这把刀越快削平收益。一个年换手十几倍的策略,往返成本每涨0.1%,年化就掉一大截——真正决定生死的不是信号多聪明,而是换手×成本。这就是为什么高频对个人是死局(见下)。

5. 换手—成本拖累全景¶

把所有策略画到「年换手 vs 成本拖累」平面,一眼看清谁被成本拖死。

In [6]:
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()
No description has been provided for this image

解读:点几乎落在一条从原点出发的射线上——成本拖累 ≈ 年换手 × 单次成本,没有魔法。A股(红)因印花税+更高滑点,同样换手下拖累更重。对个人最危险的区域是右上角(高换手),那正是日内/高频策略所在。

6. backtrader 交叉验证(独立引擎复核)¶

用成熟框架 backtrader 跑同一个 SPY 双均线,验证自写向量化引擎没算错。

In [7]:
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. 小结(被数据逼出来的,不是预设的)¶

  1. 过去十年,简单择时/均值回归策略大多跑不赢买入持有,即便不含成本;含成本更差。
  2. 成本拖累 ≈ 年换手 × 单次成本,是条铁律;高换手策略被成本系统性削平。
  3. A股因印花税+滑点,对短线更不友好。
  4. 「不含成本的漂亮净值」是营销常见话术——永远问:含成本后还剩多少?换手多少?

下一步 03 揭示更隐蔽的杀手:即便含了成本,回测本身也能系统性骗你。