Skip to content

Money in C++: Fixed-Point Prices, Rounding and Overflow

Represent prices and money as scaled integers with a small Fixed type, choose rounding modes deliberately, multiply without overflow, and test it all against 128-bit arithmetic.

A million cents that do not add up

Add one cent to a balance a million times and the balance should be 10,000.00. With double, it is not:

main.cppc++23
#include <cstdint>
#include <print>

int main() {
    // Representation: 0.01 and 0.1 have no exact binary form.
    std::println("0.01 is stored as {:.25f}", 0.01);
    std::println("0.1  is stored as {:.25f}", 0.1);
    std::println("0.1 + 0.2 == 0.3: {}", 0.1 + 0.2 == 0.3);

    // Accumulation: add one cent a million times.
    double total = 0;
    std::int64_t cents = 0;
    std::println("\n{:>9}  {:>22}  {:>12}  {:>10}", "additions", "double total",
                 "exact", "error");
    for (int i = 1; i <= 1'000'000; ++i) {
        total += 0.01;
        cents += 1;
        if (i == 100 || i == 10'000 || i == 1'000'000) {
            double exact = double(cents / 100);  // whole units here, so exact
            std::println("{:>9}  {:>22.12f}  {:>12.2f}  {:>10.3e}", i, total, exact,
                         total - exact);
        }
    }
    std::println("\ninteger cents: {} = {}.{:02}", cents, cents / 100, cents % 100);
}
Output

Press Run to compile and run this example.

0.01 is stored as 0.0100000000000000002081668
0.1  is stored as 0.1000000000000000055511151
0.1 + 0.2 == 0.3: false

additions            double total         exact       error
      100          1.000000000000          1.00   6.661e-16
    10000        100.000000000014        100.00   1.425e-11
  1000000      10000.000000171856      10000.00   1.719e-07

integer cents: 1000000 = 10000.00

Two different things go wrong here, and it helps to keep them apart.

The first is representation error. A double stores a binary fraction, and 0.01 has no finite binary expansion, just as 1/3 has no finite decimal one. The value actually stored is the nearest binary fraction, slightly more than 0.01. The same is true of 0.1 and 0.2 and 0.3, which is why 0.1 + 0.2 == 0.3 is false: each side is rounded differently.

The second is accumulation error. Each addition rounds its result to the nearest double, and those roundings do not cancel out. After 100 additions the total is off by less than 10 to the power -15; after a million it is off by about 1.7 times 10 to the power -7. The error grows with the number of operations, and with the size of the running total, because a larger total leaves fewer bits for the cents.

For statistics, a relative error of 10 to the power -11 is excellent. For a ledger it is a defect. A balance must be exactly the sum of its transactions, two systems that process the same transactions must agree to the cent, and a reconciliation that compares them with == must not fail on noise. The integer column in the output has none of these problems: one million cents is exactly one million cents.

This lesson builds the arithmetic the rest of the course relies on: prices and amounts as scaled integers, a small Fixed type, explicit rounding, overflow handling, and currencies as types. Earlier lessons already store prices as integer ticks; the order book lesson showed double prices failing as map keys, and the matching engine lesson checked price * qty for overflow. Here is the full treatment.

Prices and amounts as scaled integers

The idea is old and simple: store an integer number of the smallest unit you care about. There are two common versions.

  • Ticks. The course's Price counts ticks, the instrument's minimum price step. With a tick of 0.01, 100.02 is stored as 10002. A tick does not have to be a power of ten: with a tick of 0.25, the price 100.75 is 403 ticks. Ticks make "is this a valid price?" trivial (every integer is one) and make the next price up exactly p + 1.
  • Scaled decimals. Store the value times a fixed power of ten, 10^D, where D is the number of decimal places. With D = 2, 12.34 is 1234; with D = 4, it is 123400. This is the natural form for amounts of money, fees and rates, and it is how many market-data formats carry prices: Nasdaq's TotalView-ITCH sends prices as integers with four implied decimal places.

