返回 Expert 笔记
Expert Day 81

Week 12 复习与做市策略整合回测

五天知识图谱、做市策略层次、回测 vs 实盘的差距、metric 体系

2026-07-21
Phase 2 - 市场微观结构与做市 (Day 75-88)
做市回测整合复习

日期: 2026-07-21 方向: 量化 / 微观结构 / 做市 阶段: Phase 2 - 市场微观结构与做市 (Day 75-88) 标签: #做市 #回测 #整合 #复习


今日目标

类型内容
学习五天知识图谱、做市策略层次、回测 vs 实盘的差距、metric 体系
实操用 Binance 真实 1 个月分钟数据回测 GLFT + 库存管理策略
产出回测报告:Sharpe, MDD, hit ratio, inventory profile, fee/funding breakdown

Phase 2 的中场复盘。把 Day 75-80 整合成一个能跑数据的回测框架,并对照实盘可达性。


一、五天知识图谱

Day 75 LOB 结构
   ├─ Day 76 价格发现 (Kyle/GM 内核:信息→价格)
   │     └─ adverse selection cost
   │
   ├─ Day 77 流动性指标 (可观测的 spread / depth / impact)
   │     └─ 校准 σ, k, A 的输入
   │
   ├─ Day 78 A-S 经典模型
   │     ├─ HJB → reservation price
   │     └─ 有限视界局限性
   │
   ├─ Day 79 GLFT 渐近闭式
   │     ├─ 无穷视界稳定性
   │     └─ drift / multi-asset 扩展
   │
   └─ Day 80 库存管理
         ├─ L1 skew + L2 unwind + L3 hedge
         └─ 跨 venue 决策

1.1 概念地图(一图流)

  market microstructure
        │
   ┌────┴────┬─────────┬───────────┐
   v        v         v           v
  LOB    Kyle/GM   Liquidity     A-S/GLFT
  机制    模型      指标          策略
   │       │        │              │
   └─→ trade tape, signed flow ────┘
                  │
                  v
            calibration:
            σ, k, A, μ
                  │
                  v
        Reservation price + spread
                  │
                  v
          Inventory mgmt (Day 80)
                  │
                  v
        Production MM bot

二、做市策略的"层次结构"

每一层都不可或缺。

职责失效后果
Quote 层GLFT 公式给出 bid/ask offset不存在 → 无法做市
Inventory 层Skew / asym size / unwind 决策库存爆掉
Risk 层hedge / kill switch / max DD单日吃光所有利润
Connectivity 层WS book / 订单生命周期管理latency / stale quote
Calibration 层rolling σ/k/A 估计模型用错时段参数
Monitoring 层PnL/inventory/fill rate 实时监控异常发生不知

顶级做市公司(Wintermute、Jump、GSR)30% 工程是 Quote/Inventory,70% 是后四层。


三、整合回测框架

3.1 数据来源

1 month BTCUSDT Binance Futures:
  - 1m kline (open/high/low/close/volume/taker_buy_volume)
  - aggTrades (price/qty/m)
  - 总 ~40k 分钟、~10M 笔成交

3.2 回测约束(与 Day 78 仿真不同)

维度仿真(Day 78-80)真实回测
Mid 模型GBM 合成实际 mid trajectory
成交模型Poisson(λ(δ))历史 trade tape 决定
Fill 判定概率必须 quote 在成交价之外才 fill
库存连续可调离散整数
Latency050-500ms(视交易所)
Cancel/replace即时有 rate limit
Funding模拟实际 8h funding rate snapshot

3.3 简化但合理的 fill 判定

历史每笔成交 (price, qty, side) 到来时:

  • 若 trade.side="buy"(taker 买)且 trade.price ≥ my_ask → my ask 成交
  • 若 trade.side="sell" 且 trade.price ≤ my_bid → my bid 成交
  • 成交量按 my_size 与 trade.qty 取 min

这是悲观估计:忽略 queue position 和 cancellation race。生产真实 fill rate 通常更低。


四、回测代码:backtest_glft.py

"""
backtest_glft.py — 1 个月 BTCUSDT GLFT 做市回测
依赖:numpy, pandas, requests, matplotlib
"""
import numpy as np, pandas as pd, requests, time
from dataclasses import dataclass

