Skip to content

Infrastructure

Self-Match Prevention, Post-Only, IOC, FOK: Order-Type Edge Cases That Will Burn You at 3 AM

What the Binance, OKX, and Bybit docs say about Post-Only, SMP, IOC, and FOK - and what actually happens in production during thin markets, high volatility, and edge-case fills.

12 min
#order-types #exchange-connectivity #post-only #smp #market-making #binance #bybit

We were running a market-making strategy on Bybit during a thin overnight session. The strategy placed a Post-Only limit order at a price that was technically inside the spread. Bybit’s response: the order was accepted, the fill notification arrived, and we paid taker fees on a strategy that was never supposed to pay taker fees. No error, no rejection - just a silent conversion from maker to taker with no indication in the order response.

This is the kind of failure that doesn’t appear in documentation, doesn’t trigger an exception in your code, and costs you money slowly until a PnL review surfaces it. Understanding order-type edge cases - specifically what each exchange does at the boundary conditions - is one of the higher-leverage pieces of operational knowledge for systematic trading.

Post-Only Orders: The Silent Conversion Problem

Post-Only is designed for market makers: place a limit order at a price, but only if it would rest in the book (become a maker). If the order would immediately match against existing orders (become a taker), reject it.

Every major exchange implements Post-Only differently at the edge cases.

Binance: LIMIT_MAKER

Binance calls Post-Only orders LIMIT_MAKER. The behavior is defined: if your order would match immediately, the order is rejected with error code -2010 (“Order would immediately match and take”).

# Binance Post-Only order
order = {
    "symbol": "BTCUSDT",
    "side": "BUY",
    "type": "LIMIT_MAKER",  # Not "LIMIT" - "LIMIT_MAKER"
    "quantity": "0.001",
    "price": "43500.00",
    "timestamp": int(time.time() * 1000),
}

What happens during a fast market:

  • You submit LIMIT_MAKER at 43500 (current ask is 43501)
  • By the time the order reaches Binance’s matching engine, the ask has moved to 43499
  • Your 43500 bid would now match against the 43499 ask
  • Binance rejects: {"code": -2010, "msg": "Order would immediately match and take."}

This is the correct behavior. You handle the rejection by not placing the order. The problem: at high order rates, a significant fraction of your LIMIT_MAKER attempts may be rejected during volatile periods. If your strategy doesn’t handle -2010 gracefully (just retrying without repricing), you’ll loop indefinitely without placing orders.

OKX: postOnly flag on LIMIT orders

OKX’s Post-Only is a flag on a regular LIMIT order:

order = {
    "instId": "BTC-USDT-SWAP",
    "tdMode": "cross",
    "side": "buy",
    "ordType": "limit",
    "px": "43500",
    "sz": "0.001",
    "postOnly": True,  # This flag
}

OKX’s behavior when a postOnly=True order would cross the book: the order is cancelled, not rejected. The response returns a successful order placement with status “cancelled”. Your order tracking must check final status, not just the placement response.

resp = await okx_client.place_order(order)
order_id = resp['data'][0]['ordId']

# Wait for fill/cancel
status = await okx_client.get_order_status(order_id)
if status['state'] == 'canceled':
    # Post-Only rejection - reprice and retry
    pass

Bybit: The Silent Conversion

Bybit’s Post-Only behavior is where I encountered the problem described above. Bybit has a parameter timeInForce: "PostOnly", but the actual behavior depends on the tgtIfmoePostOnly (target if maker-or-else) setting.

When timeInForce = "PostOnly" and the order would cross the book, Bybit’s default behavior is to convert the order to a market order at the best available price, not to reject it.

This is the exact opposite of what “Post-Only” means. The setting that causes rejection (maker-or-cancel behavior) requires explicitly setting:

order = {
    "category": "linear",
    "symbol": "BTCUSDT",
    "side": "Buy",
    "orderType": "Limit",
    "qty": "0.001",
    "price": "43500",
    "timeInForce": "PostOnly",
    # This additional parameter controls rejection vs conversion:
    "mmp": True,  # Market Maker Protection - activates rejection behavior
}

