BullionVault course · Lesson 0001

Lesson 0001 · pricing

Money That Doesn't Leak

Troy ounces, kilograms, and the three ways a bullion desk loses a penny.

BullionVault holds $7.9 billion for 130,000 people. At that size, arithmetic is not a detail — it is the product. This lesson gets you to the point where you can look at unfamiliar pricing code and name what is wrong with it before you run anything.

You will meet three failure modes, all real, all seeded into a working Maven project you are about to break open:

  1. A division that throws.
  2. A conversion that lies, and whose test suite is green enough to let it ship.
  3. A rounding rule that is a business decision, not a technical one.
Why this, first

Every other lesson in this course sits on top of this one. You cannot match orders, settle trades, or reason about a lost update until the numbers themselves are trustworthy. And in a pairing test, money-representation is the first thing an interviewer at a bullion house will probe.

The domain, in one paragraph

The wholesale market quotes bullion per troy ounce. BullionVault's order board executes and settles per kilogram — orders are placed in kilograms, balances are quoted in kilograms — while letting clients deal in increments as small as one gram.1 So every price that arrives from the market must cross a unit boundary before anyone can deal on it. That boundary is where the money leaks.

The one constant to memorise

One troy ounce is exactly 31.1034768 grams. Not approximately — that is the legal definition. Which means one kilogram is 1000 / 31.1034768 troy ounces, and that number is where the trouble starts.

Do this now

Task · 20 minutes

Open the exercise and get a red bar before you read another word.

cd ~/Desktop/training/bullionvault/exercise/commodities-service
JAVA_HOME=$(/usr/libexec/java_home -v 25) ~/.local/maven/bin/mvn test

Read the brief, then work the three groups in order. Do not edit the tests. Come back here when you are stuck or green.

What you should see, verbatim:

[ERROR] Tests run: 13, Failures: 3, Errors: 9, Skipped: 0

Thirteen tests. Twelve failing. One passing. Hold on to that one — it is the most instructive thing in the exercise.


Leak one · the division that throws

The naive conversion reads exactly the way the sentence "convert per-ounce to per-kilogram" sounds:

quote.perTroyOunce()
     .multiply(BigDecimal.valueOf(Quantity.GRAMS_PER_KILOGRAM))
     .divide(GRAMS_PER_TROY_OUNCE);

And it detonates:

java.lang.ArithmeticException: Non-terminating decimal expansion;
    no exact representable decimal result.
	at java.base/java.math.BigDecimal.divide(BigDecimal.java:1810)
	at com.bullionvault.commodities.Pricing.executionPricePerKilogram(Pricing.java:37)

This is BigDecimal being honest. 3000000 / 31.1034768 is 96452.2397058… forever. A double would have shrugged and handed you a nearby number; BigDecimal.divide(BigDecimal) has no mandate to invent precision, so it refuses.2

The rule

Every BigDecimal.divide on money needs a scale and a RoundingMode, or a MathContext. The one-argument form is only safe when you can prove the division terminates — and dividing by a physical conversion constant never terminates.

Where this bites next

There are two decisions here, not one: how many decimal places, and which way the last one goes. It is easy to make only the second and think you are done, because divide(divisor, RoundingMode) compiles and stops the exception:

dividend.divide(OZ, RoundingMode.HALF_UP)     → 96452.24     scale 2
dividend.divide(OZ, 6, RoundingMode.HALF_UP)  → 96452.239706 scale 6

The two-argument overload takes its scale from the dividend — here 3000.00 × 1000 = 3000000.00, scale 2, inherited from how the spot price happened to be written. So the price arrives correct to the penny and wrong to the test. Ask for the scale you actually want.

Choosing the mode

Ask one question: who does this rounding favour?

A unit conversion favours nobody — it is arithmetic, not a charge — so it wants a symmetric mode. HALF_UP is the plain reading of "round it", and it is what the test expects. CEILING or FLOOR would tilt every price in one direction, which on a two-sided order board means one side of every trade quietly pays for it.

Contrast with commission further down this lesson, where CEILING is correct — because there the business stated a rule about who the part-penny belongs to.

Why is ArithmeticException here better than a slightly-wrong number?

  1. Throwing is measurably faster than returning a slightly incorrect result
  2. The exception is caught upstream and the spot feed is then retried
  3. A loud failure is far cheaper to find than a quiet one
  4. Unchecked exceptions force the caller to document arithmetic assumptions

