📝

Draft article

This draft is visible to admins and superusers only. Sign in with an authorized account.

← 返回文章列表
July 23, 2026
5 分钟阅读

滑点曲线,而非滑点常数:经得起实盘检验的成本模型

滑点曲线,而非滑点常数:经得起实盘检验的成本模型
#滑点
#交易成本
#市场冲击
#回测
#执行
#tca
#平方根定律
#成本模型

大多数回测中最昂贵的一行代码是 slippage_bps = 5。这并不是因为 5 是个错误的数字——按一整年的成交平均来看,它甚至可能是对的——而是因为一个常数在条件上是错的,而你的策略选择流程正是一台专门用于发现并利用这些条件性误差的机器。滑点是订单规模、价差、波动率和可用流动性的函数。常数只是这个函数在某一个点上的取值,然后被外推到所有地方,包括凌晨 3 点的清算级联行情,那时你的均值回归信号触发得最猛烈,而订单簿的深度只有平时的十分之一。回测在那里也只收 5 bps。实盘可不会。

本文讨论的是回测循环内部的成本模型:那个会从每一笔模拟交易中扣除的函数 cost(size, market_state)。它刻意不去讨论如何事前预测冲击以安排执行——那是一个配套问题,从模型角度已在价格冲击建模中讨论,从最优调度角度已在 Almgren-Chriss 中讨论。这里的问题更为狭窄,而对任何通过回测来选择策略的人来说也更为紧迫:什么样的函数应当取代你现在所用的常数,你如何从手头实际拥有的数据中拟合它,以及这个答案在多大程度上会改变你最终运行的是哪一个策略。最后这一部分有一个鲜明的实证答案:在四种成本模型下对同一策略家族的 20 个变体重新排名,看着排行榜整个翻转过来。

成本模型阶梯

从恒定 bps 到平方根参与率的四级成本模型阶梯

正如成交模拟阶梯一样,成本模型构成一个保真度阶梯,每一级都需要更多数据,并消除一个特定的系统性偏差。这些级别是累积的:每一级都保留其下方的项,并新增一项。

M0:恒定 bps

C0=fee+cC_0 = \text{fee} + c

一个数字,应用于每一笔交易。三种失效模式,按危害由小到大排列:

  1. 无视规模。 一笔 10k的小单和一笔10k 的小单和一笔 5M 的母单支付相同的成本。任何跨越资金容量层级的策略比较都毫无意义。
  2. 无视市场状态。 平静的周二下午和 FTX 崩盘时支付相同的成本。这种误差不是噪声;它与那些事件驱动型策略集中交易的状态恰好呈负相关。
  3. 选择上有毒。 当你扫描 200 种参数组合时,优化器会发现,在平坦成本下,换手相对于现实是廉价的,于是把整个搜索朝高频变体的方向偏移。成本误差因此对模型选择变成了一种有方向性的力,而不是对 PnL 的对称性影响。

M1:与价差成比例

C1=fee+st2C_1 = \text{fee} + \frac{s_t}{2}

在决策时间戳上收取实测的半价差(外加手续费,手续费是确定性的,应始终作为单独的项)。这是第一个真正对市场做出响应的模型:价差会在有新闻时、在盘口开盘时、在成交稀薄的山寨币中扩大,所以成本现在至少与一个流动性变量同步变动。它仍然遗漏的是:价差定价的只是一笔顶部盘口小单的成交。任何更大的订单都会吃穿订单簿,而这种吃穿对 sts_t 是不可见的。

M2:按波动率缩放

C2=fee+st2+cσσtC_2 = \text{fee} + \frac{s_t}{2} + c_\sigma \, \sigma_t

添加一项与短期波动率成比例的项。其理由既是实证的也是结构性的:价差和深度由做市商设定,他们的逆向选择风险和存货风险随 σ\sigma 缩放(把 Avellaneda-Stoikov 报价逻辑反过来看),所以需求流动性的成本天然以波动率为单位来计量。当你完全没有订单簿数据时——只有日线、长历史——用 cσσtc_\sigma \sigma_tcσc_\sigma 取日波动率的 0.05–0.1 左右,就已经是一个远胜于任何常数的模型,因为它至少会随市场状态一起呼吸。

