[Sandbox] Bracket exits rejected as "would have increased position" on a live node: reduce-only check runs before the sandbox's own entry fill reaches the cache

Author: utx0Created Sep 17, 2026Updated Sep 17, 2026

Bug Report

Confirmation

  • I've re-read the relevant sections of the documentation.
  • I've searched existing issues and discussions to avoid duplicates.
  • I've reviewed or skimmed the source code (or examples) to confirm the behavior is not by design.
  • I've tested this issue using a recent pre-release or development wheel (2.0.0rc5) and can still reproduce it. The relevant code is unchanged on develop as of 2026-09-17 (b3d059dd4f).

Expected behavior

A bracket from OrderFactory.bracket() (market entry + reduce-only stop-market SL + reduce-only limit TP) submitted with submit_order_list() to a SandboxExecutionClient inside a LiveNode should fill the entry and accept both exits, as it does in BacktestEngine with the same OrderMatchingEngine and use_reduce_only=True.

Actual behavior

The entry fills and both exits are rejected:

Reduce-only order O-...-2 (STOP_MARKET-BUY) would have increased position
Reduce-only order O-...-3 (LIMIT-BUY) would have increased position

Since 9da48e0399 ("Enforce reduce-only execution semantics", in rc5) there is no way around it in the sandbox: with use_reduce_only=False the same exits are rejected outright with Reduce-only orders are not supported by this matching engine, and the bracket factory sets reduce_only=True on the SL/TP legs unconditionally.

Cause

In a live node the sandbox does not deliver order events to the execution engine synchronously. SandboxExecutionClient::start installs an event handler that pushes every event onto the live exec event channel (crates/adapters/sandbox/src/execution.rs, try_get_exec_event_sender()), and the runner drains it later. But SandboxInner::apply_submit_order_list validates every leg of the list in one synchronous pass, and the reduce-only check in OrderMatchingEngine::process_order looks the position up in the cache (position_for_order_in_cache). At that moment the entry's OrderFilled is still sitting in the channel, so the cache has no position and both reduce-only children are rejected.

BacktestEngine is unaffected because its execution client calls msgbus::send_order_event directly, so the fill and position are in the cache before the next leg is validated. The same strategy passes there with use_reduce_only=True. Real venue adapters are also unaffected; the Hyperliquid adapter, for example, stages the children itself and submits them only after the parent fills.

The strategy receives PositionOpened before the two OrderRejected events, which makes the log look fine at first glance; the rejection decision was simply made before the fill was processed.

Steps to reproduce

  1. Build a LiveNode with SandboxExecutionClientConfig(venue=..., oms_type=NETTING, account_type=MARGIN, bar_execution=True, use_reduce_only=True, ...) (defaults) and any live data client for the same venue (I used HyperliquidDataClientConfig with public data, no key).
  2. Strategy: on a bar, bracket = self.order_factory.bracket(instrument_id=..., order_side=..., quantity=..., time_in_force=TimeInForce.GTC, sl_trigger_price=..., tp_price=...) then self.submit_order_list(bracket).
  3. Observe: entry OrderFilled, PositionOpened, then OrderRejected for the SL and TP with the reason above.
  4. Run the same strategy in BacktestEngine with use_reduce_only=True: the exits are accepted and fill.

Also reproduced on 2.0.0rc5.dev20260913 and, before the enforcement commit, on 2.0.0rc4 (same rejection reason).

Code snippets or logs

00:42:02.180679 [INFO] Swing-NEAR-SMK-901: SELL 53.9 @~2.6815 stop 2.6960 target 2.6526 (ATR 0.0072)
00:42:02.180771 [INFO] OrderInitialized(client_order_id=O-20260917-004202-SMK-901-1, side=SELL, type=MARKET, quantity=53.9, reduce_only=false, ...)
00:42:02.180806 [INFO] OrderInitialized(client_order_id=O-20260917-004202-SMK-901-2, side=BUY, type=STOP_MARKET, quantity=53.9, reduce_only=true, ...)
00:42:02.180812 [INFO] OrderInitialized(client_order_id=O-20260917-004202-SMK-901-3, side=BUY, type=LIMIT, quantity=53.9, post_only=true, reduce_only=true, ...)
00:42:02.180819 [INFO] [CMD]--> SubmitOrderList(instrument_id=NEAR-USD-PERP.HYPERLIQUID, order_list=OL-20260917-004202-SMK-901-1, position_id=None)
00:42:02.181232 [INFO] OrderFilled(client_order_id=O-20260917-004202-SMK-901-1, venue_order_id=HYPERLIQUID-0-1, account_id=HYPERLIQUID-SMOKE-001, ...)
00:42:02.181300 [INFO] PositionOpened({ position_id: "NEAR-USD-PERP.HYPERLIQUID-Swing-NEAR-SMK-901", entry: Sell, side: Short, ... })
00:42:02.181334 [WARN] OrderRejected(client_order_id=O-20260917-004202-SMK-901-2, reason='Reduce-only order O-20260917-004202-SMK-901-2 (STOP_MARKET-BUY) would have increased position')
00:42:02.181361 [WARN] OrderRejected(client_order_id=O-20260917-004202-SMK-901-3, reason='Reduce-only order O-20260917-004202-SMK-901-3 (LIMIT-BUY) would have increased position')

With use_reduce_only=False on the same setup:

OrderRejected(..., reason='Reduce-only orders are not supported by this matching engine')

Possible fixes

  • Have the sandbox's reduce-only validation consult the matching engine's own fills (it has just filled the parent) rather than only the cache, or
  • Stage OTO children in the sandbox until the parent fills, as the venue adapters do, or
  • Drain the exec event channel between legs when applying an order list in the sandbox.

Workaround I am using: in paper mode the strategy submits the entry alone and submits the reduce-only SL/TP from the entry's on_order_filled, cancelling the survivor in on_position_closed. That passes on the same sandbox.

Specifications

  • OS platform: Linux-7.2.5-100.fc43.x86_64 (Fedora Linux 43)
  • Python version: 3.14.7
  • nautilus_trader version: 2.0.0rc5 (also 2.0.0rc5.dev20260913 and 2.0.0rc4)
  • Installed from: package index wheel (packages.nautechsystems.io)
  • Adapter/venue: SandboxExecutionClient with Hyperliquid data (HYPERLIQUID mainnet, public data only)

Source: nautechsystems/nautilus_trader