Either way, addition and subtraction are integer operations and therefore exact. Comparison is exact. Multiplying by an integer quantity is exact. The only operation that needs a decision is division (including multiplying by a fraction, such as a rate), because the exact result can need more decimal places than you store. That decision is the rounding mode, and it gets its own section below.

The cost is range. A std::int64_t holds values up to 9,223,372,036,854,775,807, about 9.2 * 10^18. Every decimal place you keep divides the largest representable amount by ten:

Decimals DLargest value
2about 9.2 * 10^16
4about 9.2 * 10^14
8about 9.2 * 10^10

Two decimals leave plenty of room for any single balance. Eight decimals, used where prices or quantities need very fine steps, leave about 92 billion units, and a product of two such numbers does not fit at all, as the overflow section shows.

A Fixed type in code

A fixed-point type wraps the integer and its scale so that the compiler keeps track of them. Fixed<D> stores the raw integer raw = value * 10^D. It deliberately has no constructor from double: the only ways to make one are from a raw integer (from_raw) or from text (parse), and both are exact. Fixed<2> x = 0.1; does not compile.

main.cppc++23
#include <charconv>
#include <compare>
#include <cstdint>
#include <expected>
#include <format>
#include <limits>
#include <optional>
#include <print>
#include <string>
#include <string_view>

enum class Rounding { TowardZero, HalfUp, HalfEven };  // HalfUp: ties away from zero

// num / den, rounded to an integer by `mode`. Requires den > 0.
constexpr std::int64_t div_round(std::int64_t num, std::int64_t den, Rounding mode) {
    std::int64_t q = num / den;  // C++ truncates toward zero
    std::int64_t r = num % den;  // same sign as num
    if (r == 0 || mode == Rounding::TowardZero) return q;
    std::int64_t dist = r < 0 ? -r : r;     // how far past q, in units of 1/den
    std::int64_t away = num < 0 ? q - 1 : q + 1;
    if (dist > den - dist) return away;     // more than half
    if (dist < den - dist) return q;        // less than half
    if (mode == Rounding::HalfUp) return away;
    return q % 2 == 0 ? q : away;           // exactly half: HalfEven picks the even one
}

constexpr std::int64_t pow10(int n) { return n == 0 ? 1 : 10 * pow10(n - 1); }

enum class ParseError { Empty, BadChar, TooManyDecimals, OutOfRange };

template <int Decimals>
class Fixed {
    static_assert(Decimals >= 0 && Decimals <= 18);
public:
    static constexpr std::int64_t kScale = pow10(Decimals);

    constexpr Fixed() = default;
    static constexpr Fixed from_raw(std::int64_t raw) {
        Fixed f;
        f.raw_ = raw;
        return f;
    }
    constexpr std::int64_t raw() const { return raw_; }

    // Unchecked, like std::int64_t itself; see checked_mul later in the lesson.
    friend constexpr Fixed operator+(Fixed a, Fixed b) {
        return from_raw(a.raw_ + b.raw_);
    }
    friend constexpr Fixed operator-(Fixed a, Fixed b) {
        return from_raw(a.raw_ - b.raw_);
    }
    friend constexpr Fixed operator*(Fixed a, std::int64_t n) {
        return from_raw(a.raw_ * n);
    }
    constexpr Fixed divide(std::int64_t n, Rounding mode) const {  // n > 0
        return from_raw(div_round(raw_, n, mode));
    }
    friend constexpr auto operator<=>(Fixed, Fixed) = default;

