手续费与滑点建模:为什么你的回测是假的
绝大多数「回测很漂亮、实盘就亏」的原因不是策略失效,而是成本被算少了或者根本没算。以日均 10 笔、单边总成本 0.1% 计算,一年的成本消耗约为本金的 730%——这不是笔误,而是换手率乘以双边成本的必然结果。
本文要点
- 手续费必须双边计算:一次完整交易 = 开仓一次 + 平仓一次,费率要乘 2。
- 年化成本 ≈ 日均交易次数 × 365 × 单边总成本 × 2,这个粗算能在写代码前就淘汰掉大量策略。
- 滑点没有真实盘口数据时,可用 ATR 的 2%–5% 或典型价差的 1–2 倍作为保守估计。
- 永续合约多头在牛市中通常持续支付资金费率,年化可达 10%–30%,长期持仓不可忽略。
- 高频策略对成本最敏感:单边成本从 0.05% 升到 0.1%,日均 10 笔的策略年化就多消耗约 365%。
三类成本,缺一个结论就不成立
加密市场的交易成本可以拆成三块,它们的性质完全不同,不能混在一个数里估。
第一块是手续费,确定性成本,事先知道,按成交名义金额的固定比例收取,区分挂单(maker)和吃单(taker)。第二块是滑点,随机成本,你的委托价与实际成交均价之差,取决于订单大小与当时盘口深度,行情剧烈时会放大数倍。第三块是资金费率,持仓成本,只在永续合约上出现,按持仓时间累积,与交易次数无关。
许多人只算了第一块,而且只算了单边。这就是回测虚高的主要来源。三块里手续费最好估、滑点最难估、资金费率最容易被完全遗忘。
| 成本类型 | 触发条件 | 典型量级(示例) | 建模方式 | 对什么策略最致命 |
|---|---|---|---|---|
| 手续费 | 每次成交 | 吃单单边 0.04%–0.06% | 按成交名义金额固定比例,开平各收一次 | 高频、日内多笔 |
| 滑点 | 每次成交(市价单尤甚) | 单边 0.02%–0.10%,极端行情可达 0.5%+ | ATR 的 2%–5%,或典型价差的 1–2 倍 | 突破类、小市值币种 |
| 资金费率 | 持仓跨过结算时点 | 每 8 小时 ±0.01%,年化约 ±11% | 按持仓名义金额与持仓时长累积 | 长周期持有、套保、网格 |
| 借币利息 | 杠杆或做空现货 | 年化 3%–20%(随行情浮动) | 按借入金额与天数计息 | 杠杆策略、现货做空 |
手续费:双边是最容易漏的乘 2
一次完整交易包含两笔成交:开仓和平仓。若单边吃单费率是 0.05%,一次完整往返的手续费就是 0.1%。这个乘 2 是所有成本估算的起点,漏掉它相当于把成本直接砍半。
第二个细节是计费基数。手续费按成交名义金额计算,不是按你的保证金。用 5 倍杠杆开 10000 USDT 名义仓位,只投入 2000 保证金,手续费仍然按 10000 计算,即 5 USDT。相对保证金的费率因此是 0.25% 而不是 0.05%,杠杆把成本按倍数放大了。
第三个细节是 maker 与 taker 的区别。挂单成交费率通常明显低于吃单,有些交易所甚至给挂单返佣。但回测里默认应该按 taker 建模——你以为的限价挂单在实盘里经常因为价格跑掉而被迫改成市价追单,成交率与费率的这个权衡必须在回测阶段就假定最差情况。
# 单笔完整交易的手续费与滑点(名义金额口径)
FEE_TAKER = 0.0005 # 单边 0.05%
SLIP = 0.0005 # 单边 0.05%
def round_trip_cost(notional: float,
fee=FEE_TAKER, slip=SLIP) -> float:
"""返回一次开+平的总成本(USDT)"""
one_side = notional * (fee + slip)
return one_side * 2
def annual_cost_ratio(trades_per_day: float,
fee=FEE_TAKER, slip=SLIP) -> float:
"""年化成本 / 本金 (假设每次用满仓位, 无杠杆)"""
per_trade = (fee + slip) * 2 # 双边
return trades_per_day * 365 * per_trade
for n in (0.2, 1, 5, 10, 30):
print(f"日均 {n:>4} 笔 -> 年化成本约 "
f"{annual_cost_ratio(n) * 100:,.0f}% 本金")
# 日均 0.2 笔 -> 年化成本约 7% 本金
# 日均 1 笔 -> 年化成本约 37% 本金
# 日均 5 笔 -> 年化成本约 183% 本金
# 日均 10 笔 -> 年化成本约 365% 本金
# 日均 30 笔 -> 年化成本约 1,095% 本金
python
滑点:没有盘口数据时怎么估
滑点是委托时的参考价与实际成交均价之差。理想做法是用逐笔成交与盘口快照数据精确模拟,但这类数据体量大、获取成本高,多数个人研究者拿不到。退一步有两种可用的近似方法。
方法一:按 ATR 比例。取 14 周期 ATR,滑点设为 ATR 的 2%–5%。这个方法的逻辑是波动越大、盘口越容易被拉开,滑点自然越高。优点是自适应行情,剧烈波动期会自动加大惩罚;缺点是与订单大小无关。
方法二:按典型价差倍数。用买卖价差的 1–2 倍。BTC 主流合约价差常在万分之一到万分之二之间,山寨币可能到千分之几。这个方法更贴近微观结构,但需要你至少采样过一段真实盘口。
两种方法都建议再叠加一个订单规模惩罚项:当单笔名义金额超过盘口前五档累计量的某个比例时,线性追加滑点。小资金账户可以忽略这一项,资金规模大到能吃穿几档盘口时它就成了主导成本。
def slippage_atr(atr: float, price: float, k: float = 0.03) -> float:
"""按 ATR 比例估滑点, 返回相对价格的比率"""
return k * atr / price
def slippage_spread(bid: float, ask: float,
mult: float = 1.5) -> float:
mid = (bid + ask) / 2
return mult * (ask - bid) / 2 / mid
def fill_price(ref_px, side, slip_rate, fee=FEE_TAKER):
"""side: +1 买入, -1 卖出。滑点总是往不利方向"""
px = ref_px * (1 + side * slip_rate)
eff = px * (1 + side * fee) # 把费用折进有效成交价
return px, eff
python
资金费率:持仓越久越贵
永续合约靠资金费率把合约价格锚定在现货附近。当合约价高于现货(市场偏多),多头付钱给空头;反过来空头付多头。多数交易所每 8 小时结算一次,即每天三次。
常态费率约为每次 0.01%,折算年化约 11%。这个数字在温和行情里不算大,但在情绪亢奋期费率可能连续多日达到每次 0.1% 甚至更高,年化超过 100%。对持仓周期以周计的趋势策略来说,这是一笔必须计入的持续支出。
反过来,资金费率也可以是收入。方向中性的套保组合(现货多 + 永续空)赚的正是这笔钱。做这类策略时资金费率是收益来源,建模精度直接决定结论对错,不能用常数近似,必须用历史费率数据逐期结算。
def apply_funding(pos_notional: float, side: int,
funding_series, ts_start, ts_end) -> float:
"""结算区间内累计资金费。side=+1 多头, -1 空头。
funding_series: 索引为结算时点的历史费率(如 0.0001)
返回值为负表示支出。
"""
f = funding_series.loc[ts_start:ts_end]
# 费率为正时多头付钱, 故乘 -side
return float((-side) * (f * pos_notional).sum())
python
具体演算:成本如何把盈利吃成亏损
下面用一组假设数字演示,全部为示例演算,不代表任何实际策略表现。设一条 15 分钟级别的短线策略,回测区间一年,日均 10 笔完整交易(即约 3650 笔/年),每笔用满账户名义金额,无杠杆。不计成本时年化收益 180%,单笔平均毛收益约 0.049%。
现在逐项加入成本。单边手续费 0.05%、单边滑点 0.05%,一笔往返成本 0.2%(双边各 0.1%)。单笔平均毛利 0.049% 远小于往返成本 0.2%,每笔净结果是亏 0.151%。3650 笔累计下来,账户不是收益打折,而是彻底归零。
这不是极端构造的例子,而是短线策略的常态:信号的平均毛利经常小于往返成本。判断一条策略能否活下来,只需要一步算术——平均单笔毛利是否显著高于往返成本,建议留出至少 2 到 3 倍的余量。
| 成本设定 | 单笔净收益 | 年化结果(示例) | 结论 |
|---|---|---|---|
| 零成本(理想) | +0.049% | 约 +180% | 回测报告上的漂亮数字 |
| 仅单边手续费 0.05% | -0.001% | 约 -3% | 只算单边就已经不赚了 |
| 双边手续费 0.1% | -0.051% | 接近全部亏光 | 漏掉乘 2 结论完全反转 |
| 双边手续费 + 双边滑点 0.2% | -0.151% | 远快于归零 | 策略不成立 |
| 同上但日均降到 1 笔、单笔毛利 0.6% | +0.40% | 约 +340%(示例) | 低频高毛利才有生存空间 |
最后一行给出了唯一的出路:要么提高单笔毛利,要么降低交易频率。提高毛利通常意味着持仓更久、目标更远;降低频率意味着更严格的信号过滤。两者都会减少交易次数,而这正是成本约束下的正确方向。
把成本加进你的引擎很简单,实现细节见从零搭建最小回测引擎;加完之后一定要重新做参数扫描,因为最优参数会明显向低频方向移动,相关方法见过拟合识别。
把成本当成设计约束而不是事后扣减
多数人的流程是:先做出漂亮曲线,最后加成本,发现不行再回头调。这个顺序浪费大量时间,因为成本会改变最优参数本身,事后加的结果并不是「打折版」而是完全不同的策略。
更有效的做法是把成本前置:写策略之前先做一次上一节那样的餐巾纸算术,估出这条思路需要多少单笔毛利才能存活。若你的信号显然达不到,直接换思路,不必写代码。这一步通常只要五分钟,能省掉几天。
参数扫描也要在含成本条件下做。含成本的最优参数往往比零成本版本更慢、更保守、交易更少,而这个版本才是真正可执行的。同一套参数在零成本下最优、含成本下崩掉,是很常见的现象。
- 先算再写:估算需要的单笔毛利门槛,判断思路是否有生存空间。
- 参数扫描含成本:不要拿零成本最优参数去加成本验证。
- 做敏感度:把成本乘 1.5 倍和 2 倍再跑,若结论反转说明余量不足。
- 记录换手率:把年化换手率作为一等指标输出,它直接决定成本量级。
- 实盘对账:小资金跑几周后,用真实成交明细反推实际成本,回填进回测参数。
常见问题
回测里滑点设多少算合理?
没有普适值,取决于币种流动性与你的订单规模。研究阶段建议对 BTC、ETH 等主流品种用单边 0.03%–0.05%,中小市值币种用 0.1% 以上,并额外跑一次两倍滑点的敏感度测试。若结论在两倍滑点下反转,说明策略余量不足,不应上实盘。
用限价挂单能省下手续费,回测能按 maker 费率算吗?
不建议。限价单存在不成交风险,而未成交的往往正是行情快速走开的那些机会——也就是最赚钱的那批信号。按 maker 建模会同时低估成本和高估成交率,双重乐观。稳妥做法是按 taker 建模,把省下的费用当成实盘的意外收益。
现货策略是不是就不用管资金费率了?
现货没有资金费率,但若使用杠杆或做空,会有借币利息,年化通常在 3%–20% 区间且随行情浮动。逻辑与资金费率相同:持仓时间越长,这项成本占比越高,长周期策略必须计入。
年化成本 365% 这个数字是不是算错了?
没有算错,它是换手率的结果而非亏损率。日均 10 笔往返,每笔往返成本 0.2%,一年 3650 笔,累计手续费支出就达本金的 730%——文中 365% 对应的是单边 0.05% 的口径。这笔钱由每笔交易的毛利来支付,因此关键比较对象是「平均单笔毛利 vs 往返成本」,而不是与年化收益直接相减。