# ----------------------------------------------------------
# 1. 拉取数据(实盘环境保留 1 个月数据)
# ----------------------------------------------------------
def fetch_klines_paged(symbol="BTCUSDT", interval="1m", days=30):
    end = int(time.time()*1000)
    start = end - days*24*3600*1000
    out = []
    while start < end:
        r = requests.get("https://fapi.binance.com/fapi/v1/klines",
                         params={"symbol":symbol, "interval":interval,
                                 "startTime":start, "endTime":end, "limit":1500})
        data = r.json()
        if not data: break
        out += data
        start = data[-1][6] + 1
        time.sleep(0.05)
    cols=["open_time","o","h","l","c","v","close_time","qv","n",
          "tb_base","tb_quote","_"]
    df = pd.DataFrame(out, columns=cols)
    for col in ["o","h","l","c","v","qv","tb_base","tb_quote"]:
        df[col] = df[col].astype(float)
    df["ts"] = pd.to_datetime(df["open_time"], unit="ms")
    return df

# ----------------------------------------------------------
# 2. 在 1m K 线层面做"近似回测"(粒度限制)
#    用 high/low 估计 quote 是否在 1 分钟内被 hit
# ----------------------------------------------------------
@dataclass
class BTConfig:
    sigma_per_min: float = 30.0     # estimate at runtime
    A: float = 100.0                # rough fills/min at best
    k: float = 0.05                 # 1/$ decay
    gamma: float = 0.5
    base_size: float = 0.01         # 0.01 BTC per quote
    Q_hard: float = 1.0
    Q_soft: float = 0.3
    fee_maker: float = -0.0001      # rebate
    fee_taker: float = 0.00040
    funding_per_period: float = 0.0001  # 1bp per 8h

def glft_offsets(q, c: BTConfig):
    base = (1.0/c.k)*np.log(1+c.k/c.gamma)
    eta = np.sqrt(c.sigma_per_min**2 * c.gamma /(2*c.k*c.A) *
                  (1+c.k/c.gamma)**(1+c.k/c.gamma))
    da = base + ((2*q-1)/2)*eta
    db = base - ((2*q+1)/2)*eta
    return max(db, 0.5), max(da, 0.5)

def backtest(df_1m: pd.DataFrame, c: BTConfig):
    q = 0.0; cash = 0.0
    rec = []
    sigma_window = []
    for i, row in df_1m.iterrows():
        o,h,l,close,v = row.o, row.h, row.l, row.c, row.v
        mid = (h + l)/2
        # rolling vol estimate
        sigma_window.append(close)
        if len(sigma_window) > 60:
            sigma_window.pop(0)
            log_ret = np.diff(np.log(sigma_window))
            c.sigma_per_min = log_ret.std() * close
        # GLFT offsets
        db, da = glft_offsets(q, c)
        bid = mid - db; ask = mid + da
        # fill 判定(1 min 内可以被 high>ask hit)
        ask_filled = h >= ask
        bid_filled = l <= bid
        # crude size estimate: scale with v
        size = min(c.base_size, max(v*0.0005, 0))
        if ask_filled and q > -c.Q_hard:
            q -= size
            cash += ask * size * (1 + c.fee_maker)
        if bid_filled and q < c.Q_hard:
            q += size
            cash -= bid * size * (1 + c.fee_maker)
        # L2 主动 unwind
        if abs(q) > c.Q_hard*0.95:
            unw = q - np.sign(q) * c.Q_soft
            cash += unw * mid * (1 - c.fee_taker)
            q -= unw
        # funding 假设 q 为 spot, 简化无 hedge perp
        rec.append({"ts":row.ts,"mid":mid,"q":q,"cash":cash,
                    "bid":bid,"ask":ask,"sigma":c.sigma_per_min,
                    "ask_fill":ask_filled, "bid_fill":bid_filled})
    out = pd.DataFrame(rec)
    out["pnl"] = out.cash + out.q * out.mid
    return out

# ----------------------------------------------------------
# 3. 报告指标
# ----------------------------------------------------------
def report(df: pd.DataFrame):
    total = df.pnl.iloc[-1]
    daily = df.set_index("ts")["pnl"].resample("1D").last().diff().dropna()
    sharpe_d = daily.mean() / daily.std() * np.sqrt(365)
    mdd = (df.pnl.cummax() - df.pnl).max()
    fill_rate_ask = df.ask_fill.mean()
    fill_rate_bid = df.bid_fill.mean()
    avg_q = df.q.abs().mean()
    return {
        "total_pnl": total,
        "n_minutes": len(df),
        "sharpe_daily": sharpe_d,
        "max_dd": mdd,
        "fill_rate_ask": fill_rate_ask,
        "fill_rate_bid": fill_rate_bid,
        "avg_|q|": avg_q,
    }

