NautilusTrader
Tutorials

On-Chain Grid Market Making with Short-Term Orders (dYdX)

This tutorial runs the shipped GridMarketMaker strategy on dYdX v4 through the Rust LiveNode. The strategy places symmetric limit orders around the mid and skews the grid to manage inventory. The venue cycles short-term orders through time-to-block expiry instead of explicit cancels.

Introduction

A grid market maker maintains a ladder of resting buy and sell limits at fixed price intervals around the current mid. When an order fills, the strategy profits from the spread between the buy and sell levels. Inventory management keeps net exposure within max_position so the grid does not accumulate a directional position.

Inventory skewing (Avellaneda-Stoikov inspired)

When the long position increases, the entire grid shifts down (cheaper buys, cheaper sells) to encourage a sell-side fill. When the short position increases, the grid shifts up. This follows the Avellaneda-Stoikov framework, adapted to a discrete grid.

Why dYdX v4

dYdX v4 has these features for market making:

  • Short-term orders with ~20-second expiry: low-latency placement, no on-chain storage cost.
  • ~0.5-second block times for fast confirmation cycles.
  • No gas fees for cancellations: short-term cancels are free under GTB replay protection.
  • On-chain order book with deterministic per-block matching.
  • Batch cancel: one MsgBatchCancel clears every short-term order.

Prerequisites

Funded dYdX account

You need a dYdX account with USDC collateral. The example trades on mainnet by default; Run the example shows how to switch to testnet. See the Testnet setup section in the integration guide for instructions on creating and funding a testnet account. An API trading key is optional: the adapter signs with the account's own private key unless you set up permissioned key trading.

Environment variables

# Mainnet
export DYDX_PRIVATE_KEY="0x..."
export DYDX_WALLET_ADDRESS="dydx1..."

# Testnet
export DYDX_TESTNET_PRIVATE_KEY="0x..."
export DYDX_TESTNET_WALLET_ADDRESS="dydx1..."

Strategy overview

Geometric grid pricing

Each level is a fixed percentage (basis points) away from mid:

Buy level N:  mid * (1 - bps/10000)^N - skew
Sell level N: mid * (1 + bps/10000)^N - skew

Where skew = skew_factor * net_position.

For a 3-level grid with grid_step_bps=100 (1%) around a mid of 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

With a long-2 position and skew_factor=1.0, the entire grid shifts down by 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

Inventory management

The strategy enforces position limits through two mechanisms:

  • max_position: a hard cap on net exposure (long or short). The strategy skips a level if its projected exposure would exceed this cap.
  • Projected exposure: before each level, the strategy tracks worst-case exposure per side (current position + all pending buy / sell orders). This prevents commitments beyond the limit.

The cancel_all_orders call is asynchronous. Pending orders can still fill between the cancel request and acknowledgement. The worst-case exposure check per side prevents momentary over-exposure during cancel-requote transitions.

Requote threshold

requote_threshold_bps controls how much the mid must move before the strategy cancels all open orders and places a fresh grid:

  • Lower threshold (5 bps): faster response, more cancel/place transactions.
  • Higher threshold (50 bps): fewer transactions, but orders may sit further from the current price.

Configuration

ParameterTypeDefaultDescription
instrument_idInstrumentIdrequiredInstrument to trade (e.g. ETH-USD-PERP.DYDX).
max_positionQuantityrequiredMaximum net exposure (long or short).
trade_sizeQuantityNoneSize per grid level. If None, uses instrument's min_quantity or 1.0.
num_levelsusize3Number of buy and sell levels.
grid_step_bpsu3210Grid spacing in basis points (10 = 0.1%).
skew_factorf640.0How aggressively to shift the grid based on inventory.
requote_threshold_bpsu325Minimum mid-price move in bps before re-quoting.
expire_time_secsOption<u64>NoneOrder expiry in seconds. Uses GTD when set, GTC otherwise.
on_cancel_resubmitboolfalseResubmit grid on next quote after an unexpected cancel.

