Nautilus Trader 复合做市策略(Composite Market Maker)完整指南:基于 book mid 报价、库存与外部信号双偏置的实现与调优
【免费下载链接】nautilus_traderProduction-grade Rust-native trading engine with deterministic event-driven architecture项目地址: https://gitcode.com/GitHub_Trending/na/nautilus_trader
导读
本文基于 Nautilus Trader 仓库内置的CompositeMarketMaker示例策略,深入讲解一种「单交易对做市、双信号源驱动」的做市策略实现:它围绕目标交易品种的盘口中间价(book mid)挂出单边买一与卖一,并通过**库存偏置(inventory skew)与外部信号偏置(signal skew)**两套独立的位移机制来管理风险与捕捉方向性收益。读完本文,你将掌握该策略的报价循环、重新报价(requote)门控逻辑、最坏情况持仓上限计算,以及全部配置参数在 config.rs 与 strategy.rs 中的底层实现,并能在 Rust 与 Python(v2)两条路径上直接落地使用。
策略概述:一个目标盘 + 一个信号盘的组合
CompositeMarketMaker是一个「单一交易对」的做市策略,其定义位于 crates/trading/src/examples/strategies/composite_market_maker/mod.rs:
围绕目标交易品种的 book mid 报价单边买单与单边卖单,一个第二交易品种(典型为
SyntheticInstrument)提供信号,其相对基线的残差(residual)将双边报价整体上移或下移;库存偏置则将双边报价朝当前持仓的反方向位移。
策略同时订阅两个交易品种的报价流:
- 目标交易品种(target instrument):策略实际报价挂单的市场,例如
OCPI-H100-PERP.AX; - 信号交易品种(signal instrument):驱动信号偏置的价格来源。任何发布 quote 的品种都可以充当信号源,但
SyntheticInstrument是典型选择——它可以让你把一个多腿的复合信号编码成公式(如多品种价差、指数合成),并在多个策略间复用同一信号。仓库文档中信号品种的示例 ID 为SEMI-COMPOSITE.SYNTH。
每次目标品种 tick 到来时,策略评估是否需要重新报价;而信号品种的 tick 只被动更新内部last_signal值,永远不会独立触发 requote,它要等到下一个目标 tick 才会被读取并影响报价。
报价循环(Quoting Cycle):六步流程
原文档给出的报价循环如下,每次目标品种报价 tick 都会触发:
- 计算锚点(anchor):取最新 quote 的中间价作为锚点;
- requote 门控:如果锚点与信号残差的价格影响(
signal_skew_factor * residual)自上次挂单以来都未移动超过requote_threshold_bps,则跳过本次重新报价; - 撤销旧单:取消目标品种上的所有现存订单;
- 读取头寸与最坏情况暴露:从缓存(cache)中读取当前净头寸,以及每侧的最坏情况暴露(未平仓头寸 + 所有挂单中的买单或卖单);
- 计算双边价格:在锚点基础上叠加库存偏置与信号偏置,并在提交每一侧之前执行
max_position上限检查; - 提交剩余侧订单:以
post_only限价单形式提交通过检查的那一侧。
锚点与残差门控的源码实现
在 strategy.rs 中,requote 门控被拆成两个独立的判断函数:
should_requote_on_anchor:比较当前锚点与last_quoted_anchor,相对位移|anchor - last| / last >= requote_threshold_bps / 10_000时返回true;没有上次锚点或上次锚点为 0 时直接短路返回true(保证首次能播种挂单);should_requote_on_residual:当signal_skew_factor == 0.0时直接返回false(信号残差无法影响报价,也就不该触发 requote);否则计算残差变化带来的价格影响|residual - last_residual| * |signal_skew_factor|,再除以锚点与 bps 阈值比较;should_requote = should_requote_on_anchor || should_requote_on_residual。
关键设计点:requote 阈值是「锚点位移」与「信号残差价格影响」二选一,哪个先越过阈值就触发重新报价。这意味着即使目标盘口纹丝不动,只要信号残差变化足够大,也会「穿透」requote 门控完成挂单刷新。这正对应原文档的说明:取消/重挂(cancel/replace)流量与真实价格变动成正比,同时仍能捕捉目标 tick 之间的信号更新。
有单才跳过,无单必重挂
on_quote中还有一处容易被忽略的逻辑:if !self.should_requote(...) && has_resting { return Ok(()); }。即只有当门控未通过且盘口上仍有挂单时才直接返回;如果当前没有挂单(例如全部被外部取消或成交),即使位移未达阈值也会重新报价补单。has_resting通过缓存统计orders_open_count与orders_inflight_count得到。
库存偏置(Inventory Skew)
原文档给出公式:
inventory_shift = inventory_skew_factor * net_position当inventory_skew_factor为正且策略持有多头时,双边同时下移:买价离市场更远(更难成交、防止继续加仓),卖价离市场更近(更易成交、鼓励减仓)。位移是对称的,因此报价价差(spread)保持不变,改变的只是价差相对锚点的位置。
源码中对应的实现位于compute_quotes(strategy.rs):
let inventory_shift = self.config.inventory_skew_factor * net_position; let signal_shift = self.config.signal_skew_factor * signal_residual; // Positive signal residual lifts both sides; positive inventory lowers both sides. let total_shift = signal_shift - inventory_shift; let bid_f64 = anchor_f64 - half_spread + total_shift; let ask_f64 = anchor_f64 + half_spread + total_shift;注意net_position的符号约定:持仓为正(多头)时inventory_shift为正,total_shift = signal_shift - inventory_shift会整体下移;持仓为负(空头)时整体上移。这一符号关系在 tests.rs 中有明确验证:
- 多头 2 单位、
inventory_skew_factor=1.0、锚点 1000、半价差 100bps(10 元)时:bid = 988.00、ask = 1008.00(整体下移 2 元); - 空头 -2 单位时:bid = 992.00、ask = 1012.00(整体上移 2 元)。
报价的 tick 化与防交叉处理
计算出原始 bid/ask 浮点价格后,代码使用instrument.next_bid_price(bid_f64, 0)向下取整到最近的合法买价 tick,用instrument.next_ask_price(ask_f64, 0)向上取整到最近的合法卖价 tick——这种取整方向保证了非交叉报价对在取整后仍然不交叉。但当偏置超过半价差(粗 tick 品种上)时取整仍可能让买价 ≥ 卖价,此时compute_quotes直接返回空订单列表(crossed检查),放弃本轮挂单而不是提交会吃单的订单。这是源码注释明确说明的防御逻辑。
信号偏置(Signal Skew)
原文档给出公式:
residual = (signal_mid - baseline) / baseline signal_shift = signal_skew_factor * residual当signal_skew_factor为正且残差为正(信号价格高于基线)时,双边报价同幅上移——这正是信号预示目标品种即将上行漂移时想要的挂单位置。基线的取值有两种方式:
- 配置
signal_baseline:显式给出(对回测是确定性的); - 不配置(
None):捕捉第一个观察到的信号 mid 作为基线。
源码中signal_residual()(strategy.rs)的实现为:
match (self.last_signal, self.signal_baseline) { (Some(signal), Some(baseline)) if baseline != 0.0 => signal / baseline - 1.0, _ => 0.0, }signal / baseline - 1.0即(signal - baseline) / baseline。测试 tests.rs 覆盖了三个边界情形:
- 没有信号时残差恒为 0.0;
- 基线 100、信号 110 时残差为 0.10;
- 基线为 0 时守卫生效,返回 0.0 避免除零。
信号 tick 处理(on_quote前半段)只做两件事:更新last_signal = f64::midpoint(bid, ask);若signal_baseline尚未设置则用首个信号 mid 捕获基线。测试test_signal_tick_updates_last_signal_and_captures_baseline_once验证了基线只捕获一次;test_signal_tick_does_not_overwrite_explicit_baseline验证显式配置的基线永远不会被信号 tick 覆盖。
持仓上限(Position Limits):最坏情况投影
在提交每一侧之前,策略会投影最坏情况的单侧暴露:
worst_long = 净持仓(带方向) + 所有挂单中的买单剩余量 worst_short = 净持仓(带方向) - 所有挂单中的卖单剩余量源码中(strategy.rs)通过缓存同时遍历orders_open与orders_inflight,用AHashSet去重后按方向累加leaves_qty。随后在compute_quotes中执行上限检查:
if let Some(price) = bid_price && worst_long + trade_size <= max_pos { orders.push((OrderSide::Buy, price)); } if let Some(price) = ask_price && worst_short - trade_size >= -max_pos { orders.push((OrderSide::Sell, price)); }为什么连 in-flight 订单也算:异步撤单在途期间,挂单仍然可能成交并计入持仓,因此必须把未确认撤单的订单继续视为暴露,max_position才能在上限处真正「封顶」。这与仓库中同目录下的GridMarketMaker采用同一模式(见 mod.rs 的模块注释)。测试test_compute_quotes_max_position_blocks_buy/test_compute_quotes_max_position_blocks_sell验证了触顶后对应侧订单被丢弃、只保留另一侧。
需要强调的是:策略在触顶时停止加仓,但不会主动减仓。偏置会让某一侧变得更「划算」,那一侧成交把仓位打回来是唯一的退出路径——这是原文档明确描述的行为边界。
配置参数全表
原文档给出的完整配置表如下,字段定义可在 config.rs 中逐条核对:
| Parameter | Type | Default | Description |
|---|---|---|---|
instrument_id | InstrumentId | required | Target instrument the strategy quotes on. |
signal_instrument_id | InstrumentId | required | Signal instrument (typically a synthetic) whose mid drives the signal residual. |
max_position | Quantity | required | Hard cap on net exposure (long or short). |
trade_size | Option<Quantity> | None | Size per quote. WhenNone, resolves from the instrument'smin_quantity. |
half_spread_bps | u32 | 5 | Half the desired quoted spread, in basis points of the anchor. |
inventory_skew_factor | f64 | 0.0 | Price units per unit of net position. Both sides shift down by this times position. |
signal_skew_factor | f64 | 0.0 | Price units per unit of normalized signal residual. Both sides shift up. |
signal_baseline | Option<f64> | None | Baseline price for the signal residual. WhenNone, captured from the first signal. |
requote_threshold_bps | u32 | 5 | Minimum anchor or signal-residual price-impact move in bps before re-quoting. |
expire_time_secs | Option<u64> | None | Order expiry in seconds. When set, orders use GTD time-in-force. |
on_cancel_resubmit | bool | false | Resubmit on the next quote after an external cancel. |
源码层的参数语义补充
base: StrategyConfig:每个示例策略配置都内嵌一个基础策略配置。CompositeMarketMakerConfig的默认base为strategy_id: "COMPOSITE_MM-001"、order_id_tag: "001"(config.rs),测试test_config_defaults逐项断言了这些默认值;trade_size = None的解析时机:在on_start中从缓存解析目标品种,取min_quantity;若品种未定义最小数量,则回退为Quantity::new(1.0, size_precision)(strategy.rs);half_spread_bps的语义:5表示半价差 5bps,即偏置前的完整报价价差为 10bps;expire_time_secs:设置后订单使用TimeInForce::Gtd,expire_time = now + expire_time_secs(基于策略时钟);on_cancel_resubmit:为true时,若发生非策略自发的外部撤单(如交易所主动撤掉短期订单),策略会重置last_quoted_anchor/last_quoted_residual,让下一个 tick 直接重新挂单。实现上策略用pending_self_cancels集合区分「自己发起的撤单」与「外部/协议撤单」(strategy.rs),测试test_on_order_canceled_self_cancel_preserves_anchor与test_on_order_canceled_protocol_cancel_resets_anchor验证了这一区分;- 订单事件重置:
on_order_rejected与on_order_expired都会无条件重置锚点与残差状态并清理pending_self_cancels,保证被拒/过期后能立即重新报价。
生命周期行为
从DataActortrait 的实现可以看到完整的生命周期管理(strategy.rs):
on_start:解析目标品种信息(price_precision、size_precision、min_quantity),解析trade_size,随后订阅目标与信号两个品种的 quote 流;on_stop:取消目标品种全部订单、平掉全部仓位,并退订两个 quote 流;on_reset:清空全部运行状态——instrument、price_precision、last_quoted_anchor、last_quoted_residual、last_signal、pending_self_cancels,并把signal_baseline恢复为配置值(未配置则恢复为None),trade_size恢复为配置值。测试test_on_reset_reverts_signal_baseline_to_config_value验证了显式基线的恢复行为。
调优指南(Tuning Guidelines)
原文档给出的四组调优建议,是实战中最有价值的经验参数:
- 流动性好的盘口(Liquid book):小
half_spread_bps(5 到 10)搭配紧requote_threshold_bps(2 到 5)。策略能吃到价差,但必须跟上中间价的移动速度; - 流动性差的盘口(Illiquid book):更宽的
half_spread_bps(20 到 50)与requote_threshold_bps(10 到 20)。报价能容忍陈旧盘口条件,不会在每一次微小抖动上都造成撤单风暴; - 库存控制(Inventory control):选择
inventory_skew_factor,使factor * max_position落在半价差的 10% 到 50% 区间。超过这个区间,偏置后的报价会穿越活跃盘口,作为 post-only 订单被拒;低于这个区间,偏置太小不足以改变成交概率; - 信号权重(Signal weighting):选择
signal_skew_factor,使factor * typical_residual是 tick size 的小倍数。例如信号偏离基线 5%、signal_skew_factor = 1.0时产生 0.05 个价格单位的位移——对低价品种有实际意义,对高价品种则几乎不可见。
结合源码可以进一步理解这些建议的机理:半价差、库存位移、信号位移最终在compute_quotes中合并为total_shift,再经过next_bid_price/next_ask_price的 tick 取整。当|total_shift|超过半价差时,报价对会交叉并整轮被丢弃——这正是「偏置过大会导致挂不出单」的源码级原因;而requote_threshold_bps则通过should_requote控制撤单重挂的频率,直接决定 cancel/replace 流量与交易所费率成本。
Rust 用法
原文档给出的 Rust 用法如下(路径为 crates/trading/src/examples/strategies/composite_market_maker/mod.rs 导出的CompositeMarketMaker/CompositeMarketMakerConfig):
use nautilus_model::{identifiers::InstrumentId, types::Quantity}; use nautilus_trading::examples::strategies::{ CompositeMarketMaker, CompositeMarketMakerConfig, }; let config = CompositeMarketMakerConfig::builder() .instrument_id(InstrumentId::from("OCPI-H100-PERP.AX")) .signal_instrument_id(InstrumentId::from("SEMI-COMPOSITE.SYNTH")) .max_position(Quantity::from("100")) .trade_size(Quantity::from("100")) .half_spread_bps(25) .inventory_skew_factor(0.0005) .signal_skew_factor(0.5) .requote_threshold_bps(10) .build(); let strategy = CompositeMarketMaker::new(config); node.add_strategy(strategy)?;配置采用bon::Builder派生构造器(见 config.rs),必填项为instrument_id、signal_instrument_id、max_position,其余参数均有默认值——test_config_defaults断言了half_spread_bps = 5、requote_threshold_bps = 5、两个 skew factor 均为0.0、trade_size = None、signal_baseline = None、expire_time_secs = None、on_cancel_resubmit = false。
Python 用法(v2)
在 Python v2 中,将配置传给LiveNode或BacktestEngine的add_builtin_strategy即可。Python 只负责提供配置,策略本身完全运行在 Rust 侧:
from nautilus_trader.trading import CompositeMarketMakerConfig config = CompositeMarketMakerConfig( instrument_id=InstrumentId.from_str("OCPI-H100-PERP.AX"), signal_instrument_id=InstrumentId.from_str("SEMI-COMPOSITE.SYNTH"), max_position=Quantity.from_str("100"), trade_size=Quantity.from_str("100"), half_spread_bps=25, inventory_skew_factor=0.0005, signal_skew_factor=0.5, requote_threshold_bps=10, ) node.add_builtin_strategy("CompositeMarketMaker", config)CompositeMarketMakerConfig通过pyo3暴露为nautilus_trader.trading模块下的 Python 类(config.rs),并登记在 python/nautilus_trader/trading/init.pyi 的类型存根中。Python 侧还可以额外传入strategy_id与order_id_tag覆盖默认策略 ID 与订单 ID 前缀,这一用法在仓库测试 python/tests/unit/trading/test_example_configs.py 中有直接示例:
CompositeMarketMakerConfig( instrument_id=INSTRUMENT_ID, signal_instrument_id=SIGNAL_INSTRUMENT_ID, max_position=Quantity.from_str("1"), strategy_id=STRATEGY_ID, order_id_tag=ORDER_ID_TAG, )测试覆盖:行为契约的可验证依据
策略的完整行为契约由 tests.rs 中的 30 余个rstest测试固化,可作为理解与改造策略时的行为参考:
- requote 门控:无上次报价时必重挂;阈值内不重挂;恰达阈值重挂;锚点不动但信号残差价格影响越过阈值时重挂;
signal_skew_factor = 0时残差门控完全失效; - 除零防御:基线为 0 时残差返回 0;锚点为 0 时残差门控短路为
false;上次锚点为 0 时锚点门控短路为true; - 报价计算:无偏置时的对称报价(bid = anchor - half_spread,ask = anchor + half_spread);库存偏置多/空头方向的位移;信号偏置正残差的上移;组合偏置的叠加;偏置对称性保证价差不变;
max_position阻断买单/卖单; - 事件处理:自撤单保留锚点状态、外部撤单(开启 resubmit 时)重置锚点、拒绝/过期无条件重置、
on_reset全状态清理与基线回退。
适用边界与注意事项
- 本策略是仓库内置的示例策略(位于
crates/trading/src/examples/),定位是展示 Nautilus Trader 策略框架的完整写法(配置构造、DataActor生命周期、缓存读取、订单提交、事件处理),并非开箱即用的生产系统——上线前仍需结合具体交易所的费率结构、盘口深度与风控要求自行评估; - 策略提交的是
post_only限价单(strategy.rs 中Some(true)参数),一旦偏置让报价穿越活跃盘口,订单会被交易所按 post-only 违约拒绝——这正是调优指南中「偏置过大会被拒」的出处; - 库存上限是「停止加仓」而非「主动减仓」,退出路径完全依赖偏置让某一侧更易成交,在极端单边行情下需要外部风控兜底。
如需继续深入,可对照阅读仓库中同目录的其他示例策略(如 grid_mm、delta_neutral_vol),以及add_builtin_strategy在 crates/backtest/src/python/engine.rs 与 crates/live/src/python/node.rs 中的注册实现,以了解示例策略在回测引擎与实盘节点上的完整接入链路。
【免费下载链接】nautilus_traderProduction-grade Rust-native trading engine with deterministic event-driven architecture项目地址: https://gitcode.com/GitHub_Trending/na/nautilus_trader
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考