    // "-12.5" -> -12.50 for Decimals = 2. Rejects anything it cannot represent exactly.
    static std::expected<Fixed, ParseError> parse(std::string_view s) {
        bool negative = s.starts_with('-');
        if (negative) s.remove_prefix(1);
        auto dot = s.find('.');
        std::string_view whole = s.substr(0, dot);
        std::string_view frac = dot == s.npos ? "" : s.substr(dot + 1);
        if (whole.empty() && frac.empty()) return std::unexpected(ParseError::Empty);
        if (frac.size() > std::size_t(Decimals))
            return std::unexpected(ParseError::TooManyDecimals);
        std::uint64_t units = 0, fraction = 0;
        auto digits = [](std::string_view text,
                         std::uint64_t& out) -> std::optional<ParseError> {
            if (text.empty()) return std::nullopt;
            const char* last = text.data() + text.size();
            auto [end, ec] = std::from_chars(text.data(), last, out);  // no sign allowed
            if (ec == std::errc::result_out_of_range) return ParseError::OutOfRange;
            if (ec != std::errc{} || end != last) return ParseError::BadChar;
            return std::nullopt;
        };
        if (auto e = digits(whole, units)) return std::unexpected(*e);
        if (auto e = digits(frac, fraction)) return std::unexpected(*e);
        fraction *= std::uint64_t(pow10(Decimals - int(frac.size())));  // "5" -> 50
        // Largest magnitude: 2^63 - 1 for a positive value, 2^63 for a negative one.
        auto limit = std::uint64_t(std::numeric_limits<std::int64_t>::max()) + negative;
        if (units > (limit - fraction) / kScale)
            return std::unexpected(ParseError::OutOfRange);
        std::uint64_t mag = units * kScale + fraction;
        return from_raw(std::int64_t(negative ? 0 - mag : mag));  // wraps as intended
    }

    std::string str() const {
        // Magnitude as unsigned, so that the most negative value does not overflow.
        std::uint64_t mag = raw_ < 0 ? 0 - std::uint64_t(raw_) : std::uint64_t(raw_);
        std::string out = std::format("{}{}", raw_ < 0 ? "-" : "", mag / kScale);
        if constexpr (Decimals > 0)
            out += std::format(".{:0{}}", mag % kScale, Decimals);  // 5 -> ".05"
        return out;
    }

private:
    std::int64_t raw_ = 0;
};
using Money = Fixed<2>;   // currency amounts in cents
using Px = Fixed<4>;      // prices with 4 decimal places

int main() {
    auto price = Px::parse("101.2525").value();
    auto amount = Money::parse("-0.50").value();
    std::println("price {}  raw {}", price.str(), price.raw());
    std::println("amount {}  raw {}", amount.str(), amount.raw());
    std::println("3 x price = {}", (price * 3).str());
    std::println("price / 4 = {} (half-even)",
                 price.divide(4, Rounding::HalfEven).str());
    std::println("price < 101.26: {}", price < Px::parse("101.26").value());

    constexpr const char* kError[] = {"empty", "bad character", "too many decimals",
                                      "out of range"};
    for (std::string_view text : {"12.3", "12.345", "1e3", "", "-",
                                  "99999999999999999.99"}) {
        auto parsed = Money::parse(text);
        if (parsed) std::println("{:>22} -> {}", text, parsed->str());
        else std::println("{:>22} -> error: {}", text, kError[int(parsed.error())]);
    }
    // Money m = 0.1;   // does not compile: there is no conversion from double
}
Output

Press Run to compile and run this example.

price 101.2525  raw 1012525
amount -0.50  raw -50
3 x price = 303.7575
price / 4 = 25.3131 (half-even)
price < 101.26: true
                  12.3 -> 12.30
                12.345 -> error: too many decimals
                   1e3 -> error: bad character
                       -> error: empty
                     - -> error: empty
  99999999999999999.99 -> error: out of range