Choosing parameters

  • grid_step_bps: [50, 100] bps in volatile markets, [5, 20] bps in calm conditions. Wider grids capture more spread per fill but fill less often.
  • skew_factor: start at 0.0. A value of 0.5 shifts the grid by 0.5 price units per unit of net position. Too much skew can move the grid entirely above or below mid.
  • expire_time_secs: for dYdX short-term orders, set to 8 seconds. This fits inside the 40-block (~20 s) short-term window and keeps the orders on the fast short-term path. When None, orders use GTC and the long-term path.
  • on_cancel_resubmit: triggers a resubmission on the next quote tick after a cancel that the strategy did not initiate (short-term order expiry from the indexer, self-trade prevention, risk limits). The indexer emits a cancel event for each short-term order shortly after it expires. This flag resets the requote anchor so the next quote rebuilds the grid, even if the mid has not moved beyond requote_threshold_bps.

dYdX-specific considerations

Short-term order expiry

When expire_time_secs=8, orders are classified as short-term by the adapter:

  1. The adapter checks 8s < max_short_term_secs (40 blocks * ~0.5s = ~20s).
  2. The adapter submits the order as short-term with GoodTilBlock = current_height + N.
  3. The order expires on chain after approximately eight seconds if it does not fill. Expiry costs no gas (GTB replay protection handles it on chain). The indexer still emits an OrderCanceled event for each expired order shortly after the expiry block. Thus, the strategy observes expiry through the normal cancel event path.

This is the recommended configuration for market making because:

  • Short-term orders have lower latency.
  • Expiry has no on-chain gas cost.
  • Continuous requoting (driven by the indexer-emitted cancel events when on_cancel_resubmit=true) replaces expired orders.

See the order classification section in the integration guide for full details.

Unexpected cancels and on_cancel_resubmit

The pending_self_cancels set distinguishes self-initiated from unexpected cancels:

  1. When the strategy calls cancel_all_orders, it records all open order IDs in pending_self_cancels.
  2. When on_order_canceled runs:
    • If the order ID is in pending_self_cancels, it is a self-cancel and requires no action.
    • Otherwise, the strategy did not initiate the cancel (short-term order expiry, self-trade prevention, or a risk limit). With on_cancel_resubmit=true, it resets last_quoted_mid so the next quote triggers a full grid resubmission.

This prevents unnecessary requotes during the strategy's own cancel requests, while it still responds to unexpected cancels.

The on_order_filled callback also removes fully filled orders from pending_self_cancels. If an order fills before the cancel acknowledgement arrives, this prevents stale entries from accumulating. Partial fills retain the order ID so a later self-cancel is still identified correctly.

Order quantization

The adapter's OrderMessageBuilder automatically quantizes price and size for dYdX markets. No manual rounding or conversion is needed. See Price and size quantization for details.

Post-only orders

The strategy submits all grid orders with post_only=true. The exchange accepts, then immediately cancels, any post-only order that would cross the spread at match time. The strategy sees OrderCanceled, not OrderRejected. Thus, every fill uses the maker fee rate, and the grid never takes its own offers during requote transitions.

Running and stopping

Environment setup

Credentials load from environment variables or a .env file at the project root (loaded automatically through dotenvy):

# Direct export
export DYDX_PRIVATE_KEY="0x..."
export DYDX_WALLET_ADDRESS="dydx1..."
# .env equivalent
DYDX_PRIVATE_KEY=0x...
DYDX_WALLET_ADDRESS=dydx1...

Run the example

This command places real orders on dYdX mainnet by default. The DYDX_NETWORK constant near the top of crates/adapters/dydx/examples/node_grid_mm.rs is DydxNetwork::Mainnet. The example reads DYDX_PRIVATE_KEY and DYDX_WALLET_ADDRESS. To run against testnet, set DYDX_NETWORK to DydxNetwork::Testnet and rebuild. The example then reads DYDX_TESTNET_PRIVATE_KEY and DYDX_TESTNET_WALLET_ADDRESS.

cargo run --example dydx-grid-mm --package nautilus-dydx --features examples

Graceful shutdown

Press Ctrl+C to stop the node. The shutdown sequence:

  1. The node receives SIGINT, the trader stops, and on_stop runs.
  2. The strategy cancels all orders and closes positions.
  3. The node processes residual events during a 5-second grace period (delay_post_stop_secs).
  4. The clients disconnect and the node exits.

Code walkthrough

The main function lives at crates/adapters/dydx/examples/node_grid_mm.rs. This simplified copy inlines the example's constants:

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(())
}

Configuration points:

  • dotenvy::dotenv().ok(): loads .env from the project root if present.
  • with_reconciliation(false): disabled for simplicity; enable in production to resume state across restarts.
  • with_delay_post_stop_secs(5): grace period for pending cancel and close events to finalize during shutdown.

Event flow

Strategy internals

