BullionVault course · Lesson 0002
Lesson 0002 · matching
Four rules for matching orders, three of them broken, and one bug that costs the client money on every trade.
Lesson 0001 made the numbers trustworthy. This one is about the thing those numbers are for: BullionVault's order board, where clients deal directly with each other rather than against a dealer's quote.1 That is the actual product, and matching is its core.
Pull the new files, then get the red bar before reading on.
cd ~/Desktop/training/bullionvault/exercise/commodities-service
JAVA_HOME=$(/usr/libexec/java_home -v 25) ~/.local/maven/bin/mvn test
You should see 25 tests, 8 failing — your 13 from lesson
0001 still green, plus twelve new ones of which four already pass. Work
OrderBook.place until all 25 are green. As before,
do not edit the tests.
The failures, in full:
buyerDealsAtTheRestingOfferPrice expected USD 96000.000000/kg but was USD 99000.000000/kg
sellerDealsAtTheRestingBidPrice expected USD 96200.000000/kg but was USD 90000.000000/kg
eachFillKeepsItsOwnRestingPrice expected [96000, 96100] but was [99000, 99000]
fillKnowsItsConsideration expected USD 24000.00 but was USD 24750.00
earlierOfTwoEqualOffersDealsFirst expected [early] but was [late]
sweepsEqualPricesInArrivalOrder expected [first,second,third] but was [third,second,first]
smallOrderTakesPartOfALargeRestingOffer expected [250g] but was []
remainderOfAPartlyFilledRestingOrderStaysOnBoard expected [750g] but was [250g, 1000g]
Eight failures, but not eight bugs. Group them by what they assert and the count collapses: four are about price, two about order, two about quantity. Three bugs.
That is why each test in this suite asserts on one thing only — the counterparty, or the price, or the remaining quantity. Tests that assert on everything all go red together and tell you nothing about which rule is broken. Diagnosability is a property you design into a test suite.
Both tests in this group pass. Worth a moment anyway, because it is the only rule the code got right and you should be able to say why:
incomingSide == Side.BID
? Comparator.comparing(Order::limit) // cheapest offer first
: Comparator.comparing(Order::limit).reversed(); // highest bid first
The asymmetry is the domain, not a trick: a buyer wants the lowest number and a seller wants the highest, so "best" reverses with the side.
This failure is the most legible thing in the suite:
sweepsEqualPricesInArrivalOrder
expected: [first, second, third]
but was: [third, second, first]
Exactly reversed. Not scrambled, not off by one — reversed. That is the signature of a stack, and there is one hiding in the code:
resting.addFirst(incoming.withQuantity(remaining));
Orders are pushed onto the front of the list. The comparator sorts on price
alone, and Stream.sorted is a stable sort — it preserves the
existing order of equal elements. So equal prices come out newest-first, and the
client who has been waiting longest gets served last.
Fix A. Change addFirst to add.
Both time-priority tests go green. I have run this — it genuinely passes.
Fix B. Put the rule in the comparator:
Comparator<Order> byPrice = incomingSide == Side.BID
? Comparator.comparing(Order::limit)
: Comparator.comparing(Order::limit).reversed();
return byPrice.thenComparingLong(Order::sequence);
Fix A leaves a business rule — first come, first served — living
inside an accident of how a list is appended to. Nothing names it, no test
fails if someone later swaps the ArrayList for a HashSet,
parallelises the stream, or reloads the book from Oracle in whatever order the
rows come back. Fix B says the rule out loud, and holds regardless.
The solution I verified deliberately keeps addFirst.
All 25 tests pass anyway — which is the proof that the comparator, not the
list, is now carrying the rule.
A test expects [first, second, third] and gets [third, second, first]. What does the exact reversal tell you?
Close, and it is genuinely part of the story — but a missing tie-break alone gives you whatever order the source had. It does not explain why that order is reversed.
Java's Stream.sorted and Collections.sort are specified as stable, so equal elements keep their relative order. Instability would give you an arbitrary order, not a perfectly reversed one.
Exact reversal is a stack signature. A stable sort faithfully preserved an input that was already newest-first, because addFirst pushes rather than appends. Perfect reversal points at the data structure; scrambling would point at the comparator.
Iterating backwards would do it, but the loop is a plain enhanced-for over the candidate list. Look for the reversal where the list is built.
Changing addFirst to add makes both time-priority tests pass. Why is that not the fix?
Both are amortised constant time, and performance is not the argument. Even if it were, correctness would not be traded for it here.
Green tests, invisible rule. Nothing in the code says "earlier orders deal first" — it is an emergent property of one append. Swap the collection, reload the book from Oracle in a different row order, or parallelise the stream, and the rule silently breaks with every test still green.
The resting() view is an unordered snapshot and no test asserts an order on it. Matching order is decided entirely by the comparator.
It is guaranteed — Stream.sorted is specified as stable for ordered streams. That guarantee is exactly what makes the bug reproducible.
BullionVault says it plainly: "you will deal at a better price than your limit price if someone is still quoting a price which is better than your limit when your order is received."2
The code does the opposite:
fills.add(new Fill(incoming.id(), r.id(), r.quantity(), incoming.limit()));
Your limit is the worst price you will accept. Filling at it means every client always pays their worst case. In the test, a buyer willing to go to 99,000 meets a seller resting at 96,000 — and is charged 99,000.
| Dealt at | 250 g costs | |
|---|---|---|
| Resting offer's price (correct) | 96,000.00/kg | USD 24,000.00 |
| Incoming limit (the bug) | 99,000.00/kg | USD 24,750.00 |
Three per cent, silently, on every trade — and note which test caught the
consequence: fillKnowsItsConsideration, which runs your lesson 0001
Pricing.consideration over the fill. The money code was right. It was
handed the wrong price.
The fix is one identifier: r.limit().
A bid at 99,000 crosses a resting offer at 96,000. At what price should they deal, and why?
Midpoint matching does exist in some venues, but it is a different market design and not what BullionVault describes. It would also make the resting order's advertised price a fiction.
This is the bug. A limit is the worst price you will accept, not the price you want. Filling everyone at their limit means nobody ever benefits from a good board.
Consistency is not enough when one convention systematically overcharges. "We always do it wrong" is not a defence anyone will accept about client money.
The resting order is a firm public commitment to deal at 96,000, and it earned priority by arriving first. The incoming buyer gets the 3,000 of price improvement — which is exactly the benefit of arriving to a well-stocked board.
Two failures, one line:
if (r.quantity().compareTo(remaining) > 0) {
continue; // resting order is bigger than what I want — skip it entirely
}
A 250 g buyer meets a 1 kg offer and walks away with nothing. Note the direction: an incoming order larger than a resting one fills fine, so this only breaks when you want less than is on the board — which, on a market that lets people deal in single grams against kilobars, is most of the time.
What it should do: deal the smaller of the two, and put back whatever is left of the resting order.
long dealt = Math.min(remaining.grams(), r.quantity().grams());
fills.add(new Fill(incoming.id(), r.id(), Quantity.ofGrams(dealt), r.limit()));
remaining = Quantity.ofGrams(remaining.grams() - dealt);
resting.remove(r);
long unfilled = r.quantity().grams() - dealt;
if (unfilled > 0) {
resting.add(r.withQuantity(Quantity.ofGrams(unfilled)));
}
BullionVault's own description matches: some or all executes, and what is left is posted back as an active limit — and only the unexecuted part can be cancelled, because the filled part is a done deal.2
Why does Order.withQuantity return a new order rather than mutating the existing one?
Fills are financial records. If the order they referred to could change underneath them, an audit of a half-filled order would be reconstructing history from something that had already moved. Replacing rather than mutating also matters enormously in lesson 0003, when two threads reach the same resting order.
Record components are final, but that is a consequence of choosing a record — the design question is why a record was the right choice here.
The opposite: replacing allocates a new object per partial fill. That cost is accepted deliberately, in exchange for the guarantee.
The sort takes a snapshot of the candidate list before the loop runs, so it is unaffected either way.
On CME Globex, a resting order that is modified loses its time priority in which case?
Too broad. Reducing quantity keeps priority — you are asking for less of what you already queued for, which takes nothing from anyone behind you.
Price change, quantity increase, or account change all reset the timestamp. The logic is fairness: increasing your size is effectively joining the back of the queue for the extra, and changing price puts you in a different queue entirely. Reducing is free.
A cancel-and-replace certainly loses priority, but so do several in-place modifications — which is the more interesting half of the rule.
If modification were free, everyone would rest a one-gram order early and inflate it the moment the market moved. The rule exists to stop exactly that.
Why does each test here assert on only one thing — the counterparty, or the price, or the quantity?
True of plain JUnit assertions, and a reason to keep tests focused — but it is a mechanical consequence, not the design goal.
Twenty-five tests run in under a tenth of a second here. Speed is not driving this decision.
Eight failures collapsed into three bugs precisely because the assertions were separated. Had each test checked counterparty and price and quantity together, all eight would have failed on whichever assertion came first, and the suite would have told you only that matching was broken.
The nesting is for readable output, not file size — everything is in one class.
When you get to green, the natural next question is the one a real desk has
to answer: what happens when two orders arrive at the same
millisecond? sequence makes that decidable here — but only
because something upstream is handing out sequence numbers, and that something
is a shared mutable counter. Which is lesson 0003.
CME Globex Matching Algorithms — a real exchange documenting its own matching rules. Read the FIFO section and the note on when a modified order loses its timestamp. Twenty minutes, and it is the vocabulary an interviewer at a marketplace will expect: FIFO, pro-rata, allocation, priority. Everything in this lesson is the FIFO case.
Bring me your OrderBook.place when it's green — I'll
review it the way a peer reviewer at BullionVault would, since peer code review
is one of the three responsibilities in their spec. And if you think one of these
tests encodes the wrong rule, say so: two of them are judgement calls, not facts.