Or use the order filter PostOnly which has been clarified in Bybit V5 to mean cancellation (not conversion). Always verify current Bybit documentation - this behavior has changed across API versions.

How to verify Post-Only behavior for any exchange:

Before running live, place a Post-Only order at a price that’s guaranteed to cross the book (well inside the spread). Observe the response. If you’re billed taker fees or the order fills immediately without a rejection/cancellation response, you have a Post-Only conversion problem.

Self-Match Prevention (SMP)

Self-Match Prevention (SMP) exists to prevent a trading entity from crossing orders with itself - which creates a false impression of trading activity and may be a regulatory violation in some jurisdictions.

Exchanges implement SMP differently:

Cancel maker: When your buy and sell orders would match each other, cancel the resting maker order and let the new order continue to the book.

Cancel taker: Cancel the incoming taker order. The maker order remains in the book.

Cancel both: Cancel both orders when they would self-match.

Binance SMP: Binance calls this “Self-Trade Prevention” (STP) and introduced it in 2023. You specify the behavior per order:

order = {
    "symbol": "BTCUSDT",
    "side": "BUY",
    "type": "LIMIT",
    "quantity": "0.001",
    "price": "43500",
    "selfTradePreventionMode": "EXPIRE_TAKER",  # Cancel incoming order
    # Options: EXPIRE_TAKER, EXPIRE_MAKER, EXPIRE_BOTH, NONE
}

The default for Binance (when no STP mode is specified) changed in 2023 from “NONE” (allow self-trades) to “EXPIRE_MAKER” for some account types. If your strategy depends on a specific SMP behavior, always explicitly specify the mode. Don’t rely on the default.

OKX SMP: OKX uses an “SMP group ID” system. Accounts in the same SMP group have their orders prevented from matching each other:

order = {
    "instId": "BTC-USDT-SWAP",
    "side": "buy",
    "ordType": "limit",
    "px": "43500",
    "sz": "0.001",
    "stpId": "1",  # SMP group ID - orders with same stpId won't self-match
    "stpMode": "cancel_taker",  # Or "cancel_maker", "cancel_both"
}

Bybit SMP: Bybit implements SMP via selfMatchingPreventionType parameter. Available options are CancelMaker, CancelTaker, CancelBoth. Not all product types support all modes.

The edge case that burns you: SMP fires at the exchange matching engine level. If your strategy sends a buy and sell for the same symbol within milliseconds of each other (e.g., a delta-hedging strategy that sends offsetting orders as part of a position adjustment), one of them will be cancelled by SMP without any application-level error. Your position target is not reached. If your strategy doesn’t retry cancelled SMP orders, you’ll have persistent position drift.

async def place_order_with_smp_retry(
    exchange_client,
    order_params: dict,
    max_retries: int = 3,
) -> dict:
    """Retry order placement when cancelled by SMP."""
    for attempt in range(max_retries):
        result = await exchange_client.place_order(order_params)

        cancel_codes = {
            'binance': [-4003],  # STP cancellation
            'okx': ['64059'],    # SMP cancellation
            'bybit': [110025],   # SMP cancellation
        }

        exchange = order_params.get('exchange', 'binance')
        error_code = result.get('code')
        if error_code in cancel_codes.get(exchange, []):
            # SMP fired - wait briefly and retry
            await asyncio.sleep(0.05 * (attempt + 1))
            continue

        return result

    raise RuntimeError(f"Order cancelled by SMP after {max_retries} attempts")

IOC and FOK: Partial Fill Semantics

Immediate-Or-Cancel (IOC): Execute as much as possible immediately, cancel any unfilled remainder. Result: 0 to full fill, no resting order.

Fill-Or-Kill (FOK): Execute the entire quantity immediately, or cancel the entire order. Result: full fill or zero fill.