The following Rust snippets simplify the strategy source in crates/trading/src/examples/strategies/grid_mm/strategy.rs, which includes error handling and state tracking that these snippets omit.

Trade size resolution (on_start)

The strategy resolves trade size from the instrument cache in this order:

  1. The configured value.
  2. The instrument's min_quantity.
  3. 1.0 as a final fallback.
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(())
}

Quote handler (on_quote, abbreviated)

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 pricing (grid_orders)

The grid_orders method calculates geometric grid prices and enforces max_position per level:

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
}

What a 35-second mainnet run produces

A 35-second mainnet run on ETH-USD-PERP.DYDX with the example config (grid_step_bps=100, num_levels=3, skew_factor=0.5, requote_threshold_bps=10, expire_time_secs=8) captures 47 requote events, 276 order submissions, 67 accepts, and 54 cancels. ETH was trading near 2,281 USD. The price never moved sufficiently to trigger the 10 bps requote threshold. Thus, most cycles start from periodic 8-second short-term order expiry rather than price movement.

ETH-USD-PERP mid with theoretical grid bands

Figure 1. ETH-USD-PERP mid at every requote with the six theoretical grid bands (3 levels each side, 100 bps step). Mid is near 2,281 USD. The inner buy and sell levels are at ~2,258 and ~2,304 USD.

Order lifetime distribution

Figure 2. Time from OrderAccepted to OrderCanceled per short-term order, in seconds. The mass near [7, 8] seconds matches the expire_time_secs=8 setting. The smaller cluster below 6 seconds is strategy-initiated cancels during requote transitions.

Orders submitted per 250-ms bucket

Figure 3. Order submission count per 250-ms bucket, split by side. Each requote cycle places six orders (3 buys + 3 sells). The spacing between bursts is the requote interval.

Short-term order timeline

Figure 4. Theoretical short-term order timeline with expire_time_secs=8 and 0.5-second blocks. The bottom panel tracks how the chain block height advances; each order's GoodTilBlock target is set to ~16 blocks ahead, giving the eight-second expiry.

Regenerate the panels

The capture run places real orders on dYdX mainnet by default. To capture a testnet run instead, set DYDX_NETWORK to DydxNetwork::Testnet before building (see Run the example).

After building NautilusTrader from source, run these commands from the repository root:

make sync

cargo build --release --example dydx-grid-mm --package nautilus-dydx --features examples

# 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

Monitoring and understanding output

Log messages

Log messageMeaning
Requoting grid: mid=X, last_mid=YGrid refresh after a threshold move, anchor reset, or empty grid.
Submit short-term order NOrder submitted through short-term broadcast path.
BatchCancel N short-term ordersBatch cancel executed for expired/stale orders.
benign cancel error, treating as successCancel for an already-filled or expired order (normal).
Sequence mismatch detected, will resync and retryCosmos SDK sequence error, auto-recovering.

The Submit short-term order, BatchCancel, and benign cancel lines log at DEBUG level. The example sets stdout_level: LevelFilter::Info, so change it to LevelFilter::Debug to see them.

Expected behavior patterns

  1. Startup: instruments load, WebSocket connects, first quote triggers initial grid.
  2. Steady state: the grid persists across ticks. Requotes occur when mid moves by at least requote_threshold_bps, the grid is empty, or an event resets the anchor. Rejections and expiry events reset the anchor. Unexpected cancels reset it with on_cancel_resubmit=true.
  3. Fills: position updates, skew adjusts, the next requote shifts the grid.
  4. Expiry: short-term orders expire on chain after approximately eight seconds. The indexer emits a cancel event for each, and the next quote refreshes the grid.
  5. Shutdown: all orders canceled, positions closed, WebSocket disconnected.

Customization tips

High vs low volatility

ConditionAdjustment
High volatilityWider grid_step_bps ([100, 200]), fewer num_levels, lower skew_factor.
Low volatilityTighter grid_step_bps ([10, 30]), more num_levels, higher skew_factor.
Thin liquidityIncrease requote_threshold_bps to reduce cancel frequency.

Multiple instruments

Run separate GridMarketMaker instances per instrument. Each instance manages its own grid, position, and cancel state independently:

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))?;

Mainnet vs testnet toggle

The example selects the network from the DYDX_NETWORK constant near the top of the file (DydxNetwork::Mainnet by default). Change it to DydxNetwork::Testnet and rebuild to run against testnet.

Further reading

On this page