NarrowGate:Maker Quote EV 与被动做市研究框架

TL;DR:读者收益摘要

NarrowGate 不是一个“做市策略已经跑通并盈利”的项目,而是一个用于验证 maker quote EV 的研究框架。它研究的核心问题是:一笔被动限价单被成交之后,到底是在收取 spread,还是在替更快的信息交易者接风险。

这篇文章只讲算法与证据框架;C++、pybind、x86 benchmark 和 live hot path 细节拆到另一篇:NarrowGate:C++ Tick Replay 与低延时工程边界

读完这篇文章,至少可以得到六个结论:

  1. maker fill 不能简单等价于 spread 收益,必须用 fill 后 markout 和 inventory exposure 衡量;
  2. 多市场 reference 有信息量,但不等于统一打开 multi_market 就有正 EV;
  3. tick replay、queue ahead、latency、TTL 和 maker fill gate 会显著改变 bar 回测结论;
  4. 很多早期结果已经被后续审计推翻:连续跨日/月度 replay、坏日/gap 污染、markout EMA latch、fills/day 分母错误,都不能再当作 alpha 证据;
  5. 当前项目没有选出可以称为“最优 live 参数”的策略结论;quote EV、SELL resiliency、toxic-risk、campaign stop-add 等都只能留在 shadow/evidence 或 replay shadow 层;
  6. 动态到达率、主动对冲、多 venue lead-lag 和库存终局仍然是明确的未完成边界。

这篇文章也不是一份“项目顺利推进报告”。更准确地说,它是一份研究复盘:很多曾经看起来有希望的方向,后来都被更严格的数据质量、日度 fresh-start replay、live/replay 机制量对齐、bucket/OOS 校准和库存时间指标推翻或降级。保留下来的不是某个神奇参数,而是一套更不容易自欺欺人的验证流程。

NarrowGate 是我最近一段时间一直在做的 maker/被动做市研究项目。它不是从“我要写一个低延迟系统”开始的,而是从一个更朴素的问题开始:当市场有人急于成交时,站在被动一侧提供流动性,究竟是在收取 spread,还是在替更快的信息交易者接风险?

先把阅读边界说清楚:本文只从市场微观结构、价格行为学、数据质量和回测方法的角度分析 NarrowGate,不讨论也不建议任何真实交易行为。文中的 PnL、fill、markout、A/B 都是研究指标,用来验证模型假设和执行口径,而不是收益承诺。

阅读路线:先问题,再证据

如果只看现在的代码目录,很容易以为 NarrowGate 是一个“AS + LightGBM + tick replay”的技术拼盘。实际演进顺序恰好相反:每一层都是上一层暴露问题后的修正。

阶段 最初假设 真实问题 后续修正
AS 与 1s-bar 原型 spread、波动和库存足以描述报价 bar touch 无法表示 maker 排队、延迟和逆向选择 引入逐笔回放、BBO/L2、queue ahead 和 maker fill gate
原生盘口接入 有文件就能重建盘口 CryptoHFTData 存在缺小时、低 snapshot coverage;gap 会污染 rolling 和未来 label audit 成为硬边界,物理清理坏数据,并统一 continuous segment / horizon guard
BTCUSDT reference 高流动性参考市场应提升收益 reference 同时包含信息冲击和已被本地吸收的流动性冲击,统一开关会误杀机会 由布尔开关转向 shock attribution 和逐侧 quote EV
enhanced spot 更多市场能让模型更健壮 特征有信息不等于 policy 有正 EV,bid/ask 响应还不对称 降级为 risk label / moderator,先做 bucket 与 quote EV shadow
quote EV ask 侧 raw PnL 改善即可晋级 有效 fill 太少,校准和 30s markout gate 不通过;后来 interaction/local-flow 也未过 daily stability strict promotion gates、walk-forward 扩样、shadow/research 定位
连续跨日/月度 replay 长窗口汇总更稳定 坏日/gap、markout latch、状态跨日污染和 fills/day 分母会共同扭曲结论 全部改为 UTC 日度 fresh-start,并报告 daily rows

为了避免文章后面又把旧实验写成现役结论,先把当前证据账本列出来:

状态 内容 当前处理
作废 连续跨日/月度 replay 结论、坏日/gap 污染下的 quote EV A/B、markout EMA latch 污染下的 adverse 参数、fills/day 用错分母 不再用于选择 live 参数,只作为事故复盘
保留 数据质量体系、UTC 日度 fresh-start、continuous segment / horizon guard、hard gate、库存时间积分、live/replay 机制量对齐 作为后续所有实验的入场条件
待验证 multi_market、enhanced spot、quote EV、local resiliency bucket、toxic-risk、campaign stop-add、adverse/defense guard 是否真的有 alpha 只能走 shadow label、order-level denominator、OOS bucket、daily stability、inventory-time 和 side-markout gate
只读 shadow/evidence SELL resiliency、toxic-risk shadow avoidance、campaign stop-add、reducing cooldown probe 不改 live spread/TTL/size/pause;direct live arm 默认关闭或归档

整个研究链路可以概括成:

NarrowGate maker live hot path

Binance Vision / CryptoHFTData
        │
        ▼
data audit + physical cleanup + gap/horizon guard
        │
        ▼
1s bars + BBO/L2 + reference/spot features
        │
        ├──► 主 LightGBM:ret / vol / toxicity 等状态预测
        │
        ▼
AS quote core + side policy + inventory controls
        │
        ▼
tick replay:queue / latency / TTL / maker fill gate
        │
        ▼
quote trace + fill markout + cross-market shock attribution
        │
        ▼
quote EV labels/model + daily rollup A/B + strict gates

这条链路很重要,因为 quote EV 并不是从原始行情直接训练出来的万能模型。它依赖上游报价轨迹、成交路径、未来 markout 和 cross-market context;任何一层的数据边界或执行口径出错,最后得到的 AUC 和 PnL 都可能只是被污染后的精确数字。

这也是为什么数据源需要先分层,而不是全部丢进一个 dataframe。NarrowGate 里至少有四类数据:

NarrowGate research data lineage

数据层 主要来源 在项目中的角色 最容易出错的地方
execution trades Binance Vision daily/aggTrades,perp 与 spot 构造 1s/10s flow、taker tempo、spot/reference return 非日度容器混入坏日期;日度缺口会污染 rolling 和 future label
execution orderbook CryptoHFTData 原生 hourly orderbook,重放为 daily bbo/l2 tick replay 的 BBO、top-N L2、queue ahead 和执行层 microprice 小时文件缺失,或文件存在但 snapshot coverage 太低
slow market context Binance OI、long/short ratio、旧 bookDepth 百分比桶 慢变量和历史 proxy,不作为精确 L2 撮合依据 bookDepth 是百分比聚合桶,不等价于运行时 top-20 depth
cross-market anchors BTCUSDT perp、BTCUSDT spot、BTCUSDC spot 区分本地流动性冲击和更广泛的信息重定价 多个 Binance 市场高度相关,不能伪装成独立 venue consensus

因此本文里的“数据清理”不是普通 ETL 卫生问题,而是模型定义的一部分。maker 的 label 常常是条件事件:先有候选 quote,再看是否 fill,fill 后再看 1s/5s/30s mid。只要 orderbook 缺口或长 gap 横跨这个链条,P(fill)E(markout | fill) 和库存风险都会被误标。

为了避免后面术语来回跳,这里先给一个小词典。它不是交易教材,只是本文后续代码和公式里的统一含义:

术语 在 NarrowGate 里的含义
maker 挂被动限价单,等待别人主动成交;不是追着价格打单,而是在盘口提供流动性
taker 主动吃掉盘口流动性的人;他的成交方向常被用来估计短期 flow pressure
BBO best bid / best ask,即当前最优买一和卖一
mid (best_bid + best_ask) / 2,最简单的中间价
microprice 用买卖一深度加权后的中间价;它不是预测模型,只是表达盘口哪侧更薄
spread edge maker 买在 bid、卖在 ask 所获得的价格让步;这是毛收益来源,不等于最终收益
queue ahead 自己挂单前方同价位或更优价位的可见数量;价格碰到不代表一定轮到自己成交
markout fill 后若干秒的价格变化;用来问“这笔成交后来是不是变成了坏成交”
maker-signed markout 按 maker 方向归一后的 markout;买入后上涨为正,卖出后下跌为正
adverse selection 成交后价格向不利方向移动;直觉上就是“我被更快或更有信息的一方打到了”
toxicity 某侧成交后出现 adverse markout 的概率或强度估计
quote EV 不是预测价格涨跌,而是问“这一侧、这个价格、这一笔被动挂单,成交之后是否值得”
TTL / cooldown 挂单最长存活时间 / 成交后短时间限制同侧继续暴露

第一部分:Maker 量化算法与数据假设

这一部分只讨论策略研究本身:maker 订单为什么有条件 EV,AS/GLFT 公式如何提供报价坐标系,数据源和 quote labels 为什么必须经过连续性校验。暂时不谈工程实现,因为如果算法假设和数据边界没立住,任何优化都只是更快地产生错误结论。

1.1 为什么叫 NarrowGate

NarrowGate 取自“窄门”的意象。对 maker 策略来说,市场里的机会并不是越多越好。每一个 tick 都可以触发报价,但真正值得暴露的窗口必须经过几道门:

  1. 当前 spread 是否提供了足够的流动性补偿;
  2. 报价相对 mid/BBO 的位置是否合理;
  3. 主动成交和盘口压力是短期流动性冲击,还是信息驱动的重定价;
  4. reference perp 与 spot 是否确认了这个变化;
  5. 库存、挂单 TTL 和撤单频率是否仍在风险预算内。

因此 NarrowGate 不是“预测涨跌然后追单”的趋势策略。它研究的是被动限价单:当别人为了立刻成交而付出成本时,maker 是否值得站在另一边接住这笔流动性。

这里最关键的分界是:

  • 流动性冲击:某一侧突然有主动成交,但价格影响短暂,其他市场没有持续确认,本地盘口可能很快恢复;
  • 信息冲击:BTCUSDT perp、spot 或更广泛的盘口同时重定价,此时在旧价格继续挂单,往往是在给更快的信息交易者提供退出流动性。

所以每一侧 quote 都应该被看作一个条件 EV 问题:

P(fill) 高不一定是好事。一个几乎必然成交、但成交后价格立刻向不利方向移动的报价,可能比完全不成交更糟。

把 maker 的一次决策写成伪代码,大概是下面这样。真实系统当然有更多异常处理、日志和 REST 细节,但热路径上的逻辑并不神秘:

def on_requote_tick(state, market, models, cfg):
    # 1. 更新短周期市场状态:mid、BBO、depth、trade flow、reference/spot
    signal = signal_engine.compute(
        exec_trades=market.btcusdc_trades,
        exec_book=market.btcusdc_l2,
        ref_perp=market.btcusdt_perp,
        exec_spot=market.btcusdc_spot,
        ref_spot=market.btcusdt_spot,
    )

    # 2. 主模型只回答状态问题:短期 ret / vol / toxicity
    pred = models.main.predict(signal.feature_vector)

    # 3. AS quote core 给出报价坐标系
    quote = compute_quote_core(
        mid=market.mid,
        inventory=state.position,
        sigma_sq=pred.vol_10s,
        ret_10s=pred.ret_10s,
        tox_bid=pred.tox_bid,
        tox_ask=pred.tox_ask,
        depth=market.l2_topN,
        cfg=cfg.quote,
    )

    # 4. side policy 决定每一侧是否值得暴露
    bid_policy = build_side_policy(
        side="bid",
        quote_ctx=quote.bid_ctx,
        inventory=state.position,
        fill_cooldown=state.fill_cooldown,
        quote_ev=models.bid_quote_ev,
    )
    ask_policy = build_side_policy(
        side="ask",
        quote_ctx=quote.ask_ctx,
        inventory=state.position,
        fill_cooldown=state.fill_cooldown,
        quote_ev=models.ask_quote_ev,
    )

    # 5. routing 只做本次订单动作:保留、撤单、换价、新挂
    decision = route_orders(
        quote=quote,
        bid_policy=bid_policy,
        ask_policy=ask_policy,
        live_orders=state.live_orders,
        filters=market.exchange_filters,
    )
    return decision

这段伪代码也解释了为什么工程优化不能只盯着某一个公式。compute_quote_core() 的浮点数学很短;真正决定行为的是 feature state、quote context、side policy 和 routing decision 是否共享同一套语义。若只优化报价公式,却让回测、shadow 和 live 在 policy 边界上分叉,速度越快,错误结论也会来得越快。

1.2 从 AS 报价骨架开始

项目最早使用经典 Avellaneda-Stoikov 做市模型。它给出了两个很有用的结构:reservation price 和最优 spread。

其中 q 是库存,sigma 是短期波动,kappa 描述成交概率随报价距离衰减的速度。直觉上,库存偏多时 reservation price 下移,报价会更愿意卖出、更不愿继续买入;波动上升时 spread 变宽,以补偿更高的逆向选择风险。

这几个式子背后的论文假设可以压缩成三步。

第一步,Avellaneda-Stoikov 假设中间价近似服从扩散过程:

做市商持有库存 q,财富受现金和库存市值共同影响,并使用 CARA utility:

在这个设定下,多持有一单位库存会暴露未来价格方差,CARA + Brownian price 的风险惩罚项近似为:

于是 reservation price 可以理解成“愿意用来给库存做无差异估值的价格”:

第二步,论文用一个很简洁的到达率假设描述“报价离中间价越远,越不容易成交”:

在 CARA utility 下最大化一次被动成交的效用增量,会得到每一侧报价距离中的 log 项:

再叠加库存持有风险,得到总 spread:

第三步,GLFT/Guéant 体系把“指数到达率”推广成更一般的强度函数 Lambda(delta)。这时不必假设所有市场都满足 A exp(-kappa delta),而是通过 Hamiltonian 写成:

指数强度只是一个特例;如果真实 order arrival 对报价距离的衰减不是纯指数,就应该校准 Lambda 或等价的 kappa_eff,而不是迷信一个固定 kappa。NarrowGate 里保留 AS 的骨架,是因为它给出了稳定、可解释的库存坐标系;但具体的 sigmakappa、depth half-spread、adverse widening 和 quote EV gate 都要由数据重新验证。

实际系统里,AS 公式只是报价骨架。NarrowGate 的 quote core 更接近下面这个组合:

NarrowGate quote ladder

这里最容易误解的是 microprice。如果买一数量很厚、卖一很薄,microprice 会向卖一侧偏移,暗示向上打穿更容易;反过来则向买一侧偏移。它不是预测模型,而是盘口当前形状给出的局部压力。

把 microprice 写成这个形式,也可以从“下一笔成交更可能打哪一侧”来理解。若 bid_qty 远大于 ask_qty,卖一更薄,价格向上移动的局部概率更高,于是 microprice 靠近 best_ask

这也是为什么它只能作为局部盘口压力,而不能替代未来收益模型。一个厚 bid 可能是支撑,也可能只是即将撤掉的虚假流动性;真正进入策略时,还必须经过 freshness、depth coverage、reference confirmation 和 fill 后 markout 验证。

side policy 则在候选价格之后再做一次门控:

allow_bid =
    fresh_book
    and not bid_adverse_pause
    and not bid_local_extreme_pause
    and inventory < max_inventory
    and not fill_cooldown_blocks_bid

allow_ask =
    fresh_book
    and not ask_adverse_pause
    and not ask_local_extreme_pause
    and inventory > -max_inventory
    and not fill_cooldown_blocks_ask

