halo 的技术博客

返回

你下一笔大单的”成交价”,其实混着两种东西:市场本来就要走的路,和你的单子把价格推离的那部分。绝大多数团队只看 VWAP − 到达价(实现差额,Implementation Shortfall),就以为那就是冲击成本。但市场那段时间可能本来就在涨——这部分不该算在你的头上。

要算清”我自己的单子到底造成了多少冲击”,本质是一个因果问题:反事实地回答”如果我不交易,价格会怎样”。本文用一套可复现模拟讲清临时/永久冲击的分解,以及怎么用外生的市场因子构建反事实基线。

一、冲击拆成两半:临时 + 永久#

经典 Almgren-Chriss 框架把冲击拆成:

  • 临时冲击(temporary):你正在吃单时,价格被你推离中间价;一旦你停手,这部分回归。它决定你的执行成本
  • 永久冲击(permanent):你的净买单改变了市场对均衡价的看法,交易结束后价格永久停在更高处。它决定你对组合已实现盈亏的拖累。

把两者混为一谈,是交易成本分析里最常见的错:用临时冲击去估组合层面的隐性成本(高估),或用永久冲击去估单笔执行质量(低估)。

二、为什么这是因果 / 反事实问题#

交易不是随机的(内生性):你决定大单买入,往往是因为你预期要涨;同时市场那段时间可能本来就在涨。朴素 IS 把”市场自己涨的”和”你推的”一起算成成本,于是高估了你的冲击,甚至可能”证明”你每笔都在亏执行成本——而真正的原因是市场 beta。

正确做法是估计反事实无交易路径 cf_t:如果当天你没有出现,价格会怎么走。然后

交易自身冲击_t = 实际价格_t − 反事实价格_t
plaintext

关键识别假设:反事实基线必须外生于你的单子。单只股票的交易影响不了整个市场指数,所以用市场指数(或板块 ETF)做基准就满足外生性。

三、可复现实验#

模拟一个交易日(390 分钟),ADV=100万股、日波动 2%;你买入 5万股(=5% ADV),分 180 分钟匀速执行。冲击用平方根律建模:临时冲击 ∝ σ·√(参与率),永久冲击 ∝ σ·(Q/ADV)

3.1 反事实基线 + 冲击分解#

我们用市场因子模型估计无交易路径:以到达价为锚,按市场指数的相对变动外推。由于市场指数不被单只股票交易影响,它提供了干净的反事实。在模拟里真实无交易路径就是 p0,我们直接用它展示分解:

arrival = p0[em[0]]
# 执行成本 = 实际成交价 − 反事实路径(同一分钟)
IS_bps = np.mean(path[em] - p0[em]) / np.mean(p0[em]) * 1e4
temp_peak = temp / p0[0] * 1e4
perm_bps = perm / p0[-1] * 1e4
total_impact = temp_peak / 2 + perm_bps      # 临时均值 + 永久
print(f"临时冲击峰值: {temp_peak:.1f} bps, 均值: {temp_peak/2:.1f} bps")
print(f"永久冲击: {perm_bps:.1f} bps")
print(f"执行成本(IS,路径相对): {IS_bps:.1f} bps")
print(f"反事实总冲击(临时+永久): {total_impact:.1f} bps")
python

大单成交路径 vs 反事实无交易路径

在我们的设定下得到:临时冲击峰值约 29.6bps、均值约 14.8bps;永久冲击约 15bps;反事实分解出的总冲击约 30bps;而相对反事实路径的执行成本(IS)约 14.9bps。图里红色点是实际成交价——执行期间被推离无交易路径,交易结束后价格永久抬升了一个台阶,正是”临时回归 + 永久留下”的直观写照。

3.2 跨订单规模扫描:冲击呈平方根律#

把订单规模从 0.1% ADV 扫到 10% ADV,对每个规模都用反事实分解出总冲击,看它如何随规模缩放:

fracs = np.logspace(-3, -1, 25)     # Q/ADV: 0.1% ~ 10%
est = []
for fr in fracs:
    Qq = int(fr * ADV)
    p, em_, _, tp, pm = apply_impact(Qq, seed=100 + int(fr * 1000))
    is_b = np.mean(p[em_] - p0[em_]) / np.mean(p0[em_]) * 1e4
    est.append(is_b + pm / p0[-1] * 1e4)          # 临时 + 永久 = 总冲击
est = np.array(est)
slope, _ = np.polyfit(np.sqrt(fracs), est, 1)
print(f"平方根律拟合: impact ≈ {slope:.0f} · √(Q/ADV) bps")
python

