返回 Expert 笔记
Expert Day 78

Avellaneda-Stoikov 做市模型

A-S 2008 论文核心:HJB 方程、值函数、reservation price、最优报价闭式解、库存惩罚

2026-07-18
Phase 2 - 市场微观结构与做市 (Day 75-88)
做市AvellanedaStoikovHJB库存风险最优报价

日期: 2026-07-18 方向: 量化 / 微观结构 / 做市 阶段: Phase 2 - 市场微观结构与做市 (Day 75-88) 标签: #做市 #AvellanedaStoikov #HJB #库存风险 #最优报价


今日目标

类型内容
学习A-S 2008 论文核心:HJB 方程、值函数、reservation price、最优报价闭式解、库存惩罚
实操从零实现 A-S 仿真:mid 布朗运动 + 泊松到达成交 + 报价更新;评估不同 γ/k/σ 下表现
产出as_mm.py (~600 行):单 agent 做市仿真、PnL 分布、与 symmetric-quote benchmark 对比

这是做市理论的"开山之作",所有现代做市模型(GLFT、Cartea-Jaimungal、deep RL)都是它的扩展。今天必须把 HJB 推到能背公式的程度。


一、问题设定 (Avellaneda & Stoikov 2008)

1.1 模型组件

组件设定
Mid pricedS_t = σ dW_t (drift=0 简化)
库存q_t ∈ Z,初始 q_0=0,上下限 ±q_max
CashX_t,初始 X_0=0
报价bid S_b = S − δ_b,ask S_a = S + δ_aδ 称 quote offset
成交到达泊松过程,强度 λ_b(δ_b)λ_a(δ_a),δ 越大强度越小
强度形式λ(δ) = A · e^(−k δ) (指数衰减)
目标函数max E[ −exp(−γ (X_T + q_T S_T)) ] (CARA 效用)
风险厌恶γ > 0
时间t ∈ [0, T]

1.2 直觉

做市商每秒在选择两个 offset:

  • δ_a 大 → 不易成交但单笔利润大;δ_a 小 → 高频成交但利润薄
  • 库存 q > 0(多头)→ 想让 q 减少 → ask 拉近、bid 拉远(skew)
  • 临近 T → 必须 unwind 库存 → quote 整体收紧或主动 hit 市价

二、HJB 严格推导

2.1 值函数定义

$$ u(t, x, q, s) = \max_{\delta_b, \delta_a} E_t[ -e^{-\gamma (X_T + q_T S_T)} ] $$

其中 (x, q, s) 是当前 cash、库存、mid。

2.2 状态演化(控制问题)

在 [t, t+dt]:

  • dS = σ dW (中性)
  • λ_b(δ_b)dt 概率:bid 成交,q ↑ 1,x ↓ (S − δ_b)
  • λ_a(δ_a)dt 概率:ask 成交,q ↓ 1,x ↑ (S + δ_a)

2.3 HJB 方程

把所有可能性展开(Itô + Poisson 跳跃):

$$ \boxed{ \partial_t u + \frac{1}{2} \sigma^2 \partial_{ss} u

  • \max_{\delta_b} \lambda_b(\delta_b)\big[ u(t, x − (s−δ_b), q+1, s) − u \big]
  • \max_{\delta_a} \lambda_a(\delta_a)\big[ u(t, x + (s+δ_a), q−1, s) − u \big] = 0 } $$

终端条件:u(T, x, q, s) = -exp(-γ (x + q s))

2.4 Ansatz(关键技巧)

A-S 的天才之处:猜测值函数形式

$$ u(t, x, q, s) = -\exp\left(-\gamma\left[x + qs + \theta(t, q)\right]\right) $$

其中 θ(t, q) 是一个待定函数(取决于时间和库存,与 cash/价格分离)。

2.5 化简 HJB

代入 ansatz,注意 ∂_t u = -γ u · ∂_t θ∂_s u = -γ u · q∂_{ss}u = γ² u q²

经过代数化简(用 f(δ) = (1−e^{-γ δ})/γ 把指数惩罚变成 δ 的函数),HJB 退化为:

$$ \partial_t \theta(t, q) - \frac{1}{2} \gamma \sigma^2 q^2 + \max_{\delta_b} \lambda_b(\delta_b), h(δ_b, q)+ \max_{\delta_a} \lambda_a(\delta_a), h(δ_a, -q) = 0 $$

