From 2b33817008322476f56770cbe91d09555e0efbb7 Mon Sep 17 00:00:00 2001 From: Stephane Janel Date: Wed, 3 Jan 2024 22:08:10 +0100 Subject: [PATCH] Fix incorrect assert for fiat conversions in equivalent balance conversions. Also introduce market type concept to clean code around amount conversions. --- data/static/stablecoins.json | 4 +- src/api/common/include/exchangepublicapi.hpp | 20 +- src/api/common/src/exchangeprivateapi.cpp | 3 +- src/api/common/src/exchangepublicapi.cpp | 178 ++++++++++++------ .../common/test/exchangepublicapi_test.cpp | 35 +++- src/engine/src/coincenter.cpp | 8 +- src/engine/src/exchangesorchestrator.cpp | 22 +-- src/objects/CMakeLists.txt | 9 + src/objects/include/coincenterinfo.hpp | 5 +- src/objects/include/currencycode.hpp | 13 +- src/objects/include/market.hpp | 30 ++- src/objects/include/marketorderbook.hpp | 2 +- src/objects/include/monetaryamount.hpp | 4 +- src/objects/include/priceoptions.hpp | 2 +- src/objects/include/reader.hpp | 6 +- src/objects/src/coincenterinfo.cpp | 6 +- src/objects/src/market.cpp | 28 ++- src/objects/test/market_test.cpp | 54 ++++++ src/tech/include/cct_const.hpp | 3 +- 19 files changed, 300 insertions(+), 132 deletions(-) create mode 100644 src/objects/test/market_test.cpp diff --git a/data/static/stablecoins.json b/data/static/stablecoins.json index efffb0d5..e8a233e5 100644 --- a/data/static/stablecoins.json +++ b/data/static/stablecoins.json @@ -4,5 +4,7 @@ "DAI": "USD", "TUSD": "USD", "HUSD": "USD", - "LUSD": "USD" + "LUSD": "USD", + "FDUSD": "USD", + "BUSD": "USD" } \ No newline at end of file diff --git a/src/api/common/include/exchangepublicapi.hpp b/src/api/common/include/exchangepublicapi.hpp index c673aaec..7a1a393c 100644 --- a/src/api/common/include/exchangepublicapi.hpp +++ b/src/api/common/include/exchangepublicapi.hpp @@ -29,6 +29,8 @@ class ExchangePublic : public ExchangeBase { static constexpr int kDefaultDepth = MarketOrderBook::kDefaultDepth; static constexpr int kNbLastTradesDefault = 100; + enum class MarketPathMode : int8_t { kStrict, kWithLastFiatConversion }; + using Fiats = CommonAPI::Fiats; virtual ~ExchangePublic() = default; @@ -52,13 +54,14 @@ class ExchangePublic : public ExchangeBase { /// Attempts to convert amount into a target currency. /// Conversion is made according to given price options, which uses the 'Maker' prices by default. - std::optional convert(MonetaryAmount from, CurrencyCode toCurrency, - const PriceOptions &priceOptions = PriceOptions()) { + std::optional estimatedConvert(MonetaryAmount from, CurrencyCode equiCurrency, + const PriceOptions &priceOptions = PriceOptions()) { MarketOrderBookMap marketOrderBookMap; Fiats fiats = queryFiats(); MarketSet markets; - MarketsPath conversionPath = findMarketsPath(from.currencyCode(), toCurrency, markets, fiats, true); - return convert(from, toCurrency, conversionPath, fiats, marketOrderBookMap, priceOptions); + MarketsPath conversionPath = + findMarketsPath(from.currencyCode(), equiCurrency, markets, fiats, MarketPathMode::kWithLastFiatConversion); + return convert(from, equiCurrency, conversionPath, fiats, marketOrderBookMap, priceOptions); } /// Attempts to convert amount into a target currency. @@ -105,19 +108,18 @@ class ExchangePublic : public ExchangeBase { /// Retrieve the shortest array of markets that can convert 'fromCurrencyCode' to 'toCurrencyCode' (shortest in terms /// of number of conversions) of 'fromCurrencyCode' to 'toCurrencyCode'. - /// Important: fiats are considered equivalent and can always be convertible with their rate. /// @return array of Market (in the order in which they are defined in the exchange), /// or empty array if conversion is not possible /// For instance, findMarketsPath("XLM", "XRP") can return: /// - XLM-USDT /// - XRP-USDT (and not USDT-XRP, as the pair defined on the exchange is XRP-USDT) MarketsPath findMarketsPath(CurrencyCode fromCurrencyCode, CurrencyCode toCurrencyCode, MarketSet &markets, - const Fiats &fiats, bool considerStableCoinsAsFiats = false); + const Fiats &fiats, MarketPathMode marketsPathMode = MarketPathMode::kStrict); MarketsPath findMarketsPath(CurrencyCode fromCurrencyCode, CurrencyCode toCurrencyCode, - bool considerStableCoinsAsFiats = false) { + MarketPathMode marketsPathMode = MarketPathMode::kStrict) { MarketSet markets; - return findMarketsPath(fromCurrencyCode, toCurrencyCode, markets, queryFiats(), considerStableCoinsAsFiats); + return findMarketsPath(fromCurrencyCode, toCurrencyCode, markets, queryFiats(), marketsPathMode); } using CurrenciesPath = SmallVector; @@ -127,7 +129,7 @@ class ExchangePublic : public ExchangeBase { /// gives only the currencies in order. /// For instance, findCurrenciesPath("XLM", "XRP") can return ["XLM", "USDT", "XRP"] CurrenciesPath findCurrenciesPath(CurrencyCode fromCurrencyCode, CurrencyCode toCurrencyCode, - bool considerStableCoinsAsFiats = false); + MarketPathMode marketsPathMode = MarketPathMode::kStrict); std::optional computeLimitOrderPrice(Market mk, CurrencyCode fromCurrencyCode, const PriceOptions &priceOptions); diff --git a/src/api/common/src/exchangeprivateapi.cpp b/src/api/common/src/exchangeprivateapi.cpp index b4a73b83..37a3a5ab 100644 --- a/src/api/common/src/exchangeprivateapi.cpp +++ b/src/api/common/src/exchangeprivateapi.cpp @@ -67,7 +67,8 @@ void ExchangePrivate::addBalance(BalancePortfolio &balancePortfolio, MonetaryAmo log::debug("{} Balance {}", exchangeName, amount); balancePortfolio.add(amount); } else { - std::optional optConvertedAmountEquiCurrency = _exchangePublic.convert(amount, equiCurrency); + std::optional optConvertedAmountEquiCurrency = + _exchangePublic.estimatedConvert(amount, equiCurrency); MonetaryAmount equivalentInMainCurrency; if (optConvertedAmountEquiCurrency) { equivalentInMainCurrency = *optConvertedAmountEquiCurrency; diff --git a/src/api/common/src/exchangepublicapi.cpp b/src/api/common/src/exchangepublicapi.cpp index acc875fd..3862c65f 100644 --- a/src/api/common/src/exchangepublicapi.cpp +++ b/src/api/common/src/exchangepublicapi.cpp @@ -1,15 +1,17 @@ #include "exchangepublicapi.hpp" #include -#include #include #include #include +#include #include -#include #include +#include "cct_allocator.hpp" #include "cct_exception.hpp" +#include "cct_flatset.hpp" +#include "cct_smallset.hpp" #include "cct_smallvector.hpp" #include "cct_vector.hpp" #include "coincenterinfo.hpp" @@ -23,6 +25,7 @@ #include "monetaryamount.hpp" #include "priceoptions.hpp" #include "priceoptionsdef.hpp" +#include "unreachable.hpp" namespace cct::api { ExchangePublic::ExchangePublic(std::string_view name, FiatConverter &fiatConverter, CommonAPI &commonApi, @@ -45,33 +48,52 @@ std::optional ExchangePublic::convert(MonetaryAmount from, Curre } const ExchangeInfo::FeeType feeType = priceOptions.isTakerStrategy() ? ExchangeInfo::FeeType::kTaker : ExchangeInfo::FeeType::kMaker; + + if (marketOrderBookMap.empty()) { + std::lock_guard guard(_allOrderBooksMutex); + marketOrderBookMap = queryAllApproximatedOrderBooks(1); + } + for (Market mk : conversionPath) { - CurrencyCode mFromCurrencyCode = from.currencyCode(); - assert(mk.canTrade(mFromCurrencyCode)); - CurrencyCode mToCurrencyCode = mk.base() == from.currencyCode() ? mk.quote() : mk.base(); - std::optional optFiatLikeFrom = _coincenterInfo.fiatCurrencyIfStableCoin(mFromCurrencyCode); - CurrencyCode fiatFromLikeCurCode = (optFiatLikeFrom ? *optFiatLikeFrom : mFromCurrencyCode); - std::optional optFiatLikeTo = _coincenterInfo.fiatCurrencyIfStableCoin(mToCurrencyCode); - CurrencyCode fiatToLikeCurCode = (optFiatLikeTo ? *optFiatLikeTo : mToCurrencyCode); - bool isFromFiatLike = optFiatLikeFrom || fiats.contains(mFromCurrencyCode); - bool isToFiatLike = optFiatLikeTo || fiats.contains(mToCurrencyCode); - if (isFromFiatLike && isToFiatLike) { - from = _fiatConverter.convert(MonetaryAmount(from, fiatFromLikeCurCode), fiatToLikeCurCode); - } else { - if (marketOrderBookMap.empty()) { - std::lock_guard guard(_allOrderBooksMutex); - marketOrderBookMap = queryAllApproximatedOrderBooks(1); - } - auto it = marketOrderBookMap.find(mk); - if (it == marketOrderBookMap.end()) { + switch (mk.type()) { + case Market::Type::kFiatConversionMarket: { + // should be last market + const bool isToCurrencyFiatLike = fiats.contains(toCurrency); + if (!isToCurrencyFiatLike) { + // convert of fiat like crypto-currency (stable coin) to fiat currency is only possible if the destination + // currency is a fiat. It cannot be done for an intermediate conversion + return std::nullopt; + } + const CurrencyCode mFromCurrencyCode = from.currencyCode(); + const CurrencyCode mToCurrencyCode = mk.opposite(mFromCurrencyCode); + const CurrencyCode fiatLikeFrom = _coincenterInfo.tryConvertStableCoinToFiat(mFromCurrencyCode); + const CurrencyCode fiatFromLikeCurCode = fiatLikeFrom.isNeutral() ? mFromCurrencyCode : fiatLikeFrom; + const CurrencyCode fiatLikeTo = _coincenterInfo.tryConvertStableCoinToFiat(mToCurrencyCode); + const CurrencyCode fiatToLikeCurCode = fiatLikeTo.isNeutral() ? mToCurrencyCode : fiatLikeTo; + + const bool isFromFiatLike = fiatLikeFrom.isDefined() || fiats.contains(mFromCurrencyCode); + const bool isToFiatLike = fiatLikeTo.isDefined() || fiats.contains(mToCurrencyCode); + + if (isFromFiatLike && isToFiatLike) { + return _fiatConverter.convert(MonetaryAmount(from, fiatFromLikeCurCode), fiatToLikeCurCode); + } return std::nullopt; } - const MarketOrderBook &marketOrderbook = it->second; - std::optional optA = marketOrderbook.convert(from, priceOptions); - if (!optA) { - return std::nullopt; + case Market::Type::kRegularExchangeMarket: { + const auto it = marketOrderBookMap.find(mk); + if (it == marketOrderBookMap.end()) { + throw exception("Should not happen - regular market should be present in the markets list"); + } + const MarketOrderBook &marketOrderBook = it->second; + const std::optional optA = marketOrderBook.convert(from, priceOptions); + if (!optA) { + return std::nullopt; + } + from = _exchangeInfo.applyFee(*optA, feeType); + break; } - from = _exchangeInfo.applyFee(*optA, feeType); + default: + unreachable(); } } return from; @@ -79,10 +101,14 @@ std::optional ExchangePublic::convert(MonetaryAmount from, Curre namespace { -// Optimized struct containing a currency and a reverse bool to keep market directionality information +// Struct containing a currency and additional information to create markets with detailed information (order, market +// type) struct CurrencyDir { + constexpr auto operator<=>(const CurrencyDir &) const noexcept = default; + CurrencyCode cur; - bool isLastRealMarketReversed; + bool isLastRealMarketReversed = false; + bool isRegularExchangeMarket = false; }; using CurrencyDirPath = SmallVector; @@ -92,6 +118,14 @@ class CurrencyDirFastestPathComparator { explicit CurrencyDirFastestPathComparator(CommonAPI &commonApi) : _commonApi(commonApi) {} bool operator()(const CurrencyDirPath &lhs, const CurrencyDirPath &rhs) { + // First, favor paths with the least number of non regular markets + const auto hasNonRegularMarket = [](CurrencyDir curDir) { return !curDir.isRegularExchangeMarket; }; + const auto lhsNbNonRegularMarkets = std::ranges::count_if(lhs, hasNonRegularMarket); + const auto rhsNbNonRegularMarkets = std::ranges::count_if(rhs, hasNonRegularMarket); + if (lhsNbNonRegularMarkets != rhsNbNonRegularMarkets) { + return lhsNbNonRegularMarkets > rhsNbNonRegularMarkets; + } + // First, favor the shortest path if (lhs.size() != rhs.size()) { return lhs.size() > rhs.size(); @@ -99,8 +133,14 @@ class CurrencyDirFastestPathComparator { // For equal path sizes, favor non-fiat currencies. Two reasons for this: // - In some countries, tax are automatically collected when any conversion to a fiat on an exchange is made // - It may have the highest volume, as fiats are only present on some regions - auto isFiat = [this](CurrencyDir curDir) { return _commonApi.queryIsCurrencyCodeFiat(curDir.cur); }; - return std::ranges::count_if(lhs, isFiat) > std::ranges::count_if(rhs, isFiat); + const auto isFiat = [this](CurrencyDir curDir) { return _commonApi.queryIsCurrencyCodeFiat(curDir.cur); }; + const auto lhsNbFiats = std::ranges::count_if(lhs, isFiat); + const auto rhsNbFiats = std::ranges::count_if(rhs, isFiat); + if (lhsNbFiats != rhsNbFiats) { + return lhsNbFiats > rhsNbFiats; + } + // Equal path length, equal number of fiats. Compare lexicographically the two to ensure deterministic behavior + return !std::ranges::lexicographical_compare(lhs, rhs); } private: @@ -109,42 +149,52 @@ class CurrencyDirFastestPathComparator { } // namespace MarketsPath ExchangePublic::findMarketsPath(CurrencyCode fromCurrency, CurrencyCode toCurrency, MarketSet &markets, - const Fiats &fiats, bool considerStableCoinsAsFiats) { + const Fiats &fiats, MarketPathMode marketsPathMode) { MarketsPath ret; if (fromCurrency == toCurrency) { return ret; } - std::optional optFiatFromStableCoin = - considerStableCoinsAsFiats ? _coincenterInfo.fiatCurrencyIfStableCoin(toCurrency) : std::nullopt; - const bool isToFiatLike = optFiatFromStableCoin.has_value() || fiats.contains(toCurrency); + const auto isFiatLike = [this, marketsPathMode, &fiats](CurrencyCode cur) { + return (marketsPathMode == MarketPathMode::kWithLastFiatConversion && + _coincenterInfo.tryConvertStableCoinToFiat(cur).isDefined()) || + fiats.contains(cur); + }; + + const auto isToCurrencyFiatLike = isFiatLike(toCurrency); CurrencyDirFastestPathComparator comp(_commonApi); - vector searchPaths(1, CurrencyDirPath(1, CurrencyDir(fromCurrency, false))); - using VisitedCurrenciesSet = std::unordered_set; - VisitedCurrenciesSet visitedCurrencies; - do { - std::pop_heap(searchPaths.begin(), searchPaths.end(), comp); + vector searchPaths(1, CurrencyDirPath(1, CurrencyDir(fromCurrency))); + using VisitedCurrencyCodesSet = + SmallSet, allocator, FlatSet>>; + VisitedCurrencyCodesSet visitedCurrencies; + while (!searchPaths.empty()) { + std::ranges::pop_heap(searchPaths, comp); CurrencyDirPath path = std::move(searchPaths.back()); searchPaths.pop_back(); - CurrencyCode lastCurrencyCode = path.back().cur; - if (visitedCurrencies.contains(lastCurrencyCode)) { + CurrencyCode cur = path.back().cur; + if (visitedCurrencies.contains(cur)) { continue; } - if (lastCurrencyCode == toCurrency) { + if (cur == toCurrency) { + // stop criteria const int nbCurDir = path.size(); ret.reserve(nbCurDir - 1); for (int curDirPos = 1; curDirPos < nbCurDir; ++curDirPos) { - if (path[curDirPos].isLastRealMarketReversed) { - ret.emplace_back(path[curDirPos].cur, path[curDirPos - 1].cur); + const auto curDir = path[curDirPos]; + const auto marketType = + curDir.isRegularExchangeMarket ? Market::Type::kRegularExchangeMarket : Market::Type::kFiatConversionMarket; + if (curDir.isLastRealMarketReversed) { + ret.emplace_back(curDir.cur, path[curDirPos - 1].cur, marketType); } else { - ret.emplace_back(path[curDirPos - 1].cur, path[curDirPos].cur); + ret.emplace_back(path[curDirPos - 1].cur, curDir.cur, marketType); } } return ret; } + // Retrieve markets if not already done if (markets.empty()) { std::lock_guard guard(_tradableMarketsMutex); markets = queryTradableMarkets(); @@ -153,31 +203,35 @@ MarketsPath ExchangePublic::findMarketsPath(CurrencyCode fromCurrency, CurrencyC return ret; } } - for (Market mk : markets) { - if (mk.canTrade(lastCurrencyCode)) { - CurrencyDirPath &newPath = searchPaths.emplace_back(path); - const bool isLastRealMarketReversed = lastCurrencyCode == mk.quote(); - const CurrencyCode newCur = mk.opposite(lastCurrencyCode); - newPath.emplace_back(newCur, isLastRealMarketReversed); - std::push_heap(searchPaths.begin(), searchPaths.end(), comp); - } + bool alreadyInsertedTargetCurrency = false; + for (Market mk : markets | std::views::filter([cur](Market mk) { return mk.canTrade(cur); })) { + const bool isLastRealMarketReversed = cur == mk.quote(); + constexpr bool isRegularExchangeMarket = true; + const CurrencyCode newCur = mk.opposite(cur); + alreadyInsertedTargetCurrency |= newCur == toCurrency; + + CurrencyDirPath &newPath = searchPaths.emplace_back(path); + newPath.emplace_back(newCur, isLastRealMarketReversed, isRegularExchangeMarket); + std::ranges::push_heap(searchPaths, comp); } - const std::optional optLastFiat = - considerStableCoinsAsFiats ? _coincenterInfo.fiatCurrencyIfStableCoin(lastCurrencyCode) : std::nullopt; - const bool isLastFiatLike = optLastFiat || fiats.contains(lastCurrencyCode); - if (isToFiatLike && isLastFiatLike) { - searchPaths.emplace_back(std::move(path)).emplace_back(toCurrency, false); - std::push_heap(searchPaths.begin(), searchPaths.end(), comp); + if (isToCurrencyFiatLike && !alreadyInsertedTargetCurrency && isFiatLike(cur)) { + constexpr bool isLastRealMarketReversed = false; + constexpr bool isRegularExchangeMarket = false; + const CurrencyCode newCur = toCurrency; + + CurrencyDirPath &newPath = searchPaths.emplace_back(std::move(path)); + newPath.emplace_back(newCur, isLastRealMarketReversed, isRegularExchangeMarket); + std::ranges::push_heap(searchPaths, comp); } - visitedCurrencies.insert(std::move(lastCurrencyCode)); - } while (!searchPaths.empty()); + visitedCurrencies.insert(std::move(cur)); + } return ret; } ExchangePublic::CurrenciesPath ExchangePublic::findCurrenciesPath(CurrencyCode fromCurrency, CurrencyCode toCurrency, - bool considerStableCoinsAsFiats) { - MarketsPath marketsPath = findMarketsPath(fromCurrency, toCurrency, considerStableCoinsAsFiats); + MarketPathMode marketsPathMode) { + MarketsPath marketsPath = findMarketsPath(fromCurrency, toCurrency, marketsPathMode); CurrenciesPath ret; if (!marketsPath.empty()) { ret.reserve(marketsPath.size() + 1U); diff --git a/src/api/common/test/exchangepublicapi_test.cpp b/src/api/common/test/exchangepublicapi_test.cpp index 3d5ae552..794be1c1 100644 --- a/src/api/common/test/exchangepublicapi_test.cpp +++ b/src/api/common/test/exchangepublicapi_test.cpp @@ -14,23 +14,35 @@ #include "exchangepublicapi_mock.hpp" #include "exchangepublicapitypes.hpp" #include "fiatconverter.hpp" +#include "generalconfig.hpp" #include "loadconfiguration.hpp" #include "market.hpp" #include "marketorderbook.hpp" #include "monetaryamount.hpp" +#include "monitoringinfo.hpp" +#include "reader.hpp" #include "runmodes.hpp" #include "timedef.hpp" #include "volumeandpricenbdecimals.hpp" namespace cct::api { +namespace { +class StableCoinReader : public Reader { + string readAll() const override { return R"({"USDT": "USD"})"; } +}; +} // namespace class ExchangePublicTest : public ::testing::Test { protected: settings::RunMode runMode = settings::RunMode::kTestKeys; LoadConfiguration loadConfiguration{kDefaultDataDir, LoadConfiguration::ExchangeConfigFileType::kTest}; - CoincenterInfo coincenterInfo{runMode, loadConfiguration}; + CoincenterInfo coincenterInfo{runMode, loadConfiguration, GeneralConfig(), + MonitoringInfo(), Reader(), StableCoinReader()}; CommonAPI commonAPI{coincenterInfo, Duration::max()}; FiatConverter fiatConverter{coincenterInfo, Duration::max()}; // max to avoid real Fiat converter queries MockExchangePublic exchangePublic{kSupportedExchanges[0], fiatConverter, commonAPI, coincenterInfo}; + + MarketSet markets{{"BTC", "EUR"}, {"XLM", "EUR"}, {"ETH", "EUR"}, {"ETH", "BTC"}, {"BTC", "KRW"}, + {"USD", "EOS"}, {"SHIB", "ICP"}, {"AVAX", "ICP"}, {"AVAX", "USDT"}}; }; namespace { @@ -38,24 +50,31 @@ using CurrenciesPath = ExchangePublic::CurrenciesPath; using Fiats = ExchangePublic::Fiats; } // namespace -TEST_F(ExchangePublicTest, FindFastestConversionPath) { - MarketSet markets{{"BTC", "EUR"}, {"XLM", "EUR"}, {"ETH", "EUR"}, {"ETH", "BTC"}}; +TEST_F(ExchangePublicTest, FindConversionPath) { EXPECT_CALL(exchangePublic, queryTradableMarkets()).WillRepeatedly(::testing::Return(markets)); EXPECT_EQ(exchangePublic.findMarketsPath("BTC", "XLM"), MarketsPath({Market{"BTC", "EUR"}, Market{"XLM", "EUR"}})); EXPECT_EQ(exchangePublic.findMarketsPath("XLM", "ETH"), MarketsPath({Market{"XLM", "EUR"}, Market{"ETH", "EUR"}})); - EXPECT_EQ(exchangePublic.findMarketsPath("ETH", "KRW"), MarketsPath({Market{"ETH", "EUR"}, Market{"EUR", "KRW"}})); + EXPECT_EQ(exchangePublic.findMarketsPath("ETH", "KRW"), MarketsPath({Market{"ETH", "BTC"}, Market{"BTC", "KRW"}})); EXPECT_EQ(exchangePublic.findMarketsPath("EUR", "BTC"), MarketsPath({Market{"BTC", "EUR"}})); - EXPECT_EQ(exchangePublic.findMarketsPath("EOS", "KRW"), MarketsPath()); + EXPECT_EQ(exchangePublic.findMarketsPath("SHIB", "USDT"), + MarketsPath({Market{"SHIB", "ICP"}, Market{"AVAX", "ICP"}, Market{"AVAX", "USDT"}})); + EXPECT_EQ(exchangePublic.findMarketsPath("SHIB", "KRW"), MarketsPath()); + EXPECT_EQ(exchangePublic.findMarketsPath("SHIB", "KRW", ExchangePublic::MarketPathMode::kWithLastFiatConversion), + MarketsPath({Market{"SHIB", "ICP"}, Market{"AVAX", "ICP"}, Market{"AVAX", "USDT"}, + Market{"USDT", "KRW", Market::Type::kFiatConversionMarket}})); +} + +TEST_F(ExchangePublicTest, FindCurrenciesPath) { + EXPECT_CALL(exchangePublic, queryTradableMarkets()).WillRepeatedly(::testing::Return(markets)); EXPECT_EQ(exchangePublic.findCurrenciesPath("BTC", "XLM"), CurrenciesPath({"BTC", "EUR", "XLM"})); EXPECT_EQ(exchangePublic.findCurrenciesPath("XLM", "ETH"), CurrenciesPath({"XLM", "EUR", "ETH"})); - EXPECT_EQ(exchangePublic.findCurrenciesPath("ETH", "KRW"), CurrenciesPath({"ETH", "EUR", "KRW"})); + EXPECT_EQ(exchangePublic.findCurrenciesPath("ETH", "KRW"), CurrenciesPath({"ETH", "BTC", "KRW"})); EXPECT_EQ(exchangePublic.findCurrenciesPath("EUR", "BTC"), CurrenciesPath({"EUR", "BTC"})); - EXPECT_EQ(exchangePublic.findCurrenciesPath("EOS", "KRW"), CurrenciesPath()); + EXPECT_EQ(exchangePublic.findCurrenciesPath("SHIB", "KRW"), CurrenciesPath()); } TEST_F(ExchangePublicTest, RetrieveMarket) { - MarketSet markets{{"BTC", "KRW"}, {"XLM", "KRW"}, {"USD", "EOS"}}; EXPECT_CALL(exchangePublic, queryTradableMarkets()).WillOnce(::testing::Return(markets)); EXPECT_EQ(exchangePublic.retrieveMarket("BTC", "KRW"), Market("BTC", "KRW")); diff --git a/src/engine/src/coincenter.cpp b/src/engine/src/coincenter.cpp index 3fbcfdad..4e5c7ec3 100644 --- a/src/engine/src/coincenter.cpp +++ b/src/engine/src/coincenter.cpp @@ -250,10 +250,10 @@ MarketOrderBookConversionRates Coincenter::getMarketOrderBooks(Market mk, Exchan BalancePerExchange Coincenter::getBalance(std::span privateExchangeNames, const BalanceOptions &balanceOptions) { CurrencyCode equiCurrency = balanceOptions.equiCurrency(); - const auto optEquiCur = _coincenterInfo.fiatCurrencyIfStableCoin(equiCurrency); - if (optEquiCur) { - log::warn("Consider {} instead of stable coin {} as equivalent currency", *optEquiCur, equiCurrency); - equiCurrency = *optEquiCur; + const auto equiCur = _coincenterInfo.tryConvertStableCoinToFiat(equiCurrency); + if (equiCur.isDefined()) { + log::warn("Consider {} instead of stable coin {} as equivalent currency", equiCur, equiCurrency); + equiCurrency = equiCur; } BalancePerExchange ret = _exchangesOrchestrator.getBalance(privateExchangeNames, balanceOptions); diff --git a/src/engine/src/exchangesorchestrator.cpp b/src/engine/src/exchangesorchestrator.cpp index a821d1dc..93a8237d 100644 --- a/src/engine/src/exchangesorchestrator.cpp +++ b/src/engine/src/exchangesorchestrator.cpp @@ -134,8 +134,9 @@ MarketOrderBookConversionRates ExchangesOrchestrator::getMarketOrderBooks(Market MarketOrderBookConversionRates ret(selectedExchanges.size()); auto marketOrderBooksFunc = [mk, equiCurrencyCode, depth](Exchange *exchange) { std::optional optConversionRate = - equiCurrencyCode.isNeutral() ? std::nullopt - : exchange->apiPublic().convert(MonetaryAmount(1, mk.quote()), equiCurrencyCode); + equiCurrencyCode.isNeutral() + ? std::nullopt + : exchange->apiPublic().estimatedConvert(MonetaryAmount(1, mk.quote()), equiCurrencyCode); MarketOrderBook marketOrderBook(depth ? exchange->queryOrderBook(mk, *depth) : exchange->queryOrderBook(mk)); if (!optConversionRate && !equiCurrencyCode.isNeutral()) { log::warn("Unable to convert {} into {} on {}", marketOrderBook.market().quote(), equiCurrencyCode, @@ -403,7 +404,6 @@ ExchangeAmountMarketsPathVector FilterConversionPaths(const ExchangeAmountPairVe ExchangeAmountMarketsPathVector ret; int publicExchangePos = -1; - constexpr bool considerStableCoinsAsFiats = false; api::ExchangePublic *pExchangePublic = nullptr; for (const auto &[exchangePtr, exchangeAmount] : exchangeAmountPairVector) { if (pExchangePublic != &exchangePtr->apiPublic()) { @@ -413,8 +413,8 @@ ExchangeAmountMarketsPathVector FilterConversionPaths(const ExchangeAmountPairVe api::ExchangePublic &exchangePublic = *pExchangePublic; MarketSet &markets = marketsPerPublicExchange[publicExchangePos]; - MarketsPath marketsPath = - exchangePublic.findMarketsPath(fromCurrency, toCurrency, markets, fiats, considerStableCoinsAsFiats); + MarketsPath marketsPath = exchangePublic.findMarketsPath(fromCurrency, toCurrency, markets, fiats, + api::ExchangePublic::MarketPathMode::kStrict); const int nbMarketsInPath = static_cast(marketsPath.size()); if (nbMarketsInPath == 1 || (nbMarketsInPath > 1 && @@ -563,14 +563,13 @@ TradeResultPerExchange ExchangesOrchestrator::smartBuy(MonetaryAmount endAmount, MarketSetsPerPublicExchange marketsPerPublicExchange(publicExchanges.size()); - FixedCapacityVector marketOrderbooksPerPublicExchange( + FixedCapacityVector marketOrderBooksPerPublicExchange( publicExchanges.size()); api::CommonAPI::Fiats fiats = QueryFiats(publicExchanges); ExchangeAmountToCurrencyToAmountVector trades; MonetaryAmount remEndAmount = endAmount; - constexpr bool considerStableCoinsAsFiats = false; for (int nbSteps = 1;; ++nbSteps) { bool continuingHigherStepsPossible = false; const int nbTrades = static_cast(trades.size()); @@ -587,7 +586,7 @@ TradeResultPerExchange ExchangesOrchestrator::smartBuy(MonetaryAmount endAmount, continue; } auto &markets = marketsPerPublicExchange[publicExchangePos]; - auto &marketOrderBookMap = marketOrderbooksPerPublicExchange[publicExchangePos]; + auto &marketOrderBookMap = marketOrderBooksPerPublicExchange[publicExchangePos]; for (CurrencyCode fromCurrency : pExchange->exchangeInfo().preferredPaymentCurrencies()) { if (fromCurrency == toCurrency) { continue; @@ -597,8 +596,8 @@ TradeResultPerExchange ExchangesOrchestrator::smartBuy(MonetaryAmount endAmount, std::none_of(trades.begin(), trades.begin() + nbTrades, [pExchange, fromCurrency](const auto &tuple) { return std::get<0>(tuple) == pExchange && std::get<1>(tuple).currencyCode() == fromCurrency; })) { - auto conversionPath = - exchangePublic.findMarketsPath(fromCurrency, toCurrency, markets, fiats, considerStableCoinsAsFiats); + auto conversionPath = exchangePublic.findMarketsPath(fromCurrency, toCurrency, markets, fiats, + api::ExchangePublic::MarketPathMode::kStrict); const int nbConversions = static_cast(conversionPath.size()); if (nbConversions > nbSteps) { continuingHigherStepsPossible = true; @@ -682,7 +681,6 @@ TradeResultPerExchange ExchangesOrchestrator::smartSell(MonetaryAmount startAmou } // check from which exchanges we can start trades, minimizing number of steps per trade - constexpr bool considerStableCoinsAsFiats = false; for (int nbSteps = 1;; ++nbSteps) { bool continuingHigherStepsPossible = false; int exchangePos = 0; @@ -699,7 +697,7 @@ TradeResultPerExchange ExchangesOrchestrator::smartSell(MonetaryAmount startAmou continue; } MarketsPath path = pExchange->apiPublic().findMarketsPath(fromCurrency, toCurrency, markets, fiats, - considerStableCoinsAsFiats); + api::ExchangePublic::MarketPathMode::kStrict); if (static_cast(path.size()) > nbSteps) { continuingHigherStepsPossible = true; } else if (static_cast(path.size()) == nbSteps) { diff --git a/src/objects/CMakeLists.txt b/src/objects/CMakeLists.txt index 80570ed4..bbbf876a 100644 --- a/src/objects/CMakeLists.txt +++ b/src/objects/CMakeLists.txt @@ -57,6 +57,15 @@ add_unit_test( coincenter_objects ) +add_unit_test( + market_test + test/market_test.cpp + LIBRARIES + coincenter_objects + DEFINITIONS + CCT_DISABLE_SPDLOG +) + add_unit_test( marketorderbook_test test/marketorderbook_test.cpp diff --git a/src/objects/include/coincenterinfo.hpp b/src/objects/include/coincenterinfo.hpp index edf0d725..eed7c36b 100644 --- a/src/objects/include/coincenterinfo.hpp +++ b/src/objects/include/coincenterinfo.hpp @@ -4,7 +4,6 @@ #include #include #include -#include #include #include @@ -46,8 +45,8 @@ class CoincenterInfo { } /// If 'stableCoinCandidate' is a stable crypto currency, return its associated fiat currency code. - /// Otherwise, return 'std::nullopt' - std::optional fiatCurrencyIfStableCoin(CurrencyCode stableCoinCandidate) const; + /// Otherwise, return a default currency code + CurrencyCode tryConvertStableCoinToFiat(CurrencyCode maybeStableCoin) const; const ExchangeInfo &exchangeInfo(std::string_view exchangeName) const; diff --git a/src/objects/include/currencycode.hpp b/src/objects/include/currencycode.hpp index 761f76df..51d1d9ca 100644 --- a/src/objects/include/currencycode.hpp +++ b/src/objects/include/currencycode.hpp @@ -212,7 +212,9 @@ class CurrencyCode { /// Returns a 64 bits code constexpr uint64_t code() const noexcept { return _data; } - constexpr bool isNeutral() const noexcept { return !(_data & CurrencyCodeBase::kFirstCharMask); } + constexpr bool isDefined() const noexcept { return static_cast(_data & CurrencyCodeBase::kFirstCharMask); } + + constexpr bool isNeutral() const noexcept { return !isDefined(); } constexpr char operator[](uint32_t pos) const { return CurrencyCodeBase::CharAt(_data, static_cast(pos)); } @@ -233,6 +235,7 @@ class CurrencyCode { } private: + friend class Market; friend class MonetaryAmount; // bitmap with 10 words of 6 bits (from ascii [33, 95]) + 4 extra bits that will be used by @@ -245,15 +248,15 @@ class CurrencyCode { explicit constexpr CurrencyCode(uint64_t data) : _data(data) {} - constexpr bool isLongCurrencyCode() const { return _data & CurrencyCodeBase::kBeforeLastCharMask; } + constexpr bool isLongCurrencyCode() const { return static_cast(_data & CurrencyCodeBase::kBeforeLastCharMask); } - constexpr void setNbDecimals(int8_t nbDecimals) { + constexpr void uncheckedSetAdditionalBits(int8_t data) { // For currency codes whose length is > 8, only 15 digits are supported // max 64 decimals for currency codes whose length is maximum 8 (most cases) - _data = static_cast(nbDecimals) + (_data & (~CurrencyCodeBase::DecimalsMask(isLongCurrencyCode()))); + _data = static_cast(data) + (_data & (~CurrencyCodeBase::DecimalsMask(isLongCurrencyCode()))); } - constexpr int8_t nbDecimals() const { + constexpr int8_t getAdditionalBits() const { return static_cast(_data & CurrencyCodeBase::DecimalsMask(isLongCurrencyCode())); } diff --git a/src/objects/include/market.hpp b/src/objects/include/market.hpp index a0b0eada..576018ed 100644 --- a/src/objects/include/market.hpp +++ b/src/objects/include/market.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include "cct_format.hpp" @@ -14,23 +15,25 @@ namespace cct { /// Important note: BTC/ETH != ETH/BTC. Use reverse() to reverse it. class Market { public: - using TradableAssets = std::array; + enum class Type : int8_t { kRegularExchangeMarket, kFiatConversionMarket }; Market() noexcept(std::is_nothrow_default_constructible_v) = default; - Market(CurrencyCode first, CurrencyCode second) : _assets({first, second}) {} + Market(CurrencyCode first, CurrencyCode second, Type type = Type::kRegularExchangeMarket) : _assets({first, second}) { + setType(type); + } /// Create a Market from its string representation. /// The two currency codes must be separated by given char separator. - Market(std::string_view marketStrRep, char currencyCodeSep); + explicit Market(std::string_view marketStrRep, char currencyCodeSep = '-', Type type = Type::kRegularExchangeMarket); - bool isDefined() const { return !base().isNeutral() && !quote().isNeutral(); } + bool isDefined() const { return base().isDefined() && quote().isDefined(); } bool isNeutral() const { return base().isNeutral() && quote().isNeutral(); } /// Computes the reverse market. /// Example: return XRP/BTC for a market BTC/XRP - Market reverse() const { return Market(_assets[1], _assets[0]); } + [[nodiscard]] Market reverse() const { return {_assets[1], _assets[0]}; } /// Get the base CurrencyCode of this Market. CurrencyCode base() const { return _assets[0]; } @@ -40,15 +43,20 @@ class Market { /// Given 'c' a currency traded in this Market, return the other currency it is paired with. /// If 'c' is not traded by this market, return the second currency. - CurrencyCode opposite(CurrencyCode c) const { return _assets[1] == c ? _assets[0] : _assets[1]; } + [[nodiscard]] CurrencyCode opposite(CurrencyCode cur) const { return _assets[1] == cur ? _assets[0] : _assets[1]; } + + /// Tells whether this market trades given monetary amount based on its currency. + bool canTrade(MonetaryAmount ma) const { return canTrade(ma.currencyCode()); } - bool canTrade(MonetaryAmount a) const { return canTrade(a.currencyCode()); } - bool canTrade(CurrencyCode c) const { return base() == c || quote() == c; } + /// Tells whether this market trades given currency code. + bool canTrade(CurrencyCode cur) const { return std::ranges::find(_assets, cur) != _assets.end(); } - auto operator<=>(const Market&) const = default; + constexpr auto operator<=>(const Market&) const noexcept = default; string str() const { return assetsPairStrUpper('-'); } + Type type() const { return static_cast(_assets[0].getAdditionalBits()); } + friend std::ostream& operator<<(std::ostream& os, const Market& mk); /// Returns a string representing this Market in lower case @@ -60,7 +68,9 @@ class Market { private: string assetsPairStr(char sep, bool lowerCase) const; - TradableAssets _assets; + void setType(Type type) { _assets[0].uncheckedSetAdditionalBits(static_cast(type)); } + + std::array _assets; }; } // namespace cct diff --git a/src/objects/include/marketorderbook.hpp b/src/objects/include/marketorderbook.hpp index de396110..ea9a3184 100644 --- a/src/objects/include/marketorderbook.hpp +++ b/src/objects/include/marketorderbook.hpp @@ -149,7 +149,7 @@ class MarketOrderBook { struct AmountPrice { using AmountType = MonetaryAmount::AmountType; - bool operator==(const AmountPrice& o) const = default; + bool operator==(const AmountPrice& o) const noexcept = default; AmountType amount = 0; AmountType price = 0; diff --git a/src/objects/include/monetaryamount.hpp b/src/objects/include/monetaryamount.hpp index 24d3981d..1285600d 100644 --- a/src/objects/include/monetaryamount.hpp +++ b/src/objects/include/monetaryamount.hpp @@ -130,7 +130,7 @@ class MonetaryAmount { return _curWithDecimals.withNoDecimalsPart(); } - [[nodiscard]] constexpr int8_t nbDecimals() const { return _curWithDecimals.nbDecimals(); } + [[nodiscard]] constexpr int8_t nbDecimals() const { return _curWithDecimals.getAdditionalBits(); } [[nodiscard]] constexpr int8_t maxNbDecimals() const { return _curWithDecimals.isLongCurrencyCode() @@ -362,7 +362,7 @@ class MonetaryAmount { } } - constexpr void setNbDecimals(int8_t nbDecs) { _curWithDecimals.setNbDecimals(nbDecs); } + constexpr void setNbDecimals(int8_t nbDecs) { _curWithDecimals.uncheckedSetAdditionalBits(nbDecs); } AmountType _amount; CurrencyCode _curWithDecimals; diff --git a/src/objects/include/priceoptions.hpp b/src/objects/include/priceoptions.hpp index e3534bac..85611c3d 100644 --- a/src/objects/include/priceoptions.hpp +++ b/src/objects/include/priceoptions.hpp @@ -48,7 +48,7 @@ class PriceOptions { string str(bool placeRealOrderInSimulationMode) const; - bool operator==(const PriceOptions &) const = default; + bool operator==(const PriceOptions &) const noexcept = default; private: MonetaryAmount _fixedPrice; diff --git a/src/objects/include/reader.hpp b/src/objects/include/reader.hpp index 5770ccae..a683fae4 100644 --- a/src/objects/include/reader.hpp +++ b/src/objects/include/reader.hpp @@ -7,13 +7,15 @@ namespace cct { class Reader { public: + Reader() noexcept = default; + + virtual ~Reader() = default; + // Read all content and return a string of it. virtual string readAll() const { return {}; } // Read all content, and constructs a json object from it. json readAllJson() const; - - virtual ~Reader() = default; }; } // namespace cct \ No newline at end of file diff --git a/src/objects/src/coincenterinfo.cpp b/src/objects/src/coincenterinfo.cpp index 6771aca4..a9e07581 100644 --- a/src/objects/src/coincenterinfo.cpp +++ b/src/objects/src/coincenterinfo.cpp @@ -123,12 +123,12 @@ CurrencyCode CoincenterInfo::standardizeCurrencyCode(std::string_view currencyCo return standardizeCurrencyCode(CurrencyCode(currencyCode)); } -std::optional CoincenterInfo::fiatCurrencyIfStableCoin(CurrencyCode stableCoinCandidate) const { - auto it = _stableCoinsMap.find(stableCoinCandidate); +CurrencyCode CoincenterInfo::tryConvertStableCoinToFiat(CurrencyCode maybeStableCoin) const { + const auto it = _stableCoinsMap.find(maybeStableCoin); if (it != _stableCoinsMap.end()) { return it->second; } - return std::nullopt; + return {}; } const ExchangeInfo& CoincenterInfo::exchangeInfo(std::string_view exchangeName) const { diff --git a/src/objects/src/market.cpp b/src/objects/src/market.cpp index 2447f399..40d806ec 100644 --- a/src/objects/src/market.cpp +++ b/src/objects/src/market.cpp @@ -8,23 +8,39 @@ #include "cct_exception.hpp" #include "cct_string.hpp" #include "toupperlower.hpp" +#include "unreachable.hpp" namespace cct { -Market::Market(std::string_view marketStrRep, char currencyCodeSep) { - std::size_t sepPos = marketStrRep.find(currencyCodeSep); +Market::Market(std::string_view marketStrRep, char currencyCodeSep, Type type) { + const std::size_t sepPos = marketStrRep.find(currencyCodeSep); if (sepPos == std::string_view::npos) { - throw exception("Market string representation {} should have a separator", marketStrRep); + throw exception("Market string representation {} should have a separator {}", marketStrRep, currencyCodeSep); + } + if (marketStrRep.find(currencyCodeSep, sepPos + 1) != std::string_view::npos) { + throw exception("Market string representation {} should have a unique separator {}", marketStrRep, currencyCodeSep); } _assets.front() = std::string_view(marketStrRep.begin(), marketStrRep.begin() + sepPos); _assets.back() = std::string_view(marketStrRep.begin() + sepPos + 1, marketStrRep.end()); + + setType(type); } string Market::assetsPairStr(char sep, bool lowerCase) const { - string ret(_assets.front().str()); + string ret; + switch (type()) { + case Type::kRegularExchangeMarket: + break; + case Type::kFiatConversionMarket: + ret.push_back('*'); + break; + default: + unreachable(); + } + base().appendStrTo(ret); if (sep != 0) { ret.push_back(sep); } - _assets.back().appendStrTo(ret); + quote().appendStrTo(ret); if (lowerCase) { std::ranges::transform(ret, ret.begin(), tolower); } @@ -32,7 +48,7 @@ string Market::assetsPairStr(char sep, bool lowerCase) const { } std::ostream& operator<<(std::ostream& os, const Market& mk) { - os << mk.base() << '-' << mk.quote(); + os << mk.str(); return os; } diff --git a/src/objects/test/market_test.cpp b/src/objects/test/market_test.cpp new file mode 100644 index 00000000..432d083a --- /dev/null +++ b/src/objects/test/market_test.cpp @@ -0,0 +1,54 @@ +#include "market.hpp" + +#include + +namespace cct { +TEST(MarketTest, DefaultConstructor) { + Market market; + + EXPECT_TRUE(market.base().isNeutral()); + EXPECT_TRUE(market.quote().isNeutral()); + EXPECT_TRUE(market.isNeutral()); + EXPECT_FALSE(market.isDefined()); + EXPECT_EQ(Market(), market); +} + +TEST(MarketTest, CurrencyConstructor) { + Market market(CurrencyCode("ETH"), "USDT"); + + EXPECT_EQ(market.base(), CurrencyCode("ETH")); + EXPECT_EQ(market.quote(), CurrencyCode("USDT")); + EXPECT_FALSE(market.isNeutral()); + EXPECT_TRUE(market.isDefined()); + EXPECT_EQ(Market("eth", "usdt"), market); +} + +TEST(MarketTest, StringConstructor) { + Market market("sol-KRW"); + + EXPECT_EQ(market.base(), CurrencyCode("SOL")); + EXPECT_EQ(market.quote(), CurrencyCode("KRW")); + EXPECT_EQ(Market("sol", "KRW"), market); +} + +TEST(MarketTest, IncorrectStringConstructor) { + EXPECT_THROW(Market("sol"), exception); + EXPECT_THROW(Market("BTC-EUR-"), exception); +} + +TEST(MarketTest, StringRepresentationRegularMarket) { + Market market("shib", "btc"); + + EXPECT_EQ(market.str(), "SHIB-BTC"); + EXPECT_EQ(market.assetsPairStrUpper('/'), "SHIB/BTC"); + EXPECT_EQ(market.assetsPairStrLower('|'), "shib|btc"); +} + +TEST(MarketTest, StringRepresentationFiatConversionMarket) { + Market market("USDT", "EUR", Market::Type::kFiatConversionMarket); + + EXPECT_EQ(market.str(), "*USDT-EUR"); + EXPECT_EQ(market.assetsPairStrUpper('('), "*USDT(EUR"); + EXPECT_EQ(market.assetsPairStrLower(')'), "*usdt)eur"); +} +} // namespace cct \ No newline at end of file diff --git a/src/tech/include/cct_const.hpp b/src/tech/include/cct_const.hpp index a87533b4..c1ea85aa 100644 --- a/src/tech/include/cct_const.hpp +++ b/src/tech/include/cct_const.hpp @@ -18,8 +18,7 @@ static constexpr std::string_view kDepositAddressesFileName = "depositaddresses. static constexpr std::string_view kSupportedExchanges[] = {"binance", "bithumb", "huobi", "kraken", "kucoin", "upbit"}; -static constexpr int kNbSupportedExchanges = - static_cast(std::distance(std::begin(kSupportedExchanges), std::end(kSupportedExchanges))); +static constexpr int kNbSupportedExchanges = static_cast(std::size(kSupportedExchanges)); static constexpr int kTypicalNbPrivateAccounts = kNbSupportedExchanges;