Skip to content

Commit

Permalink
Support scientific notation for MonetaryAmount
Browse files Browse the repository at this point in the history
  • Loading branch information
sjanel committed Jun 14, 2024
1 parent 9749a14 commit 7728734
Show file tree
Hide file tree
Showing 5 changed files with 166 additions and 105 deletions.
2 changes: 1 addition & 1 deletion src/engine/src/stringoptionparser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ std::pair<MonetaryAmount, StringOptionParser::AmountType> StringOptionParser::pa
_pos = originalPos;
return ret;
}
MonetaryAmount amount(amountStr, MonetaryAmount::IfNoAmount::kNoThrow);
MonetaryAmount amount(amountStr, MonetaryAmount::ParsingMode::kAmountOptional);
if (amount == 0) {
if (fieldIs == FieldIs::kMandatory) {
throw invalid_argument("Expected a non-zero amount");
Expand Down
42 changes: 24 additions & 18 deletions src/objects/include/monetaryamount.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -45,18 +45,17 @@ class MonetaryAmount {
using AmountType = int64_t;

enum class RoundType : int8_t { kDown, kUp, kNearest };
enum class IfNoAmount : int8_t { kThrow, kNoThrow };

/// Constructs a MonetaryAmount with a value of 0 of neutral currency.
constexpr MonetaryAmount() noexcept : _amount(0) {}

/// Constructs a MonetaryAmount representing the integer 'amount' with a neutral currency
constexpr explicit MonetaryAmount(std::integral auto amount) noexcept : _amount(amount) { sanitizeIntegralPart(); }
constexpr explicit MonetaryAmount(std::integral auto amount) noexcept : _amount(amount) { sanitizeIntegralPart(0); }

/// Constructs a MonetaryAmount representing the integer 'amount' with a currency
constexpr explicit MonetaryAmount(std::integral auto amount, CurrencyCode currencyCode) noexcept
: _amount(amount), _curWithDecimals(currencyCode) {
sanitizeIntegralPart();
sanitizeIntegralPart(0);
}

/// Construct a new MonetaryAmount from a double.
Expand All @@ -70,14 +69,15 @@ class MonetaryAmount {
/// number of decimals
constexpr MonetaryAmount(AmountType amount, CurrencyCode currencyCode, int8_t nbDecimals) noexcept
: _amount(amount), _curWithDecimals(currencyCode) {
sanitizeDecimals(nbDecimals, maxNbDecimals());
sanitizeIntegralPart();
sanitize(nbDecimals);
}

/// Constructs a new MonetaryAmount from a string, containing an optional CurrencyCode.
enum class ParsingMode : int8_t { kAmountMandatory, kAmountOptional };

/// Constructs a new MonetaryAmount from a string containing up to {amount, currency} and a parsing mode.
/// - If a currency is not present, assume default CurrencyCode
/// - If the currency is too long to fit in a CurrencyCode, exception will be raised
/// - If only a currency is given, invalid_argument exception will be raised when ifNoAmount == IfNoAmount::kThrow
/// - If only a currency is given, invalid_argument exception will be raised when parsingMode is kAmountMandatory
/// - If given string is empty, it is equivalent to a default constructor
///
/// A space can be present or not between the amount and the currency code.
Expand All @@ -89,7 +89,7 @@ class MonetaryAmount {
/// "-345.8909" -> -345.8909 units of no currency
/// "36.61INCH" -> 36.63 units of currency INCH
/// "36.6 1INCH" -> 36.6 units of currency 1INCH
explicit MonetaryAmount(std::string_view amountCurrencyStr, IfNoAmount ifNoAmount = IfNoAmount::kThrow);
explicit MonetaryAmount(std::string_view amountCurrencyStr, ParsingMode parsingMode = ParsingMode::kAmountMandatory);

/// Constructs a new MonetaryAmount from a string representing the amount only and a currency code.
/// Precision is calculated automatically.
Expand Down Expand Up @@ -250,7 +250,9 @@ class MonetaryAmount {

/// Truncate the MonetaryAmount such that it will contain at most maxNbDecimals.
/// Does nothing if maxNbDecimals is larger than current number of decimals
constexpr void truncate(int8_t maxNbDecimals) noexcept { sanitizeDecimals(nbDecimals(), maxNbDecimals); }
constexpr void truncate(int8_t maxNbDecimals) noexcept {
setNbDecimals(sanitizeDecimals(nbDecimals(), maxNbDecimals));
}

/// Get a string on the currency of this amount
[[nodiscard]] string currencyStr() const { return _curWithDecimals.str(); }
Expand Down Expand Up @@ -356,33 +358,37 @@ class MonetaryAmount {
constexpr MonetaryAmount(bool, AmountType amount, CurrencyCode curWithDecimals) noexcept
: _amount(amount), _curWithDecimals(curWithDecimals) {}

constexpr void sanitizeDecimals(int8_t nowNbDecimals, int8_t maxNbDecimals) noexcept {
constexpr int8_t sanitizeDecimals(int8_t nowNbDecimals, int8_t maxNbDecimals) noexcept {
const int8_t nbDecimalsToTruncate = nowNbDecimals - maxNbDecimals;
if (nbDecimalsToTruncate > 0) {
_amount /= ipow10(static_cast<uint8_t>(nbDecimalsToTruncate));
nowNbDecimals -= nbDecimalsToTruncate;
}
simplifyDecimals(nowNbDecimals);
}

constexpr void simplifyDecimals(int8_t nbDecs) noexcept {
if (_amount == 0) {
nbDecs = 0;
nowNbDecimals = 0;
} else {
for (; nbDecs > 0 && _amount % 10 == 0; --nbDecs) {
for (; nowNbDecimals > 0 && _amount % 10 == 0; --nowNbDecimals) {
_amount /= 10;
}
}
setNbDecimals(nbDecs);
return nowNbDecimals;
}

constexpr void sanitizeIntegralPart() {
constexpr int8_t sanitizeIntegralPart(int8_t nbDecs) noexcept {
if (_amount >= kMaxAmountFullNDigits) {
if (!std::is_constant_evaluated()) {
log::warn("Truncating last digit of integral part {} which is too big", _amount);
}
_amount /= 10;
if (nbDecs > 0) {
--nbDecs;
}
}
return nbDecs;
}

constexpr void sanitize(int8_t nbDecimals) {
setNbDecimals(sanitizeIntegralPart(sanitizeDecimals(nbDecimals, maxNbDecimals())));
}

constexpr void setNbDecimals(int8_t nbDecs) { _curWithDecimals.uncheckedSetAdditionalBits(nbDecs); }
Expand Down
194 changes: 114 additions & 80 deletions src/objects/src/monetaryamount.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,11 @@ constexpr int kNbMaxDoubleDecimals = std::numeric_limits<double>::max_digits10;
constexpr void RemovePrefixSpaces(std::string_view &str) {
str.remove_prefix(std::ranges::find_if(str, [](char ch) { return ch != ' '; }) - str.begin());
}

constexpr void RemoveTrailingSpaces(std::string_view &str) {
str.remove_suffix(std::ranges::find_if(std::ranges::reverse_view(str), [](char ch) { return ch != ' '; }) -
std::ranges::rbegin(str));
}
constexpr void RemoveTrailingZeros(std::string_view &str) {
str.remove_suffix(std::ranges::find_if(std::ranges::reverse_view(str), [](char ch) { return ch != '0'; }) -
std::ranges::rbegin(str));
}

inline int ParseNegativeChar(std::string_view &amountStr) {
int negMult = 1;
Expand All @@ -71,81 +68,121 @@ inline int ParseNegativeChar(std::string_view &amountStr) {
return negMult;
}

inline MonetaryAmount::AmountType HeuristicRounding(std::size_t dotPos, std::string_view &amountStr) {
std::size_t bestFindPos = 0;
for (std::string_view pattern : {"000", "999"}) {
std::size_t findPos = amountStr.rfind(pattern);
if (findPos != std::string_view::npos && findPos > dotPos) {
while (amountStr[findPos - 1] == pattern.front()) {
--findPos;
}
if (amountStr[findPos - 1] == '.') {
continue;
}
bestFindPos = std::max(bestFindPos, findPos);
}
}
if (bestFindPos != 0) {
const bool roundingUp = amountStr[bestFindPos] == '9';
log::trace("Heuristic rounding {} for {}", roundingUp ? "up" : "down", amountStr);
amountStr.remove_suffix(amountStr.size() - bestFindPos);
if (roundingUp) {
return 1;
}
}
return 0;
}

/// Converts a string into a fixed precision integral containing both the integer and decimal part.
/// Should be called after ParseNegativeChar because no + / - sign is expected at the start of the string.
/// @param amountStr the string to convert
/// @param heuristicRoundingFromDouble if true, more than 5 consecutive zeros or 9 in the decimals part will be rounded
inline auto AmountIntegralFromStr(std::string_view amountStr, bool heuristicRoundingFromDouble = false) {
std::pair<MonetaryAmount::AmountType, int8_t> ret;
ret.second = 0;

if (amountStr.empty()) {
ret.first = 0;
return ret;
}
std::size_t dotPos = amountStr.find('.');
MonetaryAmount::AmountType roundingUpNinesDouble = 0;
MonetaryAmount::AmountType decPart;
MonetaryAmount::AmountType integerPart;
if (dotPos == std::string_view::npos) {
decPart = 0;
integerPart = StringToIntegral<MonetaryAmount::AmountType>(amountStr);
} else {
RemoveTrailingSpaces(amountStr);
RemoveTrailingZeros(amountStr);

if (heuristicRoundingFromDouble && (amountStr.size() - dotPos - 1) == kNbMaxDoubleDecimals) {
std::size_t bestFindPos = 0;
for (std::string_view pattern : {"000", "999"}) {
std::size_t findPos = amountStr.rfind(pattern);
if (findPos != std::string_view::npos && findPos > dotPos) {
while (amountStr[findPos - 1] == pattern.front()) {
--findPos;
}
if (amountStr[findPos - 1] == '.') {
continue;
}
bestFindPos = std::max(bestFindPos, findPos);
}
/// @param heuristicRoundingFromDouble if true, more than 3 consecutive 0 or 9 in the decimals part will be rounded
inline std::pair<MonetaryAmount::AmountType, int8_t> AmountIntegralFromStr(std::string_view amountStr,
bool heuristicRoundingFromDouble = false) {
auto amountStrSz = amountStr.size();

std::size_t dotPos = std::string_view::npos;
MonetaryAmount::AmountType integralValue = 0;
int8_t nbDecimals = 0;

std::size_t charPos;

int roundingUpNinesDouble = 0;

// We do a manual parsing here to be able to make a single integral conversion while skipping a possible dot.
// It results in a code actually simpler than if we were using std::from_chars twice (before and after the dot).
for (charPos = 0; charPos < amountStrSz; ++charPos) {
const char ch = amountStr[charPos];
if (ch == '.') {
if (dotPos != std::string_view::npos) {
throw exception("Amount string {} with multiple dots", amountStr);
}
if (bestFindPos != 0) {
const bool roundingUp = amountStr[bestFindPos] == '9';
log::trace("Heuristic rounding {} for {}", roundingUp ? "up" : "down", amountStr);
amountStr.remove_suffix(amountStr.size() - bestFindPos);
if (roundingUp) {
roundingUpNinesDouble = 1;
}
dotPos = charPos;
if (heuristicRoundingFromDouble) {
roundingUpNinesDouble = HeuristicRounding(dotPos, amountStr);
amountStrSz = amountStr.size();
heuristicRoundingFromDouble = false;
}
continue;
}
if (ch == 'E' || ch == 'e') {
// scientific notation, we need to handle it.
// we assume that we are at the end of the string here.
nbDecimals -= StringToIntegral(amountStr.substr(charPos + 1));
break;
}
ret.second = static_cast<int8_t>(amountStr.size() - dotPos - 1);
// dotPos is still valid as we erased only past elements
if (amountStr.size() > std::numeric_limits<MonetaryAmount::AmountType>::digits10 + 1) {
int8_t nbDigitsToRemove =
static_cast<int8_t>(amountStr.size() - std::numeric_limits<MonetaryAmount::AmountType>::digits10 - 1);
if (nbDigitsToRemove > ret.second) {
throw exception("Received amount string {} whose integral part is too big", amountStr);
if (ch < '0' || ch > '9') {
if (ch == ' ') {
break;
}
log::trace("Received amount string '{}' too big for MonetaryAmount, truncating {} digits", amountStr,
nbDigitsToRemove);
amountStr.remove_suffix(nbDigitsToRemove);
ret.second -= nbDigitsToRemove;
throw exception("Amount string {} with invalid character {}", amountStr, ch);
}
std::string_view decPartStr = amountStr.substr(dotPos + 1);
decPart = decPartStr.empty() ? 0 : StringToIntegral<MonetaryAmount::AmountType>(decPartStr);
if (dotPos == 0) {
integerPart = 0;
} else {
integerPart =
StringToIntegral<MonetaryAmount::AmountType>(std::string_view(amountStr.begin(), amountStr.begin() + dotPos));

// normal case - it is a digit
int intDigit = ch - '0';

// First let's check for overflow
if (integralValue > (std::numeric_limits<MonetaryAmount::AmountType>::max() - intDigit) / 10) {
// we will overflow if we add this digit.
// there are two cases:
// - either we are parsing decimals, in this case we can just drop the remaining ones.
// - there are no decimals, in this case we should throw an exception.
if (dotPos == std::string_view::npos) {
throw exception("Amount string {} integral part is too big", amountStr);
}

// continue instead of break to ensure we don't forget about scientific notation
--nbDecimals;
continue;
}

integralValue = integralValue * 10 + intDigit;
}

// At this point, charPos points to the end of the amount string, but before the scientific notation if it exists.
// That way, we can adjust the nbDecimals based on dotPos as well.
if (dotPos != std::string_view::npos) {
nbDecimals += static_cast<int8_t>(charPos - dotPos - 1);
}

// This part could be optional but the current MonetaryAmount model does not allow for negative decimals (ie
// positive exponents).
if (nbDecimals < 0) {
// as usual, check for overflow
const auto multiplier = ipow10(-nbDecimals);
if (integralValue > std::numeric_limits<MonetaryAmount::AmountType>::max() / multiplier) {
throw exception("Received amount string {} whose integral part is too big", amountStr);
}
integralValue *= multiplier;
nbDecimals = 0;
}

ret.first = integerPart * ipow10(ret.second) + decPart + roundingUpNinesDouble;
return ret;
return {integralValue + roundingUpNinesDouble, nbDecimals};
}

} // namespace

MonetaryAmount::MonetaryAmount(std::string_view amountCurrencyStr, IfNoAmount ifNoAmount) {
MonetaryAmount::MonetaryAmount(std::string_view amountCurrencyStr, ParsingMode parsingMode) {
const int negMult = ParseNegativeChar(amountCurrencyStr);

auto last = amountCurrencyStr.begin();
Expand All @@ -155,38 +192,35 @@ MonetaryAmount::MonetaryAmount(std::string_view amountCurrencyStr, IfNoAmount if
++last;
}
const std::string_view amountStr(amountCurrencyStr.begin(), last);
int8_t nbDecimals;
std::tie(_amount, nbDecimals) = AmountIntegralFromStr(amountStr);
_amount *= negMult;
const auto [amountInt, nbDecimals] = AmountIntegralFromStr(amountStr);
_amount = amountInt * negMult;
std::string_view currencyStr(last, endIt);
RemoveTrailingSpaces(currencyStr);
RemovePrefixSpaces(currencyStr);
if (ifNoAmount == IfNoAmount::kThrow && !currencyStr.empty() && amountStr.empty()) {
if (parsingMode == ParsingMode::kAmountMandatory && !currencyStr.empty() && amountStr.empty()) {
throw invalid_argument("Cannot construct MonetaryAmount with a currency without any amount");
}
_curWithDecimals = CurrencyCode(currencyStr);
sanitizeDecimals(nbDecimals, maxNbDecimals());
sanitize(nbDecimals);
}

MonetaryAmount::MonetaryAmount(std::string_view amountStr, CurrencyCode currencyCode) : _curWithDecimals(currencyCode) {
const int negMult = ParseNegativeChar(amountStr);
int8_t nbDecimals;
std::tie(_amount, nbDecimals) = AmountIntegralFromStr(amountStr);
_amount *= negMult;
sanitizeDecimals(nbDecimals, maxNbDecimals());
const auto [amountInt, nbDecimals] = AmountIntegralFromStr(amountStr);
_amount = amountInt * negMult;
sanitize(nbDecimals);
}

MonetaryAmount::MonetaryAmount(double amount, CurrencyCode currencyCode) : _curWithDecimals(currencyCode) {
std::stringstream amtBuf;
amtBuf << std::setprecision(kNbMaxDoubleDecimals) << std::fixed << amount;
int8_t nbDecimals;
std::string_view strView = amtBuf.view();
const int negMult = ParseNegativeChar(strView);

std::tie(_amount, nbDecimals) = AmountIntegralFromStr(strView, true);
_amount *= negMult;
const auto [amountInt, nbDecimals] = AmountIntegralFromStr(strView, true);
_amount = amountInt * negMult;

sanitizeDecimals(nbDecimals, maxNbDecimals());
sanitize(nbDecimals);
}

MonetaryAmount::MonetaryAmount(double amount, CurrencyCode currencyCode, RoundType roundType, int8_t nbDecimals)
Expand Down Expand Up @@ -267,7 +301,7 @@ void MonetaryAmount::round(MonetaryAmount step, RoundType roundType) {
}
}
}
sanitizeDecimals(nowNbDecimals, nowNbDecimals);
setNbDecimals(sanitizeDecimals(nowNbDecimals, nowNbDecimals));
}

void MonetaryAmount::round(int8_t nbDecimals, RoundType roundType) {
Expand Down Expand Up @@ -302,7 +336,7 @@ void MonetaryAmount::round(int8_t nbDecimals, RoundType roundType) {
}
}

sanitizeDecimals(currentNbDecimals, nbDecimals);
setNbDecimals(sanitizeDecimals(currentNbDecimals, nbDecimals));
}

bool MonetaryAmount::isCloseTo(MonetaryAmount otherAmount, double relativeDifference) const {
Expand Down
2 changes: 1 addition & 1 deletion src/objects/test/marketorderbook_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
namespace cct {
namespace {
using AmountAtPriceVec = MarketOrderBook::AmountPerPriceVec;
} // namespace

MarketOrderBookLines CreateMarketOrderBookLines(std::initializer_list<OrderBookLine> init) {
MarketOrderBookLines marketOrderBookLines;
Expand All @@ -32,6 +31,7 @@ MarketOrderBookLines CreateMarketOrderBookLines(std::initializer_list<OrderBookL

return marketOrderBookLines;
}
} // namespace

constexpr bool operator==(const AmountPrice &lhs, const AmountPrice &rhs) {
return lhs.amount == rhs.amount && lhs.price == rhs.price;
Expand Down
Loading

0 comments on commit 7728734

Please sign in to comment.