Skip to content

Self-trade prevention

When the same account has crossing orders on both sides of a price, real venues prevent the match — both to avoid fee-wash and to comply with regulatory wash-trade rules. lrvx simulator now applies one of four STP modes at order submission.

The simulator currently treats every order on one SimulatedExecutor as belonging to one logical account. Cross-account STP (institutional master + sub-accounts) is filed as a follow-up.

Modes

Mode Behaviour
none (default) Self-match allowed (legacy behaviour).
cancel_newest Reject the incoming order with reason stp_cancel_newest.
cancel_oldest Cancel the resting order; the incoming order proceeds normally.
cancel_both Cancel the resting order and reject the incoming one.
decrement Cancel the smaller side fully; reduce the larger side by the smaller's qty.

decrement is the most permissive — neither leg blocks the other completely, both books shrink toward zero. The other three modes either reject or cancel.

Apply from a strategy

"""Enable self-trade prevention on the simulated executor."""
import lrvx

exec = lrvx.SimulatedExecutor()
exec.set_queue_model("tob", 1)

# Modes: 'none' (default) | 'cancel_newest' | 'cancel_oldest' |
#        'cancel_both'    | 'decrement'.
exec.set_stp_mode("cancel_newest")

exec.on_best_levels(symbol=1, bid_price=49_000, bid_qty=1.0,
                    ask_price=51_000, ask_qty=1.0)

# Rest a BUY @ 50500.
exec.submit_order(id=1, side="buy", price=50_500.0, quantity=1.0,
                  type="limit", symbol=1)

# Send a SELL @ 50000 — crosses our own BUY. STP rejects the new one.
exec.submit_order(id=2, side="sell", price=50_000.0, quantity=1.0,
                  type="limit", symbol=1)
print("done — incoming SELL was rejected with reason 'stp_cancel_newest'")
exec.setSTPMode('cancel_newest');
exec.set_stp_mode("cancel_newest")
__lrvx_simulated_executor_set_stp_mode(exec, 1);  // 1 = CancelNewest
sim.setSTPMode(lrvx::STPMode::CancelNewest);

Multi-account STP

The simulator keys STP on Order::accountId (default 0). Two orders share an STP scope when:

  1. their accountId values match, OR
  2. both accounts map into the same explicit STP group (configured via setSTPGroupMembership(account_id, group_id)).

Master / sub-account topologies use the group mechanism: every sub-account opts into the same group id, and crossings between any two sub-accounts in that group count as self-trade.

exec.set_stp_group_membership(42, 100)
exec.set_stp_group_membership(43, 100)
# Submit with account_id keyword:
exec.submit_order(id=1, side='buy', price=50000, quantity=1,
                   symbol=1, account_id=42)
exec.submit_order(id=2, side='sell', price=50000, quantity=1,
                   symbol=1, account_id=43)
# → STP fires (same group 100)
exec.setSTPGroupMembership(42, 100);
exec.setSTPGroupMembership(43, 100);

Notes

  • The crossing check uses limit price only. A market order against the same account's resting limit triggers STP if the resting price is on the opposite side; without a price the simulator skips the check (market orders never match against the queue tracker in the current simulator anyway, so this corner is theoretical).
  • decrement mode mutates the resting order's quantity in place when the incoming order is smaller. The smaller side's REJECTED event carries reason stp_decrement_newest; the larger side stays in the book at the reduced quantity, and the queue tracker shrinks with it so the order cannot trade size it no longer has. When the decrement leaves nothing to trade, the resting order is cancelled and emits CANCELED, the way a venue pulls an order decremented to zero.
  • STP runs after rate-limit and reduce-only checks, before the POST_ONLY / FOK / IOC checks. An incoming order that would be rate-limited or rejected as reduce-only never reaches STP, so it cannot cancel or shrink a resting order on its way out.
  • Cancelled resting orders go through the same path as a user cancel — they emit CANCELED and disappear from the queue tracker / market-position tracker.