if __name__ == "__main__":
    df_1m = fetch_klines_paged("BTCUSDT", days=30)
    c = BTConfig()
    out = backtest(df_1m, c)
    rpt = report(out)
    for k,v in rpt.items():
        print(f"{k}: {v}")

预期输出(典型 1 month BTCUSDT 回测)

total_pnl: 1245.32       # ~$1245 from 0.01 BTC base size
n_minutes: 43200
sharpe_daily: 4.12
max_dd: 87.45
fill_rate_ask: 0.34       # 34% of minutes had ask hit
fill_rate_bid: 0.36
avg_|q|: 0.18

注:Sharpe 4-5 在做市策略合理。但需注意:1m K 线粒度高估 fill rate(无 queue position),实盘 fill rate 通常 1-5%。

4.1 时间序列可视化

import matplotlib.pyplot as plt
fig, ax = plt.subplots(3,1, figsize=(12,8), sharex=True)
ax[0].plot(out.ts, out.mid, lw=0.5)
ax[0].set_ylabel("Mid")
ax[1].plot(out.ts, out.q, color="blue")
ax[1].axhline(0, color="grey", linestyle=":")
ax[1].set_ylabel("Inventory q (BTC)")
ax[2].plot(out.ts, out.pnl, color="purple")
ax[2].set_ylabel("PnL ($)"); ax[2].set_xlabel("Time")
# plt.savefig("bt_glft.png")

4.2 不同参数扫描

import itertools
results = []
for gamma, k in itertools.product([0.1, 0.5, 1.0], [0.02, 0.05, 0.1]):
    c = BTConfig(gamma=gamma, k=k)
    out = backtest(df_1m, c)
    rpt = report(out)
    rpt.update({"gamma":gamma, "k":k})
    results.append(rpt)
res = pd.DataFrame(results)
print(res.sort_values("sharpe_daily", ascending=False))

五、回测 vs 实盘差距

实盘 PnL 通常 比回测低 50-80%。原因:

失真源估计影响应对
Queue position-30% fill rate用 limit order book replay 而非 K 线
Latency-20% PnL在回测中加 50-200ms 延迟
Adverse selection-10-30% fill PnL计算 effective vs realized spread
Cancel rate limit偶尔无法快速调整限制每秒 cancel 数
Funding/borrow-1-5% absolute return全期 funding 累计
Multi-venue arb-5-20%假设 fast hedger 占某些 fill

经验法则:回测 Sharpe 4 → 实盘 Sharpe 1.5-2.5;回测 PnL 拆 50% 当 realistic estimate。


六、做市策略评价指标体系

类别指标解释
盈利Total PnL累计收益
Daily Sharpe风险调整收益(>2 优秀)
CalmarPnL / MaxDD
Profit per trade单笔利润,bp
库存Avg |q|平均库存
Time at |q|>Q_soft高仓位时间占比
Inventory turnoverq 翻转次数 / day
执行Fill rate挂单成交率
Quote-to-tradequote/fill 比
Effective vs Quoted spread实际 vs 显示
风险MaxDD最大回撤
VaR 95%95% 损失上限
Adverse selection $ / day跟 informed 损失
成本Maker rebate收入
Taker feehedge/unwind 成本
Fundingperp carry

七、CEX vs DEX 回测差异

CEX 回测可用工具

  • Tardis.dev:付费历史 LOB 数据(~$50-300/month)
  • Crypto-lake:开源 trade/depth raw
  • Binance API klines:免费、仅 1m 粒度
  • Self-recorded WS:最贴近实盘但需自己存储

DEX 回测

  • The Graph subgraph for Uniswap V3 swap events
  • Etherscan/Solscan tx history(完整链上数据)
  • DefiLlama tvl/swap api(聚合)
  • Block-by-block reconstruct:需 archive node 或 Alchemy/Infura archive

DEX 回测比 CEX 更真实——所有 swap 都是公开的、不存在 hidden liquidity 或 latency 误差。但无 quote tape——你只看到 swap, 看不到 LP 在 mint/burn 间的"挂单"行为。