当库存偏多时,bid 是增加风险的一侧,ask 是降低风险的一侧;库存偏空时反过来。因此同一个 adverse guard 对 bid/ask 的含义并不对称,这也是 quote EV 必须按 side 训练和评估的原因。

但真实盘口不会只服从这几个变量。因此项目后来逐步加入:

  • microprice 与盘口 imbalance;
  • 动态 kappa 和近端深度;
  • fee-aware spread floor;
  • LightGBM 的波动率、收益和 toxicity 预测;
  • CJP 风格库存偏移和库存衰减;
  • adverse guard、defense guard 与逐侧暂停;
  • adaptive/fragile TTL 与 cooldown;
  • BTCUSDT reference perp、BTCUSDC/BTCUSDT spot;
  • quote EV 对 fill probability、markout bucket 和 extreme adverse 的建模。

AS 在这里不是一个直接给答案的“万能公式”,而是稳定的报价坐标系。机器学习和策略 guard 不应该随意取代这个坐标系,而应该回答更局部的问题:这一侧现在是否值得挂、要离 BBO 多远、该暴露多久、库存代价是否已经过高。

主模型和 quote EV 在这里扮演不同角色。主 LightGBM 描述市场状态,例如短期收益、波动与 toxicity;quote EV 则站在某一笔候选报价的角度,估计“是否会成交、成交后是否有利、尾部是否危险”。前者回答市场正在发生什么,后者回答这一侧是否值得承担一次具体的 maker 暴露。把二者混成一个方向预测器,反而会丢掉 maker 最关心的条件性。

项目也因此从早期 1s-bar 回测逐渐转向 tick replay。bar 回测适合快速检查公式和参数方向,但只知道价格是否触及;maker 是否真的成交,还取决于挂单前方的队列、订单到达时间、撤单延迟和同价位主动成交量。没有这些状态,回测很容易把“价格碰到过”错误地当成“我成交了”。

简化后的 queue/fill 逻辑可以写成:

NarrowGate maker order lifecycle

def process_aggressive_trade(order, trade):
    if not order.is_live:
        return None

    # BUY maker order fills only when sell aggressor trades at or below bid.
    # SELL maker order fills only when buy aggressor trades at or above ask.
    if not trade.crosses(order.side, order.price):
        return None

    remaining = trade.qty

    # Visible queue ahead must be consumed before our order can fill.
    eaten = min(order.queue_left, remaining)
    order.queue_left -= eaten
    remaining -= eaten

    if remaining <= 0:
        return None

    fill_qty = min(order.remaining_qty, remaining)
    order.remaining_qty -= fill_qty

    return Fill(side=order.side, price=order.price, qty=fill_qty)

成交后再按 maker 方向计算 markout:

这个定义让“对 maker 有利”的成交质量统一为正值。比如买入后价格上涨是正 markout,卖出后价格下跌也是正 markout。quote EV 和 adverse guard 关心的不是成交本身,而是 fill 条件下未来 markout 是否补偿了库存和尾部风险。

1.3 参数映射:gamma/kappa/sigma 不应该靠玄学拍脑袋

AS 公式里的几个参数在代码里有非常具体的含义:

参数 / 信号 代码里的来源 对报价的直接影响 调参时主要看什么
gamma strategy.gamma,也会被库存比例和方向信号调整成 g_eff 库存 reservation price 偏移、基础 spread 项 abs_inventory_time_s、InvAdj、max inventory hit
sigma_sq tick replay / live rolling variance;可与 LightGBM vol_10svol_blend 混合 gamma * sigma_sq,以及 regime spread scale spread<100、cap hit、波动窗口下 markout
kappa strategy.kappap3_kappa_eff;可用 depth-kappa 根据近端深度调整 log(1 + gamma / kappa) spread 项;kappa 越小 spread 越宽 fills/day、fills/order、queue calibration
dir_10s LightGBM 方向概率 reservation price shift、asymmetry、gamma_dir_bonus 方向分桶 markout、库存是否被方向信号放大
ret_10s LightGBM 未来收益预测 ret_skew * mid,再被 ret_shift_max_pct 限幅 逐侧 1s/5s markout、cap compression
tox_bid/ask LightGBM 逐侧 toxicity side adverse widen / shrink / pause toxic bucket realized markout、false pause rate
markout EMA fill 后 maker-signed markout markout spread scale、hybrid pause latch pause 时长、恢复后 fill markout

简化后的 quote mapping 可以这样理解:

sigma_sq = rolling_sigma_sq
if ml_enabled and vol_blend > 0 and pred_vol > 0:
    sigma_sq = (1 - vol_blend) * rolling_sigma_sq + vol_blend * pred_vol

kappa_base = p3_kappa_eff if p3_kappa_eff > 0 else cfg.kappa
kappa_used = depth_kappa(kappa_base, depth) if use_depth_kappa else kappa_base

g_eff = gamma
if abs(inventory) > 0:
    g_eff *= 1.0 + (abs(inventory) / max_inventory) ** 2

reservation = fair_price - inventory * g_eff * sigma_sq

kappa_spread = max(kappa_used * kappa_ratio, 1e-12)
spread = gamma * sigma_sq + (2.0 / gamma) * log(1.0 + gamma / kappa_spread)
spread *= regime_spread_scale

if ret_skew > 0:
    shift = clamp(
        pred_ret * ret_skew * mid,
        -ret_shift_max_pct * spread / 2,
        +ret_shift_max_pct * spread / 2,
    )
    reservation += shift

bid = floor_tick(reservation - spread / 2 * (1 - asym))
ask = ceil_tick(reservation + spread / 2 * (1 + asym))

再往后才是逐侧 policy:

if tox_bid >= adverse_toxicity_threshold:
    bid_policy.spread_mult = max(bid_policy.spread_mult, adverse_spread_mult)
    bid_policy.size_mult = min(bid_policy.size_mult, 0.70)
    bid_policy.allow_exposure_increase = False

if bid_side_adverse_pause:
    bid_policy.allow_post = False

所以机器学习不是直接“预测价格然后下单”。它有三类入口:

  1. 改变物理 quotevol_10sret_10sdir_10s 影响 reservation、spread、asymmetry;
  2. 改变 policy gatetox_bid/ask、markout EMA、microprice shift 影响 widen/shrink/pause;
  3. 改变风险读法:quote EV 与 shadow calibration 不直接替代 AS,而是判断某一侧 quote 是否值得继续提供流动性。

实操调参时,不应该先问“哪个 gamma PnL 最高”,而应该按顺序看:

1. spread/cap sanity:spread<100、cap hit、final spread distribution
2. execution sanity:placed orders/day、fills/day、fills/order
3. adverse sanity:1s/5s/30s maker-signed markout by side
4. inventory sanity:abs inventory-time、InvAdj、pnl per inventory-hour
5. calibration sanity:toxicity / quote EV bucket 是否和 realized markout 对齐

如果一个参数组合 raw PnL 更高,但靠库存时间暴露翻倍换来,或者只在 cap_hit 很高的状态下好看,它就不是更稳的参数。

1.4 Side Policy 的逐侧 pause:高波动先 widening,信息冲击才 pause

很多人一听到 “pause” 会理解成:市场波动大,所以不挂单。这个理解不够精确。

在 NarrowGate 里,高波动首先应该由 AS/GLFT 或 regime spread 处理,也就是把 spread 拉宽:

如果只是 realized vol 高、盘口跳动快,但没有明显的方向性信息冲击,直接 pause 可能会把所有高 spread 补偿机会都关掉。因此 pause 更像“熔断某一侧暴露”的机制,主要看逐侧 adverse context,而不是看总波动。

核心判断可以写成人话:

  • widen:这一侧可能更危险,但还可以用更差的价格提供流动性;
  • pause:这一侧成交后大概率是接信息风险,继续挂单没有意义;
  • cooldown:市场刚打过这一侧,短时间内不急着恢复;
  • decay:如果很久没有新证据,旧的 adverse 不能永久锁死策略。