M3:参与率的平方根

C3=fee+st2+Yσt(QVt)δ,δ0.40.7,Y=O(1)C_3 = \text{fee} + \frac{s_t}{2} + Y \, \sigma_t \left(\frac{Q}{V_t}\right)^{\delta}, \qquad \delta \approx 0.4\text{–}0.7, \quad Y = O(1)

规模项。QQ 是你的订单规模,VtV_t 是相关时间窗内的市场成交量,σt\sigma_t 是同一窗内的波动率。这个函数形式不是为了方便;它是市场微观结构中被复现最多的实证结果之一,值得精确地说清楚它从何而来。

理论起点是 Kyle (1985),"Continuous auctions and insider trading"(Econometrica 53(6), 1315–1335)。在 Kyle 的模型中,做市商把价格设为净订单流的线性函数,Δp=λq\Delta p = \lambda q,其中

λ=12Σ0σu\lambda = \frac{1}{2}\frac{\sqrt{\Sigma_0}}{\sigma_u}

——单位流量的冲击与价值不确定性成比例、与噪声交易量成反比。Kyle 的 lambda 给这一领域提供了它的流动性单位(深度是 1/λ1/\lambda),以及它第一个可检验的论断:每交易美元的成本取决于信息与流动性之比,而非某个常数。但这个模型对规模是线性的,而数据说的是另一回事。

数据说的是凹的。Almgren, Thum, Hauptmann and Li (2005),"Direct estimation of equity market impact"(Risk, July 2005),拟合了 Citigroup 交易台约 700,000 笔美股订单,发现临时冲击随参与率的 3/53/5 次幂增长——他们明确地检验并否定了线性和纯平方根,支持 δ=0.6\delta = 0.6——而永久冲击的结果是 0.89±0.100.89 \pm 0.10,在统计上与线性无法区分(这令人安心,因为 Huberman and Stanzl (2004) 证明了非线性的永久冲击会招致操纵)。Tóth et al. (2011),"Anomalous price impact and the critical nature of liquidity in financial markets"(Physical Review X 1, 021006),使用来自 Capital Fund Management 横跨多个期货市场的近 500,000 笔元订单,发现 E[ΔP]YσdQ/Vd\mathbb{E}[\Delta P] \approx Y \sigma_d \sqrt{Q/V_d},其中 YY 为量级为一的常数——并给出了如今已成标准的解释:可见的订单簿只是潜在流动性中一个微乎其微的部分,而潜在流动性在中间价附近对价格是局部线性的,这使得吃穿它的成本按平方根缩放。后续工作在边缘处做了细化:Zarinelli, Treccani, Farmer and Lillo (2015),"Beyond the square root"(Market Microstructure and Liquidity 1(2)),在七百万笔 ANcerno 机构元订单上表明,平方根在约两个数量级的订单规模范围内拟合良好,但对数形式则能延伸到五个数量级;Bucci et al. (2019, Physical Review Letters 122, 108302)记录了从极低参与率下的线性(类 Kyle)冲击到其上方平方根冲击的过渡。而 Frazzini, Israel and Moskowitz (2018),"Trading Costs"(SSRN 3229719),使用 AQR 在 19 年间价值 $1.7 万亿的实盘执行,发现已实现成本比早期学术估计小一个数量级——这是一个警示:在别人的订单流上拟合出的系数,由于紧迫性不同、信息含量不同,并不能迁移到你自己身上。

对于回测循环,实用的总结是三句话。冲击随波动率缩放。冲击对规模是凹的,指数在你自己的数据支持 0.60.6 之前应当当作 0.50.5 处理。前因子 YY 的量级为一,但会随交易场所、资产、以及——关键在于——你自己的交易风格而变化,这正是为什么下一节讲的是如何拟合它而非照抄它。深度校准(衰减核、瞬态与永久冲击的分解、交叉冲击)属于冲击建模文章的范畴;回测只需要这条曲线。

举一个具体数字以确定量级。BTC 永续,日波动率 σd=250\sigma_d = 250 bps,交易场所日成交量 V_d = \6B,B,Y = 0.75,,\delta = 0.5$:

订单规模 QQ Q/VdQ/V_d Q/Vd\sqrt{Q/V_d} 冲击项 + 半价差 (0.5 bp)
$50k 0.0008% 0.0029 0.5 bp 1.0 bp
$500k 0.008% 0.0091 1.7 bp 2.2 bp
$5M 0.083% 0.0289 5.4 bp 5.9 bp
$50M 0.83% 0.0913 17.1 bp 17.6 bp

规模跨越三个数量级,成本跨越约 18 倍的因子。没有任何常数能覆盖这张表,而每一次在保持 slippage_bps 固定的同时改变仓位规模的策略比较,都是在悄悄地拿 50k处的苹果去和50k 处的苹果去和 5M 处的橙子作比较。

拟合曲线:用你自己的成交,或谁的都用不上

用你自己的成交

正确的数据是你自己的 TCA 记录:每一笔母单一行,包含到达时的中间价、已实现的成交 VWAP、订单规模,以及决策时的市场状态——这正是执行落差度量作为副产品所产生的那份数据集。以 bps 计的对到达价的落差,经同期波动率归一化后,对参与率做回归,就能直接得到 YYδ\delta

陷阱在于噪声。单笔订单的落差是冲击加上执行窗内的价格噪声,而噪声占了主导:对于日波动率 250 bps 下的 10 分钟执行,噪声项是 σd10/144021\sigma_d\sqrt{10/1440} \approx 21 bps,而冲击信号或许只有 3 bps。每个观测点 0.15 的信噪比正是 Almgren et al. 需要 700k 笔订单的原因,也是你必须在分桶中位数上拟合、绝不在原始点上拟合的原因:

import numpy as np
import pandas as pd

def fit_sqrt_curve(orders: pd.DataFrame) -> tuple[float, float]:
    """orders: one row per parent order.
       is_bps    -- implementation shortfall vs arrival mid, bps, cost-positive
       sigma_bps -- volatility at arrival (same window used at simulation time)
       q_over_v  -- parent size / market volume over the execution window
    Fits I = Y * sigma * (Q/V)^delta on participation-bucket medians."""
    df = orders.query("q_over_v > 0 and sigma_bps > 0").copy()
    df["i_norm"] = df.is_bps / df.sigma_bps            # impact in vol units
    df["bucket"] = pd.qcut(np.log10(df.q_over_v), 12, duplicates="drop")
    b = (df.groupby("bucket", observed=True)
           .agg(i=("i_norm", "median"), qv=("q_over_v", "median")))
    b = b[b.i > 0]               # noisy buckets can go negative; drop, don't clip
    delta, log_y = np.polyfit(np.log(b.qv), np.log(b.i), 1)
    return float(np.exp(log_y)), float(delta)

在几千笔加密母单上,这个拟合的稳定性已经能达到 YY 约 ±30%、δ\delta 约 ±0.1 的水平——粗糙,但拟合曲线与猜测常数之间的差别不是 30%,而是整个形状。每季度重新拟合一次;YY 的漂移本身就是一种诊断信号(在参与率不变的情况下 YY 上升,意味着要么交易场所的流动性状态变了,要么你的订单流变得更有信息含量/更易被察觉)。

冷启动:还没有成交数据

在第一笔实盘订单之前,你只有公开数据,以及两个诚实的选项。

深度吃穿订单簿。 在每个规模上对 L2 快照做吃穿成本的平均,方法与成交模拟器对每笔交易所做的完全一样,只是聚合成一条曲线。这是一个符号已知的下界:快照显示的是在别人订单流之后残存的流动性,补充动态是不可见的,而且你自己较早的子单会在你较晚的子单到达之前就把订单簿推动了。

def curve_from_book(snapshots, sizes_usd, safety=1.75):
    """snapshots: iterable of (mid, asks), asks = [(price, qty), ...] best-first.
    Depth-walk cost is a LOWER bound on realized cost -- scale it."""
    rows = []
    for q_usd in sizes_usd:
        costs = []
        for mid, asks in snapshots:
            rem, paid, got = q_usd / mid, 0.0, 0.0
            for p, q in asks:
                x = min(q, rem)
                paid += x * (p - mid); got += x; rem -= x
                if rem <= 0:
                    break
            if rem <= 0:
                costs.append(paid / got / mid * 1e4)
        rows.append((q_usd, safety * float(np.median(costs))))
    return pd.DataFrame(rows, columns=["q_usd", "cost_bps"])