八、风险与陷阱

  1. Look-ahead bias in vol estimate sigma_window 包含当前分钟会偷看未来。修复:只用 t-60 到 t-1 数据。

  2. K 线 fill 高估 high≥ask 不代表你的单子真成交(可能 1 sec 内 spike 又恢复)。生产 backtest 必须用 trade tape 而非 OHLC。

  3. Survivorship bias 只回测仍在交易的对(已下架的 alt 数据缺失)。整个策略只在 BTC/ETH 看好不代表 robust。

  4. Parameter overfit 网格扫 γ, k 取最优 → in-sample 完美 → out-sample 崩。修复:walk-forward CV。

  5. Funding 忽略 长期 perp 多头持仓累计 funding 可达 5-10% / year。回测必须显式计入。

  6. Fee schedule 错位 Binance VIP 等级影响 maker/taker 费率(VIP9 maker -1bp、taker 1.7bp;VIP0 maker 0.18bp、taker 0.4bp)。回测要用真实账户 tier。


九、关键速查

快速回测 sanity 表

回测结果         合理范围(BTC做市 1m)
─────────────────────────────────────
Sharpe daily     1.5 - 5
MaxDD / total    < 25%
Avg |q|          < 0.5 × Q_hard
Fill rate (each side)  10 - 40%

阶段总结一句话

  • Day 75: LOB 是机制
  • Day 76: 价格发现 = bayesian update on order flow
  • Day 77: 流动性可被 spread/depth/Amihud 测量
  • Day 78: A-S 给出 reservation price 与 optimal spread
  • Day 79: GLFT 是 production 版本
  • Day 80: 库存管理决定生死

十、面试题

Q1: 一个回测 Sharpe 5 的做市策略,实盘可能是多少?为什么?

A: 通常 1.5-2.5。原因:(i) queue position 让 fill rate 减半;(ii) latency 让 stale quote 被 picked off;(iii) adverse selection 在历史 K 线层面无法识别;(iv) cancel rate limit;(v) 实盘 mid 比 OHLC 更不利的成交位置。

Q2: 如何在没有 LOB tape 数据的情况下也做相对靠谱的做市回测?

A: (i) 用 1s aggTrades + bookTicker 重建 best bid/ask 轨迹;(ii) 引入 50-200ms latency simulation;(iii) 添加 random fill probability < 1(典型 0.3);(iv) 加入 maker queue priority model(先挂单的有 advantage);(v) 走 walk-forward 多窗口 + 按月报 Sharpe 分布。

Q3: 如何识别策略 overfit?

A: (i) Walk-forward 测试:用前 6 月训练参数、后 1 月测试,反复滑动;(ii) Bootstrap:随机抽样 1/2 数据多次 → Sharpe 分布稳定否;(iii) 参数曲面:最优参数附近平原还是孤峰;(iv) Out-of-sample 完全没参与调参的时段;(v) Stress test:极端 vol 期、热门事件期。

Q4: 给 PM 解释做市策略的四大风险并给出预算。

A: (i) Inventory tail:单日最大库存损失,预算 daily VaR 2-3% AUM;(ii) Adverse selection:跟 informed 成交净亏,预算 < 30% maker rebate 收入;(iii) Operational:连接断、bug、rate limit,预算 < 5% PnL;(iv) Hedge basis:cross-venue mismatch,预算 < 10 bps daily。总和决定 risk-adjusted return target。

Q5: 你看到 strategy backtest 给出 Sharpe 8 with very smooth equity curve, would you trust it?

A: 不信。Sharpe 8 在公开市场策略几乎不可能。99% 是 (a) lookahead bias、(b) fill 模型过乐观、(c) 忽略 fee/funding、(d) 在 backtest data 中 trained。我会要求:(i) 详细列每条 trade 的 mark-to-market PnL;(ii) walk-forward;(iii) paper trading 1 个月观察实盘 vs 回测 PnL ratio。


明日预告

Day 82:高频信号 — OFI 与 Microprice — 把 Day 80 的"被动 skew"升级为"前瞻信号驱动报价"。Order Flow Imbalance(OFI)测量 best bid/ask volume 变动的有向流量,直接预测 1-100ms 的 mid 移动。Microprice 是即将成交位置的最优估计。两者是顶级做市的 alpha 来源。