A few details in this code are the difference between a type that works on test data and one that works on all data.

  • Parsing is exact or it fails. "12.345" has more decimals than Fixed<2> can store, so parse rejects it instead of rounding silently. If the caller wants rounding, it should ask for it, with a mode.
  • The sign is handled separately. std::from_chars parses the digits as an unsigned number, and parse applies the minus sign itself. Parsing "-0.50" as a signed whole part -0 followed by a fraction 50 would lose the sign, because -0 and 0 are the same integer, and give +0.50.
  • Range is checked before multiplying. units * kScale + fraction is computed only after checking that it fits. A negative value may have a magnitude one larger than a positive one (-2^63 exists, +2^63 does not), and parse allows for that.
  • Formatting uses an unsigned magnitude. str() converts to std::uint64_t before splitting into units and fraction, because negating the most negative std::int64_t overflows.
  • Arithmetic is plain integer arithmetic. +, - and * by an integer are exact as long as the result fits. They are unchecked here, like std::int64_t itself; the overflow section adds checking. divide takes a rounding mode, always.

operator<=> is defaulted, so comparisons compare raw values, which is correct because two Fixed<D> of the same D share a scale. Fixed<2> and Fixed<4> are different types and cannot be compared or added by accident; converting between them is a multiplication or a rounded division by a power of ten. If templates are new to you, Class Templates covers what Fixed<D> relies on.

Rounding is a decision, so name it

When an exact result falls between two representable values, something must choose. The three modes you meet most often in money code are:

  • Toward zero (truncation): drop the extra digits. This is what C++ integer division does, for positive and negative numbers alike.
  • Half-up: to the nearest value; a result exactly halfway goes away from zero. 2.5 becomes 3 and -2.5 becomes -3. Java's RoundingMode.HALF_UP and Python's ROUND_HALF_UP define it this way. std::round and std::llround round halfway cases away from zero too, whatever the current floating-point rounding mode. (Some people use "half-up" to mean "toward plus infinity" on ties; say which one you mean.)
  • Half-even, also called banker's rounding: to the nearest value; a result exactly halfway goes to the neighbor whose last digit is even. 2.5 becomes 2, 3.5 becomes 4. It is the default rounding of IEEE 754 binary floating-point arithmetic and of Python's decimal module.

div_round from the Fixed program implements all three on integers. The program below prints them side by side, then measures what each mode does to a total. It rounds a million random amounts with three decimals (thousandths) to whole cents, and adds up the difference between each rounded amount and its exact value.

main.cppc++23
#include <cstdint>
#include <print>

enum class Rounding { TowardZero, HalfUp, HalfEven };  // HalfUp: ties away from zero

// num / den, rounded to an integer by `mode`. Requires den > 0.
constexpr std::int64_t div_round(std::int64_t num, std::int64_t den, Rounding mode) {
    std::int64_t q = num / den;  // C++ truncates toward zero
    std::int64_t r = num % den;  // same sign as num
    if (r == 0 || mode == Rounding::TowardZero) return q;
    std::int64_t dist = r < 0 ? -r : r;     // how far past q, in units of 1/den
    std::int64_t away = num < 0 ? q - 1 : q + 1;
    if (dist > den - dist) return away;     // more than half
    if (dist < den - dist) return q;        // less than half
    if (mode == Rounding::HalfUp) return away;
    return q % 2 == 0 ? q : away;           // exactly half: HalfEven picks the even one
}

struct Lcg {
    std::uint64_t s;
    std::uint32_t next() {
        s = s * 6364136223846793005ULL + 1442695040888963407ULL;
        return std::uint32_t(s >> 33);
    }
    int range(int lo, int hi) {  // inclusive
        return lo + int(next() % std::uint32_t(hi - lo + 1));
    }
};