Good-Till-Cancelled (GTC): Rest in the order book until filled or explicitly cancelled. Partial fills are possible.

The critical distinction: IOC allows partial fills, FOK does not.

For a 100 BTC FOK order when only 70 BTC is available at the limit price: the entire order is cancelled. You get zero fills.

For a 100 BTC IOC order in the same situation: you get 70 BTC filled, 30 BTC cancelled.

This matters for execution strategy design. If you need a minimum fill amount to make your hedge valid, use FOK. If any fill amount is acceptable and you’ll place another order for the remainder, use IOC. GTC is for resting market-making orders where you want the order to remain in the book.

Exchanges implement IOC validation differently:

Binance: IOC partial fills are reported via a single ExecutionReport with status: PARTIALLY_FILLED followed by status: CANCELED when the remainder is cancelled. You get two WebSocket messages, not one.

Bybit: IOC and FOK orders result in a single ExecutionReport with the final filled quantity. The order status is Filled for full fill or Cancelled for partial/no fill, with cumExecQty reflecting any partial fill.

def compute_fill_from_bybit_ioc(order_result: dict) -> tuple[float, str]:
    """
    For Bybit IOC orders, extract fill quantity regardless of order status.
    An IOC order can be 'Cancelled' but still have a partial fill.
    """
    cum_exec_qty = float(order_result.get('cumExecQty', 0))
    status = order_result.get('orderStatus')
    return cum_exec_qty, status  # Always check cumExecQty, not just status

Iceberg Orders: Minimum Display Quantity Math

Iceberg orders hide a large order behind a small displayed quantity. Only the display quantity is visible in the order book. When the display quantity fills, a new display lot is placed from the hidden reserve.

For example, an iceberg order for 100 BTC with 1 BTC display quantity shows 1 BTC in the book. After each 1 BTC fills, another 1 BTC is displayed until the full 100 BTC is filled.

Exchange-level iceberg support varies:

# Binance: iceberg via "icebergQty" parameter
order = {
    "symbol": "BTCUSDT",
    "side": "BUY",
    "type": "LIMIT",
    "timeInForce": "GTC",
    "quantity": "100",        # Total hidden + displayed quantity
    "price": "43500",
    "icebergQty": "1.0",      # Displayed quantity per lot
}
# Minimum icebergQty = MIN_NOTIONAL / price
# For BTC at $43,500: min display = ~$10 / $43,500 ≈ 0.00023 BTC

# Bybit: doesn't natively support exchange-level icebergs on all products
# Implementation requires algorithmic slicing on client side

The minimum display quantity constraint catches engineers who don’t read the exchange’s filter specifications. If your display quantity is below the minimum, the order is rejected with a filter error. Fetch the symbol’s exchange info and validate against ICEBERG_PARTS and MIN_NOTIONAL filters before placing:

async def get_binance_min_iceberg_qty(
    symbol: str,
    price: float,
    session: aiohttp.ClientSession,
) -> float:
    async with session.get(
        f"https://fapi.binance.com/fapi/v1/exchangeInfo"
    ) as resp:
        info = await resp.json()

    sym_info = next(s for s in info['symbols'] if s['symbol'] == symbol)
    filters = {f['filterType']: f for f in sym_info['filters']}

    min_notional = float(filters.get('MIN_NOTIONAL', {}).get('notional', 5))
    min_qty = float(filters.get('LOT_SIZE', {}).get('minQty', 0.001))

    iceberg_min = max(min_qty, min_notional / price)
    return iceberg_min

Exchange-Level vs Broker-Level Order Types

This distinction matters for crypto trading via a prime broker or aggregator:

Exchange-level order types are executed by the exchange’s matching engine. Post-Only, IOC, FOK, GTC, LIMIT, MARKET are exchange-level on most crypto venues.

Broker-level order types are implemented by an intermediary sitting between you and the exchange. TWAP, VWAP, Iceberg (on Bybit), and trailing stops on some exchanges are broker-level. The broker receives your order, slices it, and submits exchange-level orders on your behalf.

