Skip to content

[Bug Report] Same-price orders match LIFO instead of FIFO (new-order insertion uses >=/<= instead of >/<) #1

Description

@OPTIONPOOL

Found while integrating AmerSurkovic/MatchingEngine into an open matching-engine benchmark, the Matching Engine Performance Challenge — it cross-checks engines against the byte-identical consensus of other open-source engines. While wiring the engine up I noticed that same-price orders fill last-in-first-out instead of first-in-first-out: both of OrderBook's insertion methods splice a newly arrived order in before any existing resting order at the same price, so among ties the most recently arrived order fills first.

Separately, cross-checking this engine's output against the benchmark's multi-reference consensus at the 2M-order scale, I saw divergence on 33 of 40 (seed, scenario) runs (30 with differing output, 3 where the run hit the benchmark's per-run time limit before producing output) — a pattern consistent with a priority-ordering difference, though confirming that this specific fix accounts for all of it would need a full harness re-run I haven't done. The standalone reproduction below demonstrates the ordering bug directly and stands on its own regardless.

Pinned at current master (facc8ee628b301d58b6db55d3d2dbdd6a61839e4, "Matching engine + tests" — the repo's only substantive commit).

Environment

  • Commit: facc8ee628b301d58b6db55d3d2dbdd6a61839e4
  • OpenJDK 21, aarch64 (javac/java only — the engine has zero third-party dependencies once its one JUnit-based test class is set aside)
  • Driven through the engine's order-submission entry point, OrderBook.addOrder(Order)

What happens

implementations/OrderBook.java keeps two ArrayList<Order>, each grown by a linear insertion scan whose own comments state the intent — _buyOrders so that "Passive BUY order with highest price will be always the first element", _sellOrders so that "Passive SELL order with lowest price will be always the first element." Both scans use a non-strict comparison at the tie-break:

  • addBuyOrder, lines 33-34: else if(order.getPrice() >= _buyOrders.get(i).getPrice()) { _buyOrders.add(i, order); ...
  • addSellOrder, lines 61-62: else if(order.getPrice() <= _sellOrders.get(i).getPrice()){ _sellOrders.add(i, order); ...

>=/<= means a new order priced equal to an existing resting order is spliced in at index ibefore that existing order — instead of after it. OrderProcessor.processOrder (the matcher) always consumes its contra list from the front (it_current.next(), off the iterator OrderBook.getOrderIterator hands back), so among same-price orders the most-recently-inserted one now sits closer to the front and fills first.

Minimal reproduction

Public API only: new Order(book, price, quantity, side) (side: 1 = BUY, 2 = SELL, per interfaces/Order.java's own javadoc), then book.addOrder(order). Rest two same-price SELL orders with distinguishable quantities, then send one crossing BUY smaller than either:

// Repro.java — build (from src/): javac interfaces/*.java implementations/*.java Repro.java
// run:   java Repro
import implementations.Order;
import implementations.OrderBook;

public class Repro {
    public static void main(String[] args) {
        OrderBook book = new OrderBook();

        Order sellA = new Order(book, 100, 7, 2); // first-arrived,  qty 7
        Order sellB = new Order(book, 100, 9, 2); // second-arrived, qty 9
        book.addOrder(sellA);
        book.addOrder(sellB);
        System.out.println("before: sellA.qty=" + sellA.getQuantity() + " sellB.qty=" + sellB.getQuantity());

        Order buy = new Order(book, 100, 5, 1); // crosses; smaller than either resting order
        book.addOrder(buy);
        System.out.println("after:  sellA.qty=" + sellA.getQuantity() + " sellB.qty=" + sellB.getQuantity());
    }
}
$ javac interfaces/*.java implementations/*.java Repro.java && java Repro
4
4
before: sellA.qty=7 sellB.qty=9
2
after:  sellA.qty=7 sellB.qty=4

(The bare 4 / 4 / 2 lines are the engine's own OrderBook.addOrder printing its status code on every call — see the smaller notes at the bottom.)

sellB.qty drops from 9 to 4 and sellA.qty stays at 7: the crossing order filled against sellB, the second-arrived order, leaving sellA, the first-arrived order, untouched. With the fix below, the same repro instead reduces sellA (7→2) and leaves sellB at 9 — the first-arrived order fills first.

Suggested fix

Strict comparisons insert a same-price arrival after its existing peers instead of before them:

-            else if(order.getPrice() >= _buyOrders.get(i).getPrice()) {
+            else if(order.getPrice() > _buyOrders.get(i).getPrice()) {
                 _buyOrders.add(i, order);
-            else if(order.getPrice() <= _sellOrders.get(i).getPrice()){
+            else if(order.getPrice() < _sellOrders.get(i).getPrice()){
                 _sellOrders.add(i, order);

I compiled this into a scratch copy and confirmed both directions: the repro above now leaves sellA (first-arrived) reduced and sellB untouched, and all 10 scenarios in the engine's own src/tests/OrderBookTests.java (re-run without the JUnit dependency, since none of it is otherwise needed to compile the engine) produce byte-identical status-code sequences and quantities before and after — none of those 10 rest two same-price orders on the same side, so the fix changes nothing they already commit to.

Also: the matcher never exits early, so every order pays a full contra-side scan

Separate from the priority bug above: OrderProcessor.processOrder (implementations/OrderProcessor.java, lines 21-56) walks its entire contra list with no break:

while(it_current.hasNext()){
        orderOnTheBook = it_current.next();
        if((order.getPrice() >= orderOnTheBook.getPrice() && side == 1) || (order.getPrice() <= orderOnTheBook.getPrice() && side == 2)){
            ...
        }
    if(partialEdit && !it_current.hasNext())
        return 6;
}
return 4;

_buyOrders/_sellOrders are kept sorted specifically so the best price leads and, per the invariant the insertion methods' own comments describe, once one resting order fails to cross, none further out can either — but the loop above never uses that: on a non-crossing top-of-book, or once a sweep runs past its last crossing level, it keeps calling it_current.next() and re-checking price against every remaining resting order, all the way to the end of the list, before returning. Insertion (addBuyOrder/addSellOrder) is a linear scan too — addSellOrder's own comment concedes "For big data, binary search could be implemented to speed up the addSellOrder!" but none is. Between the two, every submitted order costs O(book depth on the relevant side), whether or not it crosses anything.

Through the same benchmark adapter, median throughput at a 1M-order workload was 0.02 M msgs/s on the benchmark's deep-book scenario, versus 0.20-1.25 M/s on shallower ones; at the 2M-order scale the deep-book runs took roughly 9-10 minutes each, with two of eight exceeding the benchmark's ~10-minute per-run watchdog outright (one normal-scenario run did too).

Two standalone, public-API-only microbenchmarks isolate each half of the mechanism. The first isolates pure insertion cost: rest N orders one at a time into a fresh, empty book (strictly increasing price, so nothing ever crosses and each new order sorts to the tail — the worst case for addSellOrder's own scan):

N=10,000    total=    62.7 ms   avg= 6,273 ns/op
N=50,000    total= 1,460.6 ms   avg=29,213 ns/op
N=100,000   total= 6,956.0 ms   avg=69,560 ns/op
  ratio avg(50k)/avg(10k)  = 4.66   (N ratio  5.00)
  ratio avg(100k)/avg(10k) = 11.09  (N ratio 10.00)

Average per-op cost scales with book size almost exactly as N grows — the signature of an O(N) insertion, unconditionally paid on every new order.

The second isolates the matching loop's missing early exit from insertion cost: build a deep one-sided resting book (ascending-price SELL orders, contra side left empty), then fire a single BUY priced below the best resting ask — i.e. a non-crossing top-of-book, which a correctly-early-exiting matcher can reject after looking at just one resting order — and time only that one call (median of 7 warmed trials; a System.gc() + short sleep after building the book keeps collection from that phase out of the timed window):

                        as-shipped      with the break-early fix (below)
resting depth=5,000       11.2 us                    5.4 us
resting depth=25,000      34.2 us                    7.9 us
resting depth=50,000      61.9 us                    9.5 us

As-shipped, the single non-crossing call gets ~5.5x slower as depth grows 10x — it is paying for the whole contra side every time. With the fix, the same call is nearly flat across the same depth range (~1.8x versus depth's 10x), consistent with a single price check per call instead of a full scan. (Absolute timings in both tables are specific to the environment above; the scaling ratios are the point.)

Suggested fix

Exit the loop as soon as the current head no longer crosses — the sort the engine already maintains makes this valid — and fold that into the existing partialEdit-based return so an order that partially filled before hitting a non-crossing level still reports 6, not 4:

                         order.setQuantity(0);
                         return 2;
                     }
                 }
+                else {
+                    // Sorted contra side (see addBuyOrder/addSellOrder): once the
+                    // current head no longer crosses, none further out can either.
+                    break;
+                }
 
             // Order has been partially filled
             if(partialEdit && !it_current.hasNext())
                 return 6;
 
         }
 
-        // No orders on the book
-        return 4;
+        // No orders on the book (or a partial fill stopped short of the end)
+        return partialEdit ? 6 : 4;
 
     }

I compiled this into the same scratch copy as the priority fix and confirmed: the same 10 OrderBookTests.java scenarios still produce identical status-code sequences and quantities (none of them depend on the removed tail-scanning), and the timings above are the actual as-shipped-vs-patched numbers from that build. This fix leaves insertion cost untouched — it addresses the matching loop only.

Smaller notes

A few smaller things noticed along the way, not filed as separate issues:

  • 32-bit domain throughout: price/quantity are Java int end-to-end (interfaces/Order.java's getPrice/setPrice/getQuantity/setQuantity, and the backing _price/_quantity fields in implementations/Order.java) — a silent-overflow ceiling for venues quoting large notional or fine ticks.
  • Validation asymmetry: the Order constructor rejects price <= 0 || quantity <= 0 (implementations/Order.java:14-19), but the setters setPrice/setQuantity only reject values < 0 — so a zero-price or zero-quantity order can't be constructed, but can be produced afterward by mutating an already-constructed order.
  • Status code has no programmatic path out: OrderBook.addOrder computes an outcome status code and only System.out.printlns it (implementations/OrderBook.java:90); the method itself returns void, so a caller has no way to read the code other than parsing stdout.

This is just a time-stamped snapshot of one specific commit, offered back in case it's useful — not a comment on the project, which reads cleanly otherwise. Thanks for sharing the engine; happy to send either repro above as a small test if that helps.

Respectfully submitted.

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