一个更接近当前策略口径的简化伪代码是:

def update_side_pause(side, now, fill_markout_bps, state):
    dt = now - state.last_update_ts

    # wall-clock decay:坏日、长 gap、静默行情后不能让旧 markout 永久锁死。
    state.markout_ema *= math.exp(-dt / state.tau_s)

    if fill_markout_bps is not None:
        state.markout_ema = (
            state.alpha * fill_markout_bps
            + (1.0 - state.alpha) * state.markout_ema
        )

    if state.markout_ema < -state.pause_threshold_bps:
        extra = state.base_pause_s * abs(state.markout_ema) / state.pause_threshold_bps
        state.pause_until = now + clamp(extra, state.min_pause_s, state.max_pause_s)

    return now < state.pause_until

在策略层面还要区分“增加库存风险的一侧”和“降低库存风险的一侧”:

def is_exposure_increasing(side, inventory):
    if side == "bid":
        return inventory >= 0.0      # 继续买入会加大 long 暴露
    if side == "ask":
        return inventory <= 0.0      # 继续卖出会加大 short 暴露

因此 side policy 不是一个全局开关,而是一个逐侧、逐库存方向、逐市场状态的 gate:

if vol_high:
    quote.spread *= vol_spread_multiplier

if side_toxic and exposure_increasing:
    quote.allow_post = False
    quote.reason = "ADVERSE_SIDE_PAUSE"
elif side_toxic:
    quote.spread *= adverse_widen_multiplier

if local_extreme_against_side and not inventory_reducing:
    quote.allow_post = False
    quote.reason = "DEFENSE_GUARD"

所以,高波动不必然 pause;pause 专门针对“这侧成交之后的 markout/毒性证据已经不能靠 spread 补偿”的状态。否则策略会把 volatility risk 和 information risk 混为一谈。

这里必须把历史结论说清楚:早期我确实做过 adverse threshold 的日段 grid,也一度把 2.5 / 6.0 这样的组合看成 raw 与 InvAdj 的折中。但后续复盘发现,当时的证据链并不够干净。

主要问题不是某个阈值本身,而是 replay/live 机制还在被修正:

  • markout EMA 曾经可能在坏日、长 gap 或行情静默后形成 latch,导致一侧长期 pause;
  • fill cooldown 曾经会挡住减库存方向,风险降低订单也被误杀;
  • thin_depth 阈值一度按过高的 depth baseline 解释 BTCUSDC,导致 defense reason 常驻;
  • SYNC_ADJUST 的 hard degrade 曾经过敏,一次 sync discrepancy 就可能触发 120 秒降级;
  • fills/day 的分母在不同报告里混用了 full calendar day、quality day 和 active-trade day;
  • native sweep 在若干 policy 细节未对齐前不能用于选参。

因此,旧的 adverse threshold 表格现在只能作为“为什么要修机制”的事故复盘,而不能作为 live 最优参数依据。保留下来的结论是机制层的:

# 旧 adverse 证据必须随 wall-clock 衰减,不能跨坏日/长 gap 永久生效。
markout_ema *= exp(-dt / tau_s)

# cooldown / pause 只应该阻止增加库存风险的一侧;
# 减库存方向应该优先保留,除非 stale/sync/inventory hard block 触发。
if fill_cd_active and exposure_increasing(side, inventory):
    allow_exposure_increase = False

这也是后面所有 alpha 搜索重新开始的原因。我们不再问“哪个 adverse 阈值最优”,而是先问:

  1. replay 挂单数量是否和 live 同数量级;
  2. fills/hour、bid/ask split、spread、block reason 是否能对齐;
  3. fill selection 和 maker-signed markout 是否在 OOS 日段保持稳定;
  4. 通过这些机制 gate 后,再谈某个 policy 是否有 alpha。

同一轮也训练过新的 models/saved_btcusdc_daily_retrain_20260627,但它没有晋级:成交数更高,raw/InvAdj 却明显更差。这反过来提醒我,maker 模型不是“越活跃越好”,很多时候更高的 fill count 只是更高频地接到了有毒流。

1.5 动态 kappa:AS 是坐标系,不是答案

Avellaneda-Stoikov 给了一个非常有用的坐标系:

但真正困难的是右边这些变量如何变成实时市场状态,而不是公式本身。

当前 NarrowGate 对 kappa 的处理有三层:

  1. 静态 strategy.kappa 作为 baseline;
  2. p3_kappa_eff / general intensity 用历史 fill probability 拟合更一般的 Lambda(delta)
  3. depth-kappa 用近端盘口深度把 kappa 做有限比例调整。

这还不等于“动态到达率已经被完全校准”。真正金融上更尖锐的问题是:

当市场发生信息冲击时,远端 quote 的成交概率可能不是指数衰减,而是突然变成“深价位也会被扫”。这时 kappa 不是简单变大或变小,而是整条 Lambda(delta) 曲线形状改变。当前系统里 toxicity 更多是进入逐侧 policy:

if toxicity >= toxicity_threshold:
    side_policy.spread_mult = max(side_policy.spread_mult, widen_mult)
    side_policy.size_mult = min(side_policy.size_mult, shrink_mult)
    side_policy.allow_exposure_increase = False

if side_adverse_pause:
    side_policy.allow_post = False

也就是说,当前实现更像:

而不是:

这是一条明确的未完成边界。未来如果要真正“搞定 AS 坐标系”,需要让 toxicity、reference shock 和 live fill calibration 共同形变 Lambda(delta),并要求变形后的曲线能通过 shadow fill probability calibration,而不只是让某个聚合窗口 A/B 的 raw PnL 变好。

1.6 第一场真正的麻烦来自数据,而不是模型

项目的数据主要来自两类来源:

  • Binance Vision 的 aggTrades、bookDepth、metrics,以及后续补充的 spot 数据;
  • CryptoHFTData 的原生 orderbook 数据,用于重建 BBO 和 top-N L2。

最开始很容易产生一种错觉:文件存在,就代表这一小时的数据可用。实际并不是这样。CryptoHFTData 中出现过两种更隐蔽的问题:

  1. 某些小时文件直接缺失;
  2. 文件存在,但内部缺少足够的 orderbook snapshot,无法重建可靠的盘口状态。

如果这些日期继续进入训练和回测,问题不只是一段时间“没有成交”。maker 的 order lifetime 可能很短,日与日之间离散本身未必严重;真正危险的是 rolling feature 或未来 label 跨过了坏日期和长 gap。

例如一个 30 秒 markout label,如果第 30 秒对应的价格来自长时间缺口之后,那么这个 label 在数学上有值,在市场含义上却已经失效。rolling volatility、taker tempo、cross-market return 也可能把两个不连续的市场状态拼在一起。

因此后来做了三层修正。

1.6.1 审计结果成为硬边界

orderbook audit 中不可用或低 coverage 的日期不再只是报告里的警告,而是进入统一的 data_quality.py。训练、回测、quote trace、shock audit 和 quote EV 都必须二次过滤,不能依赖某一个上游脚本“碰巧已经删过”。

1.6.2 物理数据也同步清理

坏日期对应的 CryptoHFTData 和 Binance raw 数据被物理删除。由于跨日压缩包可能混合好日期与坏日期,不能只在聚合容器里删掉某一天,所以改为:

  1. 删除覆盖坏日期的非日度 aggTrades 容器;
  2. 从 Binance Vision 按日重新下载 retained good days;
  3. 验证坏日期日度文件命中为 0,非日度容器残留为 0。

这样既避免磁盘继续保留不可用数据,也让物理层和逻辑层使用同一份质量边界。2026-06-06 的清理先把覆盖坏日期的非日度 aggTrades 容器删掉,再用 Binance Vision daily 文件回填 retained good days;2026-06-28 的粒度清理进一步把 parquet 容器收敛到 YYYY-MM-DD 日文件。