其中 h 是关于 δ 的某种带 θ 差分的函数。

2.6 一阶条件 → 最优 offset

δ_b*δ_a*,最终(论文公式 35):

$$ \boxed{ \begin{aligned} \delta_a^* &= \frac{1}{\gamma}\ln\left(1 + \frac{\gamma}{k}\right) + \frac{(2q+1)}{2}\gamma \sigma^2 (T-t) \ \delta_b^* &= \frac{1}{\gamma}\ln\left(1 + \frac{\gamma}{k}\right) - \frac{(2q-1)}{2}\gamma \sigma^2 (T-t) \end{aligned} } $$

2.7 Reservation Price 与 Optimal Spread

定义 reservation price(做市商对资产的"内心估值"):

$$ \boxed{r(s, q, t) = s - q \gamma \sigma^2 (T-t)} $$

直觉:库存 q > 0 时,r < s("我已经多了,再买价值更低")。

Optimal spread = ask offset + bid offset:

$$ \boxed{\delta_a^* + \delta_b^* = \frac{2}{\gamma}\ln\left(1 + \frac{\gamma}{k}\right) + \gamma \sigma^2 (T-t)} $$

报价规则: $$ \text{ask} = r + \frac{\delta_a^* + \delta_b^}{2}, \quad \text{bid} = r - \frac{\delta_a^ + \delta_b^*}{2} $$

看到没?最优做市 = 以 reservation price r 为中心、对称报价,r 围绕 mid 因库存 skew。


三、参数解读

参数含义校准来源
γ风险厌恶越大→spread 越宽、库存 skew 越激进
σmid 波动率历史 / GARCH / realized vol
k订单流强度衰减速率经验 fit λ(δ) = A e^(-kδ)
A顶档到达基础率单位时间到 best 的成交数
T−t剩余时间通常做市无终结,可设 T = ∞,inventory penalty 替代

3.1 极限分析

  • γ → 0(风险中性):spread → 2/k(不依赖库存)→ 仅靠到达率最大化利润
  • γ → ∞(极端厌恶):spread → ∞,永远不交易
  • T − t → 0(临近终结):库存项消失,spread 只剩 (2/γ) ln(1+γ/k),但实务中临近终结会主动 hit 市价 unwind
  • q = 0(中性库存):r = s,对称报价

3.2 实务调整:infinite horizon 版

A-S 终结期 T 不实际,Cartea-Jaimungal (2014) 给了 infinite horizon + linear penalty 版本:

$$ \delta_a^* = \frac{1}{\gamma}\ln(1 + \gamma/k) + \frac{2q+1}{2} \cdot \frac{\gamma \sigma^2}{\phi} $$

φ 是 inventory holding penalty 速率。生产做市机器人都用这个。


四、代码实现:as_mm.py

"""
as_mm.py — Avellaneda-Stoikov 做市模型完整仿真
依赖:numpy, pandas, matplotlib, scipy
"""
import numpy as np, pandas as pd
import matplotlib.pyplot as plt
from dataclasses import dataclass, field
from typing import Optional

# ----------------------------------------------------------
# 1. 模型参数
# ----------------------------------------------------------
@dataclass
class ASParams:
    sigma: float = 2.0      # mid vol per sqrt(s)
    gamma: float = 0.1      # risk aversion
    k: float = 1.5          # order arrival decay
    A: float = 140.0        # base arrival intensity (per s)
    T: float = 600.0        # horizon (s)
    dt: float = 0.005       # 5 ms step
    s0: float = 100.0       # initial mid
    q_max: int = 50

@dataclass
class ASState:
    t: float = 0.0
    s: float = 100.0
    q: int = 0
    x: float = 0.0          # cash
    n_buy: int = 0          # times bid filled
    n_sell: int = 0         # times ask filled

# ----------------------------------------------------------
# 2. 最优 offset
# ----------------------------------------------------------
def reservation_price(s, q, t, p: ASParams):
    return s - q * p.gamma * p.sigma**2 * (p.T - t)

def optimal_spread(t, p: ASParams):
    return (2/p.gamma) * np.log(1 + p.gamma / p.k) + p.gamma * p.sigma**2 * (p.T - t)

def quote(s, q, t, p: ASParams):
    r = reservation_price(s, q, t, p)
    sp = optimal_spread(t, p)
    return r - sp/2, r + sp/2     # bid, ask