Speed is irrelevant here, and throwing is not faster than arithmetic. The question is about failure visibility, not performance.

Nothing retries it. Re-fetching the same quote would produce the same non-terminating division — the input was never the problem.

This is the whole argument for BigDecimal's strictness. The alternative — silently rounding to some default — is a bug that ships, reconciles wrong for months, and is found by an accountant. A stack trace is found in twenty seconds.

ArithmeticException is unchecked, so it forces nothing. Even if it were checked, that would be about compilation, not correctness.

Why does Price carry 6 decimal places when Money carries 2?

  1. Oracle's NUMBER type stores exactly six digits after the decimal point
  2. Rounding the per-kilo rate first makes large orders drift by pennies
  3. Six digits is the precision the wholesale spot market publishes at
  4. BigDecimal performs faster when every operand shares an identical scale

Oracle's NUMBER takes whatever precision and scale you declare. The choice of 6 is a domain decision made in Java, not a storage constraint.

Right, and it is the general principle: carry precision through the calculation and round once, at the end, where the money lands. A kilo price truncated to the penny is wrong by up to £0.005 per kilogram — trivial on one gram, real across a 400 oz bar.

The feed quotes per troy ounce, typically to two decimals. Six is our internal working precision, chosen for the multiplication that follows.

Matching scales avoids some alignment work, but the difference is nowhere near a reason to design a money type around it.


Leak two · the conversion that lies

This is the one that matters. Here is the naive consideration:

// grams() is a long. GRAMS_PER_KILOGRAM is an int.
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());

It uses BigDecimal. It rounds explicitly, with a named RoundingMode. It would pass most code reviews. And here is what the suite says about it:

✓ a whole kilogram costs the kilogram price
✗ a quarter kilogram costs a quarter as much
    expected: <USD 24113.06> but was: <USD 0.00>
✗ the smallest dealable amount, one gram, still has a price
    expected: <USD 96.45> but was: <USD 0.00>
✗ an odd number of grams rounds to the nearest penny
    expected: <USD 3568.73> but was: <USD 0.00>

quantity.grams() / 1000 is integer division. 250 g becomes 0 kg. A client buys a quarter kilo of gold and is charged nothing.

The thing to actually take away

Now look again at the test that passed. It bought exactly one kilogram — the single quantity for which integer division is accidentally correct. If the original developer had written only that test, this code ships, and the desk gives gold away to anyone dealing under a kilo.

A green bar is evidence about your test data, not about your code. When you pick fixtures, deliberately pick ones that cannot be right by accident.

In an interview, you are shown code whose tests are all green. What is the strongest next question?

  1. “Which inputs would still be correct if this logic were wrong?”
  2. “Is there enough coverage measured across all of these branches?”
  3. “Could we profile this to see where the hot path really is?”
  4. “Should these assertions be moved into parameterised test cases here?”

This is the habit worth rehearsing out loud. It is exactly the question that catches the 1 kg fixture: one test, 100% line coverage of consideration, and a catastrophic bug. Interviewers notice this question.

Line coverage would have reported this method fully covered. Coverage tells you a line ran, not that it ran on an input that could distinguish right from wrong.

Performance is a different concern entirely, and asking about it here signals you have accepted the correctness claim at face value.

Parameterising is a good instinct and would likely have surfaced this — but it is a refactoring of the tests, not a question about whether they prove anything.

Why does Quantity store whole grams as a long, rather than kilograms?

  1. Long arithmetic avoids the object allocation that BigDecimal would incur
  2. Kilograms cannot be represented at all without some loss of precision
  3. The database column for a bullion balance is an integer type
  4. One gram is the smallest dealable unit, so no quantity is fractional

True but beside the point. If grams were divisible you would reach for BigDecimal and accept the allocations, exactly as Money does.

A kilogram figure is perfectly representable as a BigDecimal. The argument against it is about the domain, not about representability.

Schema follows the model, not the other way round — and this exercise has no database at all.

This is the argument. The board deals in increments as small as one gram and no smaller, so a fractional quantity is not a rounding problem — it is not a thing that exists. Making it unrepresentable is the same move as storing money in minor units.


Leak three · the rounding nobody can derive