这里还有一个很容易误解的点:物理文件粒度也会反过来诱导研究口径。一开始我以为“只要逻辑层按天切 replay,底层保留少量跨日容器也没关系”,但这会让后续脚本、文档和人脑都不断滑回跨日聚合分析。后来我把本地 market data 重新扫了一遍,并把剩余非日度容器全部清掉:

数据 当前物理粒度 对研究口径的影响
Binance raw trades / aggTrades retained daily CSV 可以直接按 UTC day 切 replay 和 flow
CryptoHFTData 重放后的 BBO/L2 daily parquet tick replay、queue ahead、microprice 的主执行口径
raw metrics / 5m metrics daily CSV + daily parquet OI/多空比按 UTC day join,不再依赖非日度 metrics parquet
engineered features features_YYYY-MM-DD.parquet 训练 dataset 是日文件拼接结果,不再从月 feature 容器读取
bookDepth proxy depth_1s/*-YYYY-MM-DD.parquet 仅作 legacy proxy;主执行层仍用 CryptoHFT BBO/L2

后来我又犯过一次很典型的错误:以为“目录里只要大体都是日文件就够了”。实际重新扫 ~/MarketData/NarrowGate_BTCUSDC 后,仍能在 rawbars_1sbbo/l2raw_trades/trade_features 和 metrics 层看到坏日残留。这个问题在 2026-07-01 又做了一次 cross-source hard-exclude 清理:以 data_quality.COMPLETE_DATA_POLICY.excluded_orderbook_days("BTCUSDC") 的 152 个 UTC day 为准,删除命中坏日期的 raw / BBO / L2 / bars / depth / metrics / trade feature 文件 664 个,约 1.738GB,二次扫描坏日期残留为 0。

这个结果也修正了“最小有限子集”的说法:物理层不应该保留坏日,也不应该保留跨坏日的非日度容器;但质量合格的日度好日仍然可以作为 OOS/research pool 保留,不需要裁成一个过小、固定、容易过拟合的子集。某一天是否进入训练、replay 或 promotion evidence,由 retained daily universe 和 data_quality.py 的逻辑过滤决定。

2026-06-28 与 2026-07-01 的最终口径是:~/MarketData/NarrowGate_BTCUSDC 下策略相关行情容器按 UTC day 组织,坏日期物理残留为 0。它看起来像清理磁盘,其实是清理研究边界:物理容器和策略结论都默认按 UTC day 组织。新的核心入口也因此改成:

python3 models/backtest_tick.py --symbol BTCUSDC --day 2026-05-15 --ml --engine cpp
python3 models/tick_ab.py adaptive_ttl --symbol BTCUSDC --days 2026-05-15 2026-05-16 --engine cpp
python3 models/quote_ev_ab_tick.py --symbol BTCUSDC --days 2026-05-15 2026-05-16 --arms baseline ask_ev_soft_widen
python3 models/quote_decomposition_tick.py --symbol BTCUSDC --days 2026-05-15 2026-05-16 --engine cpp
python3 models/cross_market_shock_audit.py --symbol BTCUSDC --trace-tag daily_trace --days 2026-05-15 2026-05-16

旧的跨日聚合入口不再作为研究主口径或 promotion 证据;连续模式只适合显式诊断。长窗口 replay 最危险的地方不是“统计时间更长”,而是 adverse/defense/markout EMA 这类状态会跨坏日和长 gap 延续,把一个早期毒性状态扩散成后面几天的假性停报。

但这里有一个 live 风险边界不能被日度 fresh-start 掩盖:正式 retained-day replay 默认每天冷启动;如果真实 live 在好日/坏日边界还有持仓,研究口径会在 segment 末尾 mark-to-market 后从下一个 retained day 重新开始,不会模拟带仓穿过坏日的路径。这个设计能避免坏数据污染 label,却不能回答“实盘持仓穿越数据坏日怎么办”。因此 session/live policy 仍需要单独的 boundary inventory audit 或 continuous replay 诊断。

1.6.3 rolling 与 label 使用同一套连续段定义

项目增加了统一的:

continuous_segment_ids(index, max_gap_s=5)
mask_valid_horizon(index, horizon_s, max_gap_s=5)

这两个函数不是简单的 dropna。连续段要同时切断无效时间戳、时间倒退和超长间隔;future label 则先用 searchsorted 找到目标 horizon 的第一条观测,再验证它仍处于同一 segment,并且没有晚于目标太多。当前 Python fallback 的核心逻辑如下(省略时间戳类型兼容代码):

def continuous_segment_ids(index_like, max_gap_s=5.0):
    ts = _coerce_utc_timestamps(index_like)
    ns = ts.view("int64")
    invalid = np.asarray(ts.isna(), dtype=bool)

    breaks = np.zeros(len(ts), dtype=bool)
    breaks[0] = True
    if len(ts) > 1:
        delta = ns[1:].astype(float) - ns[:-1].astype(float)
        breaks[1:] = (
            invalid[:-1]
            | invalid[1:]
            | (delta < 0.0)
            | (delta > max_gap_s * 1e9)
        )
    return np.cumsum(breaks, dtype=np.int64) - 1


def mask_valid_horizon(index_like, horizon_s, max_gap_s=5.0):
    ts = _coerce_utc_timestamps(index_like)
    ns = ts.view("int64")
    segments = continuous_segment_ids(ts, max_gap_s)

    target_ns = ns.astype(float) + horizon_s * 1e9
    future_idx = np.searchsorted(ns, target_ns, side="left")
    valid = (~ts.isna()) & (future_idx < len(ts))

    rows = np.flatnonzero(valid)
    future = future_idx[rows]
    valid[rows] = (
        (segments[future] == segments[rows])
        & ((ns[future] - target_ns[rows]) <= max_gap_s * 1e9)
    )
    return valid

rolling feature 则按 segment 分组后再滚动,而不是算完 rolling 再把坏日期删掉:

segment = continuous_segment_ids(frame.index, max_gap_s=5)
frame["ret_std_60s"] = (
    frame.groupby(segment, sort=False)["log_ret"]
         .rolling(60, min_periods=20)
         .std()
         .reset_index(level=0, drop=True)
)

valid_30s = mask_valid_horizon(frame.index, horizon_s=30, max_gap_s=5)
labels.loc[~valid_30s, "markout_30s"] = np.nan

在时间戳全部有效时,同一语义可以走更快的实现;有非法值时必须保留 fallback 和显式报错,避免快路径为了性能吞掉数据异常。

feature_engineer.pycross_market_shock_audit.pytrain_quote_ev.py 共用同一口径。rolling feature 只在连续 segment 内计算,未来 horizon 跨 gap 时 label 直接失效。

这件事看起来不像模型升级,但它比多加几十个 feature 更重要。一个更复杂的模型只会更擅长拟合被污染的数据。

到这里,项目的研究顺序也被迫改变了:不是先训练一个更大的模型,再在结果异常时检查数据;而是先证明每个 feature 和 label 都来自连续、可用的市场片段,然后才允许它进入训练和回放。对于订单生命周期很短的 maker 来说,日与日之间不连续未必有问题,跨越不连续边界计算 rolling 或 future horizon 才是问题

1.7 为什么加入 BTCUSDT 和 spot 后,结果反而更差

BTCUSDC 的本地成交和盘口并不是孤立市场。项目先加入 BTCUSDT perpetual 作为 reference,后来又重建 enhanced spot features:

cv_ref_perp_*
cv_exec_spot_*
cv_ref_spot_*

从直觉上说,多一个高流动性的 reference、多两个 spot anchor,算法应该更健壮。数据也确实带来了信息:cross-market shock audit 显示,很多 adverse fill 能被 BTCUSDT reference move 解释;补齐 spot 后,相关窗口的 spot_available_rate 达到 1.0。

但“数据有信息”不等于“把开关打开就能形成正 EV”。

multi_market.enabled=true/false 的早期多段汇总 A/B 中,minimal 口径下 true 的 raw PnL 全部差于 false,合计约 -14.05。enhanced spot + audit-quality 重跑后,同样的整体开关仍然没有变成稳定收益来源,raw 差值合计约 -18.14。这段只作为反例背景;新的候选需要看日度 fresh-start rows 和日度 rollup 稳定性。

这次 enhanced 主模型并不是只拿几天 2026 数据做 smoke test。它的 train/validation split 实际包含 2025-08/09 和 2025-11/12,补齐后的 cv_exec_spot_*cv_ref_spot_* 在 train/val/test 中非零覆盖率均为 1.0,audit 坏日期残留为 0。换言之,“结果更差”不能简单归因于 spot 列全零或坏日期仍在训练;它更像是 policy 对新增信息的使用方式有问题。

这不是简单说明 reference 无效,而是说明当前使用方式太粗。cross-market 分组给出了更细的答案:

  • reference_information_shock 的 fill 后 markout 明显更差,说明 reference 确认了信息冲击;
  • reference_confirmed_absorbed 多数反而为正,说明 reference 虽然移动,但本地流动性已经吸收冲击;
  • spot adverse return 有解释力,但整体弱于 BTCUSDT perp 的短期 adverse return;
  • bid 与 ask 两侧的响应并不对称。

如果把 reference 信号直接变成统一 widen/pause 开关,就会把“应该避开的信息冲击”和“可以承接的已吸收流动性冲击”一起过滤掉。新增数据提高的是可分辨性,不是自动提供一条单调的收益规则。

后续 alpha 重建进一步把这个结论收窄了。我们做过 ref_favorable_gt2e-5spot_unconfirmedspot_confirmedref_confirmedref_adverse_gt5e-5 等 bucket 审计,也检查过 sign convention、label leakage、side / distance / near depth / queue rank / guard state / day 的拆分。结果并不是“reference favorable 就能放开”:

  • spot_confirmedspot_adverse_gt5e-5ref_confirmedref_adverse_gt5e-5 更稳定地表现为负向 risk label;
  • ref_favorable_gt2e-5spot_unconfirmed 偶尔有正 markout,但样本稀疏、OOS daily stability 不够;
  • xmarket interaction 接进 quote EV 后,没有同时通过 bucket、daily stability、InvAdj/raw、inventory time 和 side markout gate;
  • 直接把 xmarket 变成 widen/TTL/size policy,容易把“少成交”误读成“更聪明”。

因此当前 xmarket 的正确位置不是 direct alpha policy,而是 moderator / risk label:在本市场已经呈现可吸收冲击时,它可以帮助判断是否有更广泛的信息重定价;但它不能单独决定“该挂”。

这也解释了一个常见的机器学习误区:feature 更多、数据源更多,只会扩大模型可以表达的空间;它不会自动保证目标函数、时间对齐、样本覆盖和执行 policy 都正确。

1.8 多市场 reference:有信息不等于能赚钱

BTCUSDT perp、BTCUSDT spot、BTCUSDC spot 确实有信息量。source audit 和 shock attribution 都显示:很多 adverse fills 能被 BTCUSDT reference move 解释,spot adverse return 也有增量信号。

但这不是最终答案。真正的问题是 reference 信号的时效性:

如果 reference 的重定价领先本地执行市场 50ms,而你的端到端反应是 5ms,这个信号可能有执行价值;如果 reference 已经在 0.5ms 内传导完,本地 quote 看到它时,只是在看一个滞后指标。此时统一打开 multi_market 可能不是增强模型,而是把“已经被吸收的流动性冲击”和“真正的信息冲击”混在一起,导致该接的流动性不接、该躲的信息冲击又躲不掉。

当前 NarrowGate 对这个问题的回答是 partial:

  • 已经把 multi_market.enabled 从“收益开关”降级为“需要被审计的 source 条件变量”;
  • 已经做了 local-only、perp、spot、组合 source profile 的隔离 A/B;
  • 已经要求 freshness、basis residual、coverage 和 shock label;
  • 已经把 xmarket direct policy 退回 quote EV / shadow label / bucket evidence 层;
  • 但没有独立 venue 的原始毫秒/微秒级数据,因此不能验证真正的 cross-venue lead-lag 和 latency arbitrage。

换句话说,现在能说的是:Binance 内部多源数据有解释力,但不能证明它形成了可执行的多 venue alpha

1.9 quote EV:方向看起来对,但统计还不允许下结论

为了避免把 cross-market 信息硬编码成一个布尔开关,项目又训练了逐侧 quote EV 模型。它不直接预测 BTC 下一秒涨跌,而是分别估计:

  • P(fill)
  • fill 后 1s/5s/30s markout bucket;
  • extreme adverse probability;
  • reference/spot 对冲击性质的确认。

在早期 enhanced spot A/B 中,ask_ev_soft_widen 曾得到相对 baseline 的 raw PnL +14.55、InvAdj +0.0049。只看这两个研究指标,它很像一个值得继续做 shadow 验证的候选;但这个结果后来被降级为“引发复盘的历史线索”,不能再被单独用作候选晋级理由。

但再往下看,证据并不够:

  • 用于 baseline quote label 的真实 fill 只有 bid/ask 各约 334 条;
  • validation 窗口的有效 filled rows 只有 bid 58、ask 56;
  • strict promotion gate 要求至少 100 条 valid filled rows,且 fill calibration MAE 不高于 0.05;
  • 实际 bid/ask calibration MAE 约为 0.078/0.243,均未通过;
  • 30s expected-markout calibration MAE 也仍然很大。

后来把质量合格的 2025-08/09/11/12 加入 walk-forward,训练侧 fill 增加到 bid 414、ask 415,但 validation 可用 feature 覆盖不足,窗口里只剩 bid 26、ask 25 条有效 fill。扩样改善了训练数量,却没有解决验证集覆盖和校准问题。

随后又做了几轮更严格的重建:

  • 加入 compact quote context 小字段,而不是一次性把几十个底层诊断字段全塞进模型;
  • 加入 ref_favorable + quote EV markout head 非负 + 非 thin depth + side-specific distance/rank 的 quote-time interaction;
  • 加入 local-flow / resiliency 字段,例如 near depth、refill edge、queue rank、taker flow deceleration;
  • 加入 calendar / regional session 特征,检查 UTC week-hour、Tokyo、Singapore/HK、London、US、重合时段是否只是成交强度差异;
  • SELL + queue_high + flow_decay_high 和更窄的 local resiliency bucket 做 daily OOS。

这些方向有局部线索,但仍没有形成 promotion:有些 bucket 是 sparse favorable,有些只在单日或少数日段成立,有些改善了 fill selection 却没有同时改善 InvAdj、库存时间和 side markout。结论不是“这些特征没用”,而是“还不能直接变成 live 参数”。

这里的“两万多行 labels”和“三百多条 fill”也不是矛盾。前者是被评估的候选 quote rows,绝大多数报价本来就不会成交;后者才是能够观测 fill 后 markout、用于校准条件结果的有效样本。P(fill) 可以从大量未成交报价中学习,但 E(markout | fill) 的统计强度最终仍受真实 filled rows 限制。这正是 maker quote-level 模型最容易出现“数据行很多、有效监督很少”的地方。

所以目前最诚实的结论是:ask-only 只保留为 quote EV / shadow 训练问题,不能因为一次 raw PnL 改善就跳过样本质量与校准 gate。bid 侧在 enhanced shadow eval 中还出现过明显退化,更不能把 both-side 结果当作自然升级。

同样需要降级的是 SELL resiliency。这个方向最初看起来像一个极窄的 soft-keep:只有在 SELL 侧、local resiliency 强、flow deceleration 明显、local rank/depth/refill 条件合格,且 reference/spot 不显示 adverse 的时候,才考虑 cap ask spread multiplier。但后续审计发现两个问题:第一,旧 direct live arm 会真的改变 spread multiplier,而 replay 没有等价语义;第二,字段名里容易把 rank 误读成 queue/front-rank,而当前实现实际是 recent local price rank。

因此当前配置已经把直接 live arm 归档:sell_resiliency_live_enabled=false,误打开时应 fail-fast,除非显式设置研究 dry-run override。它现在的价值不是改 live 报价,而是写 sell_resiliency_shadow.csv,把 ref_advspot_advlocal_rankflow_decelnear_depthrefill_edge 和 hit denominator 记下来,让第二天能复盘这个 bucket 到底触发了多少次、失败在哪个条件、fill 后 markout 是否真的更好。

2026-07-01 之后我也不再把待验证 alpha 只塞进孤立 bucket 做结论。bucket 仍然有用,但它只是诊断切片;更稳妥的主表是 order-level denominator:每一行是一笔 placed order,带 quote-time state、fill outcome、1s/5s/20s/30s markout、campaign risk、shadow flags 和 explainable scores。只有当连续 score 能在日级解释 fill rate、markout、tail 和 campaign risk,才有资格被映射成 spread/skew/lifecycle 的 shadow knob。

1.10 Shadow Mode:在线校准比回测 PnL 更诚实

NarrowGate 不应该把 quote EV 直接从回测 A/B 推到 live。更合理的中间层是 shadow mode:实盘收行情,在线计算 quote、quote EV、side policy 和 would-place/would-fill,但不把它当成交易建议,也不把它当成真实成交承诺。

shadow mode 的核心问题不是“预测收益是多少”,而是:

以及:

需要看的不是一个 raw PnL 数字,而是一组校准表:

Shadow 检查 说明 如果失败
fill probability calibration 预测的 P(fill) 分桶后,真实/可回放 fill rate 是否接近 quote EV 只能做排序,不能做阈值
realized-vs-pred markout 高 EV bucket 是否真的有更好 markout EV label 或 feature 泄漏/错配
placed orders/day shadow/replay 是否和 live 同数量级 guard/pause/requote gating 不一致
fills / placed order 每个挂单的成交概率是否接近 live queue ahead 或 latency floor 不对
side split bid/ask fill 是否失衡 side policy 或库存压力错误
inventory time 正 edge 是否靠堆库存换来 风险调整后不可用

实盘里很常见的情况是:回测看起来排到了,live 却因为 latency floor 根本没进入同一个队列位置。这个问题不能用“系统更快”简单解决,因为它通常来自三层延迟:

  • 行情到达本机的网络/解码延迟;
  • 策略从行情到下单的决策延迟;
  • 订单从本机到交易所并 ack 的新单延迟。

因此 shadow mode 需要把 measured new/cancel latency 注入 replay,再比较 live order log 与 replay would-fill:

def shadow_fill_check(event, measured_latency):
    quote = compute_quote(event)
    activation_ts = event.ts + measured_latency.new_order_ms

    replay_fill = replay_queue_fill(
        side=quote.side,
        price=quote.price,
        activation_ts=activation_ts,
        l2=event.future_l2,
        trades=event.future_trades,
    )

    return {
        "pred_fill_prob": quote.ev_fill_prob,
        "would_fill": replay_fill is not None,
        "latency_ms": measured_latency.new_order_ms,
        "queue_ahead": replay_fill.queue_ahead if replay_fill else None,
    }

这也是为什么 quote EV promotion 要比普通模型上线更保守。当前这种逐侧 quote EV 如果 valid fills 仍然偏少,或者 calibration/Brier/bucket markout 不过 gate,即使某个聚合窗口 ask-only A/B 看起来 raw 为正,也只能算 shadow/research 候选,不能直接当作 live 结论。

Shadow 小结

  • quote EV 的关键不是单次 A/B raw PnL,而是 P(fill)、markout bucket 和库存时间是否同时校准。
  • shadow mode 要把 live latency floor 和 would-fill replay 放在一起看,否则“回测排到了”不代表实盘有同样队列位置。
  • ask-only 候选即使方向看起来对,也必须先过样本量、Brier/calibration、bucket realized-vs-pred 和日度稳定性 gate。

1.10.1 Promotion gate:方向对还不够,必须校准过关

Shadow mode 不是为了再看一个漂亮 PnL,而是为了回答:

以及:

实操上我会把 gate 分成四层:

Gate 最低要求 含义
样本量 每 side validation valid fills >= 100,更理想是数百 少于这个数,bucket calibration 很容易飘
fill calibration 分桶 MAE <= 0.05 预测 20% fill 的桶,真实 fill rate 不能长期是 5% 或 50%
markout calibration bucket realized-vs-pred 同向,MAE 不恶化 高 EV bucket 至少不能有更差 markout
风险效率 InvAdj 不差,abs inventory-time 不恶化,fills/day 不塌 不能用堆库存换 raw PnL

当前 quote EV 的真实状态并没有过这些 gate。比如之前 strict-gated run 中,validation valid fills 只有几十个,fill calibration MAE 也超阈值。因此即使 ask_ev_soft_widen 在某个 A/B 里 raw 看起来不错,也只能被标为 shadow/research 候选。

一个更像上线前检查表的伪代码是:

def promotion_gate(summary):
    return (
        summary.valid_filled_rows >= 100
        and summary.fill_calibration_mae <= 0.05
        and summary.markout_bucket_direction_ok
        and summary.inv_adj_delta >= -small_tolerance
        and summary.abs_inventory_time_delta <= 0
        and summary.daily_rollup_stability_ok
    )

这不是说阈值永远不变,而是说必须有硬门槛。没有门槛的 shadow mode,很容易退化成“我又看了一张更漂亮的图”。

1.10.2 Shadow Replay 与 Shadow Simulation 不是同一种证据

项目里现在会刻意区分 shadow replay 和 shadow simulation。

Shadow Replay 更接近“把候选规则接到真实 retained-day / quote trace 上重放”。它要求使用同一套 replay 状态、同一套 placed-order denominator、同一套 fill/markout 口径,只是候选规则不改变 live 配置。比如 campaign stop-add 的 replay shadow arm 会问:如果当时 abs_inventory >= 0.006 BTC 或 campaign age 超过 60 分钟时停止继续加仓,placed/day、fills/day、pause、raw、InvAdj、inventory-time 和 side markout 会怎样变化。它的证据等级高于孤立 bucket,因为它真的经过了 replay 里的订单生命周期和 daily hard gate。

Shadow Simulation 则更宽泛:它可以是 paper path、what-if counterfactual、或者对某个标签的离线 proxy 估算。它适合快速问“这个想法有没有方向”,但如果没有接入真实 placed-order denominator、queue/fill 逻辑、daily stability 和 false-block/tail accounting,就不能直接叫候选 policy。

这也是为什么 toxic-risk 当前只能作为 shadow ranking / quote EV calibration 输入,而不能变成 hard veto。它能把 SELL 尾部风险分层,例如 high/extreme risk bucket 的 tail rate 明显更高;但同一区域也可能存在非常干净的吸收桶。只按一个 toxic label 砍单,会同时误杀 positive fills。所以它必须先通过 order-level shadow avoidance、tail capture、positive false-block、inventory-time、side markout 和 raw/InvAdj proxy,再谈极小 live arm。

1.11 只看 PnL 还不够:把库存风险写成积分

maker 策略可能用更长时间、更大库存换取同样的 PnL。如果 A/B 报告只列 raw PnL 和最终库存,这种风险会被隐藏。

项目因此加入库存时间暴露:

它表达的不是某一时刻仓位有多大,而是整个持仓期间承担了多少风险预算。两个 arm 即使最终都回到零库存,持仓 10 秒与持仓 2 小时也不应该被视为相同。

这些字段被接入 tick replay summary、事件引擎 HEALTH/metrics 和 A/B report,并进一步派生 time_avg_abs_inventorypnl_per_abs_inventory_hour。从此比较 ask EV、multi-market 和 enhanced spot 时,不只问“raw PnL 改善多少”,还要问“为这点改善占用了多少库存时间”。

这里要特别澄清 InvAdj。项目里的 InvAdj 不是“风险惩罚后的 PnL”,而是:

其中 inventory_pnl 是持仓库存随 mark price 漂移带来的价格路径分解。把它从 raw PnL 里扣掉,可以帮助区分“报价/成交本身的贡献”和“刚好持仓期间市场漂移”的贡献;但它不是库存风险罚项,也不能单独用于 alpha 或 live promotion。

这个区别很重要:一个 arm 可能让 InvAdj 看起来没那么差,只是因为它剥离了某段库存顺风或逆风漂移;但它仍可能有更差的 maker-signed markout、更长的库存时间、更高的 tail loss 或更严重的 positive false-block。因此现在读结果时,raw PnLInvAdj、side markout、tail/false-block、abs inventory-time 和 daily stability 必须一起看,不能只按一个数字排序。

1.12 库存终局:被动斜率不是万能退出路径

库存控制现在已经比早期完整很多。系统会记录:

这让 A/B 不只比较 raw PnL,还能比较“赚这点 PnL 占用了多少库存时间”。但这仍然不是库存终局。

做市系统真正被打穿时,reservation price 的斜率可能已经不够。举例说,当库存接近 max_inventory,且市场继续沿不利方向重定价时,继续靠“少挂增加库存的一侧、多挂减少库存的一侧”来恢复,可能会遇到两个问题:

  • 减仓一侧一直排不到队;
  • 能排到队时,价格已经变差,库存损失扩大。

当前 NarrowGate 有可选的 timeout close / IOC / emergency MARKET 兜底,但默认主线并不启用主动 taker 对冲。也就是说,它还没有实现这种路径:

BTCUSDC maker inventory
        |
        | risk budget breached
        v
choose hedge venue / instrument
        |
        | estimate hedge cost + basis + latency
        v
BTCUSDT perp / spot taker hedge
        |
        v
residual basis + inventory budget update

如果站在资金效率和风险终局看,下一阶段应该研究的是:

只有当主动瘦身成本、basis risk、taker fee、延迟和残余库存都进入同一张账,库存控制才从“被动做市参数”变成“风险闭环”。当前项目还没有走到这里。

本节工程结论

  • maker 研究不能只看 spread 或 raw PnL,必须把 fill 后 markout、库存时间和校准质量放进同一张表。
  • 多市场数据首先用于解释冲击来源,不应该直接变成一个全局开关。
  • 数据质量不是 ETL 细节,而是模型定义的一部分;rolling feature 和 future label 必须共享连续段口径。
  • quote EV 的 promotion gate 应该先过样本量、Brier/calibration、bucket realized-vs-pred 和分侧稳定性,再谈上线。

第二部分:回放验证与证据边界

到这里,maker 算法部分已经形成了一个闭环:候选报价、排队成交、fill 后 markout、cross-market attribution、quote EV 校准和库存时间积分。但它还必须落到 replay 和 evidence gate 上,否则很容易重新滑回“某个窗口 PnL 好看”的旧习惯。

2.1 为什么 bar 回测不够

bar 回测最容易自欺欺人的地方是:只要下一根 bar 的 low/high 碰到我的价格,就认为成交。maker 策略里这几乎一定会高估结果,因为真实世界里还有队列、延迟、TTL、cancel pending 和 post-only reject。

NarrowGate 的 tick replay 更保守:价格碰到只是必要条件,不是充分条件。订单进入盘口后,需要估计自己前面的 queue ahead,然后用后续 aggressive trade 去消耗这个 queue。简化逻辑是:

def activate_order(side, quote_px, activation_ts, l2_book):
    level_qty = l2_book.visible_qty_at(quote_px, activation_ts)
    queue_left = level_qty if level_qty is not None else conservative_fallback()
    return LiveOrder(side=side, price=quote_px, queue_left=queue_left)


def process_trade(order, trade):
    if not trade_crosses_order(trade, order):
        return

    aggress_qty = trade.qty
    consumed_ahead = min(order.queue_left, aggress_qty)
    order.queue_left -= consumed_ahead
    aggress_qty -= consumed_ahead

    if order.queue_left <= 0.0 and aggress_qty > 0.0:
        fill(order, min(order.remaining_qty, aggress_qty))

这当然仍然不是“真实交易所撮合队列”。历史 Top-N 深度看不到这个 level 内部每个订单的真实排队顺序,也看不到隐藏单和你前方订单的真实撤单。因此 queue ahead 的价值不是声称知道真实 FIFO,而是避免 bar touch 那种最危险的乐观假设,并把不可观测部分留给 live calibration。

2.2 Promotion gate:从漂亮数字到可用证据

一个 maker alpha 线索要从 research clue 走到 policy candidate,至少要穿过下面这条链:

数据质量
  -> replay/live 机制量
  -> placed-order denominator
  -> fill selection
  -> OOS bucket
  -> daily stability
  -> tail / positive false-block
  -> raw / InvAdj / inventory-time / side markout

其中 denominator 很关键。只看 filled rows 会产生幸存者偏差:你只看到了真正成交的订单,却不知道同一 bucket 下有多少未成交、被 guard 挡住、被 inventory limit 挡住或根本没有机会挂出的订单。order-level 主表更稳妥:每一行是一笔 placed order,带 quote-time state、fill outcome、1s/5s/20s/30s markout、campaign risk、shadow flags 和 explainable scores。

2.3 当前最诚实的结论

目前 enhanced spot 显示“更多市场能帮助解释 adverse flow”,但没有证明 multi_market.enabled=true 是稳定收益开关。ask quote EV、xmarket interaction、calendar/local-flow、SELL resiliency、toxic-risk、campaign stop-add 都提供过局部线索,但还没有同时通过 daily stability、tail/false-block、InvAdj/raw、库存时间和 side markout。

因此当前不能诚实地说已经选出了 live 最优参数。更准确地说,当前直接 quote EV live policy 和 SELL resiliency direct live arm 都已经归档;如果误打开,应由配置层 fail-fast,除非明确设置研究 dry-run override。SELL resiliency 现在只应被理解成“带独立 shadow denominator 的极窄证据标签”,而不是最终 alpha policy。

结论:窄门不是参数,而是证据门槛

NarrowGate 从一个 AS 公式实验,走到了数据审计、跨市场冲击归因、逐侧 quote EV、库存时间积分和 order-level denominator。这个过程也不断推翻自己的旧答案:更多 reference 没有自动带来更好 policy;ask EV 的 raw 改善没有通过校准;连续长窗回测被日度 fresh-start 取代;局部 SELL bucket、toxic-risk 和 campaign 控制只能先做 shadow/replay evidence。

最初的问题因此被越问越窄:不是“能不能预测 BTC”,而是“在这一侧、这个价格、这个盘口和这段库存暴露下,是否值得提供一次短暂的被动流动性”。

这可能也是“窄门”最贴切的地方。一个研究系统的成熟,不在于它接入了多少模型和数据源,而在于它愿意用多少层约束拒绝一个看起来很诱人的结论。


本文及 NarrowGate 项目仅用于市场微观结构、价格行为学、做市模型、机器学习回测方法和系统工程的学习交流与技术研究,不构成财务、投资、法律或合规建议。中国大陆关于 crypto 交易及相关业务活动的监管环境具有不确定性;任何人将相关代码用于连接交易平台、交易、商业展业或投资决策,其合规风险、资金损失、技术故障及其他后果均由使用者自行承担,与作者无关。