第 07 章 交易执行与交易成本
组合优化给出了目标权重,回测假设了成本,本章处理两者之间的环节:把一张"买入 20 万股"的母单变成市场里一笔笔真实的成交,并且度量它到底花了多少钱。市场结构、订单类型和成本度量的制度背景在第 07 册(Harris《Trading and Exchanges》)已经讲过,特别是第 04a 章(订单基础)、第 06 章(撮合规则)、第 14 章(买卖价差)、第 18 章(市价单与限价单的选择)和第 21a、21b 章(交易成本度量)。本章在这些基础上讲量化执行的四件事:成本怎样分解,冲击怎样建模,执行轨迹怎样优化,限价单与订单簿怎样模拟。
学习目标
读完本章,你应当能够:
- 用执行差额(implementation shortfall)把一笔母单的成本分解为延迟成本、交易成本、机会成本和显性费用,并说明 VWAP 基准看不到什么。
- 用有效价差、已实现价差和 Roll 估计量度量价差,知道 Roll 估计量在订单流自相关和信息交易下怎样偏离。
- 解释平方根冲击律的经验含义和一种微观机制(传播子模型),比较几种估计方法的偏差与方差,区分临时冲击与永久冲击。
- 实现 TWAP 和 VWAP 调度,量化它们相对市场 VWAP 的跟踪误差。
- 推导并实现 Almgren–Chriss 最优执行的离散解析解,用动态规划和蒙特卡洛验证,读懂成本–风险有效前沿。
- 用一个简单模型说明限价单的逆向选择,并用价格–时间优先的订单簿模拟器研究吃单冲击、排队位置和成交后的价格漂移。
读前导读
这一章在解决什么问题。 场景是交易台。投资经理决定"买入 20 万股",交给交易台;交易台要把这张母单拆成几十上百笔子单,在一天或几天里做完。本章回答交易台主管每天面对的三个问题:这张单子实际花了多少钱、钱花在了谁手里(执行差额分解,对应 CFA 三级交易成本一节里的 implementation shortfall);单子越大成本怎样上升(冲击模型);怎样排出一条"既不太急、又不太慢"的执行路线(TWAP、VWAP 和 Almgren–Chriss)。最后两节讲限价单:挂单省半个价差,但会被知情的对手方"挑着成交"。
如果你在 CFA 里学过"有效价差""VWAP""implementation shortfall",这一章把它们从定义推进到可以量化管理的程度。读法上可以把它当成一份成本会计:执行差额像成本差异分析(标准成本 vs 实际成本,差异再拆成价格差异和数量差异),延迟成本、交易成本、机会成本各自对应可以问责的环节。
需要先想起来的数学。
- 幂函数与对数线性回归。 若 \(I=YQ^\delta\),两边取对数得 \(\ln I=\ln Y+\delta\ln Q\),于是在对数坐标上是一条直线,斜率就是指数 \(\delta\)。例:\(\delta=0.5\) 时规模扩大 4 倍,冲击扩大 \(4^{0.5}=2\) 倍。参见 第 00 册第 04 章 级数与收敛 中的指数与对数。
- 协方差的运算规则。 \(\operatorname{cov}(aX+bY,Z)=a\operatorname{cov}(X,Z)+b\operatorname{cov}(Y,Z)\),独立变量的协方差为 0。Roll 估计量的推导只用到这一条。参见 第 00 册第 07 章 概率中的分析工具。
- 对多个变量求偏导求极值。 函数 \(f(x_1,\dots,x_n)\) 的最小点处,对每个 \(x_k\) 的偏导数都为零。若 \(f\) 是二次函数,这些条件就是一组线性方程。Almgren–Chriss 的"一阶条件"就是这样来的。参见 第 00 册第 05 章 多元微积分与优化。
- 双曲函数。 \(\sinh x=\frac{e^x-e^{-x}}2\),\(\cosh x=\frac{e^x+e^{-x}}2\)。\(\sinh\) 在 0 处为 0、单调递增,\(x\) 较大时近似 \(e^x/2\)。Almgren–Chriss 的最优持仓曲线就是 \(\sinh\) 形状:开始下降快,后面慢。例:\(\sinh(1)\approx1.175\),\(\sinh(2)\approx3.63\)。
- 动态规划(逆向递推)。 多期决策问题从最后一期往前算:先求最后一步的最优选择和成本,再把它当作已知代入倒数第二步,依此类推。CFA 里用二叉树从到期日倒推美式期权价格,就是同一种思路。
怎么读这一章。 7.2 节(执行差额)是全章最实用的部分,必读,尤其是"相对 VWAP 跑赢 21.7bp、实际成本 110bp"这个反差。7.4 节的平方根律结论必读,传播子模型和代码 7.3 的三种估计方法比较可以第一次只看输出后的解读。7.6 节(Almgren–Chriss)是本章数学最重的部分:第一次读可以只抓住"期望成本随交易速度上升、风险随交易速度下降,\(\lambda\) 在两者之间权衡"和有效前沿表格,推导留到第二遍配合讲解框读。7.7 节的限价单"期权视角"值得细读,它对任何下过限价单的人都有直接启发。7.8 节订单簿模拟器代码较长,可以只读输出解读。
7.1 问题与动机
先说结论:对多数中低频策略,执行成本是可以通过工程手段降低、而且降低后能直接转化为收益的那部分。 Harris 在第 07 册第 21b 章的结语里说,通过更有效的执行管理降低交易成本,往往比把同样资源投入选股更能提高绩效。原因很简单:alpha 的改进不确定,而成本的节约是确定的。
执行要回答三个问题:
| 问题 | 工具 | 本章 |
|---|---|---|
| 花了多少钱、花在哪里 | 执行差额分解、价差估计、TCA | 7.2、7.3 节 |
| 成本怎样随规模和速度变化 | 冲击模型(平方根律、传播子) | 7.4 节 |
| 怎样交易能让成本与风险的组合最优 | TWAP/VWAP、Almgren–Chriss、限价单策略 | 7.5–7.7 节 |
执行还有一个永恒的权衡(第 07 册第 19 章的"时间–价格–数量"三角):交易得快,冲击成本高;交易得慢,价格在这段时间里可能朝不利方向变动(时间风险),如果信号在衰减,还会损失 alpha。所有执行算法都是在这个权衡上选一个点。
本章的代码都是独立的小模型,每段都可以单独运行。它们与真实市场的差别必须先说明:真实的成交量曲线、价差和冲击参数因股票、时段、市场状态差别很大;冲击的真实机制远比传播子模型复杂,订单流有长记忆;真实订单簿有隐藏单、冰山单、高频做市商的策略性撤单和最小报价单位约束。这些模型的作用是讲清机制和工程方法,它们输出的数字不代表任何真实市场的成本水平。
7.2 成本分解:执行差额
7.2.1 定义
Perold(1988)的执行差额把成本定义为"纸面组合"与实际组合的价值差。纸面组合假设在决策时刻以中间价全额成交,没有任何成本(第 07 册第 21a 章 21.3.4 节)。设计划买入 \(X\) 股,决策价 \(P_{\text{dec}}\),订单到达交易台时的中间价(到达价)\(P_{\text{arr}}\),已成交 \(X_f\) 股、均价 \(\bar P\),未成交 \(X_u=X-X_f\),度量时点价格 \(P_{\text{end}}\),则(买入方向)
四项各有主人:延迟成本属于"研究–交易台"流程(信号出来到订单下达花了多久),交易成本属于执行算法和交易员,机会成本属于"没做完"的决策(限价太紧、涨停、流动性不足),显性费用属于经纪商与税费。分解的意义在于把成本归到能改进它的人那里。
推导拆解:为什么四项加起来正好等于"纸面 − 实际"?逐项展开就能看到。 第一步,纸面组合在决策价全额买入 \(X\) 股,到度量时点的收益是 \(X(P_{\text{end}}-P_{\text{dec}})\)。 第二步,实际组合只买到 \(X_f\) 股、均价 \(\bar P\),收益是 \(X_f(P_{\text{end}}-\bar P)-\text{费用}\)。 第三步,相减,并把 \(X=X_f+X_u\) 代入:\(X_f(P_{\text{end}}-P_{\text{dec}})+X_u(P_{\text{end}}-P_{\text{dec}})-X_f(P_{\text{end}}-\bar P)+\text{费用}=X_f(\bar P-P_{\text{dec}})+X_u(P_{\text{end}}-P_{\text{dec}})+\text{费用}\)。 第四步,在 \(\bar P-P_{\text{dec}}\) 中插入到达价,拆成 \((P_{\text{arr}}-P_{\text{dec}})+(\bar P-P_{\text{arr}})\),就得到延迟成本和交易成本两项。 注意 \(P_{\text{end}}\) 在已成交部分里被消掉了:已成交股份之后的涨跌,纸面和实际组合都享有,不算执行的功过。这和成本会计里把总差异拆成"价格差异 + 数量差异"是同一种恒等式拆分,拆分点(这里是 \(P_{\text{arr}}\))选在哪里,决定了哪部分归谁负责。
7.2.2 代码:一笔母单的事后归因
# 代码 7.1 执行差额(Implementation Shortfall)分解:一笔买入母单的事后归因
import numpy as np
import pandas as pd
rng = np.random.default_rng(0)
X = 200_000 # 计划买入股数
P_dec = 20.00 # 决策价:研究员下单决定时的中间价
P_arr = 20.06 # 到达价:订单到达交易台时的中间价(延迟期间价格已上涨)
fee_rate = 3e-4 # 佣金 + 规费(按成交额)
# 交易台在 8 个时段拆单执行,每段成交量与成交均价(成交价含半价差与冲击)
child_qty = np.array([30_000, 30_000, 25_000, 25_000, 20_000, 20_000, 15_000, 0]) # 最后一段因涨停未成交
mid_path = P_arr + np.cumsum(rng.normal(0.02, 0.03, 8)) # 执行期间的中间价
child_px = mid_path + 0.01 + 0.0000004 * child_qty # 半价差 1 分 + 线性临时冲击
P_end = 20.45 # 收盘(度量时点)价格,用于计算未成交部分的机会成本
filled = child_qty.sum(); unfilled = X - filled
avg_px = (child_qty * child_px).sum() / filled
fees = fee_rate * (child_qty * child_px).sum()
paper = X * (P_end - P_dec) # 纸面组合收益:决策价全额买入
real = filled * (P_end - avg_px) - fees # 实际组合收益
IS = paper - real
parts = pd.Series({
"延迟成本 (P_arr-P_dec)*已成交": filled * (P_arr - P_dec),
"交易成本 (均价-P_arr)*已成交": filled * (avg_px - P_arr),
"机会成本 (P_end-P_dec)*未成交": unfilled * (P_end - P_dec),
"显性费用": fees,
})
print(f"已成交 {filled} 股({filled / X:.0%}),成交均价 {avg_px:.4f}")
print((parts / (X * P_dec) * 1e4).round(1).to_string(), "(单位:bp,相对计划成交额)")
print(f"合计 {parts.sum() / (X * P_dec) * 1e4:.1f} bp;纸面−实际 = {IS / (X * P_dec) * 1e4:.1f} bp")
vwap_mkt = 20.21 # 当日市场 VWAP(示意)
print(f"相对 VWAP 的滑点 {(avg_px / vwap_mkt - 1) * 1e4:.1f} bp —— VWAP 基准看不到延迟成本和机会成本")
输出:
已成交 165000 股(82%),成交均价 20.1662
延迟成本 (P_arr-P_dec)*已成交 24.7
交易成本 (均价-P_arr)*已成交 43.8
机会成本 (P_end-P_dec)*未成交 39.4
显性费用 2.5 (单位:bp,相对计划成交额)
合计 110.4 bp;纸面−实际 = 110.4 bp
相对 VWAP 的滑点 -21.7 bp —— VWAP 基准看不到延迟成本和机会成本
这笔订单总成本 110bp,其中交易成本 44bp,机会成本 39bp,延迟成本 25bp,显性费用只有 2.5bp。三点值得注意:
- 四项加总严格等于"纸面收益 − 实际收益",这是检查 TCA 计算是否正确的恒等式。
- 相对 VWAP 的滑点是 −21.7bp,看起来"跑赢了 VWAP"。原因是价格全天上涨,交易台早成交,均价自然低于全天 VWAP。VWAP 基准看不到决策之后、开盘之前的延迟成本,也看不到没成交部分的机会成本。以 VWAP 为考核基准,交易员有动机把难做的单子留到最后或者干脆不做——这是第 07 册第 21a 章 21.4 节讨论过的基准偏差。
- 机会成本往往是最大也最容易被忽略的一项。上涨中买不到,等于错过了信号最强的那部分收益。
7.3 价差
7.3.1 三种价差
- 报价价差:卖一减买一。只是挂出来的价格,不一定是成交价。
- 有效价差:\(2q_t(P_t-M_t)\),\(q_t=\pm1\) 为交易方向,\(M_t\) 为成交前的中间价。它度量一笔真实交易付出的即时成本。需要逐笔成交、逐笔报价和方向(Lee–Ready 规则,第 07 册第 21a 章 21.2.2 节)。
- 已实现价差:\(2q_t(P_t-M_{t+\Delta})\),用一段时间后的中间价做基准。它度量流动性提供者真正赚到的部分;有效价差与已实现价差之差就是价格对交易方向的永久反应,即逆向选择成分(第 07 册第 14 章 14.3 节"价差的两个成分")。
只有日线或成交价、没有报价时,可以用 Roll 估计量:若中间价随机游走、交易方向独立,则成交价变动的一阶自协方差为 \(-s^2/4\),于是 \(\hat s=2\sqrt{-\operatorname{cov}(\Delta P_t,\Delta P_{t-1})}\)(推导见第 06 册第 05 章 5.3 节和第 07 册第 14 章 14.5 节)。
推导拆解:Roll 估计量的核心只有三行。 第一步,成交价 \(=\) 中间价 \(+\) 方向 × 半价差:\(P_t=M_t+q_t\,s/2\)。于是 \(\Delta P_t=\Delta M_t+\tfrac s2(q_t-q_{t-1})\)。 第二步,求相邻两次价格变动的协方差。假设中间价变动 \(\Delta M_t\) 与一切都无关,交易方向 \(q_t\) 彼此独立、取 \(\pm1\)(方差为 1)。展开 \(\operatorname{cov}\big(\tfrac s2(q_t-q_{t-1}),\tfrac s2(q_{t-1}-q_{t-2})\big)\),四个交叉项里只有 \(-q_{t-1}\) 与 \(q_{t-1}\) 那一项非零(用了"独立变量协方差为 0"),结果为 \(\tfrac{s^2}{4}\cdot(-1)=-\tfrac{s^2}4\)。 第三步,反解:\(s=2\sqrt{-\operatorname{cov}}\)。 直观含义是"买卖反弹":成交价在买一和卖一之间来回跳,制造出负的一阶自相关,跳动幅度就是价差。拆单让同方向成交连在一起(\(\rho>0\)),反弹变少,负自协方差变弱,Roll 就低估价差;这正是练习 2 要你补的修正。
7.3.2 代码:Roll 估计量的偏差
代码 7.2 让每笔成交在成交前中间价 \(\pm s/2\) 成交,然后在两个方向上破坏 Roll 的假设:交易方向一阶自相关(拆单让同方向的成交连在一起),以及成交后中间价向交易方向移动 \(\lambda q_t\)(信息成分)。
# 代码 7.2 价差估计:有效价差与 Roll 估计量,以及订单流自相关造成的偏差
import numpy as np
def simulate_trades(n=200_000, spread=0.02, sigma=0.01, rho=0.0, lam=0.0, seed=0):
"""第 t 笔成交按成交前的中间价 m_{t-1} ± spread/2 成交;成交后中间价更新为
m_t = m_{t-1} + 噪声 + lam*q_t。lam 是价差中的逆向选择(信息)成分,spread/2 - lam 是交易成本成分。
rho:交易方向的一阶自相关(拆单造成)。"""
rng = np.random.default_rng(seed)
q = np.empty(n); q[0] = 1
for t in range(1, n):
q[t] = q[t - 1] if rng.random() < (1 + rho) / 2 else -q[t - 1]
m = 10 + np.cumsum(sigma * rng.standard_t(4, n) / np.sqrt(2) + lam * q)
m_pre = np.r_[10, m[:-1]] # 成交前中间价
p = m_pre + q * spread / 2
return p, m_pre, q
def roll(p):
dp = np.diff(p)
c = np.cov(dp[1:], dp[:-1])[0, 1]
return 2 * np.sqrt(-c) if c < 0 else np.nan
print("情形 真实价差 有效价差 已实现价差 Roll 估计")
for name, kw in [("方向独立、无信息", {}),
("拆单:方向自相关 rho=0.3", dict(rho=0.3)),
("有信息:逆向选择成分 0.004", dict(lam=0.004)),
("两者兼有", dict(rho=0.3, lam=0.004))]:
p, m, q = simulate_trades(**kw)
eff = np.mean(2 * q * (p - m)) # 有效价差:需要知道成交前中间价和方向
real = np.mean(2 * q[:-50] * (p[:-50] - m[50:])) # 已实现价差:用 50 笔之后的中间价
print(f"{name:28s} {0.02:.4f} {eff:.4f} {real:8.4f} {roll(p):.4f}")
输出:
情形 真实价差 有效价差 已实现价差 Roll 估计
方向独立、无信息 0.0200 0.0200 0.0194 0.0200
拆单:方向自相关 rho=0.3 0.0200 0.0200 0.0194 0.0141
有信息:逆向选择成分 0.004 0.0200 0.0200 0.0113 0.0155
两者兼有 0.0200 0.0200 0.0081 0.0100
- 方向独立、无信息时,三种度量都等于真实价差 0.02。
- 方向自相关 0.3 时,Roll 估计低估了约 30%:同方向的成交让价格变动的负自协方差变弱。
- 有信息成分 \(\lambda=0.004\) 时,Roll 估计为 \(2\sqrt{(s/2)(s/2-\lambda)}=0.0155\),它只捕捉价差中暂时性的交易成本成分,而不包含逆向选择成分;已实现价差 \(s-2\lambda=0.012\) 也只剩下交易成本成分。两者都不是"交易者付出的成本"。对交易者而言,应该用有效价差(0.02)。
- 实务上,Roll 估计量在日线上常常因为自协方差为正而无法计算(代码中返回
nan),也会被拆单和动量污染。它适合做横截面上的相对排序和缺少报价数据时的粗估,不适合做精确的成本输入。
7.4 市场冲击与平方根律
7.4.1 经验规律
大量母单数据的研究发现,一笔规模为 \(Q\) 的母单在执行期间造成的价格冲击近似为
\(\sigma_d\) 为日波动率,\(V\) 为日成交量,\(Y\) 为量级在 1 附近的常数(第 07 册第 21b 章 21.7.2 节)。这个规律有三个值得记住的性质:冲击用波动率而不是价差来度量,所以高波动的股票冲击更大;冲击是规模的凹函数,所以拆单后的总冲击远低于把每笔子单冲击线性相加;它几乎不依赖执行时长——在一定范围内,同样的规模用一天还是半天做完,最终冲击相近,但参与率越高,冲击会略大。总成本 \(Q\cdot I(Q)\) 与 \(Q^{1.5}\) 成正比,这就是本册第 05 章 5.5 节成本惩罚中 \(3/2\) 次方项的来源。
临时与永久。 母单完成后,价格会部分回落。回落的部分是临时冲击(流动性提供者为承接库存索要的补偿),留下的是永久冲击(市场从订单流中学到的信息,第 07 册第 10 章的 Kyle 模型)。回测和组合优化需要的是交易者支付的平均成交价相对到达价的偏离,大约是峰值冲击的 2/3(在恒定速率执行时,成交均价位于冲击路径的中段)。
一种机制:传播子模型。 Bouchaud 等人提出,每笔子单对价格的影响随时间按 \(G(\ell)\propto(1+\ell)^{-\beta}\) 衰减。以恒定速率 \(r\) 执行 \(D\) 期,完成时刻的累计冲击为 \(\sum_{\ell<D}c\,r\,G(\ell)\propto rD^{1-\beta}\)。若参与率固定,\(D\propto Q\),于是 \(I\propto Q^{1-\beta}\);\(\beta=0.5\) 正好给出平方根律。这个模型同时解释了凹性和完成后的回落。
推导拆解:两处跳步补齐。 其一,求和怎么变成 \(D^{1-\beta}\):把求和近似成积分,\(\sum_{\ell=0}^{D-1}(1+\ell)^{-\beta}\approx\int_0^{D}(1+\ell)^{-\beta}d\ell=\dfrac{(1+D)^{1-\beta}-1}{1-\beta}\),\(D\) 较大时约为 \(\dfrac{D^{1-\beta}}{1-\beta}\)(这里用了幂函数的积分 \(\int u^{-\beta}du=\frac{u^{1-\beta}}{1-\beta}\),见 第 00 册第 03 章 积分)。乘上每期的冲击 \(c\,r\),得到 \(\propto rD^{1-\beta}\)。参与率固定意味着 \(r\) 固定、\(D\) 与 \(Q\) 成正比,所以 \(I\propto Q^{1-\beta}\)。 其二,"成交均价约为峰值冲击的 2/3":按平方根律,执行到时刻 \(t\) 时已累计的冲击约为 \(I_{\text{peak}}\sqrt{t/T}\)。匀速成交时,均价相对到达价的偏离是这条曲线的时间平均:\(\frac1T\int_0^T\sqrt{t/T}\,dt=\int_0^1\sqrt u\,du=\tfrac23\)。 金融直觉:传播子模型可以理解为"每一笔子单都在价格上留下一个会慢慢消退的印记"。早下的子单印记已经部分消退,所以总冲击比各笔简单相加小,这就是凹性;单子做完后不再有新印记,旧印记继续消退,就是完成后的回落。做市商承接你的库存后会逐步对冲出去,价格随之回来一部分,这是消退的经济来源。
7.4.2 代码:从模拟母单估计指数
代码 7.3 按传播子模型生成 4000 笔母单(规模从 0.1% 到 10% ADV,参与率 5%–20%),在冲击上叠加执行期间的市场波动,然后比较三种估计方法。为了看清偏差与方差,重复 20 次。
# 代码 7.3 平方根冲击律:从传播子(propagator)模型生成母单,再比较三种估计方法
import numpy as np
from scipy.optimize import curve_fit
sig_d, V, mins = 0.02, 1e6, 390 # 日波动 2%,日成交量 100 万股,每天 390 分钟
beta_k, Y0 = 0.5, 0.7 # 冲击核 G(l)=(1+l)^(-0.5)
c = Y0 * sig_d * np.sqrt(mins / 0.1) / (2 * V) # 校准:参与率 10% 时 I ≈ Y0·σ·sqrt(Q/V)
def simulate(n, rng):
frac = np.exp(rng.uniform(np.log(1e-3), np.log(0.1), n)) # 母单规模 Q/V:0.1% 到 10%
pi = rng.uniform(0.05, 0.20, n) # 参与率
clean, after = np.empty(n), np.empty(n)
D = np.maximum(1, np.round(frac * mins / pi).astype(int)) # 执行分钟数
for i in range(n):
r = pi[i] * V / mins # 每分钟成交股数
lag = D[i] - 1 - np.arange(D[i]) # 各子单到完成时刻的分钟数
clean[i] = c * r * np.sum((1 + lag) ** -beta_k) # 完成时刻的累计冲击
after[i] = c * r * np.sum((1 + lag + D[i]) ** -beta_k) # 完成后再过 D 分钟
noise = sig_d * np.sqrt(D / mins) * rng.standard_t(4, n) / np.sqrt(2) # 执行期间的市场波动
return frac, pi, clean / sig_d, (clean + noise) / sig_d, after / clean
def estimates(frac, y):
pos = y > 0
b1 = np.polyfit(np.log(frac[pos]), np.log(y[pos]), 1)[0] # 方法1:逐笔 log-log,丢弃负值
edges = np.quantile(frac, np.linspace(0, 1, 11)); k = np.digitize(frac, edges[1:-1])
fx = np.array([frac[k == j].mean() for j in range(10)]); fy = np.array([y[k == j].mean() for j in range(10)])
b2 = np.polyfit(np.log(fx), np.log(fy), 1)[0] # 方法2:分箱均值 log-log
b3 = curve_fit(lambda x, Y, d: Y * x ** d, frac, y, p0=[1, 0.5])[0][1] # 方法3:水平值 NLS
return b1, b2, b3, 1 - pos.mean()
rng = np.random.default_rng(1)
frac, pi, y_clean, y, decay = simulate(4000, rng)
print(f"无噪声时的真实指数(对 clean 冲击做 log-log): δ = {np.polyfit(np.log(frac), np.log(y_clean), 1)[0]:.3f}")
res = np.array([estimates(*simulate(4000, np.random.default_rng(100 + s))[0:4:3]) for s in range(20)])
for j, name in enumerate(["方法1 逐笔 log-log(丢弃负值)", "方法2 分箱均值 log-log", "方法3 水平值非线性最小二乘"]):
print(f"{name:24s} δ 均值 {res[:, j].mean():.3f} 标准差 {res[:, j].std():.3f}")
print(f"冲击为负、被方法1丢弃的母单占比 {res[:, 3].mean():.1%}")
X = np.c_[np.ones_like(frac), np.log(frac), np.log(pi)]
print(f"log(冲击) 对 [log规模, log参与率] 回归(无噪声): 系数 {np.round(np.linalg.lstsq(X, np.log(y_clean), rcond=None)[0][1:], 3)}")
print(f"完成后再过同样长时间,冲击剩余比例:中位数 {np.median(decay):.2f}")
输出:
无噪声时的真实指数(对 clean 冲击做 log-log): δ = 0.576
方法1 逐笔 log-log(丢弃负值) δ 均值 0.513 标准差 0.014
方法2 分箱均值 log-log δ 均值 0.573 标准差 0.053
方法3 水平值非线性最小二乘 δ 均值 0.585 标准差 0.096
冲击为负、被方法1丢弃的母单占比 38.0%
log(冲击) 对 [log规模, log参与率] 回归(无噪声): 系数 [0.573 0.427]
完成后再过同样长时间,冲击剩余比例:中位数 0.47
- 无噪声时的真实指数是 0.576 而不是 0.5,因为小母单的执行分钟数很少,离散求和偏离连续近似。这提醒我们:即使机制是"平方根",有限样本上的有效指数也可能偏离 0.5。
- 方法 1(逐笔取对数)有偏,而且看起来很精确:38% 的母单在执行期间价格反向运动,冲击为负,取不了对数只能丢弃。被丢弃的主要是小母单(它们的冲击相对噪声更小),剩下的小母单是"噪声恰好为正"的样本,冲击被高估,于是斜率被压低到 0.51,标准差只有 0.014,给人一种错误的确定感。
- 方法 2(分箱平均后取对数)和方法 3(水平值非线性最小二乘)都基本无偏,代价是方差更大。实践中常用方法 2,并配合 bootstrap 给出指数的置信区间。
- 控制规模后,参与率的弹性约为 0.43:参与率高,冲击大。实际的执行算法通常设参与率上限(如 10%–20% 的成交量),原因就在这里。
- 完成后再过同样长的时间,冲击只剩下 47%。在传播子模型中,冲击最终会全部衰减;真实市场中,信息含量使一部分冲击永久保留。
真实数据的估计还有一个本模型没有的困难:交易者的选择。交易员会在流动性好的时候加速、在价格不利时放慢,信号强的订单(往往伴随价格朝有利方向运动)会被交易得更激进。这些都让观测到的冲击与规模的关系混入了其他因素,是第 05 册第 12 章意义上的内生性问题。
白话解释:代码 7.3 的设计思路是"先造一个已知答案的世界,再看哪种估计方法能找回答案"。
simulate对每笔母单逐分钟累加子单的衰减印记,得到不含噪声的冲击clean;再叠加执行期间市场自身的波动noise(执行越久,噪声越大,所以乘 \(\sqrt{D/\text{mins}}\)),得到我们在真实数据里能观测到的冲击。三种估计方法的差别在于"先取对数还是先平均":方法 1 逐笔取对数,噪声让 38% 的观测为负,只能丢掉,留下的是被噪声往上推的样本,造成偏差;方法 2 先把规模相近的母单分成 10 组取平均,平均后的冲击几乎都为正,再取对数,偏差就消失了;方法 3 直接在原尺度上拟合 \(Y x^\delta\),不需要取对数。这和 CFA 里讲的"样本选择偏差"是一回事:按结果筛选样本(只保留冲击为正的),筛选本身就扭曲了结论。
7.5 TWAP 与 VWAP
7.5.1 调度算法
TWAP(时间加权)把母单在执行时段内均匀分配。VWAP 按预测的日内成交量曲线分配:如果历史上开盘后半小时成交了全天的 12%,就在这段时间做 12% 的量。VWAP 算法的目标是让成交均价贴近市场 VWAP;它本身并不最小化成本,只是最小化相对 VWAP 基准的跟踪误差,并让参与率在一天内大致恒定(参与率恒定意味着冲击大致均匀)。POV(按成交量比例)则实时跟随市场成交量,以固定比例参与,完成时间不确定。
日内成交量曲线在股票市场通常呈 U 形(开盘和收盘集中),A 股午间休市使曲线变成两段;期货有夜盘。成交量预测通常用过去 20 天的平均曲线,再用当日已实现的成交量做实时修正。
7.5.2 代码:相对市场 VWAP 的跟踪误差
# 代码 7.4 TWAP 与 VWAP 调度:相对市场 VWAP 的跟踪误差
import numpy as np
rng = np.random.default_rng(2)
days, M = 300, 390
u = np.linspace(0, 1, M)
profile = 1 + 2.5 * (u - 0.5) ** 2 * 4 # U 形日内成交量形状(开盘、收盘放量)
profile /= profile.sum()
vol_shape = np.sqrt(profile / profile.mean()) # 日内波动也呈 U 形,与成交量同步
vols, rets = [], []
for d in range(days):
level = rng.lognormal(0, 0.3) # 当日成交活跃度
noise = rng.lognormal(0, 0.5, M) # 分钟级噪声
vols.append(1e6 * level * profile * noise)
sig_m = 0.02 / np.sqrt(M) * rng.lognormal(0, 0.25) # 当日波动水平
rets.append(sig_m * vol_shape * rng.standard_t(4, M) / np.sqrt(2))
vols, rets = np.array(vols), np.array(rets)
prices = 20 * np.exp(np.cumsum(rets, axis=1))
def slippage(sched, d):
"""调度 sched(各分钟占比,和为 1)在第 d 天的执行均价相对市场 VWAP 的偏差(bp)。无自身冲击。"""
p = prices[d]; mkt_vwap = (vols[d] * p).sum() / vols[d].sum()
return ((sched * p).sum() / mkt_vwap - 1) * 1e4
out = {"TWAP": [], "VWAP(20日历史均值曲线)": [], "VWAP(已知当日真实曲线,不可实现)": []}
for d in range(20, days):
hist = vols[d - 20:d].mean(0); hist /= hist.sum()
out["TWAP"].append(slippage(np.ones(M) / M, d))
out["VWAP(20日历史均值曲线)"].append(slippage(hist, d))
out["VWAP(已知当日真实曲线,不可实现)"].append(slippage(vols[d] / vols[d].sum(), d))
for k, v in out.items():
v = np.array(v)
print(f"{k:24s} 平均 {v.mean():6.2f} bp 标准差 {v.std():5.2f} bp 95%分位绝对值 {np.percentile(np.abs(v), 95):5.2f} bp")
输出:
TWAP 平均 -1.05 bp 标准差 14.27 bp 95%分位绝对值 29.32 bp
VWAP(20日历史均值曲线) 平均 -0.13 bp 标准差 2.53 bp 95%分位绝对值 5.34 bp
VWAP(已知当日真实曲线,不可实现) 平均 0.00 bp 标准差 0.00 bp 95%分位绝对值 0.00 bp
- TWAP 相对 VWAP 的跟踪误差标准差 14bp,95% 分位 29bp:日内价格波动集中在开盘和收盘,而 TWAP 在这两段的参与比例远低于市场。
- 按 20 日历史均值曲线执行,跟踪误差降到 2.5bp。剩下的误差全部来自成交量曲线的预测误差——代码最后一行"已知当日真实曲线"把误差降为 0,说明在不考虑自身冲击时,VWAP 调度的唯一难题就是预测成交量曲线。
- 两种调度的平均偏差都接近 0:VWAP 和 TWAP 都不能系统性地"跑赢"市场 VWAP,它们只是控制方差。
- 本模型没有自身冲击。如果母单占市场成交量的比例较大,自己的交易会推高市场 VWAP,执行均价自然"贴近"了被自己推高的基准,这就是 VWAP 基准的另一个盲点。
7.6 Almgren–Chriss 最优执行
7.6.1 模型
Almgren 和 Chriss(2000)把执行写成一个均值–方差问题。在 \([0,T]\) 内分 \(N\) 段卖出 \(X\) 股,每段长 \(\tau=T/N\),\(x_k\) 为第 \(k\) 段结束时的剩余持仓(\(x_0=X\),\(x_N=0\)),\(n_k=x_{k-1}-x_k\) 为第 \(k\) 段卖出量。价格动态:
\(g(v)=\gamma v\) 为永久冲击,\(h(v)=\varepsilon\operatorname{sgn}(v)+\eta v\) 为临时冲击(只影响本段成交价 \(\tilde S_k\))。执行差额 \(XS_0-\sum n_k\tilde S_k\) 的期望和方差为
目标是 \(\min E+\lambda V\),\(\lambda\) 为风险厌恶系数。这是关于 \(x_1,\dots,x_{N-1}\) 的无约束凸二次规划(第 04 册第 16a 章),一阶条件是一个三对角线性方程组:
这是常系数二阶差分方程,配合边界条件 \(x_0=X\)、\(x_N=0\),解为
\(\lambda=0\) 时 \(\kappa=0\),解退化为匀速卖出(TWAP);\(\lambda\) 越大,前期卖得越快。\(1/\kappa\) 是"执行半衰期"的时间尺度:它只依赖风险厌恶、波动率和临时冲击,与母单规模无关。
推导拆解:从目标函数到差分方程,再到 \(\sinh\) 解。 第一步,写出目标中依赖 \(x_k\) 的部分。\(x_k\) 出现在两个卖出量里:\(n_k=x_{k-1}-x_k\) 和 \(n_{k+1}=x_k-x_{k+1}\),还出现在方差项 \(\lambda\sigma^2\tau x_k^2\) 里。\(E\) 中的 \(\tfrac12\gamma X^2+\varepsilon X\) 与轨迹无关,求导时消失。 第二步,对 \(x_k\) 求偏导并令其为零(用链式法则,\(n_k\) 对 \(x_k\) 的导数是 \(-1\),\(n_{k+1}\) 对 \(x_k\) 的导数是 \(+1\)):\(\dfrac{\tilde\eta}{\tau}\big[-2(x_{k-1}-x_k)+2(x_k-x_{k+1})\big]+2\lambda\sigma^2\tau x_k=0\)。 第三步,整理:\(x_{k-1}-2x_k+x_{k+1}=\dfrac{\lambda\sigma^2\tau^2}{\tilde\eta}x_k\),两边除以 \(\tau^2\) 就是正文的方程。左边是二阶差分,可以理解为持仓曲线的"弯曲程度"(离散版的二阶导数)。方程说:持仓越多,曲线弯得越厉害,即风险越大时越要加速卖出。 第四步,猜解。常系数线性差分方程的解是指数形式 \(x_k=e^{\pm\kappa t_k}\),代入得 \(e^{\kappa\tau}+e^{-\kappa\tau}-2=\tilde\kappa^2\tau^2\),即 \(2(\cosh\kappa\tau-1)=\tilde\kappa^2\tau^2\),这就是正文给出的 \(\kappa\) 与 \(\tilde\kappa\) 的关系。两个指数解组合成 \(\sinh(\kappa(T-t))\) 恰好满足 \(t=T\) 时为 0,再除以 \(\sinh(\kappa T)\) 使 \(t=0\) 时为 \(X\)。 第五步,检查极限:\(\tau\to0\) 时 \(\cosh\kappa\tau-1\approx\tfrac12\kappa^2\tau^2\)(泰勒展开),于是 \(\kappa\approx\tilde\kappa\),离散解与连续时间解一致。参见 第 00 册第 05 章 多元微积分与优化。 方差为什么是 \(\sigma^2\tau\sum x_k^2\):第 \(k\) 段的价格冲击 \(\sigma\sqrt\tau\xi_k\) 作用在当时还没卖掉的 \(x_k\) 股上,各段冲击独立,方差直接相加。这就是"手里持仓越久、风险越大"的数学表达。
动态规划视角。 因为目标是 \(x_k\) 的二次型,可以逆向递推:设 \(J_k(x)=c_kx^2\) 为第 \(k\) 段结束时持有 \(x\) 股的剩余最优成本,则
最优 \(y=\frac{a}{a+b+c_{k+1}}x\),\(c_k=\frac{a(b+c_{k+1})}{a+b+c_{k+1}}\),终端条件 \(c_{N-1}=a\)(最后一段必须卖完)。在线性冲击、算术布朗运动和均值–方差目标下,最优策略是静态的(事先确定的轨迹),动态规划只是另一种求法;这一结论在更一般的设定(例如价格有漂移、冲击非线性、目标为期望效用)下不再成立,这时动态规划就是必需的工具。
推导拆解:递推式怎么来的。\(J_k(x)\) 是"第 \(k\) 段结束时手里还有 \(x\) 股,之后最优执行的剩余成本"。下一段把持仓从 \(x\) 卖到 \(y\),当段付出临时冲击成本 \(a(x-y)^2\)(卖出量的平方乘 \(\tilde\eta/\tau\)),持有 \(y\) 股承担风险 \(by^2\),再加上之后的最优成本 \(c_{k+1}y^2\)。对 \(y\) 求导令其为零:\(-2a(x-y)+2(b+c_{k+1})y=0\),解出 \(y=\frac{a}{a+b+c_{k+1}}x\);代回去化简得 \(c_k=\frac{a(b+c_{k+1})}{a+b+c_{k+1}}\)。终端条件:倒数第二个时点持有 \(x\) 股,最后一段只能全部卖掉,成本 \(ax^2\),所以 \(c_{N-1}=a\)。 代码
ac_dp就是这两行公式:先从后往前算出每一步的"保留比例"ratio[k],再从前往后连乘得到轨迹。它与解析解差 \(10^{-9}\) 股,等于用两种独立方法对同一个答案做了核对,这和第 06 章"向量化与事件驱动回测要对账"是同一个工程习惯。
7.6.2 代码:解析解、动态规划与蒙特卡洛
参数取原论文的数值例。
# 代码 7.5 Almgren–Chriss 最优执行:解析解、动态规划与蒙特卡洛验证
import numpy as np
# 参数取 Almgren–Chriss (2000) 的数值例:卖出 100 万股,股价 50,年化波动 30%,5 天完成
X, S0, sigma = 1e6, 50.0, 0.95 # sigma:每股每 sqrt(天) 的价格波动(美元)
T, N = 5.0, 25; tau = T / N # 25 个时段,每段 0.2 天
eps, eta, gamma = 0.0625, 2.5e-6, 2.5e-7 # 固定成本(半价差)、临时冲击、永久冲击系数
eta_t = eta - 0.5 * gamma * tau # η~ = η − γτ/2
def ac_closed(lam):
"""离散时间解析解:x_k = sinh(κ(T − t_k)) / sinh(κT) · X。"""
if lam == 0:
return X * (1 - np.arange(N + 1) / N) # 风险中性:匀速(TWAP)
kt2 = lam * sigma**2 / eta_t
kappa = np.arccosh(1 + 0.5 * kt2 * tau**2) / tau
t = np.arange(N + 1) * tau
return X * np.sinh(kappa * (T - t)) / np.sinh(kappa * T)
def ac_dp(lam):
"""逆向动态规划:J_k(x) = c_k x²,每步 min_y a(x−y)² + b y² + c_{k+1} y²。"""
a, b = eta_t / tau, lam * sigma**2 * tau
c = np.zeros(N + 1); c[N - 1] = a # 最后一段必须卖完
ratio = np.zeros(N)
for k in range(N - 2, -1, -1):
ratio[k] = a / (a + b + c[k + 1]) # y = ratio·x
c[k] = a * (b + c[k + 1]) / (a + b + c[k + 1])
x = np.empty(N + 1); x[0] = X
for k in range(N - 1):
x[k + 1] = ratio[k] * x[k]
x[N] = 0.0
return x
def mean_var(x):
n = -np.diff(x)
E = 0.5 * gamma * X**2 + eps * X + eta_t / tau * np.sum(n**2)
Vv = sigma**2 * tau * np.sum(x[1:] ** 2)
return E, Vv
for lam in [1e-6]:
xc, xd = ac_closed(lam), ac_dp(lam)
print(f"λ={lam:g}: 解析解与动态规划的最大差异 {np.max(np.abs(xc - xd)):.2e} 股")
print("前 6 个时段末的剩余持仓(万股):", np.round(xc[:7] / 1e4, 1))
print("\n λ 预期成本(万美元) 成本标准差(万美元) 半衰期(天)")
for lam in [0, 1e-7, 1e-6, 1e-5]:
x = ac_closed(lam); E, Vv = mean_var(x)
hl = tau * np.argmax(x <= X / 2)
print(f"{lam:8.0e} {E / 1e4:14.1f} {np.sqrt(Vv) / 1e4:14.1f} {hl:8.1f}")
# 蒙特卡洛:t(4) 价格冲击下的实现成本分布(执行差额 = X·S0 − 卖出所得)
rng = np.random.default_rng(3)
def simulate(x, n_path=20000):
n = -np.diff(x)
xi = rng.standard_t(4, (n_path, N)) / np.sqrt(2)
S = S0 + np.cumsum(sigma * np.sqrt(tau) * xi - gamma * n, axis=1) # 永久冲击累计
S_prev = np.c_[np.full(n_path, S0), S[:, :-1]]
px = S_prev - eps - eta * n / tau # 临时冲击只影响成交价
return X * S0 - (px * n).sum(1)
print("\n蒙特卡洛(t(4) 冲击,2 万条路径)")
for lam in [0, 1e-6, 1e-5]:
cst = simulate(ac_closed(lam)); E, Vv = mean_var(ac_closed(lam))
print(f"λ={lam:g}: 实现成本均值 {cst.mean() / 1e4:6.1f} 万(理论 {E / 1e4:6.1f}),"
f"标准差 {cst.std() / 1e4:5.1f} 万(理论 {np.sqrt(Vv) / 1e4:5.1f}),99% 分位 {np.percentile(cst, 99) / 1e4:6.1f} 万")
输出:
λ=1e-06: 解析解与动态规划的最大差异 1.57e-09 股
前 6 个时段末的剩余持仓(万股): [100. 88.6 78.4 69.4 61.5 54.4 48.1]
λ 预期成本(万美元) 成本标准差(万美元) 半衰期(天)
0e+00 68.2 119.0 2.6
1e-07 69.0 112.0 2.4
1e-06 95.9 80.2 1.2
1e-05 250.9 39.8 0.4
蒙特卡洛(t(4) 冲击,2 万条路径)
λ=0: 实现成本均值 67.9 万(理论 68.2),标准差 118.9 万(理论 119.0),99% 分位 350.1 万
λ=1e-06: 实现成本均值 97.4 万(理论 95.9),标准差 79.7 万(理论 80.2),99% 分位 291.2 万
λ=1e-05: 实现成本均值 251.0 万(理论 250.9),标准差 40.2 万(理论 39.8),99% 分位 347.6 万
- 解析解与动态规划的轨迹差异在 \(10^{-9}\) 股量级,两种方法一致。
- 有效前沿:\(\lambda\) 从 0 增加到 \(10^{-6}\),预期成本从 68 万升到 96 万美元(+41%),成本标准差从 119 万降到 80 万(−33%)。再增大到 \(10^{-5}\),标准差降到 40 万,预期成本却升到 251 万。前沿在 \(\lambda=0\) 附近很平坦:从 TWAP 出发稍微加快一点,几乎不增加预期成本就能降低风险,这是 Almgren–Chriss 的一个重要实践结论。
- 蒙特卡洛在 \(t(4)\) 厚尾冲击下验证了均值和标准差公式:均值–方差公式不依赖正态分布。TWAP 的 99% 分位是 350 万,约为均值加 2.37 倍标准差,与正态分布的 2.33 很接近——执行成本是 25 个时段冲击的加权和,中心极限定理把逐段的厚尾平均掉了。真实市场的尾部风险主要来自这个模型没有的东西:执行期间的跳空、停牌、涨跌停和流动性突然消失,它们不会被平均掉,需要用情景分析单独评估。
- \(\lambda\) 怎样选:一个常见做法是让 \(\lambda\) 与组合层面的风险厌恶一致;另一个是由交易员根据紧迫度(信号衰减速度)选择执行半衰期,再反推 \(\lambda\)。信号衰减快时,"时间风险"中还应加上 alpha 损失,这相当于在价格动态中加入漂移项。
金融直觉:为什么前沿在 TWAP 附近很平坦?TWAP 是期望成本的最小点,在最小点附近,函数的一阶变化为零,稍微偏离只带来二阶(很小)的成本增加;而风险是轨迹的单调函数,稍微加快就能带来一阶(明显)的风险下降。这和 CFA 里的有效前沿完全同构:在全局最小方差组合附近,多承担一点点方差就能换来明显更高的期望收益;这里反过来,在最小成本点附近,多付一点点成本就能换来明显更低的风险。实践含义:交易台几乎永远不该做纯 TWAP,稍微前置一点几乎是免费的保险。 读表时注意单位:\(\lambda\) 的单位是"每美元方差",\(10^{-6}\) 意味着愿意为降低 1 万亿美元² 的方差支付 100 万美元,数字本身没有直观意义,所以实务里通常用执行半衰期(表中最后一列)来沟通紧迫度。
7.6.3 模型的局限
线性临时冲击与平方根律矛盾(7.4 节);冲击即时衰减与传播子模型矛盾;价格没有漂移,不考虑信号衰减;不考虑限价单和成交的不确定性。后续研究(非线性冲击、瞬态冲击的 Obizhaeva–Wang 模型、带 alpha 的执行)放松了这些假设。但这个模型的结构——在冲击成本和时间风险之间用一个风险厌恶参数做权衡——是几乎所有机构执行算法的出发点。
7.7 限价单与逆向选择
7.7.1 限价单的期权视角
挂限价买单,等于免费向市场写出了一份看跌期权:价格下跌时你一定成交(往往是被知情卖方砸到),价格上涨时你成交不了,只能追高或放弃。第 07 册第 18 章 18.2 节讨论过这一点:限价单省下了半个价差,代价是逆向选择和不成交风险。经验法则"限价单比市价单便宜"只对不知情交易者、在信息稀少的市场里成立。
7.7.2 代码:盈亏平衡的信息强度
代码 7.6 的简化模型:买入 1 股;在执行期内有一定概率出现消息,使价值跳动 \(\pm J\);坏消息时知情卖单会砸到我们的买单(成交概率 0.95),好消息时价格跑开(成交概率 0.1),无消息时成交概率 0.5;未成交的部分在期末以卖一价追买。
# 代码 7.6 限价单与逆向选择:挂在买一价,什么时候比直接吃卖一价划算
import numpy as np
rng = np.random.default_rng(4)
s, J = 0.02, 0.06 # 价差 2 分;消息使价值跳动 ±6 分
q_noise, q_inf, q_up = 0.5, 0.95, 0.1 # 无消息时成交概率;坏消息时(知情卖单砸来);好消息时(价格跑开)
n = 400_000
def limit_vs_market(p_news):
"""买入 1 股。到达中间价 m0=0。期末中间价 m1;未成交则在期末按 m1 + s/2 吃单补齐。
成本以到达中间价为基准(执行差额口径):成本 = 实际买价 − m0。"""
news = rng.random(n) < p_news
up = rng.random(n) < 0.5
m1 = np.where(news, np.where(up, J, -J), 0.0)
p_fill = np.where(news, np.where(up, q_up, q_inf), q_noise) # 成交概率与价格方向相关
filled = rng.random(n) < p_fill
pay = np.where(filled, -s / 2, m1 + s / 2) # 成交价 = 买一价;否则期末追价
adverse = np.mean(m1[filled] - (-s / 2)) # 成交后的价格变动(相对成交价)
return dict(限价单成本=pay.mean(), 市价单成本=s / 2, 成交率=filled.mean(),
成交后中间价相对成交价=adverse, 成本标准差=pay.std())
print(" 消息概率 限价单成本 市价单成本 成交率 成交后漂移 限价单成本标准差 (单位:元/股)")
for p in [0.0, 0.1, 0.2, 0.4, 0.6]:
r = limit_vs_market(p)
print(f" {p:4.1f} {r['限价单成本']:+.4f} {r['市价单成本']:+.4f} {r['成交率']:.3f}"
f" {r['成交后中间价相对成交价']:+.4f} {r['成本标准差']:.4f}")
# 解析的盈亏平衡:限价成本 = 市价成本
for p in np.linspace(0, 1, 1001):
cost_lim = (1 - p) * (q_noise * (-s / 2) + (1 - q_noise) * (s / 2)) \
+ p / 2 * (q_inf * (-s / 2) + (1 - q_inf) * (-J + s / 2)) \
+ p / 2 * (q_up * (-s / 2) + (1 - q_up) * (J + s / 2))
if cost_lim >= s / 2:
print(f"解析盈亏平衡的消息概率 ≈ {p:.3f}"); break
输出:
消息概率 限价单成本 市价单成本 成交率 成交后漂移 限价单成本标准差 (单位:元/股)
0.0 +0.0000 +0.0100 0.499 +0.0100 0.0100
0.1 +0.0025 +0.0100 0.502 +0.0049 0.0177
0.2 +0.0050 +0.0100 0.504 -0.0001 0.0228
0.4 +0.0100 +0.0100 0.510 -0.0100 0.0298
0.6 +0.0150 +0.0100 0.515 -0.0197 0.0347
解析盈亏平衡的消息概率 ≈ 0.400
- 没有消息时,限价单成本为 0(一半概率省下半个价差,一半概率付出半个价差),市价单成本是半个价差 0.01。
- 消息概率上升时,限价单的成本线性上升,盈亏平衡点在 0.4。超过它,市价单更便宜。
- "成交后漂移"一列说明逆向选择:无消息时成交后中间价比成交价高 0.01(赚到半个价差);消息概率 0.6 时,成交后中间价比成交价低 0.02,即成交本身就是坏消息。
- 成本标准差随消息概率上升:限价单把成本的不确定性从"冲击"转移到了"是否成交"。
实务含义:对信号驱动、需要尽快建仓的订单,被动挂单会系统性地在信号正确时成交不了、在信号错误时成交——它与 alpha 负相关。执行算法通常根据剩余时间、已完成比例和短期价格预测动态切换被动与主动。
推导拆解:代码里的解析盈亏平衡式按三种情形加权求期望,以中间价 0 为基准。 无消息(概率 \(1-p\)):以 0.5 的概率在买一成交,成本 \(-s/2\);否则期末追价付 \(+s/2\)。期望为 0。 坏消息(概率 \(p/2\)):以 0.95 的概率被砸到,成本 \(-s/2\),但价值已跌到 \(-J\),这笔成交相对新价值亏了;未成交时在 \(-J+s/2\) 追买。 好消息(概率 \(p/2\)):仅 0.1 的概率成交在 \(-s/2\);0.9 的概率价格跑到 \(J\),只能在 \(J+s/2\) 追买。 把三项加起来令其等于市价单成本 \(s/2\),代入 \(s=0.02\)、\(J=0.06\) 解得 \(p=0.4\)。关键在好消息那一行:限价单最该成交的时候成交概率最低,代价是 \(J+s/2\) 的追价。 金融直觉:正文说限价单是"免费写出的看跌期权"。写期权的人收权利金,这里的"权利金"是省下的半个价差;价格下跌时期权被行权(你被迫在高于新价值的价格买入),价格上涨时期权作废(你什么都没买到)。和做市商卖期权一样,权利金够不够覆盖被行权的损失,取决于市场上有多少"知情的行权者"。
7.8 订单簿模拟
7.8.1 为什么要模拟订单簿
日线回测无法回答这些问题:挂在买一的单子多久能成交?一笔市价单会吃掉几档?排队位置值多少钱?研究这些问题需要逐笔数据回放或订单簿模拟。回放历史逐笔数据的问题是:你自己的订单不会改变历史,而真实世界里它会;模拟器则可以让你的订单与模拟的订单流相互作用,但模拟出的订单流与真实市场有差距。实务中两者并用。
代码 7.7 实现一个最小的价格–时间优先撮合引擎(第 07 册第 06 章 6.3、6.5 节),订单流采用 Cont、Stoikov 和 Talreja(2010)类型的零智能模型:限价单在离对手方最优价 1–5 档的位置随机到达,越靠近越频繁;市价单规模服从几何分布;撤单强度与簿内订单数成正比。然后做三个实验:价差与深度统计、市价单逐档吃单的冲击、在买一队尾挂单的成交率与成交后价格变化。
# 代码 7.7 一个价格–时间优先的订单簿模拟器:价差、深度、吃单冲击与排队位置
import numpy as np
from collections import deque
class Book:
"""整数价位(tick)上的限价订单簿;每个价位一条 FIFO 队列(价格–时间优先),元素为 [订单号, 数量]。"""
def __init__(self, n=20000, mid=10000):
self.q = {+1: [deque() for _ in range(n)], -1: [deque() for _ in range(n)]} # +1 买, -1 卖
self.depth = {+1: np.zeros(n, int), -1: np.zeros(n, int)}
self.total = {+1: 0, -1: 0}
self.bb, self.ba = mid - 1, mid + 1
self.loc, self.alive, self.pos_in_alive = {}, [], {}
self.next_id, self.fills, self.tracked = 0, {}, {}
for k in range(1, 6): # 初始簿:两边各 5 档,每档 3 张
for _ in range(3):
self.limit(+1, mid - k); self.limit(-1, mid + k)
def best(self, side):
return self.bb if side == +1 else self.ba
def _refresh(self, side): # 最优价位被清空后向外找下一个非空价位
if side == +1:
while self.depth[+1][self.bb] == 0: self.bb -= 1
else:
while self.depth[-1][self.ba] == 0: self.ba += 1
def limit(self, side, px, qty=1, track=False):
oid = self.next_id; self.next_id += 1
self.q[side][px].append([oid, qty]); self.depth[side][px] += qty; self.total[side] += qty
self.loc[oid] = (side, px)
if side == +1 and px > self.bb: self.bb = px
if side == -1 and px < self.ba: self.ba = px
if not track:
self.pos_in_alive[oid] = len(self.alive); self.alive.append(oid)
return oid
def _drop_alive(self, oid):
i = self.pos_in_alive.pop(oid, None)
if i is None: return
last = self.alive.pop()
if last != oid: self.alive[i] = last; self.pos_in_alive[last] = i
def cancel_random(self, rng, scale=100):
if not self.alive or rng.random() > len(self.alive) / scale: return # 撤单强度与簿内订单数成正比
oid = self.alive[rng.integers(len(self.alive))]
side, px = self.loc[oid]
if self.total[side] <= 1: return # 不让任何一边被撤空
for o in self.q[side][px]:
if o[0] == oid:
self.q[side][px].remove(o); self.depth[side][px] -= o[1]; self.total[side] -= o[1]; break
self.loc.pop(oid); self._drop_alive(oid)
if self.depth[side][px] == 0 and px == self.best(side): self._refresh(side)
def market(self, side, qty, t):
"""side=+1 为市价买单,按价格–时间优先吃卖方队列。"""
opp = -side; got = 0
while got < qty and self.total[opp] > 1: # 保留最后一张,避免簿被吃空
px = self.best(opp); qu = self.q[opp][px]; o = qu[0]
take = min(o[1], qty - got)
o[1] -= take; got += take; self.depth[opp][px] -= take; self.total[opp] -= take
if o[1] == 0:
qu.popleft(); self.loc.pop(o[0], None); self._drop_alive(o[0])
if o[0] in self.tracked: self.fills[o[0]] = t
if self.depth[opp][px] == 0: self._refresh(opp)
DIST = 1 / np.arange(1, 6) ** 0.8; DIST /= DIST.sum() # 限价单离对手方最优价的档数分布
P_LIM, P_MKT = 0.50, 0.15 # 事件概率:限价单、市价单,其余为撤单
def step(b, rng, t):
u = rng.random(); side = 1 if rng.random() < 0.5 else -1
bb, ba = b.best(+1), b.best(-1)
if u < P_LIM: # 限价单:距对手方最优价 d 档
d = rng.choice(5, p=DIST) + 1
b.limit(side, ba - d if side == +1 else bb + d)
elif u < P_LIM + P_MKT: # 市价单:规模几何分布,均值 2
b.market(side, int(rng.geometric(0.5)), t)
else:
b.cancel_random(rng)
def mid(b): return (b.best(+1) + b.best(-1)) / 2
def walk_cost(b, Q):
"""市价买单 Q 张逐档吃单的成交均价相对中间价的偏离(只计算,不执行)。"""
px, need, cash = b.best(-1), Q, 0
while need > 0 and px < len(b.depth[-1]) - 1:
take = min(b.depth[-1][px], need); cash += take * px; need -= take; px += 1
return cash / (Q - need) - mid(b)
rng = np.random.default_rng(0)
b = Book(); b.tracked = {}
spread, dep, probes, due = [], [], [], deque()
costs = {Q: [] for Q in [1, 2, 5, 10, 20, 40]}
n_events = 300_000
for t in range(n_events):
step(b, rng, t)
while due and due[0][0] == t:
b.tracked[due.popleft()[1]]["mid_after"] = mid(b)
if t > 10_000 and t % 50 == 0:
bb, ba = b.best(+1), b.best(-1)
spread.append(ba - bb); dep.append([b.depth[+1][bb - k] for k in range(5)])
if t > 10_000 and t % 100 == 0:
for Q in costs: costs[Q].append(walk_cost(b, Q))
if t > 10_000 and t % 400 == 0 and t < n_events - 3000: # 探针:在买一队尾挂 1 张
bb = b.best(+1)
oid = b.limit(+1, bb, track=True)
b.tracked[oid] = dict(t0=t, ahead=b.depth[+1][bb] - 1, mid0=mid(b))
probes.append(oid); due.append((t + 300, oid)) # 300 个事件后读中间价
spread, dep = np.array(spread), np.array(dep)
print(f"平均价差 {spread.mean():.2f} tick,价差=1 tick 的时间占比 {np.mean(spread == 1):.1%}")
print("买方各档平均深度(从买一往下,张):", np.round(dep.mean(0), 2))
print("市价买单的平均冲击(tick,相对中间价):", {Q: round(float(np.mean(v)), 2) for Q, v in costs.items()})
rows = []
for oid in probes:
info = b.tracked[oid]
filled = oid in b.fills and b.fills[oid] - info["t0"] <= 200 # 200 个事件内是否成交
rows.append((info["ahead"], filled, info["mid_after"] - info["mid0"]))
rows = np.array(rows, dtype=float)
for lo, hi in [(0, 3), (3, 6), (6, 10), (10, 100)]:
m = (rows[:, 0] >= lo) & (rows[:, 0] < hi)
if m.sum():
print(f"挂单时前面排队 {lo:2d}–{hi-1:2d} 张: 探针 {m.sum():3d} 个,200 事件内成交率 {rows[m, 1].mean():.2f}")
f = rows[:, 1] == 1
print(f"挂单后 300 个事件的中间价变化:成交的探针 {rows[f, 2].mean():+.2f} tick,未成交的 {rows[~f, 2].mean():+.2f} tick,"
f"全部 {rows[:, 2].mean():+.2f} tick")
输出:
平均价差 1.34 tick,价差=1 tick 的时间占比 76.5%
买方各档平均深度(从买一往下,张): [3.81 5.49 5.72 5.29 4.14]
市价买单的平均冲击(tick,相对中间价): {1: 0.67, 2: 0.83, 5: 1.23, 10: 1.76, 20: 2.57, 40: 3.35}
挂单时前面排队 0– 2 张: 探针 329 个,200 事件内成交率 0.94
挂单时前面排队 3– 5 张: 探针 230 个,200 事件内成交率 0.90
挂单时前面排队 6– 9 张: 探针 118 个,200 事件内成交率 0.88
挂单时前面排队 10–99 张: 探针 40 个,200 事件内成交率 0.68
挂单后 300 个事件的中间价变化:成交的探针 -0.41 tick,未成交的 +2.22 tick,全部 -0.15 tick
- 价差与深度。 平均价差 1.34 tick,76% 的时间价差是 1 tick。深度在第 2、3 档最大,买一反而较薄——买一被市价单持续消耗,这是零智能模型和真实大盘股都有的特征。
- 吃单冲击是凹的。 1 张的冲击是 0.67 tick(半个价差加上偶尔价差大于 1),40 张是 3.35 tick;规模扩大 40 倍,冲击扩大约 5 倍。逐档吃单的瞬时冲击依赖深度剖面,与 7.4 节母单的平方根律是不同时间尺度上的现象,但凹性的来源相似:越往外,每一档承接的数量越多。
- 排队位置。 前面排队 0–2 张时,200 个事件内成交率 94%;排在 10 张以后时降到 68%。在价格–时间优先的市场里,排队位置本身有价值,这是高频做市商争抢队首、尽早挂单的原因。
- 逆向选择自然出现。 模型里没有任何"知情交易者",但成交了的买单在之后 300 个事件里中间价平均下跌 0.41 tick,未成交的平均上涨 2.22 tick。原因是机械的:买一队列被市价卖单吃穿时,我们的单子才会成交,而买一被吃穿的同时价格就下移了;价格上涨时,我们的单子被留在后面。7.7 节的消息模型和这里的机械模型给出同一个结论:被动单的成交本身就携带着不利的价格信息。
模拟器的局限:订单流与价格无关(真实订单流对价格变动有反应),没有隐藏单和策略性撤单,参数没有用真实数据校准。把它用于具体决策前,至少要用真实的逐笔数据校准到达率和撤单率,并检查模拟的价差、深度、波动是否与真实数据一致。
7.9 常见陷阱与检查清单
陷阱
- 用 VWAP 考核信号驱动的交易:VWAP 看不到延迟成本和机会成本,还会被自己的交易推动。
- 只算成交部分的成本:没成交的部分有机会成本,限价太紧的执行策略看起来成本很低。
- 把 Roll 估计当成交易成本:它只捕捉暂时性成分,且被订单流自相关压低。
- 在对数尺度上逐笔回归冲击:丢掉负值样本导致偏差,并给出虚假的高精度。
- 线性外推冲击:用小单的成本推算大单,会严重低估;用平方根律外推到 30% 以上参与率也不可靠。
- 忽略冲击的回落:用峰值冲击做成本会高估,用完成很久之后的价格又会低估。
- 把被动单的成交价当作"省下的价差":成交后的不利漂移要算进去。
- 只用波动率衡量执行风险:跳空、停牌、涨跌停和流动性枯竭造成的尾部损失不在均值–方差框架内。
- 回测、优化器、执行算法用三套不同的成本模型。
检查清单
- [ ] 每笔母单记录决策时间、到达时间、到达价、每笔子单的时间、价格、数量和费用。
- [ ] TCA 用执行差额口径,四项成本加总等于纸面与实际组合之差。
- [ ] 成本模型参数(半价差、\(Y\)、\(\delta\)、参与率弹性)用自己的成交数据定期校准,并给出置信区间。
- [ ] 执行算法的紧迫度与信号衰减速度挂钩。
- [ ] 设置参与率上限和价格限制;有未成交时的处理规则。
- [ ] 回测(本册第 06 章)、组合优化(第 05 章)和执行使用同一套成本参数。
本章小结
- 执行差额把成本分解为延迟、交易、机会和显性费用四项,分别对应不同的责任人。VWAP 基准看不到延迟成本和机会成本。
- 有效价差度量交易者付出的即时成本;已实现价差与有效价差之差是逆向选择成分;Roll 估计量只捕捉暂时性成分。
- 母单冲击近似服从平方根律,传播子模型给出一种机制;估计指数时不要逐笔取对数。冲击有临时和永久两部分。
- VWAP 调度控制的是相对 VWAP 的跟踪误差,其误差来自成交量曲线的预测误差。
- Almgren–Chriss 在冲击成本与时间风险之间权衡,离散解为双曲正弦形,可用动态规划验证;前沿在 TWAP 附近很平坦。
- 限价单的成交与价格方向负相关,信息越多,逆向选择越重;在订单簿模拟器中,排队位置决定成交率,成交本身预示不利的价格变动。
练习
-
卖出方向的执行差额。 把代码 7.1 改成卖出母单,推导各项的符号,并验证恒等式。再把 VWAP 基准改为"到达价之后的区间 VWAP",比较两种基准对同一笔交易的评价。
-
Roll 估计量的修正。 在代码 7.2 中,已知方向自相关为 \(\rho\) 时,推导 \(\operatorname{cov}(\Delta P_t,\Delta P_{t-1})\) 的表达式,并据此修正 Roll 估计量。(提示:先写出 \(\operatorname{cov}(q_t,q_{t-1})=\rho\) 和 \(\operatorname{cov}(q_t,q_{t-2})=\rho^2\)。)
-
冲击核与指数。 把代码 7.3 中的核指数 \(\beta\) 改为 0.3 和 0.7,验证估计出的 \(\delta\) 约为 \(1-\beta\)。再固定参与率为 10%,参与率的影响是否消失?
-
含自身冲击的 VWAP。 在代码 7.4 中加入自身冲击:执行时每分钟价格上移 \(k\cdot(\text{本分钟自身成交}/\text{本分钟市场成交})\),市场 VWAP 也包含自己的成交。母单分别占全天成交量的 1%、5%、20% 时,相对 VWAP 的平均滑点和相对到达价的成本各是多少?
-
带漂移的 Almgren–Chriss。 假设卖出的原因是预测价格每天下跌 \(\mu\),价格动态加上 \(-\mu\tau\) 的漂移。写出新的期望成本,用代码 7.5 的动态规划思路(此时 \(J_k\) 含一次项)求最优轨迹,并说明 \(\mu\) 越大轨迹怎样变化。
-
Almgren–Chriss 与平方根冲击。 把临时冲击改为 \(h(v)=\eta|v|^{1/2}\operatorname{sgn}(v)\),目标不再是二次的。用
scipy.optimize.minimize数值求解最优轨迹,与线性冲击的解比较。 -
限价单的价格选择。 扩展代码 7.6:限价可以挂在买一或买一减一 tick(成交概率更低、但成交价更好)。在不同消息概率下,哪个价位最好?
-
订单簿模拟器的校准。 改变代码 7.7 中的市价单比例
P_MKT(0.10、0.15、0.20),观察价差、深度和"成交后漂移"如何变化。哪个参数最影响逆向选择的大小?
延伸阅读
- 第 07 册第 04a 章(市价单与限价单)、第 06 章(撮合规则)、第 10 章(知情交易者与 Kyle 模型)、第 14 章(买卖价差、Glosten–Milgrom、Roll 模型)、第 15 章(大宗交易者)、第 18 章(买方交易者与订单暴露)、第 19 章(流动性)、第 21a 章(价格基准法、执行差额)、第 21b 章(计量估计与成本预测)。
- 第 06 册第 05 章(高频数据与 Roll 模型)。
- 第 04 册第 16a、16b 章(二次规划);第 09 册第 06 章与第 10 章(优先队列与基本数据结构,订单簿的实现基础)。
- 本册第 05 章(成本惩罚)、第 06 章(回测中的成本与容量)。
- Perold, A. (1988). The Implementation Shortfall: Paper versus Reality. Journal of Portfolio Management.
- Roll, R. (1984). A Simple Implicit Measure of the Effective Bid-Ask Spread in an Efficient Market. Journal of Finance.
- Kyle, A. (1985). Continuous Auctions and Insider Trading. Econometrica.
- Glosten, L., & Milgrom, P. (1985). Bid, Ask and Transaction Prices in a Specialist Market with Heterogeneously Informed Traders. Journal of Financial Economics.
- Bertsimas, D., & Lo, A. (1998). Optimal Control of Execution Costs. Journal of Financial Markets.
- Almgren, R., & Chriss, N. (2000). Optimal Execution of Portfolio Transactions. Journal of Risk.
- Almgren, R., Thum, C., Hauptmann, E., & Li, H. (2005). Direct Estimation of Equity Market Impact. Risk.
- Cont, R., Stoikov, S., & Talreja, R. (2010). A Stochastic Model for Order Book Dynamics. Operations Research.
- Obizhaeva, A., & Wang, J. (2013). Optimal Trading Strategy and Supply/Demand Dynamics. Journal of Financial Markets.