int main() {
    constexpr Rounding kModes[] = {Rounding::TowardZero, Rounding::HalfUp,
                                   Rounding::HalfEven};
    std::println("{:>6} {:>12} {:>8} {:>10}", "x", "toward zero", "half-up", "half-even");
    for (std::int64_t tenths : {24, 25, 26, 35, -25, -35}) {  // x = tenths / 10
        std::print("{:>6.1f}", tenths / 10.0);
        int width[] = {12, 8, 10};
        for (int m = 0; m < 3; ++m)
            std::print(" {:>{}}", div_round(tenths, 10, kModes[m]), width[m]);
        std::println("");
    }

    // A million amounts with 3 decimals (thousandths), rounded to cents.
    // Sum of (rounded - exact), in thousandths: the bias of each mode.
    Lcg rng{2025};
    std::int64_t drift[3] = {};
    for (int i = 0; i < 1'000'000; ++i) {
        std::int64_t thousandths = rng.range(0, 9'999'999);  // 0.000 to 9999.999
        for (int m = 0; m < 3; ++m)
            drift[m] += div_round(thousandths, 10, kModes[m]) * 10 - thousandths;
    }
    std::println("\ndrift after 1,000,000 roundings, in currency units:");
    const char* names[] = {"toward zero", "half-up", "half-even"};
    for (int m = 0; m < 3; ++m)
        std::println("  {:<12} {:>+10.3f}", names[m], drift[m] / 1000.0);
}
Output

Press Run to compile and run this example.

     x  toward zero  half-up  half-even
   2.4            2        2          2
   2.5            2        3          2
   2.6            2        3          3
   3.5            3        4          4
  -2.5           -2       -3         -2
  -3.5           -3       -4         -4

drift after 1,000,000 roundings, in currency units:
  toward zero   -4502.302
  half-up        +503.758
  half-even        +0.968

Truncation loses 4.5 thousandths per amount on average (the dropped digit is 0 to 9, mean 4.5), so a million roundings drift by about 4,500 currency units. Half-up is unbiased on everything except ties, and every tie goes up: with one amount in ten ending in exactly 5, it drifts by about +500. Half-even sends half the ties up and half down, and drifts by about 1, which is noise. Java's documentation of HALF_EVEN describes it as the mode that statistically minimizes cumulative error over a sequence of calculations, and the last column shows that effect.

Which mode is correct is not a programming question. A fee schedule, an exchange rule, an accounting policy or a regulation usually says how to round, and sometimes says it differently for different amounts. The programming rule is that every division names its mode, so that the choice is visible in the code and in review, and so that changing it is a one-word edit.

When price times quantity does not fit

The matching engine lesson checked price * qty against the 64-bit limit before multiplying, and rejected the order when the product did not fit. Fixed-point arithmetic adds a subtler case: the intermediate product can overflow when the final result would fit easily.

Multiply two Fixed<8> numbers, a price of 65000.12345678 and a quantity of 0.33333333. The raw values are about 6.5 * 10^12 and 3.3 * 10^7. Their product has 16 decimal places and a raw value around 2.2 * 10^20, far beyond std::int64_t. The answer, rescaled to 8 decimal places, is about 21666.71, which fits with room to spare. The multiplication has to happen in a wider type, followed by a single rounded division.

GCC and Clang provide that wider type as __int128. It is a compiler extension, not standard C++: GCC documents it for targets that have an integer mode wide enough to hold 128 bits, which includes the common 64-bit targets, and Microsoft's compiler does not provide it. std::numeric_limits, std::format and the type traits do not reliably support it in standard mode, so the program below uses it only for arithmetic, and narrows back to std::int64_t with an explicit range check.

main.cppc++23
#include <cstdint>
#include <expected>
#include <format>
#include <limits>
#include <print>
#include <string>

using i128 = __int128;  // a GCC and Clang extension, not standard C++
constexpr std::int64_t kMax = std::numeric_limits<std::int64_t>::max();
constexpr std::int64_t kMin = std::numeric_limits<std::int64_t>::min();

enum class Rounding { TowardZero, HalfUp, HalfEven };
enum class MathError { Overflow };

// The same rounding division as before, for any integer type (here: 128 bits).
template <class T> constexpr T div_round(T num, T den, Rounding mode) {  // den > 0
    T q = num / den, r = num % den;
    if (r == 0 || mode == Rounding::TowardZero) return q;
    T dist = r < 0 ? -r : r, away = num < 0 ? q - 1 : q + 1;
    if (dist != den - dist) return dist > den - dist ? away : q;
    return mode == Rounding::HalfUp || q % 2 != 0 ? away : q;
}

