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. flox 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 flox_py as flox

exec = flox.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")
__flox_simulated_executor_set_stp_mode(exec, 1);  // 1 = CancelNewest
sim.setSTPMode(flox::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.
  • STP runs after rate-limit and reduce-only checks, before the POST_ONLY / FOK / IOC checks. An incoming order that would be rate-limited never reaches STP.
  • Cancelled resting orders go through the same path as a user cancel — they emit CANCELED and disappear from the queue tracker / market-position tracker.