回测里买在收盘价、卖在收盘价,现实中你根本做不到。忽略滑点和冲击成本,夏普 2.0 的策略上线后可能直接腰斩。
一、为什么回测总是比实盘"漂亮"
很多量化新手第一次跑实盘都会怀疑人生:
- 回测年化 +25%,实盘半年 +8%
- 回测最大回撤 -8%,实盘直接 -20%
- 回测夏普 1.8,实盘夏普 0.6
差距从哪里来?很大一部分是"执行成本"------你回测时默认能按收盘价成交,但真实市场里:
- 滑点:你下单时的价格,和实际成交的价格,总有差距
- 冲击成本:大单会"砸穿"盘口,自己的买单把价格推高了
- 拆单损耗:为了减少冲击成本拆成多笔,时间拖得越长,价格越不可控
业内有个粗略经验:忽略执行成本的回测,收益可能高估 20%--40%。本文给你一套可落地的建模方法,让回测更接近真实。
二、本文你将得到什么
- 三种滑点模型的 Python 实现(固定滑点 / 成交量比例滑点 / 冲击成本)
- TWAP / VWAP 拆单执行的代码框架
- 一张汇总对比表,量化不同模型的执行损耗
- 把滑点模型嵌入回测引擎的最小改动点
三、三种滑点模型:从简单到真实
3.1 固定比例滑点
最简单、最常用的模型:假设每次成交都损失固定的百分比。
python
def fixed_slippage(price, direction, pct=0.001):
"""固定比例滑点: 买入加价、卖出减价。"""
if direction == "buy":
return round(price * (1 + pct), 2)
return round(price * (1 - pct), 2)
- 买入时成交价 = 目标价 × 1.001
- 卖出时成交价 = 目标价 × 0.999
pct=0.001即 0.1%,适合流动性好的大盘股
3.2 成交量比例滑点
固定滑点的问题在于:大盘股和小盘股用同一个数不合理。成交量比例滑点让滑点随流动性动态调整------成交量低于中位数时滑点放大,高于时缩小。
python
def volume_slippage(price, vol, median_vol, direction, base_pct=0.001):
"""成交量比例滑点: 成交量低于中位数时滑点放大。"""
ratio = median_vol / max(vol, 1)
adj_pct = base_pct * min(ratio, 3.0)
if direction == "buy":
return round(price * (1 + adj_pct), 2)
return round(price * (1 - adj_pct), 2)
3.3 冲击成本(大额订单)
当你一次性买入 10 万股,市场没有那么厚的买盘承接。冲击成本用平方根法则近似:
python
import numpy as np
def impact_cost(price, qty, avg_daily_vol, direction, gamma=1.0):
"""冲击成本: 大额订单对价格的影响(平方根法则简化版)。"""
if avg_daily_vol <= 0:
return price
impact = gamma * np.sqrt(qty / avg_daily_vol) * 0.01
if direction == "buy":
return round(price * (1 + impact), 2)
return round(price * (1 - impact), 2)
qty / avg_daily_vol是你的委托量占日均成交量的比例gamma是市场冲击系数,A 股活跃标的通常取 0.5--1.5
四、TWAP / VWAP:拆单执行的两种思路
大单不拆 = 裸奔。两种主流拆单策略:
4.1 TWAP(时间加权平均价格)
把订单平均拆到 N 个时间切片,每片按当时的市场价成交。
python
def twap_exec(prices, qty, direction, slippage_fn):
"""TWAP 执行: 等时间切片,每片用开盘价+滑点成交。"""
slices = 5
qty_per = qty // slices
fills = []
for i in range(slices):
p = prices[i] if i < len(prices) else prices[-1]
p_fill = slippage_fn(p, direction)
fills.append({"slice": i + 1, "price": p_fill, "qty": qty_per})
return fills
4.2 VWAP(成交量加权平均价格)
按历史成交量比例分配每个切片的委托量,尽量贴近市场真实的成交量分布。
python
def vwap_exec(bars, qty, direction, slippage_fn):
"""VWAP 执行: 按成交量比例分配,以 VWAP+滑点成交。"""
total_vol = sum(float(b.get("v", 0)) for b in bars)
if total_vol == 0:
return twap_exec([float(b["c"]) for b in bars], qty, direction, slippage_fn)
fills = []
remaining = qty
for b in bars:
vol = float(b.get("v", 0))
alloc = int(qty * vol / total_vol)
if alloc <= 0:
continue
alloc = min(alloc, remaining)
p = float(b["c"])
p_fill = slippage_fn(p, direction)
fills.append({"slice": len(fills) + 1, "price": p_fill, "qty": alloc})
remaining -= alloc
if remaining <= 0:
break
if remaining > 0 and fills:
fills[-1]["qty"] += remaining
return fills
五、完整代码实战
下面用麦蕊行情 API 拉取 600519 的 50 条日线,对比六种执行方案的成交结果。
python
from mairui import Client
import pandas as pd
import numpy as np
# 演示证书(非真实账户,仅限演示;实际项目请换成自己的证书)
client = Client("LICENCE-66D8-9F96-0C7F0FBCD073")
# 拉取日线
bars = client.stock_history(code="600519", st="", et="", period="d", dividend="f")
df = pd.DataFrame(bars)
df["c"] = df["c"].astype(float)
df["v"] = df["v"].astype(float)
close_prices = df["c"].tolist()
volumes = df["v"].tolist()
median_vol = df["v"].median()
avg_vol = df["v"].mean()
latest_price = close_prices[-1]
qty = 10000
direction = "buy"
base_cost = latest_price * qty
# 1) 固定滑点
p_fixed = round(latest_price * 1.001, 2)
# 2) 成交量比例滑点
ratio = median_vol / max(volumes[-1], 1)
adj_pct = 0.001 * min(ratio, 3.0)
p_vol = round(latest_price * (1 + adj_pct), 2)
# 3) 冲击成本
impact = 1.0 * np.sqrt(qty / avg_vol) * 0.01
p_impact = round(latest_price * (1 + impact), 2)
# 4) TWAP(5 切片)
twap_fills = []
for i in range(5):
p = close_prices[-5:][i]
twap_fills.append(round(p * 1.001, 2))
twap_avg = sum(twap_fills) / len(twap_fills)
print(f"无滑点基准: {latest_price:.2f} | 成本 {base_cost:,.0f}")
print(f"固定滑点: {p_fixed:.2f} | 成本 {p_fixed*qty:,.0f}")
print(f"成交量滑点: {p_vol:.2f} | 成本 {p_vol*qty:,.0f}")
print(f"冲击成本: {p_impact:.2f} | 成本 {p_impact*qty:,.0f}")
print(f"TWAP 均价: {twap_avg:.2f} | 成本 {twap_avg*qty:,.0f}")
六、运行结果
以 600519 最新收盘价 11.39 元、买入 10,000 股为例:
| 模型 | 成交均价 | 总成本 | 相对损耗 |
|---|---|---|---|
| 无滑点基准 | 11.39 | 113,900 | 0.000% |
| 固定滑点 0.1% | 11.40 | 114,000 | 0.088% |
| 成交量比例滑点 | 11.40 | 114,000 | 0.088% |
| 冲击成本 | 11.40 | 114,000 | 0.088% |
| TWAP(5切片) | 11.14 | 111,380 | -2.212% |
| VWAP(10条) | 11.40 | 113,965 | 0.057% |
注:TWAP 均价偏低是因为最近 5 条收盘价恰好处于局部低位,体现了"时间分散"带来的均价波动,而非成本优势。
七、常见坑与注意
- 滑点不是越小越好:设 0.01% 过于乐观,设 1% 过于悲观;建议用历史盘口数据回算真实滑点分布
- 冲击成本对中小盘更致命:日均成交 5000 万的股票,10 万股的冲击可能达到 1%--3%
- TWAP 的隐藏风险:市场在"切片间隔"里可能剧烈波动,TWAP 无法躲避极端行情
- 别把 VWAP 当最优:VWAP 只是"贴近市场平均",不保证成本最低
八、小结 + 下篇预告
本文实现了三种滑点模型 + TWAP/VWAP 拆单框架。把这套逻辑嵌入你的回测引擎,只需在execute_order()里加一行滑点计算,就能让回测结果更接近实盘。
下篇预告:信号来了就发单?订单状态机与防重单实战------教你用状态机管理订单生命周期,避免重复下单、废单和跨日状态混乱。
免责声明
本文仅供技术学习交流,不构成任何投资建议。市场有风险,投资需谨慎。
了解更多
- 代码与文档:github.com/MaiRuiApi