Skip to content

[Bug Report] Trades print at the aggressor price, not the resting maker price #2

Description

@OPTIONPOOL

Found while running cpp_orderbook through an open-source matching-engine benchmark, the Matching Engine Performance Challenge — it cross-checks engines against the byte-identical consensus of Liquibook, QuantCup and Exchange-core.

A sell order that crosses a higher resting bid executes at the seller's own (lower) limit instead of the maker's price. In OrderBook::matchOrders the fill price comes from MIN(bid->_price, ask->_price) (orderbook.cpp:24). Since matching only happens when bid->_price >= ask->_price (orderbook.cpp:22), that MIN always returns ask->_price — fine when a buyer lifts a resting ask, wrong when a seller hits a resting bid.

// orderbook.cpp:22-32
if (bid->_price >= ask->_price) {
    int qty = MIN(bid->remaining, ask->remaining);
    F price = MIN(bid->_price, ask->_price);          // line 24

    Order* aggressor = aggressorSide == Order::BUY ? bid : ask;
    Order* opposite  = aggressorSide == Order::BUY ? ask : bid;

    bid->fill(qty,price);
    ask->fill(qty,price);

    const Trade trade(price, qty, *aggressor, *opposite);

Cause. The price is derived from MIN of the two limits rather than from the maker. matchOrders already identifies the resting side as opposite (orderbook.cpp:27), but uses it only for the Trade's counterparty identity, never for the price. So on a sell-initiated cross the maker is named correctly while the price is pulled from the wrong side. Worse, both bid->fill(qty,price) and ask->fill(qty,price) get the same bad price, and fill() (order.h:58-62) folds it into _avgPrice, so both orders also report a wrong averagePrice() (order.h:97). Quantity, remaining, and book state stay consistent — the error is purely in price.

Repro Any sell whose limit is below a resting bid (resting_bid_price > incoming_ask_price): the cross is valid and the trade prints at the seller's limit. Against the benchmark's three baseline engines, the first divergence on the normal scenario is a sell crossing a resting bid — same qty, same maker (232140), same taker (932154), different price:

baselines:  1,76,33857,87,232140,932154
robaho:     1,76,33559,87,232140,932154

Trade counts match exactly (62,474 on normal), so it's a pricing difference, not over/under-matching. The static scenario passes byte-for-byte because its sparse fills meet at the mid where bid->_price == ask->_price and MIN coincides with the maker — consistent with the mechanism.

Fix. Price the fill from the maker, which matchOrders already holds as opposite. Compute opposite before the price:

Order* aggressor = aggressorSide == Order::BUY ? bid : ask;
Order* opposite  = aggressorSide == Order::BUY ? ask : bid;

int qty   = MIN(bid->remaining, ask->remaining);
F   price = opposite->_price;   // execute at the resting (maker) price

The buy-aggressor case is unchanged (opposite is the ask, so opposite->_price == ask->_price == MIN(...)); the sell-aggressor case is fixed (opposite is the bid). Happy to share the failing workload.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions