Skip to content

Extending Beyond Physics: Currency as Custom Dimension

Try it live on Compiler Explorer

Overview

mp-units is a general-purpose quantities and units library — everything that can be counted or measured benefits from its type-safe dimensional analysis. This example demonstrates creating a custom base dimension for currency, implementing type-safe currency conversions with exchange rates, and computing a multi-currency portfolio total.

Key Concepts

Custom Base Dimension

You can define entirely new base dimensions beyond the standard ISQ/SI system:

inline constexpr struct dim_currency final : base_dimension<"$"> {} dim_currency;

This creates a new fundamental dimension orthogonal to physical dimensions like length or mass.

Custom Units and Quantity Specification

Once you have a base dimension, define a quantity specification and its units:

QUANTITY_SPEC(currency, dim_currency);

inline constexpr struct euro final : named_unit<"EUR", kind_of<currency>> {} euro;
inline constexpr struct us_dollar final : named_unit<"USD", kind_of<currency>> {} us_dollar;
inline constexpr struct great_british_pound final : named_unit<"GBP", kind_of<currency>> {} great_british_pound;
inline constexpr struct japanese_jen final : named_unit<"JPY", kind_of<currency>> {} japanese_jen;
// clang-format on

namespace unit_symbols {

inline constexpr auto EUR = euro;
inline constexpr auto USD = us_dollar;
inline constexpr auto GBP = great_british_pound;
inline constexpr auto JPY = japanese_jen;

}  // namespace unit_symbols

Each currency becomes its own distinct unit, preventing accidental mixing of incompatible currencies. This is enforced at compile time:

static_assert(!std::equality_comparable_with<quantity<euro, int>, quantity<us_dollar, int>>);

This won't compile — EUR and USD quantities cannot be directly compared. The library framework cannot convert between them automatically because exchange rates are time-dependent — it does not know at what time point the conversion should happen.

User-Provided Runtime Conversion Functions

Since currency exchange rates vary over time, users must supply their own runtime conversion functions. The example implements a time-stamped rate lookup:

template<Unit auto From, Unit auto To>
[[nodiscard]] double exchange_rate(std::chrono::sys_seconds timestamp)
{
  (void)timestamp;  // get conversion ratios for this timestamp
  // Paired rates are not exact inverses — the asymmetry encodes the bid/ask spread
  static const std::map<std::pair<std::string_view, std::string_view>, double> rates = {
    {{"USD", "EUR"}, 0.9215}, {{"EUR", "USD"}, 1.0848}, {{"USD", "GBP"}, 0.7897},
    {{"GBP", "USD"}, 1.2650}, {{"USD", "JPY"}, 142.88}, {{"JPY", "USD"}, 0.00693},
    // ...
  };

  return rates.at(std::make_pair(unit_symbol(From), unit_symbol(To)));
}

Note that paired rates are not exact inverses of each other: the asymmetry encodes the bid/ask spread that a real market maker charges. The exchange_to functions apply the appropriate rate and return the converted quantity:

template<UnitOf<currency> auto To, QuantityOf<currency> From>
QuantityOf<currency> auto exchange_to(From q, std::chrono::sys_seconds timestamp)
{
  const double rate = exchange_rate<From::unit, To>(timestamp) * q.numerical_value_in(q.unit);
  return rate * From::quantity_spec[To];
}

template<UnitOf<currency> auto To, QuantityPointOf<currency> From>
QuantityPointOf<currency> auto exchange_to(From qp, std::chrono::sys_seconds timestamp)
{
  return quantity_point{exchange_to<To>(qp.quantity_from_unit_zero(), timestamp), From::point_origin};
}

Two overloads are provided: one for plain quantities (holdings, amounts) and one for quantity_point (prices anchored to an absolute zero).

quantity vs quantity_point for Currency

The example uses both types deliberately:

  • quantity_point for prices — a price of $100 is an absolute reference; $0 is meaningful. Two prices in different currencies cannot be added — the type system rejects both forms:
const quantity_point price_usd{100 * USD};
const quantity_point price_eur = exchange_to<euro>(price_usd, timestamp);
// price_usd + price_eur                          // does not compile — points can't be added
// price_usd.quantity_from_unit_zero()
//   + price_eur.quantity_from_unit_zero()        // does not compile — different units (USD ≠ EUR)
  • quantity for holdings — a portfolio position is a displacement from zero; amounts in the same currency can be freely summed, but cross-currency addition is rejected until an explicit FX conversion is applied:
const quantity pos_usd = 14'230 * USD;
const quantity pos_eur =  4'902 * EUR;
// pos_usd + pos_eur                              // does not compile — must convert first
const quantity pos_eur_usd = round<USD>(exchange_to<us_dollar>(pos_eur, timestamp)).force_in<int>();
const quantity total_usd = pos_usd + pos_eur_usd; // ok — same unit

Multi-Currency Portfolio Valuation

