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:
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:
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_pointfor 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)
quantityfor 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_pointcorrectly models prices (absolute), whilequantitycorrectly 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 quantity —
USD,EUR,GBP, andJPYare all units ofcurrency, so they participate correctly in quantity equations (e.g.,volume = currency × market_quantityholds 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 sinceEUR × sharesandUSD × shareswould then be different, incompatible quantity specs. mp-units threads this needle withkind_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, orceilproduce 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