NarrowGate:Maker Quote EV 与被动做市研究框架
TL;DR:读者收益摘要
NarrowGate 不是一个“做市策略已经跑通并盈利”的项目,而是一个用于验证 maker quote EV 的研究框架。它研究的核心问题是:一笔被动限价单被成交之后,到底是在收取 spread,还是在替更快的信息交易者接风险。
这篇文章只讲算法与证据框架;C++、pybind、x86 benchmark 和 live hot path 细节拆到另一篇:NarrowGate:C++ Tick Replay 与低延时工程边界。
读完这篇文章,至少可以得到六个结论:
- maker fill 不能简单等价于 spread 收益,必须用 fill 后 markout 和 inventory exposure 衡量;
- 多市场 reference 有信息量,但不等于统一打开
multi_market就有正 EV; - tick replay、queue ahead、latency、TTL 和 maker fill gate 会显著改变 bar 回测结论;
- 很多早期结果已经被后续审计推翻:连续跨日/月度 replay、坏日/gap 污染、markout EMA latch、fills/day 分母错误,都不能再当作 alpha 证据;
- 当前项目没有选出可以称为“最优 live 参数”的策略结论;quote EV、SELL resiliency、toxic-risk、campaign stop-add 等都只能留在 shadow/evidence 或 replay shadow 层;
- 动态到达率、主动对冲、多 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 默认关闭或归档 |
整个研究链路可以概括成:
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 里至少有四类数据:
| 数据层 | 主要来源 | 在项目中的角色 | 最容易出错的地方 |
|---|---|---|---|
| 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 都可以触发报价,但真正值得暴露的窗口必须经过几道门:
- 当前 spread 是否提供了足够的流动性补偿;
- 报价相对 mid/BBO 的位置是否合理;
- 主动成交和盘口压力是短期流动性冲击,还是信息驱动的重定价;
- reference perp 与 spot 是否确认了这个变化;
- 库存、挂单 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 的骨架,是因为它给出了稳定、可解释的库存坐标系;但具体的 sigma、kappa、depth half-spread、adverse widening 和 quote EV gate 都要由数据重新验证。
实际系统里,AS 公式只是报价骨架。NarrowGate 的 quote core 更接近下面这个组合:
这里最容易误解的是 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 逻辑可以写成:
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_10s 按 vol_blend 混合 |
gamma * sigma_sq,以及 regime spread scale |
spread<100、cap hit、波动窗口下 markout |
kappa |
strategy.kappa 或 p3_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所以机器学习不是直接“预测价格然后下单”。它有三类入口:
- 改变物理 quote:
vol_10s、ret_10s、dir_10s影响 reservation、spread、asymmetry; - 改变 policy gate:
tox_bid/ask、markout EMA、microprice shift 影响 widen/shrink/pause; - 改变风险读法: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 阈值最优”,而是先问:
- replay 挂单数量是否和 live 同数量级;
- fills/hour、bid/ask split、spread、block reason 是否能对齐;
- fill selection 和 maker-signed markout 是否在 OOS 日段保持稳定;
- 通过这些机制 gate 后,再谈某个 policy 是否有 alpha。
同一轮也训练过新的 models/saved_btcusdc_daily_retrain_20260627,但它没有晋级:成交数更高,raw/InvAdj 却明显更差。这反过来提醒我,maker 模型不是“越活跃越好”,很多时候更高的 fill count 只是更高频地接到了有毒流。
1.5 动态 kappa:AS 是坐标系,不是答案
Avellaneda-Stoikov 给了一个非常有用的坐标系:
但真正困难的是右边这些变量如何变成实时市场状态,而不是公式本身。
当前 NarrowGate 对 kappa 的处理有三层:
- 静态
strategy.kappa作为 baseline; p3_kappa_eff/ general intensity 用历史 fill probability 拟合更一般的Lambda(delta);- 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 中出现过两种更隐蔽的问题:
- 某些小时文件直接缺失;
- 文件存在,但内部缺少足够的 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 数据被物理删除。由于跨日压缩包可能混合好日期与坏日期,不能只在聚合容器里删掉某一天,所以改为:
- 删除覆盖坏日期的非日度 aggTrades 容器;
- 从 Binance Vision 按日重新下载 retained good days;
- 验证坏日期日度文件命中为 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 后,仍能在 raw、bars_1s、bbo/l2、raw_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 validrolling 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.py、cross_market_shock_audit.py 和 train_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-5、spot_unconfirmed、spot_confirmed、ref_confirmed、ref_adverse_gt5e-5 等 bucket 审计,也检查过 sign convention、label leakage、side / distance / near depth / queue rank / guard state / day 的拆分。结果并不是“reference favorable 就能放开”:
spot_confirmed、spot_adverse_gt5e-5、ref_confirmed、ref_adverse_gt5e-5更稳定地表现为负向 risk label;ref_favorable_gt2e-5和spot_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_adv、spot_adv、local_rank、flow_decel、near_depth、refill_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_inventory 与 pnl_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 PnL、InvAdj、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 交易及相关业务活动的监管环境具有不确定性;任何人将相关代码用于连接交易平台、交易、商业展业或投资决策,其合规风险、资金损失、技术故障及其他后果均由使用者自行承担,与作者无关。
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!