Commission bands are published: 0.50% under 75,000; 0.10% up to 825,000; 0.05% above — computed separately for each metal.3 Straightforward. But then the tariff says commission is “charged in whole pennies or cents” — and says nothing about which way a part-penny goes.

That single unstated word is worth real money. On a $24,113.06 deal at the 0.10% band, the commission earned is $24.113060:

Rounding modeCommission
HALF_UP24.11the desk eats the part-penny
CEILING24.12the part-penny is charged

The exercise states the rule explicitly — never bill a fraction of a penny, never bill less than the rate earns, so part-pennies go up — and the tests enforce it. Swap CEILING for HALF_UP and three of the seven go red immediately:

expected: <USD 24.12> but was: <USD 24.11>
expected: <USD 5.01>  but was: <USD 5.00>
The interview move

When a spec is silent on rounding direction, do not pick one quietly. Say out loud: “the tariff doesn't state a direction — I'll assume part-pennies round up in the house's favour, and I'll put that in the test name so it's a decision someone can overturn.” That sentence is worth more than the implementation.

The band table says “$75,000 to $825,000”. Which test pins down what that means?

  1. A deal by a client whose trailing volume is around four hundred thousand
  2. A deal by a client whose trailing volume is exactly seventy five thousand
  3. A deal by a client whose trailing volume is more than nine hundred thousand
  4. A deal by a client whose trailing volume is exactly zero, being brand new

A value in the middle of the band confirms the rate but says nothing about where the band starts. Both an inclusive and an exclusive reading pass this test.

Boundaries are where banded rules break, and “to” is ambiguous in English. Testing the exact threshold is what turns a sentence in a tariff into a decision in code. The exercise tests both edges, at 75,000 and at 825,000.

This exercises the top band, not the boundary between the first two. Useful, but not the discriminating case.

Zero is a good test — it is the new-client case — but it is nowhere near the threshold being questioned.

Why does Money's constructor use RoundingMode.UNNECESSARY?

  1. So that Money instances can be safely cached and shared between threads
  2. So that arithmetic on Money is faster by skipping the rescaling entirely
  3. So that rounding a client's cash is always an explicit, deliberate choice
  4. So that Money and BigDecimal can be compared using equals interchangeably

Records are immutable and thread-safe regardless of rounding mode. That property comes free from the record, not from this decision.

It still calls setScale; it just refuses when precision would be lost. There is no performance argument here.

Exactly. A value type that quietly rounds hides the one decision that actually costs money. Throwing forces the calling code — which knows whether this is a consideration (half-up) or a commission (ceiling) — to state the rule. It is also why the naive consideration had to call setScale in the first place.

It does fix the scale, which makes equals well-behaved between two Money values — but that is a side effect, and Money never compares equal to a bare BigDecimal anyway.

What does new BigDecimal("1.50").equals(new BigDecimal("1.5")) return?

  1. False, because equality on BigDecimal compares scale as well
  2. True, because both values represent an identical numeric quantity
  3. False, because string constructors produce distinct object identities
  4. True, because equals delegates internally to the compareTo method

1.50 has scale 2, 1.5 has scale 1, so equals says no while compareTo says zero. This is the most-asked BigDecimal interview question, and the reason a BigDecimal should never go into a HashSet or be a HashMap key. Pinning the scale in Money's constructor is what makes Money.equals safe.

Numerically identical, yes — and that is exactly the trap. BigDecimal deliberately distinguishes values that differ only in scale.

Object identity is ==. BigDecimal genuinely overrides equals; it just includes scale in the comparison.

The opposite is true: equals is stricter than compareTo, which is why the class is documented as having a natural ordering inconsistent with equals.


Say it out loud

This is a pairing test. Getting to green silently scores badly. Before your next session, get to green again from a clean checkout while narrating. The three sentences worth having ready:

Read this

Primary source

The java.math.BigDecimal class documentation (Java 25) — the class-level javadoc, not the method list. Twenty minutes. It explains scale, the rounding modes, and states outright that the natural ordering is inconsistent with equals. Everything in this lesson is in there, said once, by the people who wrote the class.

References

Carry on

Stuck, or disagree with one of these calls? Ask me. I'm your teacher for this course — bring me the red bar, the stack trace, or the argument. If a test looks wrong to you, say so; sometimes the test is wrong, and noticing that is the skill.