Portfolio positions are held in their native currencies as plain quantity values. Adding positions across currencies does not compile, making the type system enforce the discipline of explicit FX conversion:

  // Multi-currency portfolio: positions held in native currencies (quantities, not prices)
  const quantity pos_usd = 14'230 * USD;
  const quantity pos_eur = 4'902 * EUR;
  const quantity pos_gbp = 1'464 * GBP;
  const quantity pos_jpy = 890'000 * JPY;

  // Cross-currency arithmetic does not compile — must go through explicit FX conversion:
  // const quantity bad = pos_usd + pos_eur;  // does not compile

  const quantity pos_eur_usd = round<USD>(exchange_to<us_dollar>(pos_eur, timestamp)).force_in<int>();
  const quantity pos_gbp_usd = round<USD>(exchange_to<us_dollar>(pos_gbp, timestamp)).force_in<int>();
  const quantity pos_jpy_usd = round<USD>(exchange_to<us_dollar>(pos_jpy, timestamp)).force_in<int>();
  const quantity total_usd = pos_usd + pos_eur_usd + pos_gbp_usd + pos_jpy_usd;

The rounding decision is made explicitly at the call site: round<USD> rounds to the nearest whole dollar; floor<USD> or ceil<USD> could be substituted depending on the desired settlement convention. .force_in<int>() changes the representation type to int so the result stays consistent with the integer-valued input positions.

Example Usage

int main()
{
  using namespace unit_symbols;
  using namespace std::chrono;

  const auto timestamp = time_point_cast<seconds>(system_clock::now() - hours{24});

  // FX conversion of a single price (quantity_point: $0 is a meaningful absolute reference)
  const quantity_point price_usd{100 * USD};
  const quantity_point price_eur = exchange_to<euro>(price_usd, timestamp);
  std::cout << price_usd.quantity_from_unit_zero() << " -> " << price_eur.quantity_from_unit_zero() << "\n";

  // the below don't compile
  // std::cout << (price_usd + price_usd).quantity_from_unit_zero() << "\n";
  // std::cout << price_usd.quantity_from_unit_zero() + price_eur.quantity_from_unit_zero() << "\n";

  // Multi-currency portfolio: positions held in native currencies (quantities, not prices)
  const quantity pos_usd = 14'230 * USD;
  const quantity pos_eur = 4'902 * EUR;
  const quantity pos_gbp = 1'464 * GBP;
  const quantity pos_jpy = 890'000 * JPY;

  // Cross-currency arithmetic does not compile — must go through explicit FX conversion:
  // const quantity bad = pos_usd + pos_eur;  // does not compile

  const quantity pos_eur_usd = round<USD>(exchange_to<us_dollar>(pos_eur, timestamp)).force_in<int>();
  const quantity pos_gbp_usd = round<USD>(exchange_to<us_dollar>(pos_gbp, timestamp)).force_in<int>();
  const quantity pos_jpy_usd = round<USD>(exchange_to<us_dollar>(pos_jpy, timestamp)).force_in<int>();
  const quantity total_usd = pos_usd + pos_eur_usd + pos_gbp_usd + pos_jpy_usd;

  std::cout << "Portfolio positions:\n";
  std::cout << "  " << pos_usd << "\n";
  std::cout << "  " << pos_eur << "  =>  " << pos_eur_usd << "\n";
  std::cout << "  " << pos_gbp << "  =>  " << pos_gbp_usd << "\n";
  std::cout << "  " << pos_jpy << "  =>  " << pos_jpy_usd << "\n";
  std::cout << "Portfolio total (USD): " << total_usd << "\n";
}

Sample Output:

100 USD -> 92.15 EUR
Portfolio positions:
  14230 USD
  4902 EUR  =>  5318 USD
  1464 GBP  =>  1852 USD
  890000 JPY  =>  6168 USD
Portfolio total (USD): 27568 USD

Why This Matters

  • General-Purpose Library: Demonstrates mp-units working with non-physical measurable quantities
  • Type Safety for Finance: Prevents mixing currencies without explicit conversion, caught at compile time
  • Affine Space Design: quantity_point correctly models prices (absolute), while quantity correctly models holdings (additive)
  • Explicit Rounding: The bid/ask spread and rounding convention are visible at the call site, not hidden inside a conversion helper
  • Domain Modeling: The dimensional analysis pattern applies to any domain with unit-like concepts

This pattern can be adapted for other non-physical domains: cryptocurrencies, points/rewards systems, game economies, or any system where different "units" should not be accidentally mixed.

Practical Considerations

Currency conversions differ from typical physical unit conversions:

  • Multiple independent units of the same quantityUSD, EUR, GBP, and JPY are all units of currency, so they participate correctly in quantity equations (e.g., volume = currency × market_quantity holds for any currency unit). Yet they carry no compile-time conversion factor between them. Most units libraries cannot model this: either all units of a quantity are mutually convertible via a fixed ratio (impossible for FX), or they are defined as separate dimensions — which breaks dimensional equations since EUR × shares and USD × shares would then be different, incompatible quantity specs. mp-units threads this needle with kind_of<currency>.
  • Exchange rates are time-dependent — the framework cannot know when you want to convert
  • Paired rates are not exact inverses — the difference encodes the bid/ask spread
  • Rounding convention matters — round, floor, or ceil produce different results and the choice should be explicit at the call site
  • For fixed-point financial arithmetic with sub-cent precision, see the companion trade_execution example