追踪公开成交的足迹。 按规模对主动成交分桶,测量每笔成交后几秒钟内的中间价变动,读出一条经验冲击曲线。这能恢复凹形状以及大致正确的 σ\sigma 缩放关系,但其水平存在一个你事前无法确定符号的偏差:公开成交回报是其他交易者的决策,所以大额成交是有条件地带信息的(相对于你自己无信息的再平衡,会把测得的冲击往上偏),而对流动性最敏感的交易者恰恰会拆单来隐藏(往下偏)。用它来定形状,不要用来定水平。

无论哪种方式,冷启动的操作流程都是:取公开数据曲线,乘以 1.5–2 的安全系数,小额交易,并开始填充那份最终会取代它的 TCA 表。公开曲线是脚手架,不是承重墙。

市场状态依赖:同一笔订单,五倍的成本

成本曲线在平静、正常和承压波动率状态下扇形展开

C3C_3 中的每一项都依赖于市场状态,而在波动率飙升时它们会同时向对你不利的方向移动。价差与短期波动率成比例,所以 sts_t 会扩大 5–10 倍。σt\sigma_t 直接进入冲击项。成交量 VtV_t 也会上升——这天真地看会降低参与率——但它上升的幅度小于分子里的 σ\sigma,而且这个抵消掩盖了一个更棘手的效应:中间价附近的挂单深度崩溃得比成交量快得多,所以已实现曲线的陡峭程度会超出仅凭承压输入代入拟合公式所预测的水平。

具体来说,用上面拟合的曲线,一笔 $3M 的 BTC 订单:

  • 平静日:σd=150\sigma_d = 150 bps,价差 1 bp,V_d = \6B。成本B。成本 = 0.5 + 0.75 \times 150 \times \sqrt{0.0005} = 0.5 + 2.5 = 3.0$ bps。
  • 危机日(LUNA、FTX 级别):σd=700\sigma_d = 700 bps,价差 6 bps,V_d = \18B。成本B。成本 = 3.0 + 0.75 \times 700 \times \sqrt{0.000167} = 3.0 + 6.8 = 9.8$ bps。

公式给出的是 3.3 倍。压力日的已实现 TCA 一贯高于公式——正是平方根项看不到的那个深度崩溃——而这正是一个实测的压力乘数应当发挥作用的地方。在我们的成交数据中,危机分桶的残差在承压输入公式之上跑出 1.4–1.8 倍,使得同一笔订单的总压力成本落在平静成本的 4.5–6 倍。这就是"同一笔订单在波动率飙升时贵 5 倍"这句话诚实的来源:大约 2 倍来自扣除成交量后的 σ\sigma,2 倍来自价差,其余来自深度蒸发的速度快于成交量统计所承认的。

其实现是一个条件成本模型:按市场状态分桶各自拟合曲线,或等价地拟合一条曲线加上一张以决策时计算的已实现波动率分位数为键的乘数表(仅用滚动窗口——在全样本上计算的市场状态标签,是通过成本模型偷偷带入的前视):

市场状态(滚动 1h 波动率分位) 频率 价差乘数 曲线乘数(实测)
平静 (< p25) 25% 0.6x 0.7x
正常 (p25–p75) 50% 1.0x 1.0x
升高 (p75–p95) 20% 1.8x 1.6x
压力 (> p95) 5% 5–10x 3–6x

Frazzini, Israel and Moskowitz (2018) 在他们的机构数据中记录了同样的现象:已实现成本与同期波动率强烈同步变动,而缺少时变波动率项的成本模型恰恰会给尾部错误定价。这张表之所以比其 5% 的压力行所暗示的更重要,原因在于相关性:策略并不会均匀地采样这几行。一个波动率突破系统大部分交易都在底部两行完成。一个平静市场做市商在顶部一行赚钱,并在底部一行止损离场。用每个策略自己的市场状态直方图给它的交易加权——而不是对所有策略收取无条件平均成本——是整个页面上对事件驱动系统而言最大的单项 PnL 修正,其价值超过阶梯上任何相邻两级之间的差异。