std::expected<std::int64_t, MathError> narrow(i128 v) {
    if (v > kMax || v < kMin) return std::unexpected(MathError::Overflow);
    return std::int64_t(v);
}

std::expected<std::int64_t, MathError> checked_mul(std::int64_t a, std::int64_t b) {
    return narrow(i128(a) * b);  // exact in 128 bits: |a * b| < 2^126
}

// a * b / den, rounded once at the end. The product may need 128 bits even
// when the result fits in 64.
std::expected<std::int64_t, MathError> mul_div(std::int64_t a, std::int64_t b,
                                               std::int64_t den, Rounding mode) {
    return narrow(div_round(i128(a) * b, i128(den), mode));
}

std::string fmt8(std::int64_t raw) {  // 8 decimal places
    std::uint64_t mag = raw < 0 ? 0 - std::uint64_t(raw) : std::uint64_t(raw);
    return std::format("{}{}.{:08}", raw < 0 ? "-" : "", mag / 100'000'000,
                       mag % 100'000'000);
}

int main() {
    constexpr std::int64_t kScale = 100'000'000;  // 8 decimal places for price and qty
    std::int64_t price = 6'500'012'345'678;        // 65000.12345678
    std::int64_t qty = 33'333'333;                 // 0.33333333
    std::println("price {} x qty {}", fmt8(price), fmt8(qty));
    std::println("raw product fits in 64 bits: {}", checked_mul(price, qty).has_value());
    auto notional = mul_div(price, qty, kScale, Rounding::HalfEven);
    std::println("notional via 128 bits:       {}", fmt8(*notional));

    // Policy: what should happen when a value does not fit?
    std::int64_t long_px = 3'000'000'000'000'000'000, long_qty = 4;  // 1.2e19
    std::int64_t short_notional = 9'000'000'000'000'000'000;
    std::int64_t limit = 1'000'000'000'000'000'000;
    i128 true_net = i128(long_px) * long_qty - short_notional;
    std::int64_t saturated = checked_mul(long_px, long_qty).value_or(kMax);
    std::println("\ntrue net exposure exceeds the limit: {}", true_net > limit);
    std::println("saturate: net {} -> passes the limit check: {}",
                 saturated - short_notional, saturated - short_notional <= limit);
    auto checked = checked_mul(long_px, long_qty);
    std::println("reject:   {}", checked ? "accepted" : "order rejected (overflow)");
}
Output

Press Run to compile and run this example.

price 65000.12345678 x qty 0.33333333
raw product fits in 64 bits: false
notional via 128 bits:       21666.70760226

true net exposure exceeds the limit: true
saturate: net 223372036854775807 -> passes the limit check: true
reject:   order rejected (overflow)

The notional, 21666.70760226, is correct to the last digit: the exact product is 21666.7076022595884774, rounded here to 8 decimal places, and we checked it independently with Python's decimal module. checked_mul reports that the raw product does not fit, which is the signal to use mul_div.

The second half of the output is about policy: what to do when a value does not fit. One option is to saturate: clamp to the largest representable value and carry on. The program builds a case where that is dangerous. A long position whose notional is really 1.2 * 10^19 saturates to about 9.22 * 10^18. Subtracting a short notional of 9 * 10^18 then gives a net exposure of about 2.2 * 10^17, which is under the limit of 10^18, so the check passes. The true net exposure is 3 * 10^18, three times the limit. A saturated value is a wrong value that looks like a valid one, and every calculation downstream inherits the error. Rejecting the operation, as checked_mul lets you do, stops the error at the point where it is detectable.

Signed overflow in C++ is undefined behavior, not wrap-around, so "it will just wrap and we will notice" is not an option either: the compiler may assume overflow never happens and optimize accordingly, and UBSan reports it.

Different currencies are different types