Implication: broker-level order types have different latency characteristics (the broker must make decisions), different failure modes (broker can fail independently of exchange), and different cancellation semantics (you cancel with the broker, who then cancels with the exchange).

For latency-sensitive strategies, avoid broker-level order types for anything on the execution critical path. Use them only for large orders where execution quality matters more than speed.

How This Breaks in Production

1. Bybit Post-Only silent conversion to market order Symptom: PnL shows unexpected taker fee charges. Order history shows fills at prices you didn’t expect for Post-Only orders. No errors logged. Root cause: Bybit’s default timeInForce: "PostOnly" converts to market when crossing. Strategy pays taker fees on every “Post-Only” order during volatile markets. Fix: Use "mmp": True with V5 API, or verify current Bybit behavior with a test order before deploying any market-making strategy.

2. OKX Post-Only silent cancellation not handled Symptom: Strategy appears to place orders (response code 0, order ID received) but no fills arrive. Position doesn’t build. No errors. Root cause: OKX cancelled the Post-Only order because it would cross, but returned a success response. Strategy checks only the placement response, not the eventual order status. Fix: Subscribe to order updates WebSocket channel. Track all open orders by ID and alert when status transitions to “canceled” unexpectedly.

3. Binance STP default change breaks arbitrage strategy Symptom: After a Binance API update, a cross-exchange arbitrage strategy starts having persistent position imbalances. One side fills; the other side is systematically cancelled. Root cause: Binance changed the default selfTradePreventionMode from NONE to EXPIRE_MAKER. Your arbitrage strategy’s buy and sell orders on the same account are now being self-cancelled. Fix: Explicitly specify selfTradePreventionMode: "NONE" for arbitrage strategies where self-matching is intentional and legal. Monitor Binance API changelog.

4. IOC partial fill from Binance creates double-message confusion Symptom: Position tracking is off by partial fill amounts. Strategy thinks it received one message per IOC order, but Binance sends two (PARTIALLY_FILLED then CANCELED). Root cause: Handler processes the first message (PARTIALLY_FILLED) and updates position. The second message (CANCELED) is treated as a cancellation that wipes the position update. Fix: For IOC orders, use cumExecQty from the CANCELED message (the final state) as the authoritative fill amount. Ignore intermediate PARTIALLY_FILLED messages for position accounting.

5. FOK order rejected because spread widened between submission and arrival Symptom: FOK orders fail during volatile periods at a higher rate than expected. Strategy is stuck without a position and keeps trying to place FOK orders that fail. Root cause: At the time you calculated that the limit price would fully fill, there was enough liquidity. By the time the FOK order reached the matching engine (50µs later), liquidity had moved. The FOK constraint (fill everything or cancel) is binary - any shortfall results in zero fill. Fix: For volatile markets, use IOC with a minimum fill check rather than FOK. IOC fills what’s available; your strategy logic checks if the partial fill is sufficient.

6. Iceberg order minimum lot refresh below MIN_NOTIONAL Symptom: Iceberg order placement fails with a filter error. Your icebergQty is 0.0001 BTC but Binance requires 0.00023. Root cause: MIN_NOTIONAL filter requires each displayed lot to be worth at least $10 (or whatever the exchange’s minimum). At current BTC price, your display quantity is below the minimum. Fix: Always compute minimum iceberg quantity dynamically from exchange info. Do not hardcode a display quantity - it becomes invalid as the asset price changes.


For how these order types interact with the rate limiting and weight system, see Binance Connectivity Deep Dive. For the order routing logic that decides which order type to use on which venue, see Building a Smart Order Router for Fragmented Crypto Liquidity. For the order book state required to make Post-Only pricing decisions, see Order Book Reconstruction at Scale.

Continue Reading

Enjoyed this?

Get one deep infrastructure insight per week.

Free forever. Unsubscribe anytime.

You're in. Check your inbox.