敏感性实验:四种成本模型,一份被重新洗牌的排行榜

如果成本模型只是给 PnL 加上一个恒定偏移,那么这一切对选择都无关紧要——每个变体都会等量平移,argmax 依然存活。但事实并非如此,因为成本敏感性在各变体之间相差一个数量级。让这一点具体化的实验:

设置。 一个策略家族——BTC 和 ETH 永续上的 z-score 均值回归和 Donchian 突破,1 分钟 K 线,18 个月——在持仓周期(15m / 1h / 4h / 24h)和入场阈值上做网格搜索:20 个变体,单边每日换手从 0.4x 到 11x 账户,$250k 的小单在 5 分钟内执行。每个变体在每种成本模型下各回测一次:

  • M0:仅手续费;
  • M1:手续费 + 5 bps 常数;
  • M2:手续费 + 半价差 + 0.08σ5m0.08\,\sigma_{5m};
  • M3:手续费 + 拟合曲线 Y=0.75Y=0.75δ=0.5\delta=0.5,加上上面那张市场状态乘数表。

相同的成交、相同的信号、相同的代码路径——成本模型作为构造函数参数传入回测器,就像任何一等参数一样:

from dataclasses import dataclass

@dataclass(frozen=True)
class SqrtCost:
    y: float = 0.75        # fitted impact coefficient
    delta: float = 0.5     # fitted exponent
    fee_bps: float = 2.5   # taker fee, always separate
    def cost_bps(self, q_usd: float, st) -> float:
        part = q_usd / st.window_volume_usd
        return (self.fee_bps + st.stress_mult *
                (st.spread_bps / 2 + self.y * st.sigma_bps * part ** self.delta))

for model in [ZeroCost(), ConstCost(5.0), VolCost(0.08), SqrtCost()]:
    for variant in grid:
        results[(model, variant)] = backtest(variant, cost_model=model)

结果(代表性运行;年化净收益):

变体 每日换手 M0 M1 (5 bp) M2 M3 (曲线) 排名 M1 → M3
MR-15m, z=1.0 11.4x +187% +61% +9% −14% 1 → 17
MR-15m, z=1.5 7.9x +141% +52% +15% −2% 2 → 13
BO-1h, k=2 3.1x +74% +43% +28% +19% 3 → 4
MR-1h, z=2.0 2.4x +58% +38% +25% +21% 4 → 2
BO-4h, k=3 0.9x +39% +33% +29% +26% 7 → 1

20 个策略排名在四种成本模型间翻转的凹凸图

各排行榜之间的排名相关性(Kendall τ\tau):τ(M0,M1)=0.79\tau(\text{M0},\text{M1}) = 0.79——常数几乎不会重排任何东西,它是一种税,不是一个模型。τ(M1,M3)=0.39\tau(\text{M1},\text{M3}) = 0.39——M1 前十名中有六个在 M3 下跌出前十,而 M1 的冠军从 +61% 变成负数。τ(M2,M3)=0.74\tau(\text{M2},\text{M3}) = 0.74——单单波动率项就恢复了大部分的重排;规模项则为那些相对于窗口成交量交易规模较大的变体收尾。其机制正是 M0 那一级的选择毒性:平坦的 5 bps 补贴了那些实际成本 15 bps 的交易(大额、快速、处于压力中),并过度征税了那些成本 2 bps 的交易(小额、耐心、处于平静中),所以按 M1 排名会系统性地抬高那些真实成本被低估得最厉害的变体。优化器找到的不是 alpha;它找到的是你成本模型的误差项。

这就是为什么成本模型应当归入与前向滚动切分或目标函数相同的思维范畴:一个你要去变动的一等回测参数,而不是一个你设定一次就不管的环境常数。如果某个变体的排名在 M2 和 M3 之间稳定,那么它相对于成本的优势是真实的。如果它的排名在沿阶梯上升时崩溃,那么你就学到了它是一个成本模型的假象——这与一个策略在前向滚动优化中样本外死亡是同一种认知事件,理应得到同样的回应。而如果你在 M1 下选出了 200 个变体的 argmax,那么缩减夏普比率的逻辑在此适用,只是多了一个转折:你运行的是一次多重检验搜索,而其中的检验统计量本身就偏向高换手率的入场。

诚实的边界:把 PnL 作为一个区间

最后一项纪律源于承认:即便拟合之后,成本模型依然是不确定的。YY 有置信区间,压力乘数是从少数几个尾部日子估计出来的,而未来会抽取它自己的市场状态。所以不要再把净 PnL 报告成一个数字。把它报告成一个横跨若干命名成本情景的区间:

  • 乐观:深度吃穿下界,无压力乘数。绝不单独报告;它唯一的作用是界定最好情况。
  • 现实:拟合曲线、拟合的市场状态乘数——你的中心估计。
  • 悲观:Y×1.5Y \times 1.5,价差 ×2\times 2,压力行始终按 \geq 其 p75 值应用。大致就是:在每个参数置信区间不友好的那一端取值代入的拟合曲线。

两条决策规则让这个区间可以操作。第一条:按悲观分配,按现实报告。 一个只在现实模型下才能存活的策略,是在赌你的成本拟合在昂贵的那个方向上没有误差——一个你并没有有意识地下过的赌注。第二条:追踪脆弱度比率——区间宽度除以毛 PnL。以上面的实验为例:BO-4h 在 0.9x 换手下毛收益 +39%,区间 [+22%, +29%],脆弱度 0.44。MR-15m 在 11.4x 下毛收益 +187%,区间 [−31%, +34%]——脆弱度 2.2,而且策略的符号在区间内部就翻转了。以你当前的成本知识状态,后一个策略的符号是无法确定的;诚实的表述是"我们不知道"和"减少换手,直到我们知道为止"。一份打印出 +34% 却省略了区间 −31% 那一端的回测,不是乐观,而是不可证伪。

产出这个区间不花任何成本——只是在你如今反正都要跑的成本模型扫描里多加三条记录——而它改变了对话。"这个策略赚 20%"引人相信。"这个策略在我们各成本情景下赚 11–19%,并在悲观情景下仍然为正"引人分配资金。

它接在哪里

在回测引擎中,成本模型正好位于成交模拟器交还已执行数量的那个位置:成交决定你得到了什么,成本曲线决定你为此付出了什么,而那条阶梯上 L2 以上的级别(深度吃穿、队列模型)会逐步把成本模型吸收进成交模型本身——当你不直接模拟订单簿时,曲线就是你所用的参数化替身。参数在你有 TCA 记录时来自你自己的 TCA 记录,没有时则来自经安全系数缩放的公开数据。函数形式来自四十年的理论与测量——Kyle (1985) 解释了为什么每美元成本会取决于流动性,Almgren et al. (2005) 和 Tóth et al. (2011) 给出了那条凹定律本身,Almgren-Chriss 是其调度上的对应,神经网络冲击模型则是其预测的前沿。本文所添加的是那套工作流:拟合曲线,以市场状态为条件,像参数一样扫描它,并报告区间。这一切都不光鲜。但这一切都比在实盘中发现你的策略实际上处在 slippage_bps = 5 的哪一边要便宜得多。

免责声明:本文提供的信息仅用于教育和参考目的,不构成财务、投资或交易建议。加密货币交易涉及重大损失风险。

Authors

Eugen Soloviov
Eugen Soloviov

Trading-systems engineer

Trading-systems engineer building bots since 2017: cross-exchange arbitrage (connected up to 30 venues), cointegration-based pairs arbitrage across spot and futures, scalping, news and sentiment-driven strategies, trend algorithms, and portfolio management and balancing algorithms. Also builds sub-millisecond order execution, big-data warehouses, backtesting engines, AI agents, and trading interfaces (incl. open-source profitmaker.cc). Stack: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, architecture.

Newsletter

紧跟市场步伐

订阅我们的时事通讯,获取独家 AI 交易见解、市场分析和平台更新。

我们尊重您的隐私。您可以随时退订。