A price in dollars and a price in euros are both integers of cents. Adding them compiles, runs, and produces a number in no currency at all. The fix is to make the currency part of the type: Money<USD> and Money<EUR> are distinct types, and operator+ exists only between two amounts of the same currency.

main.cppc++23
#include <cstdint>
#include <format>
#include <print>
#include <string>

using i128 = __int128;  // GCC and Clang extension
enum class Rounding { TowardZero, HalfUp, HalfEven };

template <class T> constexpr T div_round(T num, T den, Rounding mode) {  // den > 0
    T q = num / den, r = num % den;
    if (r == 0 || mode == Rounding::TowardZero) return q;
    T dist = r < 0 ? -r : r, away = num < 0 ? q - 1 : q + 1;
    if (dist != den - dist) return dist > den - dist ? away : q;
    return mode == Rounding::HalfUp || q % 2 != 0 ? away : q;
}

// A currency is a type. Amounts are integer cents of that currency.
struct USD { static constexpr const char* code = "USD"; };
struct EUR { static constexpr const char* code = "EUR"; };

template <class Currency>
class Money {
public:
    static constexpr Money cents(std::int64_t c) {
        Money m;
        m.cents_ = c;
        return m;
    }
    constexpr std::int64_t cents() const { return cents_; }
    // Only Money of the same currency can be added or subtracted.
    friend constexpr Money operator+(Money a, Money b) {
        return cents(a.cents_ + b.cents_);
    }
    friend constexpr Money operator-(Money a, Money b) {
        return cents(a.cents_ - b.cents_);
    }
    std::string str() const {
        auto mag = cents_ < 0 ? 0 - std::uint64_t(cents_) : std::uint64_t(cents_);
        return std::format("{}{}.{:02} {}", cents_ < 0 ? "-" : "", mag / 100, mag % 100,
                           Currency::code);
    }
private:
    std::int64_t cents_ = 0;
};

// How many units of To one unit of From buys, with 6 decimal places.
template <class From, class To> struct Rate { std::int64_t micros; };

// The only way from one currency to another: explicit, with a stated rounding.
template <class From, class To>
Money<To> convert(Money<From> m, Rate<From, To> rate, Rounding mode) {
    i128 cents = div_round(i128(m.cents()) * rate.micros, i128(1'000'000), mode);
    return Money<To>::cents(std::int64_t(cents));  // fits for any realistic rate
}

int main() {
    auto fee = Money<USD>::cents(1'250);
    auto rebate = Money<USD>::cents(-75);
    std::println("fee + rebate = {}", (fee + rebate).str());

    Rate<USD, EUR> usd_eur{912'345};  // an illustrative rate, not a market quote
    auto in_eur = convert(fee + rebate, usd_eur, Rounding::HalfEven);
    std::println("converted    = {}", in_eur.str());
    // fee + in_eur;  // does not compile: no operator+ for Money<USD> and Money<EUR>
}
Output

Press Run to compile and run this example.

fee + rebate = 11.75 USD
converted    = 10.72 EUR

The rate type carries both currencies, so convert can only be called with a rate in the right direction, and it states its rounding mode like every other division. The rate itself is an illustrative number, not a market quote. Here is what the compiler says to the mistake the types are there to prevent:

#include <cstdint>

struct USD {};
struct EUR {};

template <class Currency>
struct Money {
    std::int64_t cents;
    friend Money operator+(Money a, Money b) { return {a.cents + b.cents}; }
};

int main() {
    Money<USD> fee{1'250};
    Money<EUR> rebate{-75};
    auto total = fee + rebate;  // dollars plus euros: what unit would the result be in?
    return int(total.cents);
}

The same technique separates other quantities that share a representation. The course's Price and Qty are both std::int64_t aliases, so price + qty compiles; that keeps the course's programs short, but a production system would usually make them distinct types and define only the operations that mean something (Price * Qty gives a notional; Price + Qty gives nothing). Strong types cost nothing at run time: Money<USD> is one std::int64_t with a type attached.