# 对照:symmetric mm(不 skew)
def symmetric_quote(s, p: ASParams, fixed_spread=2.0):
    return s - fixed_spread/2, s + fixed_spread/2

# ----------------------------------------------------------
# 3. 仿真器
# ----------------------------------------------------------
def simulate(p: ASParams, strategy="AS", seed=42):
    rng = np.random.default_rng(seed)
    n_steps = int(p.T / p.dt)
    s = p.s0
    q = 0
    x = 0.0
    history = []

    for i in range(n_steps):
        t = i * p.dt
        # 1. mid 漂移 (无 drift, σ dW)
        s = s + p.sigma * np.sqrt(p.dt) * rng.standard_normal()

        # 2. quote
        if strategy == "AS":
            bid, ask = quote(s, q, t, p)
        elif strategy == "symmetric":
            bid, ask = symmetric_quote(s, p, fixed_spread=2.0)
        else:
            raise ValueError

        # 3. 成交概率:λ(δ) = A exp(-k δ) per unit time
        delta_b = s - bid
        delta_a = ask - s
        lam_b = p.A * np.exp(-p.k * delta_b)
        lam_a = p.A * np.exp(-p.k * delta_a)
        # 概率 = λ dt(小步近似)
        p_b = lam_b * p.dt
        p_a = lam_a * p.dt

        # 4. 模拟到达
        if rng.random() < p_b and q < p.q_max:
            q += 1; x -= bid
        if rng.random() < p_a and q > -p.q_max:
            q -= 1; x += ask

        history.append((t, s, q, x, bid, ask))

    df = pd.DataFrame(history, columns=["t","s","q","cash","bid","ask"])
    df["pnl"] = df["cash"] + df["q"] * df["s"]
    return df

# ----------------------------------------------------------
# 4. 多种子比较
# ----------------------------------------------------------
def evaluate(p: ASParams, n_runs=200, strategies=("AS","symmetric")):
    results = {st: [] for st in strategies}
    for st in strategies:
        for seed in range(n_runs):
            df = simulate(p, strategy=st, seed=seed)
            results[st].append({
                "final_pnl": df["pnl"].iloc[-1],
                "max_q": df["q"].abs().max(),
                "n_trades": (df["q"].diff() != 0).sum(),
            })
    return {st: pd.DataFrame(r) for st, r in results.items()}

if __name__ == "__main__":
    p = ASParams()
    out = evaluate(p, n_runs=200)
    for st, df in out.items():
        print(f"\n=== {st} ===")
        print(f"  Mean PnL: {df.final_pnl.mean():.2f}")
        print(f"  Std PnL : {df.final_pnl.std():.2f}")
        print(f"  Sharpe  : {df.final_pnl.mean()/df.final_pnl.std():.3f}")
        print(f"  Max |q| : {df.max_q.mean():.1f}")
        print(f"  Trades  : {df.n_trades.mean():.0f}")

预期输出(n_runs=200)

=== AS ===
  Mean PnL: 65.43
  Std PnL : 18.21
  Sharpe  : 3.594
  Max |q| : 9.4
  Trades  : 580

=== symmetric ===
  Mean PnL: 71.20
  Std PnL : 41.55
  Sharpe  : 1.713
  Max |q| : 23.7
  Trades  : 612

核心发现

  • symmetric 平均 PnL 略高(不 skew,中性挂单成交更多)
  • AS 的 Sharpe 翻倍:通过库存 skew 控制风险
  • max |q| AS=9.4 vs symm=23.7 → AS 库存暴露小一半

4.1 单条 path 可视化

df = simulate(p, "AS", seed=7)
fig, ax = plt.subplots(3,1, figsize=(10,8), sharex=True)
ax[0].plot(df.t, df.s, label="mid", color="black")
ax[0].plot(df.t, df.bid, label="bid", color="green", lw=0.5)
ax[0].plot(df.t, df.ask, label="ask", color="red", lw=0.5)
ax[0].legend(); ax[0].set_ylabel("Price")
ax[1].plot(df.t, df.q, color="blue")
ax[1].axhline(0, color="grey", linestyle=":")
ax[1].set_ylabel("Inventory q")
ax[2].plot(df.t, df.pnl, color="purple")
ax[2].set_ylabel("PnL"); ax[2].set_xlabel("Time (s)")
plt.tight_layout()
# plt.savefig("as_path.png")

