在 dYdX v4 上使用 NautilusTrader 运行短期订单链上网格做市(GridMarketMaker 实战指南)
【免费下载链接】nautilus_traderProduction-grade Rust-native trading engine with deterministic event-driven architecture项目地址: https://gitcode.com/GitHub_Trending/na/nautilus_trader
本指南以 NautilusTrader 仓库内自带的GridMarketMaker策略与 dYdX v4 实时交易示例为核心,讲解如何通过 RustLiveNode在去中心化永续合约交易所上运行一套「围绕中间价对称挂单、按库存倾斜网格、依赖区块高度过期替代显式撤单」的链上网格做市系统。读完本文,你将掌握几何网格定价公式、库存偏移与仓位上限的实现原理、dYdX 短时订单(short-term order)与 GTD 过期机制的正确用法,以及从环境配置、运行示例到日志监控、参数调优的完整实战流程。
策略工作原理
网格做市策略(Grid Market Maker)会在当前中间价(mid)上下、以固定价格间隔挂出一串买方与卖方限价单。每当某一侧订单成交,策略即可赚取买卖档位之间的价差收益。库存管理负责把净敞口控制在max_position之内,防止网格累积成方向性仓位。
GridMarketMaker是仓库内自带的示例策略,源码位于 crates/trading/src/examples/strategies/grid_mm/strategy.rs,其核心事件流如下:
库存偏移(Avellaneda-Stoikov 启发)
当仓位转为净多头时,整个网格会向下平移(买价更低、卖价也更低),促使下一笔成交落在卖出一侧;当仓位转为净空头时,网格向上平移。这与 Avellaneda-Stoikov 做市框架的思想一致,只是被适配到了离散网格上:
skew = skew_factor * net_position为什么选择 dYdX v4
dYdX v4 天然适合做市场景:
- 短时订单(约 20 秒过期):下单延迟低,且无需链上存储成本;
- 约 0.5 秒的出块时间:确认周期短;
- 撤单无 gas 费用:短时订单在 GTB(Good-Til-Block)重放保护下撤单免费;
- 链上订单簿:逐区块确定性撮合;
- 批量撤单:一次
MsgBatchCancel即可清空全部短时订单。
前置条件
已注资的 dYdX 账户
需要一个以 USDC 作为抵押品的 dYdX 账户。测试网账户的创建与注资方法参见 dYdX 集成指南中的 Testnet setup 章节。测试网钱包还需在 dYdX UI 中注册一个 API 交易密钥(API Trading Key)。集成指南同时提醒:dYdX v4 交易账户(子账户 0)只有在钱包完成首次充值或交易后才会创建,在此之前 gRPC 与 Indexer 账户查询会返回 not-found,导致DydxExecutionClient.connect()在初始化交易序列时失败。
环境变量
# Mainnet export DYDX_PRIVATE_KEY="0x..." export DYDX_WALLET_ADDRESS="dydx1..." # Testnet export DYDX_TESTNET_PRIVATE_KEY="0x..." export DYDX_TESTNET_WALLET_ADDRESS="dydx1..."策略概览
几何网格定价
每一档都距离中间价一个固定的百分比(以基点表示):
Buy level N: mid * (1 - bps/10000)^N - skew Sell level N: mid * (1 + bps/10000)^N - skew其中skew = skew_factor * net_position。
以 3 档网格、grid_step_bps=100(1%)、中间价 1000.00 为例:
Sell 3: 1030.30 Sell 2: 1020.10 Sell 1: 1010.00 ─── Mid: 1000.00 ─── Buy 1: 990.00 Buy 2: 980.10 Buy 3: 970.30当净持仓为多 2 手、skew_factor=1.0时,整个网格下移 2.0:
Sell 3: 1028.30 Sell 2: 1018.10 Sell 1: 1008.00 ─── Mid: 1000.00 ─── Buy 1: 988.00 Buy 2: 978.10 Buy 3: 968.30库存管理
策略通过两种机制强制仓位上限:
max_position:净敞口(多或空)的硬上限。当加入下一档网格后预估敞口会突破该上限时,该档会被跳过。- 投影敞口跟踪(projected exposure tracking):在放置每一档之前,策略跟踪每侧的"最坏情况"敞口(当前仓位 + 所有挂单/在途买单或卖单),避免过度承诺。
由于cancel_all_orders是异步的,撤单请求与确认之间挂单仍可能成交。在撤单-重挂(cancel-requote)过渡期间跟踪最坏情况的单侧敞口,可以防止瞬时超仓。从源码看(strategy.rs),on_quote会从缓存中聚合所有未平仓与在途订单,并按leaves_qty去重累加买卖两侧的待成交数量,再与当前仓位相加得到worst_long/worst_short作为网格定价的输入。
重挂阈值
requote_threshold_bps控制中间价需要移动多少,策略才会撤掉全部在挂订单并重新放置一整张网格:
- 较低阈值(5 bps):响应更快,但撤单/下单交易更多;
- 较高阈值(50 bps):交易更少,但订单可能离当前价格更远。
should_requote的实现(strategy.rs)会在last_quoted_mid为空或为 0 时直接返回 true(首次报价必重挂),否则比较(mid - last_mid).abs() / last_mid >= requote_threshold_bps / 10000。单元测试 tests.rs 中的test_should_requote_*系列用例验证了阈值边界:1000.30 不触发(5 bps 内)、1000.50 恰好触发、999.40 反向触发。
配置
GridMarketMakerConfig定义于 crates/trading/src/examples/strategies/grid_mm/config.rs,各参数说明如下:
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
instrument_id | InstrumentId | 必填 | 交易的标的(如ETH-USD-PERP.DYDX)。 |
max_position | Quantity | 必填 | 最大净敞口(多头或空头)。 |
trade_size | Quantity | None | 每个网格档位的数量。若为None,则使用标的的min_quantity或 1.0。 |
num_levels | usize | 3 | 买、卖两侧的档位数。 |
grid_step_bps | u32 | 10 | 网格间距(基点,10 = 0.1%)。 |
skew_factor | f64 | 0.0 | 根据库存偏移网格的激进程度。 |
requote_threshold_bps | u32 | 5 | 触发重挂的最小中间价变动(bps)。 |
expire_time_secs | Option<u64> | None | 订单过期时间(秒)。设置后使用 GTD,否则使用 GTC。 |
on_cancel_resubmit | bool | false | 收到非策略发起的撤单事件后,在下一个报价时重新提交整张网格。 |
其中base字段(StrategyConfig)的默认值为strategy_id = "GRID_MM-001"、order_id_tag = "001",这已被 tests.rs 中的test_config_defaults用例锁定验证。
参数选择建议
grid_step_bps:波动市况下取 50-100 bps,平静市况下取 5-20 bps。网格越宽,每笔成交捕获的价差越大,但成交频率越低。skew_factor:从0.0起步。取0.5意味着每单位净仓位使网格偏移 0.5 个价格单位。过于激进的偏移可能让整个网格完全移到中间价上方或下方。expire_time_secs:对 dYdX 短时订单建议设为8秒。这落在 40 区块(约 20 秒)短时窗口之内,让订单走快速短时通道;设为None时订单使用 GTC 并走长时通道。on_cancel_resubmit:在收到策略未发起的撤单事件后(如 indexer 发出的短时订单过期、自成交保护、风控限制),重置重挂锚点,使下一个报价即使中间价未越过requote_threshold_bps也会重建网格。dYdX 的 indexer 会在短时订单过期后不久为每一笔订单发出 cancel 事件,正是该标志让网格得以持续更新。
dYdX 特有考量
短时订单过期
当expire_time_secs=8时,适配器会把订单归类为短时订单:
- 适配器检查
8s < max_short_term_secs(40 区块 × 约 0.5 秒 ≈ 20 秒)。 - 订单以短时方式提交,
GoodTilBlock = current_height + N。 - 约 8 秒后若未成交,订单在链上过期。过期不消耗 gas(GTB 重放保护在链上处理),但 indexer 仍会在过期区块后不久为每笔过期订单发出
OrderCanceled事件,因此策略通过常规的撤单事件路径感知到过期。
这是做市的推荐配置,原因在于:
- 短时订单延迟更低;
- 过期无链上 gas 成本;
- 由 indexer 发出的撤单事件驱动(配合
on_cancel_resubmit=true)持续替换过期订单。
更完整的订单分类逻辑(短时/长时/条件单的自动路由规则、max_short_term_secs = 40 blocks × seconds_per_block的计算方式)参见 dYdX 集成指南的 order classification 章节。
意外撤单与on_cancel_resubmit
pending_self_cancels集合用于区分自我发起的撤单与意外撤单:
- 策略调用
cancel_all_orders时,会把所有在挂订单 ID 记录进pending_self_cancels。 on_order_canceled触发时:- 若订单 ID 在
pending_self_cancels中,属于自我撤单,无需处理; - 否则说明不是策略发起的(短时订单过期、自成交保护或风控限制),重置
last_quoted_mid,让下一个报价触发完整网格重挂。
- 若订单 ID 在
这样既避免策略在自身撤单潮期间无谓重挂,又能对外部事件做出响应。on_order_filled也会从pending_self_cancels中移除订单——如果订单在撤单确认到达前成交,可防止陈旧条目累积(源码见 strategy.rs)。
值得注意的细节:只有当订单完全成交(order.is_closed())时才移除 ID,部分成交必须保留该 ID,否则后续的自我撤单会被误判为外部撤单。此外,on_order_rejected与on_order_expired都会清掉对应 ID 并重置last_quoted_mid,使下一个报价可以重试整张网格。这些行为均有对应的单元测试覆盖(tests.rs)。
订单量化
dYdX 市场的价格与数量量化由适配器的OrderMessageBuilder自动处理,无需手动取整或换算。dYdX 使用基于整数的价格/数量量化(atomic_resolution、quantum_conversion_exponent、step_base_quantums、subticks_per_tick等参数),详见 dYdX 集成指南的 Price and size quantization 章节。
Post-only 订单
网格内所有订单都以post_only=true提交。交易所会在撮合时拒绝任何穿越价差的订单,因此每笔成交都落在 maker 费率档,网格也不会在重挂过渡期间意外吃下自己的挂单。dYdX 对 post-only 订单的具体行为是「先接受、再立即取消」(而非带原因的拒绝),详见 dYdX 集成指南。
运行与停止
环境设置
凭据从环境变量或项目根目录的.env文件加载(通过dotenvy自动加载):
# Direct export export DYDX_PRIVATE_KEY="0x..." export DYDX_WALLET_ADDRESS="dydx1..."# .env equivalent DYDX_PRIVATE_KEY=0x... DYDX_WALLET_ADDRESS=dydx1...运行示例
cargo run --example dydx-grid-mm --package nautilus-dydx --features examples示例默认面向主网。如需测试网,把示例文件顶部附近的DYDX_NETWORK常量改为DydxNetwork::Testnet(需要测试网 API 交易密钥)后重新编译。
优雅关闭
按Ctrl+C停止节点,关闭序列如下:
- 收到 SIGINT,trader 停止,触发
on_stop; - 策略撤掉所有订单并平掉仓位;
- 5 秒宽限期(
delay_post_stop_secs)处理残留事件; - 客户端断开,节点退出。
代码走读
main函数位于 crates/adapters/dydx/examples/node_grid_mm.rs:
const DYDX_NETWORK: DydxNetwork = DydxNetwork::Mainnet; #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { dotenvy::dotenv().ok(); let network = DYDX_NETWORK; let environment = Environment::Live; let trader_id = TraderId::from("TESTER-001"); let account_id = AccountId::from("DYDX-001"); let node_name = "DYDX-GRID-MM-001".to_string(); let instrument_id = InstrumentId::from("ETH-USD-PERP.DYDX"); let data_config = DydxDataClientConfig { network, ..Default::default() }; let exec_config = DydxExecutionClientConfig { account_id, network, ..Default::default() }; let data_factory = DydxDataClientFactory::new(); let exec_factory = DydxExecutionClientFactory::new(); let log_config = LoggerConfig { stdout_level: LevelFilter::Info, ..Default::default() }; let mut node = LiveNode::builder(trader_id, environment)? .with_name(node_name) .with_logging(log_config) .add_data_client(None, Box::new(data_factory), Box::new(data_config))? .add_exec_client(None, Box::new(exec_factory), Box::new(exec_config))? .with_reconciliation(false) .with_delay_post_stop_secs(5) .build()?; let config = GridMarketMakerConfig::builder() .instrument_id(instrument_id) .max_position(Quantity::from("0.10")) .num_levels(3) .grid_step_bps(100) .skew_factor(0.5) .requote_threshold_bps(10) .expire_time_secs(8) .on_cancel_resubmit(true) .build(); let strategy = GridMarketMaker::new(config); node.add_strategy(strategy)?; node.run().await?; Ok(()) }配置要点:
dotenvy::dotenv().ok():若项目根目录存在.env则加载;with_reconciliation(false):为简化示例而关闭;生产环境建议开启,以便跨重启恢复状态;with_delay_post_stop_secs(5):关闭期间为待处理的撤单与平仓事件预留的宽限期。
事件流
策略内部实现
以下关键 Rust 片段取自grid_mm模块源码(crates/trading/src/examples/strategies/grid_mm/strategy.rs)。
交易数量解析(on_start)
交易数量按「配置值 → 标的min_quantity→ 1.0」的顺序从标的缓存解析:
fn on_start(&mut self) -> anyhow::Result<()> { let instrument_id = self.config.instrument_id; let (instrument, size_precision, min_quantity) = { let cache = self.cache(); let instrument = cache .instrument(&instrument_id) .ok_or_else(|| anyhow::anyhow!("Instrument {instrument_id} not found in cache"))?; ( instrument.clone(), instrument.size_precision(), instrument.min_quantity(), ) }; self.price_precision = Some(instrument.price_precision()); self.instrument = Some(instrument); if self.trade_size.is_none() { self.trade_size = Some(min_quantity.unwrap_or_else(|| Quantity::new(1.0, size_precision))); } self.subscribe_quotes(instrument_id, None, None); Ok(()) }报价处理器(on_quote,节选)
fn on_quote(&mut self, quote: &QuoteTick) -> anyhow::Result<()> { let mid_f64 = (quote.bid_price.as_f64() + quote.ask_price.as_f64()) / 2.0; let mid = Price::new( mid_f64, self.price_precision .expect("price_precision should be resolved in on_start"), ); if !self.should_requote(mid) { return Ok(()); // Mid hasn't moved enough, keep existing grid } self.cancel_all_orders( instrument_id, None, None, true, // Restrict cancellation to this strategy. None, )?; let (net_position, worst_long, worst_short) = { /* ... */ }; let grid = self.grid_orders(mid, net_position, worst_long, worst_short); if grid.is_empty() { return Ok(()); // Don't advance requote anchor when fully constrained } let (tif, expire_time) = match self.config.expire_time_secs { Some(secs) => { let now_ns = self.clock().timestamp_ns(); let expire_ns = now_ns + secs * 1_000_000_000; (Some(TimeInForce::Gtd), Some(expire_ns)) } None => (None, None), }; for (side, price) in grid { let order = self.order().limit( instrument_id, side, trade_size, price, tif, expire_time, Some(true), // post_only ); self.submit_order(order, None, None)?; } self.last_quoted_mid = Some(mid); Ok(()) }实现中还包含一个文档之外的重要防御逻辑:即使中间价未越过阈值,只要当前没有任何在挂或在途订单(网格为空),策略也会立即重挂,避免「网格被清空后因中间价波动不足而停摆」的边界情况。
网格定价(grid_orders)
计算几何网格价格,并在每一档强制执行max_position:
fn grid_orders( &self, mid: Price, net_position: f64, worst_long: Decimal, worst_short: Decimal, ) -> Vec<(OrderSide, Price)> { let instrument = self .instrument .as_ref() .expect("instrument should be resolved in on_start"); let mid_f64 = mid.as_f64(); let skew_f64 = self.config.skew_factor * net_position; let pct = self.config.grid_step_bps as f64 / 10_000.0; let trade_size = self .trade_size .expect("trade_size should be resolved in on_start") .as_decimal(); let max_pos = self.config.max_position.as_decimal(); let mut projected_long = worst_long; let mut projected_short = worst_short; let mut orders = Vec::new(); for level in 1..=self.config.num_levels { let buy_f64 = mid_f64 * (1.0 - pct).powi(level as i32) - skew_f64; let sell_f64 = mid_f64 * (1.0 + pct).powi(level as i32) - skew_f64; let buy_price = instrument.next_bid_price(buy_f64, 0); let sell_price = instrument.next_ask_price(sell_f64, 0); if let Some(buy_price) = buy_price && projected_long + trade_size <= max_pos { orders.push((OrderSide::Buy, buy_price)); projected_long += trade_size; } if let Some(sell_price) = sell_price && projected_short - trade_size >= -max_pos { orders.push((OrderSide::Sell, sell_price)); projected_short -= trade_size; } } orders }next_bid_price向下取整到最近的有效买盘 tick(≤ 理论买价),next_ask_price向上取整到最近的有效卖盘 tick(≥ 理论卖价),防止在粗 tick 标的上发生自成交。单元测试(tests.rs)验证了取整行为:1% 几何网格下第三档买价 1000×0.99³≈970.299 被取整为 970.29,第三档卖价 1000×1.01³≈1030.301 被取整为 1030.31。
max_position约束同样有测试覆盖:净持仓 9.9、trade_size=0.1、max_position=10.0时只允许 1 档买单、3 档卖单;当净持仓已达上限 10.0 时买单全部被屏蔽。若max_position小于单档数量(如 0.05 vs 0.1),整张网格为空,此时策略不会推进重挂锚点,等待后续报价继续尝试。
一次 35 秒主网运行的结果
使用示例配置(grid_step_bps=100、num_levels=3、skew_factor=0.5、requote_threshold_bps=10、expire_time_secs=8)在ETH-USD-PERP.DYDX上运行 35 秒,共捕获 47 次重挂事件、276 次订单提交、67 次接受与 54 次撤单。ETH 当时交易价接近 2,281 美元:价格从未移动超过 10 bps 的重挂阈值,因此大多数周期由周期性的 8 秒短时订单过期驱动,而非价格波动触发。
图 1.每次重挂时的 ETH-USD-PERP 中间价与六条理论网格带(每侧 3 档、100 bps 步长)叠加。中间价约在 2,281 美元附近;内侧买档与卖档约在 2,258 与 2,304 美元。
图 2.每笔短时订单从OrderAccepted到OrderCanceled的存活时间(秒)。约 7-8 秒处的密集分布与expire_time_secs=8设置吻合;6 秒以下的小簇是重挂过渡期间策略主动发起的撤单。
重新生成面板
从源码构建 NautilusTrader 后,在仓库根目录执行以下命令:
make sync # Capture a 35-second mainnet run. timeout 35 ./target/release/examples/dydx-grid-mm > /tmp/dydx_main.log 2>&1 DYDX_LOG=/tmp/dydx_main.log \ uv run --project python --no-sync \ python docs/tutorials/assets/grid_market_maker_dydx/render_panels.py监控与理解输出
日志消息
| 日志消息 | 含义 |
|---|---|
Requoting grid: mid=X, last_mid=Y | 中间价移动超过阈值,正在刷新网格。 |
Submit short-term order N | 订单已通过短时广播路径提交。 |
BatchCancel N short-term orders | 已为过期/陈旧订单执行批量撤单。 |
benign cancel error, treating as success | 对已成交或已过期订单的撤单(正常情况)。 |
Sequence mismatch detected, will resync and retry | Cosmos SDK 序列错误,正在自动恢复。 |
预期行为模式
- 启动:加载标的信息,WebSocket 连接建立,首个报价触发初始网格。
- 稳态:网格在 tick 之间持续存在;仅当中间价移动超过
requote_threshold_bps时重挂。 - 成交:仓位更新,偏移随之调整,下一次重挂会平移网格。
- 过期:短时订单约 8 秒后在链上过期;indexer 为每笔订单发出撤单事件,下一个报价刷新网格。
- 关闭:所有订单被撤,仓位被平,WebSocket 断开。
定制建议
高波动 vs 低波动
| 条件 | 调整策略 |
|---|---|
| 高波动 | 更宽的grid_step_bps(100-200)、更少的num_levels、更低的skew_factor。 |
| 低波动 | 更紧的grid_step_bps(10-30)、更多的num_levels、更高的skew_factor。 |
| 流动性薄 | 提高requote_threshold_bps以减少撤单频率。 |
多标的运行
为每个标运行独立的GridMarketMaker实例,每个实例独立管理自己的网格、仓位与撤单状态:
let btc_config = GridMarketMakerConfig::builder() .instrument_id(InstrumentId::from("BTC-USD-PERP.DYDX")) .max_position(Quantity::from("0.001")) .base( StrategyConfig::builder() .strategy_id(StrategyId::from("GRID_MM-BTC")) .order_id_tag("BTC".to_string()) .build(), ) .grid_step_bps(50) .build(); let eth_config = GridMarketMakerConfig::builder() .instrument_id(InstrumentId::from("ETH-USD-PERP.DYDX")) .max_position(Quantity::from("0.10")) .base( StrategyConfig::builder() .strategy_id(StrategyId::from("GRID_MM-ETH")) .order_id_tag("ETH".to_string()) .build(), ) .grid_step_bps(100) .build(); node.add_strategy(GridMarketMaker::new(btc_config))?; node.add_strategy(GridMarketMaker::new(eth_config))?;主网与测试网切换
示例通过文件顶部的DYDX_NETWORK常量选择网络(默认DydxNetwork::Mainnet)。改为DydxNetwork::Testnet并重新编译即可在测试网上运行。测试网的端点(HTTP、WebSocket、gRPC、faucet)会自动解析,详见 dYdX 集成指南的 Testnet setup 章节。
延伸阅读
- dYdX v4 集成指南:完整的适配器参考,涵盖订单分类、广播与重试策略、批量撤单分区、费率、限流、子账户与 API 交易密钥等。
- GridMarketMaker 策略模块:策略配置表、调优指南及 Python/Rust 两种用法。
- grid_mm 单元测试:验证默认配置、重挂阈值、几何网格取整、库存偏移、
max_position约束与pending_self_cancels行为的完整测试集。
【免费下载链接】nautilus_traderProduction-grade Rust-native trading engine with deterministic event-driven architecture项目地址: https://gitcode.com/GitHub_Trending/na/nautilus_trader
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考