BullionVault course · exercise
exercise/commodities-service
Read-only view of the exercise. Clone the workspace and run it locally to actually do it.
The sources below include the seeded bugs. If you have not yet had the red bar in front of you, stop here and go and get it — finding the bug is the skill being practised, and you only get to find it once.
A cut-down dealing desk for a bullion order board. Four value types are given to you and are correct. Two calculations are wired up and wrong. One does not exist.
Your job: make mvn test green without changing a single test.
cd ~/Desktop/training/bullionvault/exercise/commodities-service
JAVA_HOME=$(/usr/libexec/java_home -v 25) ~/.local/maven/bin/mvn test
You should see 13 tests, 12 failing. One passes. Do not trust it.
These are BullionVault's published mechanics, not invented ones.
| Trailing 12-month volume | Rate |
|---|---|
| less than 75,000 | 0.50% |
| 75,000 to 825,000 inclusive | 0.10% |
| more than 825,000 | 0.05% |
| Type | What it is | Worth reading for |
|---|---|---|
Metal | the four metals | — |
Quantity | a weight, held as whole grams | why grams and not a double of kilograms |
Money | an exact amount, at the currency's minor unit | why the constructor refuses to round |
Price | money per kilogram, at 6 dp | why its scale differs from Money's |
SpotQuote | a per-troy-ounce quote off the feed | — |
Pricing.executionPricePerKilogram and Pricing.consideration both compile, both run, and both are wrong. They are wrong in different ways — one of them announces itself loudly and one of them does not.
CommissionSchedule.commissionOn is a stub that throws. Rules 5 and 6 are the whole specification; the tests are the acceptance criteria.
ExecutionPrice — two tests, one cause.Consideration — three failing, one passing. The passing one is the lesson.CommissionSchedule — seven tests, built from scratch.The walkthrough, and why each bug is the bug, is in lesson 0001. Try it before you read it.
package com.bullionvault.commodities;
/**
* BullionVault's dealing commission, as published in the tariff.
*
* <p>The rate falls as the client deals more. It is banded on the client's
* trailing twelve-month dealing volume <em>in this metal</em> — volumes are not
* pooled across metals, so the caller passes the volume for the metal being
* dealt.
*
* <table>
* <caption>Commission bands</caption>
* <tr><th>Trailing 12-month volume</th><th>Rate</th></tr>
* <tr><td>less than 75,000</td><td>0.50%</td></tr>
* <tr><td>75,000 to 825,000 inclusive</td><td>0.10%</td></tr>
* <tr><td>more than 825,000</td><td>0.05%</td></tr>
* </table>
*
* <p>The tariff also says commission is <strong>"charged in whole pennies or
* cents"</strong>. BullionVault never bills a fraction of a penny and never
* bills less than the rate earns, so a part-penny is always taken up to the next
* whole penny.
*
* <p>This class is a stub. Making it work is your job.
*/
public final class CommissionSchedule {
private CommissionSchedule() {
}
/**
* The commission charged on a single deal.
*
* @param consideration the value of the deal
* @param trailingTwelveMonthVolume the client's dealing volume in this
* metal over the last twelve months,
* <em>before</em> this deal
* @return the commission, in whole minor units of the currency
*/
public static Money commissionOn(Money consideration, Money trailingTwelveMonthVolume) {
throw new UnsupportedOperationException(
"CommissionSchedule.commissionOn is not implemented yet");
}
}
package com.bullionvault.commodities;
import java.util.Objects;
/**
* One execution between an incoming order and a resting one.
*
* <p>A single incoming order may produce several fills, at several prices,
* against several counterparties.
*
* @param incomingOrderId the order that arrived and crossed the spread
* @param restingOrderId the order that was already on the board
* @param quantity how much dealt
* @param price the price dealt at — see the lesson, this is the
* part everybody gets wrong
*/
public record Fill(String incomingOrderId, String restingOrderId, Quantity quantity, Price price) {
public Fill {
Objects.requireNonNull(incomingOrderId, "incomingOrderId");
Objects.requireNonNull(restingOrderId, "restingOrderId");
Objects.requireNonNull(quantity, "quantity");
Objects.requireNonNull(price, "price");
}
/** What this fill costs the buyer, to the penny. */
public Money consideration() {
return Pricing.consideration(price, quantity);
}
@Override
public String toString() {
return quantity + " @ " + price + " (" + incomingOrderId + " x " + restingOrderId + ")";
}
}
package com.bullionvault.commodities;
/**
* The four metals traded on the order board.
*
* <p>Commission bands are calculated separately for each metal, so the metal is
* part of every commission question — never assume a single account-wide rate.
*/
public enum Metal {
GOLD,
SILVER,
PLATINUM,
PALLADIUM
}
package com.bullionvault.commodities;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Currency;
import java.util.Objects;
/**
* An exact amount of money, held to the currency's minor unit (2 decimal places
* for USD and GBP).
*
* <p><strong>Money never rounds for you.</strong> The constructor uses
* {@link RoundingMode#UNNECESSARY}, so handing it an amount with more precision
* than the minor unit throws rather than silently discarding a fraction of a
* penny. Rounding a customer's money is a business decision, and business
* decisions belong in the code that knows the rule — not in a value type that
* quietly picks one.
*
* <p>Note that this also fixes the scale, which is what makes {@code equals}
* safe: {@code new BigDecimal("1.5")} and {@code new BigDecimal("1.50")} are
* <em>not</em> equal to each other, because {@link BigDecimal#equals} compares
* scale as well as value.
*/
public record Money(BigDecimal amount, Currency currency) implements Comparable<Money> {
public static final Currency USD = Currency.getInstance("USD");
public static final Currency GBP = Currency.getInstance("GBP");
public Money {
Objects.requireNonNull(amount, "amount");
Objects.requireNonNull(currency, "currency");
amount = amount.setScale(currency.getDefaultFractionDigits(), RoundingMode.UNNECESSARY);
}
public static Money usd(String amount) {
return new Money(new BigDecimal(amount), USD);
}
public static Money gbp(String amount) {
return new Money(new BigDecimal(amount), GBP);
}
public static Money zero(Currency currency) {
return new Money(BigDecimal.ZERO, currency);
}
public Money plus(Money other) {
requireSameCurrency(other);
return new Money(amount.add(other.amount), currency);
}
public Money minus(Money other) {
requireSameCurrency(other);
return new Money(amount.subtract(other.amount), currency);
}
public boolean isLessThan(Money other) {
return compareTo(other) < 0;
}
@Override
public int compareTo(Money other) {
requireSameCurrency(other);
return amount.compareTo(other.amount);
}
private void requireSameCurrency(Money other) {
if (!currency.equals(other.currency)) {
throw new IllegalArgumentException(
"Currency mismatch: " + currency.getCurrencyCode() + " vs " + other.currency.getCurrencyCode());
}
}
@Override
public String toString() {
return currency.getCurrencyCode() + " " + amount.toPlainString();
}
}
package com.bullionvault.commodities;
import java.util.Objects;
/**
* An order on the board.
*
* <p>Orders are immutable. When an order is partly filled, the book replaces it
* with a copy carrying the reduced quantity rather than mutating it in place —
* so a {@link Fill} that has already happened can never be altered by a later
* one.
*
* @param id the client's reference for this order
* @param side buying or selling
* @param metal which metal
* @param quantity how much is still available to deal
* @param limit the worst price this order will accept
* @param sequence arrival order on the board; lower arrived earlier
*/
public record Order(String id, Side side, Metal metal, Quantity quantity, Price limit, long sequence) {
public Order {
Objects.requireNonNull(id, "id");
Objects.requireNonNull(side, "side");
Objects.requireNonNull(metal, "metal");
Objects.requireNonNull(quantity, "quantity");
Objects.requireNonNull(limit, "limit");
if (quantity.isZero()) {
throw new IllegalArgumentException("Order " + id + " has no quantity");
}
}
public Order withQuantity(Quantity remaining) {
return new Order(id, side, metal, remaining, limit, sequence);
}
@Override
public String toString() {
return id + " " + side + " " + quantity + " " + metal + " @ " + limit + " (#" + sequence + ")";
}
}
package com.bullionvault.commodities;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
/**
* The order board for one metal: clients dealing directly with each other.
*
* <p>An arriving order is matched against the orders already resting on the
* opposite side. Whatever cannot be matched rests on the board itself, waiting
* for a counterparty.
*
* <p>The matching rules, as the business states them:
* <ol>
* <li><strong>Price priority.</strong> A buyer takes the cheapest offer
* available; a seller hits the highest bid.</li>
* <li><strong>Time priority.</strong> Between two orders at the same price,
* the one that arrived first deals first.</li>
* <li><strong>Price improvement.</strong> A client deals at a better price
* than their limit if one is on the board when their order arrives.</li>
* <li><strong>Partial fills.</strong> An order may be filled by several
* counterparties, and whatever is left rests on the board.</li>
* </ol>
*
* <p>This implementation gets some of that right.
*/
public final class OrderBook {
private final List<Order> resting = new ArrayList<>();
/**
* Places an order, matching it against the board and resting the remainder.
*
* @return the fills that resulted, in the order they were executed
*/
public List<Fill> place(Order incoming) {
List<Fill> fills = new ArrayList<>();
Quantity remaining = incoming.quantity();
List<Order> candidates = resting.stream()
.filter(r -> r.metal() == incoming.metal())
.filter(r -> r.side() == incoming.side().opposite())
.filter(r -> crosses(incoming, r))
.sorted(bestPriceFirst(incoming.side()))
.toList();
for (Order r : candidates) {
if (remaining.isZero()) {
break;
}
if (r.quantity().compareTo(remaining) > 0) {
continue;
}
fills.add(new Fill(incoming.id(), r.id(), r.quantity(), incoming.limit()));
remaining = Quantity.ofGrams(remaining.grams() - r.quantity().grams());
resting.remove(r);
}
if (!remaining.isZero()) {
resting.addFirst(incoming.withQuantity(remaining));
}
return fills;
}
/** The orders currently resting on the board. */
public List<Order> resting() {
return List.copyOf(resting);
}
/** True if these two orders' limits overlap, so a deal is possible. */
private static boolean crosses(Order incoming, Order restingOrder) {
return incoming.side() == Side.BID
? restingOrder.limit().isAtMost(incoming.limit())
: restingOrder.limit().isAtLeast(incoming.limit());
}
private static Comparator<Order> bestPriceFirst(Side incomingSide) {
return incomingSide == Side.BID
? Comparator.comparing(Order::limit)
: Comparator.comparing(Order::limit).reversed();
}
}
package com.bullionvault.commodities;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Currency;
import java.util.Objects;
/**
* An execution price: money per kilogram of metal.
*
* <p>Held to {@link #SCALE} decimal places rather than to the currency's minor
* unit. That is deliberate. A kilogram price rounded to the penny, multiplied
* out across a large order, drifts; carrying extra digits here and rounding once
* at the end of the calculation does not.
*/
public record Price(BigDecimal perKilogram, Currency currency) implements Comparable<Price> {
/** Decimal places carried by an execution price. */
public static final int SCALE = 6;
public Price {
Objects.requireNonNull(perKilogram, "perKilogram");
Objects.requireNonNull(currency, "currency");
perKilogram = perKilogram.setScale(SCALE, RoundingMode.UNNECESSARY);
}
public static Price usdPerKilogram(String perKilogram) {
return new Price(new BigDecimal(perKilogram), Money.USD);
}
/** True if this price is no worse than {@code other} for whoever is paying. */
public boolean isAtMost(Price other) {
return compareTo(other) <= 0;
}
/** True if this price is no worse than {@code other} for whoever is selling. */
public boolean isAtLeast(Price other) {
return compareTo(other) >= 0;
}
@Override
public int compareTo(Price other) {
if (!currency.equals(other.currency)) {
throw new IllegalArgumentException(
"Currency mismatch: " + currency.getCurrencyCode() + " vs " + other.currency.getCurrencyCode());
}
return perKilogram.compareTo(other.perKilogram);
}
@Override
public String toString() {
return currency.getCurrencyCode() + " " + perKilogram.toPlainString() + "/kg";
}
}
package com.bullionvault.commodities;
import java.math.BigDecimal;
import java.math.RoundingMode;
/**
* Turns a wholesale spot quote into the numbers a client actually deals on.
*
* <p>Two steps, in this order:
* <ol>
* <li>{@link #executionPricePerKilogram} — the market quotes per troy ounce,
* the board executes per kilogram.</li>
* <li>{@link #consideration} — what this deal costs, to the penny.</li>
* </ol>
*
* <p>Both methods below are wired up and both are wrong. Neither is wrong in a
* way the compiler will tell you about.
*/
public final class Pricing {
/**
* Grams in one troy ounce. This is an exact definition, not a measurement:
* the troy ounce is defined as exactly 31.1034768 grams.
*/
public static final BigDecimal GRAMS_PER_TROY_OUNCE = new BigDecimal("31.1034768");
private Pricing() {
}
/**
* Converts a spot quote (per troy ounce) into an execution price (per
* kilogram), carried to {@link Price#SCALE} decimal places.
*/
public static Price executionPricePerKilogram(SpotQuote quote) {
BigDecimal perKilogram = quote.perTroyOunce()
.multiply(BigDecimal.valueOf(Quantity.GRAMS_PER_KILOGRAM))
.divide(GRAMS_PER_TROY_OUNCE);
return new Price(perKilogram, quote.currency());
}
/**
* The consideration for a deal: the execution price applied to the quantity
* dealt, rounded to whole minor units of the currency.
*/
public static Money consideration(Price price, Quantity quantity) {
long kilograms = quantity.grams() / Quantity.GRAMS_PER_KILOGRAM;
BigDecimal gross = price.perKilogram().multiply(BigDecimal.valueOf(kilograms));
return new Money(gross.setScale(2, RoundingMode.HALF_UP), price.currency());
}
}
package com.bullionvault.commodities;
import java.math.BigDecimal;
/**
* A weight of bullion.
*
* <p>The order board trades in increments as small as one gram, and no smaller,
* so the canonical representation is a whole number of grams. Orders are
* <em>placed</em> in kilograms and balances are <em>quoted</em> in kilograms,
* which is a presentation concern, not a storage one.
*
* @param grams whole grams; always non-negative
*/
public record Quantity(long grams) implements Comparable<Quantity> {
public static final int GRAMS_PER_KILOGRAM = 1000;
public Quantity {
if (grams < 0) {
throw new IllegalArgumentException("Quantity cannot be negative: " + grams + "g");
}
}
public static Quantity ofGrams(long grams) {
return new Quantity(grams);
}
/**
* Builds a quantity from a kilogram figure as typed into the order panel.
* Anything finer than a gram is rejected rather than rounded — the board
* cannot execute it.
*/
public static Quantity ofKilograms(String kilograms) {
BigDecimal g = new BigDecimal(kilograms).movePointRight(3);
try {
return new Quantity(g.longValueExact());
} catch (ArithmeticException e) {
throw new IllegalArgumentException(
"Quantity must be a whole number of grams, was " + kilograms + "kg", e);
}
}
public Quantity plus(Quantity other) {
return new Quantity(grams + other.grams);
}
public boolean isZero() {
return grams == 0;
}
@Override
public int compareTo(Quantity other) {
return Long.compare(grams, other.grams);
}
@Override
public String toString() {
return grams + "g";
}
}
package com.bullionvault.commodities;
/**
* Which side of the order board an order sits on.
*
* <p>A <strong>bid</strong> is an order to buy: the price a client is offered
* when they want to sell to it. An <strong>offer</strong> is an order to sell.
* Bids are always below offers on a functioning board — the gap is the spread.
*/
public enum Side {
BID,
OFFER;
public Side opposite() {
return this == BID ? OFFER : BID;
}
}
package com.bullionvault.commodities;
import java.math.BigDecimal;
import java.util.Currency;
import java.util.Objects;
/**
* A price from the wholesale spot market.
*
* <p>The market quotes <strong>per troy ounce</strong>. The order board executes
* and settles <strong>per kilogram</strong>. Converting between the two is
* {@link Pricing#executionPricePerKilogram}, and it is not as innocent as it
* looks.
*
* @param metal which metal this quote is for
* @param perTroyOunce the quoted price of one troy ounce
* @param currency the currency of the quote
*/
public record SpotQuote(Metal metal, BigDecimal perTroyOunce, Currency currency) {
public SpotQuote {
Objects.requireNonNull(metal, "metal");
Objects.requireNonNull(perTroyOunce, "perTroyOunce");
Objects.requireNonNull(currency, "currency");
if (perTroyOunce.signum() <= 0) {
throw new IllegalArgumentException("Spot price must be positive, was " + perTroyOunce);
}
}
public static SpotQuote usd(Metal metal, String perTroyOunce) {
return new SpotQuote(metal, new BigDecimal(perTroyOunce), Money.USD);
}
}
package com.bullionvault.commodities;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* The published tariff, turned into assertions.
*
* <p>Read these before you write a line of {@link CommissionSchedule}. Three of
* them are about the bands; three of them are about the boundaries between the
* bands; one of them is about a single penny.
*/
class CommissionScheduleTest {
private static final Money DEAL = Money.usd("24113.06");
@Test
@DisplayName("a new client pays the top rate of 0.50%")
void chargesTopRateWhenTheClientHasDealtNothing() {
// 24113.06 * 0.0050 = 120.5653
assertEquals(
Money.usd("120.57"),
CommissionSchedule.commissionOn(DEAL, Money.usd("0.00")));
}
@Test
@DisplayName("a client just short of the first threshold still pays 0.50%")
void chargesTopRateJustBelowTheFirstThreshold() {
assertEquals(
Money.usd("120.57"),
CommissionSchedule.commissionOn(DEAL, Money.usd("74999.99")));
}
@Test
@DisplayName("the first threshold is inclusive: 75,000 already earns 0.10%")
void dropsToTheMiddleRateExactlyAtTheFirstThreshold() {
// 24113.06 * 0.0010 = 24.113060
assertEquals(
Money.usd("24.12"),
CommissionSchedule.commissionOn(DEAL, Money.usd("75000.00")));
}
@Test
@DisplayName("the second threshold is inclusive too: 825,000 still pays 0.10%")
void staysOnTheMiddleRateExactlyAtTheSecondThreshold() {
assertEquals(
Money.usd("24.12"),
CommissionSchedule.commissionOn(DEAL, Money.usd("825000.00")));
}
@Test
@DisplayName("one cent past the second threshold earns the floor rate of 0.05%")
void dropsToTheFloorRateJustAboveTheSecondThreshold() {
// 24113.06 * 0.0005 = 12.056530
assertEquals(
Money.usd("12.06"),
CommissionSchedule.commissionOn(DEAL, Money.usd("825000.01")));
}
@Test
@DisplayName("a part-penny of commission is taken up, never dropped")
void roundsAPartPennyUpToTheNextWholePenny() {
// 1000.00 * 0.0050 = 5.00 exactly; add a cent to the deal and the
// commission gains 0.005 of a cent, which must still be charged.
assertEquals(
Money.usd("5.01"),
CommissionSchedule.commissionOn(Money.usd("1000.01"), Money.usd("0.00")));
}
@Test
@DisplayName("commission comes back in the currency of the deal")
void keepsTheCurrencyOfTheDeal() {
assertEquals(
Money.GBP,
CommissionSchedule.commissionOn(Money.gbp("1000.00"), Money.gbp("0.00")).currency());
}
}
package com.bullionvault.commodities;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* The order board's matching rules, as the business states them.
*
* <p>Each group below tests one rule and asserts on <em>only</em> the thing that
* rule governs — who you dealt with, or at what price, or how much is left. That
* is deliberate: when several rules are broken at once, tests that each assert on
* everything all go red together and tell you nothing about which rule is wrong.
*/
class OrderBookTest {
private static final Price CHEAP = Price.usdPerKilogram("96000");
private static final Price MID = Price.usdPerKilogram("96100");
private static final Price DEAR = Price.usdPerKilogram("96200");
private static final Price WELL_ABOVE = Price.usdPerKilogram("99000");
private static final Price WELL_BELOW = Price.usdPerKilogram("90000");
private OrderBook board;
private long sequence;
@BeforeEach
void setUp() {
board = new OrderBook();
sequence = 0;
}
private Order offer(String id, int grams, Price limit) {
return new Order(id, Side.OFFER, Metal.GOLD, Quantity.ofGrams(grams), limit, ++sequence);
}
private Order bid(String id, int grams, Price limit) {
return new Order(id, Side.BID, Metal.GOLD, Quantity.ofGrams(grams), limit, ++sequence);
}
@Nested
@DisplayName("price priority — the best price on the board deals first")
class PricePriority {
@Test
@DisplayName("a buyer takes the cheapest offer, not the one that arrived first")
void buyerTakesTheCheapestOffer() {
board.place(offer("dear", 1000, DEAR));
board.place(offer("cheap", 1000, CHEAP));
List<Fill> fills = board.place(bid("buyer", 1000, WELL_ABOVE));
assertEquals(List.of("cheap"), restingIds(fills));
}
@Test
@DisplayName("a seller hits the highest bid, not the one that arrived first")
void sellerHitsTheHighestBid() {
board.place(bid("low", 1000, CHEAP));
board.place(bid("high", 1000, DEAR));
List<Fill> fills = board.place(offer("seller", 1000, WELL_BELOW));
assertEquals(List.of("high"), restingIds(fills));
}
}
@Nested
@DisplayName("time priority — equal prices are broken by arrival order")
class TimePriority {
@Test
@DisplayName("between two offers at the same price, the earlier one deals")
void earlierOfTwoEqualOffersDealsFirst() {
board.place(offer("early", 1000, CHEAP));
board.place(offer("late", 1000, CHEAP));
List<Fill> fills = board.place(bid("buyer", 1000, WELL_ABOVE));
assertEquals(List.of("early"), restingIds(fills));
}
@Test
@DisplayName("a big order sweeps equal prices in arrival order")
void sweepsEqualPricesInArrivalOrder() {
board.place(offer("first", 500, CHEAP));
board.place(offer("second", 500, CHEAP));
board.place(offer("third", 500, CHEAP));
List<Fill> fills = board.place(bid("buyer", 1500, WELL_ABOVE));
assertEquals(List.of("first", "second", "third"), restingIds(fills));
}
}
@Nested
@DisplayName("price improvement — you deal better than your limit when you can")
class PriceImprovement {
@Test
@DisplayName("a buyer willing to pay more deals at the resting offer's price")
void buyerDealsAtTheRestingOfferPrice() {
board.place(offer("seller", 1000, CHEAP));
List<Fill> fills = board.place(bid("buyer", 1000, WELL_ABOVE));
assertEquals(CHEAP, fills.getFirst().price());
}
@Test
@DisplayName("a seller willing to take less deals at the resting bid's price")
void sellerDealsAtTheRestingBidPrice() {
board.place(bid("buyer", 1000, DEAR));
List<Fill> fills = board.place(offer("seller", 1000, WELL_BELOW));
assertEquals(DEAR, fills.getFirst().price());
}
@Test
@DisplayName("each fill of a sweep keeps its own counterparty's price")
void eachFillKeepsItsOwnRestingPrice() {
board.place(offer("cheapest", 500, CHEAP));
board.place(offer("dearest", 500, MID));
List<Fill> fills = board.place(bid("buyer", 1000, WELL_ABOVE));
assertEquals(List.of(CHEAP, MID), fills.stream().map(Fill::price).toList());
}
}
@Nested
@DisplayName("partial fills — an order need not be dealt all at once")
class PartialFills {
@Test
@DisplayName("a small order takes part of a large resting offer")
void smallOrderTakesPartOfALargeRestingOffer() {
board.place(offer("seller", 1000, CHEAP));
List<Fill> fills = board.place(bid("buyer", 250, WELL_ABOVE));
assertEquals(List.of(Quantity.ofGrams(250)), fills.stream().map(Fill::quantity).toList());
}
@Test
@DisplayName("what is left of a partly filled resting order stays on the board")
void remainderOfAPartlyFilledRestingOrderStaysOnTheBoard() {
board.place(offer("seller", 1000, CHEAP));
board.place(bid("buyer", 250, WELL_ABOVE));
assertEquals(List.of(Quantity.ofGrams(750)),
board.resting().stream().map(Order::quantity).toList());
}
@Test
@DisplayName("an order too big for the board rests for what it could not get")
void unmatchedRemainderRestsOnTheBoard() {
board.place(offer("seller", 400, CHEAP));
board.place(bid("buyer", 1000, WELL_ABOVE));
assertEquals(List.of(Quantity.ofGrams(600)),
board.resting().stream().map(Order::quantity).toList());
}
@Test
@DisplayName("an order that crosses nothing rests untouched and deals nothing")
void anOrderThatCrossesNothingJustRests() {
List<Fill> fills = board.place(bid("buyer", 500, CHEAP));
assertTrue(fills.isEmpty(), "expected no fills, got " + fills);
assertEquals(List.of(Quantity.ofGrams(500)),
board.resting().stream().map(Order::quantity).toList());
}
}
@Nested
@DisplayName("what the deal costs — lesson 0001, applied")
class Consideration {
@Test
@DisplayName("a fill knows its own consideration, to the penny")
void fillKnowsItsConsideration() {
board.place(offer("seller", 250, CHEAP));
List<Fill> fills = board.place(bid("buyer", 250, WELL_ABOVE));
// 96000.000000 per kg * 0.250 kg = 24000.00
assertEquals(Money.usd("24000.00"), fills.getFirst().consideration());
}
}
private static List<String> restingIds(List<Fill> fills) {
return fills.stream().map(Fill::restingOrderId).toList();
}
}
package com.bullionvault.commodities;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* The dealing desk's arithmetic, as the business describes it.
*
* <p>Every figure in here was worked out independently of the Java. If a test
* disagrees with {@link Pricing}, the Java is wrong.
*/
class PricingTest {
/**
* Illustrative spot price. Not today's gold price — a round number chosen so
* that the conversion, not the fixture, is what you are looking at.
*/
private static final SpotQuote GOLD_AT_3000 = SpotQuote.usd(Metal.GOLD, "3000.00");
@Nested
@DisplayName("converting a troy-ounce spot quote to a per-kilogram execution price")
class ExecutionPrice {
@Test
@DisplayName("one kilogram is 1000 / 31.1034768 troy ounces")
void convertsSpotQuoteToPricePerKilogram() {
// 3000.00 per troy oz * 1000 g/kg / 31.1034768 g/oz = 96452.239705...
assertEquals(
Price.usdPerKilogram("96452.239706"),
Pricing.executionPricePerKilogram(GOLD_AT_3000));
}
@Test
@DisplayName("a quote with pence in it converts just the same")
void convertsQuoteWithFractionalPence() {
assertEquals(
Price.usdPerKilogram("77565.283634"),
Pricing.executionPricePerKilogram(SpotQuote.usd(Metal.GOLD, "2412.55")));
}
}
@Nested
@DisplayName("the consideration for a deal")
class Consideration {
private static final Price GOLD_PER_KILO = Price.usdPerKilogram("96452.239706");
@Test
@DisplayName("a whole kilogram costs the kilogram price")
void chargesTheKilogramPriceForOneKilogram() {
assertEquals(
Money.usd("96452.24"),
Pricing.consideration(GOLD_PER_KILO, Quantity.ofKilograms("1")));
}
@Test
@DisplayName("a quarter kilogram costs a quarter as much")
void chargesAQuarterOfTheKilogramPriceForTwoHundredAndFiftyGrams() {
// 96452.239706 * 0.250 = 24113.0599265, to the penny 24113.06
assertEquals(
Money.usd("24113.06"),
Pricing.consideration(GOLD_PER_KILO, Quantity.ofGrams(250)));
}
@Test
@DisplayName("the smallest dealable amount, one gram, still has a price")
void pricesTheSmallestDealableQuantity() {
// 96452.239706 * 0.001 = 96.452239706, to the penny 96.45
assertEquals(
Money.usd("96.45"),
Pricing.consideration(GOLD_PER_KILO, Quantity.ofGrams(1)));
}
@Test
@DisplayName("an odd number of grams rounds to the nearest penny")
void roundsAnAwkwardQuantityToTheNearestPenny() {
// 96452.239706 * 0.037 = 3568.7328691..., to the penny 3568.73
assertEquals(
Money.usd("3568.73"),
Pricing.consideration(GOLD_PER_KILO, Quantity.ofGrams(37)));
}
}
}
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.bullionvault</groupId>
<artifactId>commodities-service</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>jar</packaging>
<name>commodities-service</name>
<description>Interview practice: pricing and commission for a bullion order board.</description>
<properties>
<maven.compiler.release>25</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<junit.version>5.12.2</junit.version>
</properties>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.15.0</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.6</version>
</plugin>
</plugins>
</build>
</project>