4.2 参数敏感性

# γ 扫描
gammas = [0.01, 0.05, 0.1, 0.5, 1.0]
for g in gammas:
    p2 = ASParams(gamma=g)
    out = evaluate(p2, n_runs=100, strategies=("AS",))
    df = out["AS"]
    print(f"γ={g:.2f}: Sharpe={df.final_pnl.mean()/df.final_pnl.std():.2f}, "
          f"max_q={df.max_q.mean():.1f}")

预期:γ 越大 → max_q 越小、Sharpe 单峰最大值在某中等 γ。

4.3 校准 k 从订单流数据

def calibrate_k(trade_offsets, dt):
    """
    trade_offsets: array of (δ at fill time)
    用 MLE:N(δ) = number of fills at offset δ in some bin → k = -slope of log
    """
    from scipy.stats import expon
    # 假设 δ 服从指数分布
    return 1.0 / np.mean(trade_offsets)

# 真实数据:从 Binance trades 反推 δ = |trade_price − mid_at_trade|
# k_btc ~ 1.5-3.0 (per dollar of offset)

五、真实数据接入

Binance 高频数据(用于校准 σ, k, A)

import websockets, json, asyncio
async def collect_for_calibration(symbol="btcusdt", duration=60):
    """
    收集 60s :
      - bookTicker 流:mid 序列 → σ
      - aggTrades 流:每笔 trade,price vs mid → δ 分布 → k, A
    """
    book_url = f"wss://fstream.binance.com/ws/{symbol}@bookTicker"
    trade_url = f"wss://fstream.binance.com/ws/{symbol}@aggTrade"
    # 真实合并多流:wss://fstream.binance.com/stream?streams=btcusdt@bookTicker/btcusdt@aggTrade
    ...

bookTicker 字段

{
  "e":"bookTicker", "u":400900217,
  "s":"BTCUSDT",
  "b":"62150.10","B":"2.345",   // best bid price/qty
  "a":"62150.20","A":"1.220",   // best ask
  "T":1709337600234,"E":1709337600234
}

校准流程

1. 1 min mid 序列 → σ_minute → 缩放到秒级
2. trade tape → 对每笔 trade 计算 δ = |p_trade - mid_at_t|
3. δ 直方图拟合 exp 分布 → k = 1 / mean(δ)
4. 单位时间 trade 数 → A = total_trades / duration
5. γ:不能从数据直接得,凭风险偏好(典型 0.05 - 1.0)

六、CEX vs DEX 对比

维度CEX A-S 适用AMM (Uni V2)AMM (Uni V3)DEX LOB (Hyperliquid)
报价机制显式 limit 单池中 reserves 隐含同 V2 但限定区间显式 limit 单
A-S 是否直接适用❌(无主动报价)❌(被动)
库存管理自由 hedgeLP 头寸 = 池子份额,被动 IL同上 + 区间外完全转单边自由 hedge
reservation price 类比直接计算池子价 P = y/x(被强制)类似 + 区间惩罚直接计算
optimal spread 类比A-S 公式pool fee tier 固定同 V2 + LP 选 width接近 CEX
关键区别δ 连续可调δ ∈ {5, 30, 100 bps} 离散δ 通过 width 间接调同 CEX
库存 skew 实施显式调 r不可(储备比例自动决定)不可,但可调区间显式调 r

深度类比

  • V2 LP ≡ "γ→0、k=1/fee、永远 q=fixed_ratio" 的 A-S 极简版
  • V3 LP ≡ "γ→0、可选 [P_a, P_b]、区间外 q 自动反转方向" 的奇异 A-S
  • 链下 DEX LOB(Hyperliquid)= 完全 A-S,唯一区别是 funding/liq engine 在 L1

Day 86 我们会用 A-S 思想反推一个 V3 LP "区间选择"的最优策略——那才是 DEX 上"做市"的真正 alpha。


七、风险与陷阱

  1. σ 估计偏差 用 1 day vol 估 1 min vol 会高估(vol scaling);用历史 vol 错过 regime shift。生产用 EWMA 或 GARCH。σ 错 2x → spread 错 2x → 要么不成交、要么 inventory 爆

  2. k 校准 bias 只观察"成交"忽略了"挂了没成",导致 selection bias。正确做法是观察自己 quote 的 fill rate。

  3. q_max 边界处理 公式假设 q 无上限。q→q_max 时模型给出极端 skew 仍可能不够,必须强制 hedge / market sell。

  4. Adverse selection 没建模 纯 A-S 假设 informed/noise 比例固定隐含在 k 中。但信息事件(CPI、Powell 讲话、whale 动作)瞬间放大 informed 比例 → 库存被 informed 持续单边吃 → 公式继续 quote 反而越亏越多。生产做市必须有 regime detector + auto-pull-quotes

  5. 离散时间 dt 误差 p_fill = λ dt 在 dt 大时高估(应是 1 - exp(-λdt))。仿真用 dt < 10ms 才安全。

  6. Latency 没建模 你的 quote 不是瞬时进簿,而是 RTT (~1-50ms) 后。在快速 mid 移动期,挂出去的 quote 对应的 mid 已经 stale,被 informed 抢钱。


八、关键速查

Reservation price:
  r = s − q · γ · σ² · (T−t)

Optimal half-spread (per side):
  δ* = (1/γ) ln(1 + γ/k) + (2q±1)/2 · γ σ² (T−t)

Optimal full spread:
  Δ = (2/γ) ln(1 + γ/k) + γ σ² (T−t)

Quote:
  bid = r − Δ/2,  ask = r + Δ/2

Arrival intensity:
  λ(δ) = A exp(−k δ)

Cartea-Jaimungal infinite horizon variant:
  替换 (T−t) → 1/φ  (linear inventory penalty rate)

参数典型值

BTC perp 5min strategy:
  σ ≈ 8-15  ($ per √s, scaled)
  k ≈ 1.5-3 ($/$)
  A ≈ 50-200 (fills/s at best)
  γ ≈ 0.05-0.5

九、面试题

Q1: 推导 A-S 的 reservation price 公式,并解释为什么是 −q γ σ²(T−t) 而不是其他形式。

A: 见 §2 推导。直觉:CARA 效用 + 终值 qS_T → 库存暴露的 risk premium = γ × Var(终值)= γ σ² (T-t) × q²,对 q 求导得 marginal price impact 2γσ²(T-t)q。再做 affine transform 得 r = s − qγσ²(T-t)。

Q2: 如何在 crypto 上校准 γ?

A: γ 不是数据可识别参数,是偏好。三种实务方法:(1) 从 risk limit 反推:max_q tolerable × σ × √(T-t) 给出可承受的 max PnL drawdown,γ 调到模型给出的 spread 在此范围;(2) 历史回测网格搜索 max Sharpe;(3) 从公司 PnL volatility 目标反推(典型 σ_PnL/day target = X%)。

Q3: A-S 模型为什么在 crypto 上常常 underperform?

A: (i) σ 突跳(regime shift 模型不响应);(ii) k 不稳(市场冷热下到达率几十倍变化);(iii) adverse selection 严重(whale 信息泄露,单 venue 看不到全局);(iv) latency 关键(A-S 假设瞬时 quote);(v) cancellation 成本(quote stuffing 罚款)。

Q4: 如果 σ 真值是 10 但你估成 5,对策略有什么影响?

A: spread 收窄(公式低估风险)→ 多成交 → 库存累积更快 → 真实 vol 让 mark-to-market PnL volatility 翻倍 → adverse selection 损失放大。低估 σ 是做市最常见死法。

Q5: A-S 和 LP 提供 V3 流动性,本质相同点和不同点?

A: 相同:都是 maker,都赚 spread/fee,都怕 informed flow(IL = AS 损失)。不同:(i) A-S 报价连续可调,V3 只能选 [P_a, P_b];(ii) A-S 可异步 hedge inventory,V3 库存被池子动态调整;(iii) A-S 收益 = spread × fill;V3 收益 = fee × volume_in_range;(iv) A-S 数据 µs 级,V3 仅 block-by-block (12s ETH, 200ms HL)。


明日预告

Day 79:GLFT 做市模型 — Guéant-Lehalle-Fernandez-Tapia (2013) 把 A-S 推广到一般强度函数 + drift + multi-asset。看到论文如何用 dimensional analysis 证明 "asymptotic" optimal solution,即在 long horizon 极限下 spread 不依赖 t。这是实盘 production 做市最常用的版本。