BullionVault course · exercise

exercise/commodities-service

commodities-service — the exercise

Read-only view of the exercise. Clone the workspace and run it locally to actually do it.

Reading ahead is cheating yourself

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.

Run it

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.

The business rules

These are BullionVault's published mechanics, not invented ones.

  1. Bullion is weighed in whole grams. The board deals in increments as small as one gram and no smaller. Orders are placed in kilograms and balances are quoted in kilograms — but a gram is the atom.
  1. The market quotes per troy ounce; the board deals per kilogram. One troy ounce is exactly 31.1034768 grams — a definition, not a measurement. Where a spot counterparty quotes per troy ounce, that is converted to a price per kilogram for execution and settlement.
  1. An execution price carries 6 decimal places, rounded half-up. Not 2. Rounding the kilogram price to the penny and then multiplying it across a large order drifts; carrying spare digits and rounding once at the end does not.
  1. A consideration is rounded to whole minor units, half-up. That is the figure that hits the client's cash balance.
  1. Commission is banded on the client's trailing twelve-month dealing volume in that metal — volumes are not pooled across metals:
Trailing 12-month volumeRate
less than 75,0000.50%
75,000 to 825,000 inclusive0.10%
more than 825,0000.05%
  1. Commission is "charged in whole pennies or cents." BullionVault never bills a fraction of a penny, and never bills less than the rate earns. A part-penny goes up.

What is given, and correct

TypeWhat it isWorth reading for
Metalthe four metals
Quantitya weight, held as whole gramswhy grams and not a double of kilograms
Moneyan exact amount, at the currency's minor unitwhy the constructor refuses to round
Pricemoney per kilogram, at 6 dpwhy its scale differs from Money's
SpotQuotea per-troy-ounce quote off the feed

What is broken

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.

What is missing

CommissionSchedule.commissionOn is a stub that throws. Rules 5 and 6 are the whole specification; the tests are the acceptance criteria.

Rules of the exercise

Suggested order

  1. ExecutionPrice — two tests, one cause.
  2. Consideration — three failing, one passing. The passing one is the lesson.
  3. 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.

Main sources

src/main/java/com/bullionvault/commodities/CommissionSchedule.java

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");
    }
}

src/main/java/com/bullionvault/commodities/Metal.java

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
}

src/main/java/com/bullionvault/commodities/Money.java

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();
    }
}

src/main/java/com/bullionvault/commodities/Price.java

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) {

    /** 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);
    }

    @Override
    public String toString() {
        return currency.getCurrencyCode() + " " + perKilogram.toPlainString() + "/kg";
    }
}

src/main/java/com/bullionvault/commodities/Pricing.java

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());
    }
}

src/main/java/com/bullionvault/commodities/Quantity.java

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";
    }
}

src/main/java/com/bullionvault/commodities/SpotQuote.java

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);
    }
}

Test sources

src/test/java/com/bullionvault/commodities/CommissionScheduleTest.java

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());
    }
}

src/test/java/com/bullionvault/commodities/PricingTest.java

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)));
        }
    }
}

Build file

pom.xml

<?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>