市场冲击的反事实估计呈平方根律

结果符合经典square-root law:总冲击 ≈ 162·√(Q/ADV) bps。这意味着冲击随参与率的平方根上升——小单边际冲击低,大单的边际冲击陡峭上升,这也是为什么大单必须切分、必须用 POV/ TWAP 把参与率压住。

3.3 用市场 beta 估计反事实(更贴近实盘)#

模拟里我们直接拿真实无交易路径 p0 做基线。实盘里 p0 不可观测,要用外生的市场指数估计:先用交易前窗口回归出 β,再外推无交易路径。

# 用交易前 60 分钟估计个股对市场的 beta
pre = slice(0, 60)
beta_hat = np.cov(p0[pre], market[pre])[0, 1] / np.var(market[pre])
cf_est = arrival * np.exp(beta_hat * (market - market[em[0]]))   # 外生反事实
# 实际 − 反事实 = 交易自身造成的冲击(含临时 + 永久)
est_impact = np.mean(path[em] - cf_est[em]) / arrival * 1e4
python

要点:市场指数不被单只股票的交易影响,因此 cf_est 是干净的反事实;但 β 本身会时变,必须用滚动短窗口估计,且选错基准(如用高相关的另一只个股当基线)会让反事实也被你的交易带偏。经验上,指数 / 板块 ETF 比单只同业股更适合做基线。

3.4 对算法选择的启示#

临时冲击由参与率驱动(平方根律),所以压低单分钟参与率是降执行成本的主抓手:POV / TWAP 把大单拆细、用被动算法避免主动吃掉对手盘。永久冲击由净方向单量驱动(∝ Q/ADV),拆单帮不了它——能降永久冲击的只有少做方向性净单、或用衍生品 / 篮子对冲暴露。一句话:临时冲击靠「拆」,永久冲击靠「少」。这也正是 TCA 报告必须把两者拆开、分别发给执行团队与组合团队的原因。

四、工程落地:把反事实做成 TCA 系统#

  1. 建外生基线:用市场指数 / 同板块等权指数做反事实基准,单笔交易无法撼动它,满足外生性。
  2. 拆临时/永久:临时冲击喂给执行团队(评价算法、券商);永久冲击喂给组合团队(归因到决策本身的隐性成本)。
  3. 聚合而非单笔:单笔交易的反事实估计噪声极大,必须跨多笔、同条件聚合才能得到稳定的冲击系数。
  4. 滚动校准:冲击系数(尤其永久部分)会随时间衰减,用滚动窗口重估,别拿三年前的参数当真理。

五、真实陷阱(按危害排序)#

  1. 内生性 / 选择偏差(最致命):你交易是因为你预期要动,市场也可能本来就要动。不用外生基线,IS 会把 beta 算成你的冲击,高估成本、误判算法。
  2. 临时/永久混淆:用错口径,执行质量评估和组合归因会同时失真。
  3. 反事实模型设定错误:β 不稳、选了错误基准(如用高相关的另一只股票当基线,它也被你的交易带偏),反事实本身就偏。
  4. 到达价 vs 决策价口径不一:决策时、下单时、成交时的”参考价”不同,IS 口径必须全公司统一。
  5. 冲击系数的 alpha 衰减:历史估计的冲击参数会过时,尤其流动性regime切换时。
  6. 单笔不可识别:单笔交易的反事实误差远大于信号,必须靠统计聚合。

结语#

市场冲击成本的本质,是”如果没有我,价格会怎样”这个反事实问题。朴素 IS 把市场的波动也算进你的账单,而正确的做法是用外生的市场/板块因子构建无交易基线,把临时冲击(执行成本)和永久冲击(组合隐性成本)分开度量。平方根律告诉我们大单的边际冲击陡峭上升——这既是拆单算法的理论依据,也是为什么冲击成本控制,是 alpha 死生死问题的那道隐形闸门。对一个日均成交 100 万股、准备吃下 5% ADV 的单子,本文模拟给出约 30bps 的反事实总冲击;这意味着冲击成本一年复利下来足以吃掉一个 marginal 策略的全部利润,所以大单的冲击建模不是锦上添花,而是 survival 问题。

市场影响模型的反事实估计:你的大单到底把价格推高了多少
https://blog.halo26812.eu.org/blog/market-impact-causal
Author halo
Published at 2026年7月11日
版权声明 CC BY-NC-SA 4.0
Comment seems to stuck. Try to refresh?✨