From c1641af2b64f7d308a403d8a19ddca4afbdd326c Mon Sep 17 00:00:00 2001 From: Stephane Janel Date: Sun, 31 Mar 2024 10:34:46 +0200 Subject: [PATCH] [Conversion] - Support transferable results for conversion --- .github/workflows/codeql-analysis.yml | 10 +- .github/workflows/macos.yml | 3 +- .github/workflows/ubuntu-clang-tidy.yml | 9 +- .github/workflows/ubuntu.yml | 9 +- src/engine/include/coincenter.hpp | 4 + src/engine/include/exchangesorchestrator.hpp | 3 + src/engine/include/queryresultprinter.hpp | 4 + src/engine/src/coincenter.cpp | 56 ++++++++-- src/engine/src/coincentercommands.cpp | 4 +- src/engine/src/exchangesorchestrator.cpp | 20 ++++ src/engine/src/queryresultprinter.cpp | 57 ++++++++++ .../test/queryresultprinter_public_test.cpp | 100 +++++++++++++++++- 12 files changed, 247 insertions(+), 32 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 5377516f..6e72d153 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -55,16 +55,14 @@ jobs: sudo apt update sudo apt install build-essential ninja-build libssl-dev libcurl4-gnutls-dev cmake git ca-certificates gzip -y --no-install-recommends - - name: Create Build Environment - run: cmake -E make_directory ${{github.workspace}}/build - - name: Configure CMake - working-directory: ${{github.workspace}}/build - run: cmake -DCMAKE_BUILD_TYPE=${{matrix.buildmode}} -GNinja .. + run: cmake -S . -B build -GNinja + env: + CMAKE_BUILD_TYPE: ${{matrix.buildmode}} - name: Build working-directory: ${{github.workspace}}/build - run: ninja + run: cmake --build . # ℹī¸ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index 33fd799c..123ba118 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -25,9 +25,10 @@ jobs: run: brew install ninja - name: Configure - run: cmake -S . -B build -DCMAKE_BUILD_TYPE=${{matrix.buildmode}} -GNinja + run: cmake -S . -B build -GNinja env: CXX: ${{matrix.compiler}} + CMAKE_BUILD_TYPE: ${{matrix.buildmode}} - name: Build working-directory: ${{github.workspace}}/build diff --git a/.github/workflows/ubuntu-clang-tidy.yml b/.github/workflows/ubuntu-clang-tidy.yml index 194b6060..8b843ad6 100644 --- a/.github/workflows/ubuntu-clang-tidy.yml +++ b/.github/workflows/ubuntu-clang-tidy.yml @@ -32,15 +32,14 @@ jobs: sudo apt install clang-tidy-${{matrix.clang-version}} sudo ln -s /usr/bin/clang-tidy-${{matrix.clang-version}} /usr/local/bin/clang-tidy - - name: Create Build Environment - run: cmake -E make_directory ${{github.workspace}}/build - - name: Configure CMake - working-directory: ${{github.workspace}}/build shell: bash run: | clang-tidy --dump-config - cmake $GITHUB_WORKSPACE -DCMAKE_BUILD_TYPE=${{matrix.buildmode}} -DCMAKE_CXX_COMPILER=clang++-${{matrix.clang-version}} -DCCT_ENABLE_CLANG_TIDY=ON -DCCT_ENABLE_ASAN=OFF -GNinja + cmake -S . -B build -DCCT_ENABLE_CLANG_TIDY=ON -DCCT_ENABLE_ASAN=OFF -GNinja + env: + CXX: clang++-${{matrix.clang-version}} + CMAKE_BUILD_TYPE: ${{matrix.buildmode}} - name: Build working-directory: ${{github.workspace}}/build diff --git a/.github/workflows/ubuntu.yml b/.github/workflows/ubuntu.yml index e5db96fb..41e70791 100644 --- a/.github/workflows/ubuntu.yml +++ b/.github/workflows/ubuntu.yml @@ -40,13 +40,12 @@ jobs: sudo ./llvm.sh ${CLANG_VERSION} if: startsWith(matrix.compiler, 'clang++') - - name: Create Build Environment - run: cmake -E make_directory ${{github.workspace}}/build - - name: Configure CMake - working-directory: ${{github.workspace}}/build shell: bash - run: cmake $GITHUB_WORKSPACE -DCMAKE_BUILD_TYPE=${{matrix.buildmode}} -DCMAKE_CXX_COMPILER=${{matrix.compiler}} -GNinja + run: cmake -S . -B build -GNinja + env: + CXX: ${{matrix.compiler}} + CMAKE_BUILD_TYPE: ${{matrix.buildmode}} - name: Build working-directory: ${{github.workspace}}/build diff --git a/src/engine/include/coincenter.hpp b/src/engine/include/coincenter.hpp index 2adc85b9..eecef086 100644 --- a/src/engine/include/coincenter.hpp +++ b/src/engine/include/coincenter.hpp @@ -102,6 +102,10 @@ class Coincenter { MonetaryAmountPerExchange getConversion(MonetaryAmount amount, CurrencyCode targetCurrencyCode, ExchangeNameSpan exchangeNames); + /// Returns given amount per exchange converted into target currency code for given exchanges, when possible. + MonetaryAmountPerExchange getConversion(std::span monetaryAmountPerExchangeToConvert, + CurrencyCode targetCurrencyCode, ExchangeNameSpan exchangeNames); + /// Query the conversion paths for each public exchange requested ConversionPathPerExchange getConversionPaths(Market mk, ExchangeNameSpan exchangeNames); diff --git a/src/engine/include/exchangesorchestrator.hpp b/src/engine/include/exchangesorchestrator.hpp index 61bb9e87..a2c052dd 100644 --- a/src/engine/include/exchangesorchestrator.hpp +++ b/src/engine/include/exchangesorchestrator.hpp @@ -50,6 +50,9 @@ class ExchangesOrchestrator { MonetaryAmountPerExchange getConversion(MonetaryAmount amount, CurrencyCode targetCurrencyCode, ExchangeNameSpan exchangeNames); + MonetaryAmountPerExchange getConversion(std::span monetaryAmountPerExchangeToConvert, + CurrencyCode targetCurrencyCode, ExchangeNameSpan exchangeNames); + ConversionPathPerExchange getConversionPaths(Market mk, ExchangeNameSpan exchangeNames); CurrenciesPerExchange getCurrenciesPerExchange(ExchangeNameSpan exchangeNames); diff --git a/src/engine/include/queryresultprinter.hpp b/src/engine/include/queryresultprinter.hpp index 680983dd..7e7edec7 100644 --- a/src/engine/include/queryresultprinter.hpp +++ b/src/engine/include/queryresultprinter.hpp @@ -3,6 +3,7 @@ #include #include #include +#include #include "apioutputtype.hpp" #include "cct_json.hpp" @@ -82,6 +83,9 @@ class QueryResultPrinter { void printConversion(MonetaryAmount amount, CurrencyCode targetCurrencyCode, const MonetaryAmountPerExchange &conversionPerExchange) const; + void printConversion(std::span startAmountPerExchangePos, CurrencyCode targetCurrencyCode, + const MonetaryAmountPerExchange &conversionPerExchange) const; + void printConversionPath(Market mk, const ConversionPathPerExchange &conversionPathsPerExchange) const; void printWithdrawFees(const MonetaryAmountByCurrencySetPerExchange &withdrawFeesPerExchange, CurrencyCode cur) const; diff --git a/src/engine/src/coincenter.cpp b/src/engine/src/coincenter.cpp index 7da4a00e..2f9cfb22 100644 --- a/src/engine/src/coincenter.cpp +++ b/src/engine/src/coincenter.cpp @@ -1,6 +1,7 @@ #include "coincenter.hpp" #include +#include #include #include #include @@ -9,7 +10,9 @@ #include "balanceoptions.hpp" #include "cct_exception.hpp" +#include "cct_invalid_argument_exception.hpp" #include "cct_log.hpp" +#include "coincentercommand.hpp" #include "coincentercommands.hpp" #include "coincentercommandtype.hpp" #include "coincenterinfo.hpp" @@ -31,14 +34,23 @@ namespace cct { namespace { -void FillTransferableCommandResults(const TradeResultPerExchange &tradeResultPerExchange, - TransferableCommandResultVector &transferableResults) { + +void FillTradeTransferableCommandResults(const TradeResultPerExchange &tradeResultPerExchange, + TransferableCommandResultVector &transferableResults) { for (const auto &[exchangePtr, tradeResult] : tradeResultPerExchange) { if (tradeResult.isComplete()) { transferableResults.emplace_back(exchangePtr->createExchangeName(), tradeResult.tradedAmounts().to); } } } + +void FillConversionTransferableCommandResults(const MonetaryAmountPerExchange &monetaryAmountPerExchange, + TransferableCommandResultVector &transferableResults) { + for (const auto &[exchangePtr, amount] : monetaryAmountPerExchange) { + transferableResults.emplace_back(exchangePtr->createExchangeName(), amount); + } +} + } // namespace volatile sig_atomic_t g_signalStatus = 0; @@ -91,7 +103,7 @@ int Coincenter::process(const CoincenterCommands &coincenterCommands) { log::debug("Sleep for {} before next command", DurationToString(waitingDuration)); std::this_thread::sleep_for(waitingDuration); } - if (nbRepeats != 1) { + if (nbRepeats != 1 && (repeatPos < 100 || repeatPos % 100 == 0)) { if (nbRepeats == -1) { log::info("Process request {}", repeatPos + 1); } else { @@ -127,8 +139,31 @@ TransferableCommandResultVector Coincenter::processCommand( break; } case CoincenterCommandType::kConversion: { - const auto conversionPerExchange = getConversion(cmd.amount(), cmd.cur1(), cmd.exchangeNames()); - _queryResultPrinter.printConversion(cmd.amount(), cmd.cur1(), conversionPerExchange); + if (cmd.amount().isDefault()) { + std::array startAmountsPerExchangePos; + bool oneSet = false; + for (const auto &transferableResult : previousTransferableResults) { + auto publicExchangePos = transferableResult.targetedExchange().publicExchangePos(); + if (startAmountsPerExchangePos[publicExchangePos].isDefault()) { + startAmountsPerExchangePos[publicExchangePos] = transferableResult.resultedAmount(); + oneSet = true; + } else { + throw invalid_argument( + "Transferable results to conversion should have at most one amount per public exchange"); + } + } + if (!oneSet) { + throw invalid_argument("Missing input amount to convert from"); + } + + const auto conversionPerExchange = getConversion(startAmountsPerExchangePos, cmd.cur1(), cmd.exchangeNames()); + _queryResultPrinter.printConversion(startAmountsPerExchangePos, cmd.cur1(), conversionPerExchange); + FillConversionTransferableCommandResults(conversionPerExchange, transferableResults); + } else { + const auto conversionPerExchange = getConversion(cmd.amount(), cmd.cur1(), cmd.exchangeNames()); + _queryResultPrinter.printConversion(cmd.amount(), cmd.cur1(), conversionPerExchange); + FillConversionTransferableCommandResults(conversionPerExchange, transferableResults); + } break; } case CoincenterCommandType::kConversionPath: { @@ -220,13 +255,13 @@ TransferableCommandResultVector Coincenter::processCommand( trade(startAmount, cmd.isPercentageAmount(), cmd.cur1(), exchangeNames, cmd.tradeOptions()); _queryResultPrinter.printTrades(tradeResultPerExchange, startAmount, cmd.isPercentageAmount(), cmd.cur1(), cmd.tradeOptions()); - FillTransferableCommandResults(tradeResultPerExchange, transferableResults); + FillTradeTransferableCommandResults(tradeResultPerExchange, transferableResults); break; } case CoincenterCommandType::kBuy: { const auto tradeResultPerExchange = smartBuy(cmd.amount(), cmd.exchangeNames(), cmd.tradeOptions()); _queryResultPrinter.printBuyTrades(tradeResultPerExchange, cmd.amount(), cmd.tradeOptions()); - FillTransferableCommandResults(tradeResultPerExchange, transferableResults); + FillTradeTransferableCommandResults(tradeResultPerExchange, transferableResults); break; } case CoincenterCommandType::kSell: { @@ -238,7 +273,7 @@ TransferableCommandResultVector Coincenter::processCommand( smartSell(startAmount, cmd.isPercentageAmount(), exchangeNames, cmd.tradeOptions()); _queryResultPrinter.printSellTrades(tradeResultPerExchange, cmd.amount(), cmd.isPercentageAmount(), cmd.tradeOptions()); - FillTransferableCommandResults(tradeResultPerExchange, transferableResults); + FillTradeTransferableCommandResults(tradeResultPerExchange, transferableResults); break; } case CoincenterCommandType::kWithdrawApply: { @@ -346,6 +381,11 @@ MonetaryAmountPerExchange Coincenter::getConversion(MonetaryAmount amount, Curre return _exchangesOrchestrator.getConversion(amount, targetCurrencyCode, exchangeNames); } +MonetaryAmountPerExchange Coincenter::getConversion(std::span monetaryAmountPerExchangeToConvert, + CurrencyCode targetCurrencyCode, ExchangeNameSpan exchangeNames) { + return _exchangesOrchestrator.getConversion(monetaryAmountPerExchangeToConvert, targetCurrencyCode, exchangeNames); +} + ConversionPathPerExchange Coincenter::getConversionPaths(Market mk, ExchangeNameSpan exchangeNames) { return _exchangesOrchestrator.getConversionPaths(mk, exchangeNames); } diff --git a/src/engine/src/coincentercommands.cpp b/src/engine/src/coincentercommands.cpp index 262cf3f3..6c6db1ed 100644 --- a/src/engine/src/coincentercommands.cpp +++ b/src/engine/src/coincentercommands.cpp @@ -80,8 +80,8 @@ void CoincenterCommands::addOption(const CoincenterCmdLineOptions &cmdLineOption if (!cmdLineOptions.conversion.empty()) { optionParser = StringOptionParser(cmdLineOptions.conversion); - const auto [amount, amountType] = optionParser.parseNonZeroAmount(); - if (amountType != StringOptionParser::AmountType::kAbsolute) { + const auto [amount, amountType] = optionParser.parseNonZeroAmount(StringOptionParser::FieldIs::kOptional); + if (amountType == StringOptionParser::AmountType::kPercentage) { throw invalid_argument("conversion should start with an absolute amount"); } _commands.emplace_back(CoincenterCommandType::kConversion) diff --git a/src/engine/src/exchangesorchestrator.cpp b/src/engine/src/exchangesorchestrator.cpp index 15a1751a..05dd2ade 100644 --- a/src/engine/src/exchangesorchestrator.cpp +++ b/src/engine/src/exchangesorchestrator.cpp @@ -342,6 +342,26 @@ MonetaryAmountPerExchange ExchangesOrchestrator::getConversion(MonetaryAmount am return convertedAmountPerExchange; } +MonetaryAmountPerExchange ExchangesOrchestrator::getConversion( + std::span monetaryAmountPerExchangeToConvert, CurrencyCode targetCurrencyCode, + ExchangeNameSpan exchangeNames) { + log::info("Query multiple conversions into {} from {}", targetCurrencyCode, + ConstructAccumulatedExchangeNames(exchangeNames)); + UniquePublicSelectedExchanges selectedExchanges = _exchangeRetriever.selectOneAccount(exchangeNames); + MonetaryAmountPerExchange convertedAmountPerExchange(selectedExchanges.size()); + _threadPool.parallelTransform( + selectedExchanges.begin(), selectedExchanges.end(), convertedAmountPerExchange.begin(), + [monetaryAmountPerExchangeToConvert, targetCurrencyCode](Exchange *exchange) { + const auto startAmount = monetaryAmountPerExchangeToConvert[exchange->publicExchangePos()]; + const auto optConvertedAmount = startAmount.isDefault() + ? std::nullopt + : exchange->apiPublic().estimatedConvert(startAmount, targetCurrencyCode); + return std::make_pair(exchange, optConvertedAmount.value_or(MonetaryAmount{})); + }); + + return convertedAmountPerExchange; +} + ConversionPathPerExchange ExchangesOrchestrator::getConversionPaths(Market mk, ExchangeNameSpan exchangeNames) { log::info("Query {} conversion path from {}", mk, ConstructAccumulatedExchangeNames(exchangeNames)); UniquePublicSelectedExchanges selectedExchanges = _exchangeRetriever.selectOneAccount(exchangeNames); diff --git a/src/engine/src/queryresultprinter.cpp b/src/engine/src/queryresultprinter.cpp index e8aa8c4e..ccbc23fc 100644 --- a/src/engine/src/queryresultprinter.cpp +++ b/src/engine/src/queryresultprinter.cpp @@ -527,6 +527,37 @@ json ConversionJson(MonetaryAmount amount, CurrencyCode targetCurrencyCode, return ToJson(CoincenterCommandType::kConversion, std::move(in), std::move(out)); } +json ConversionJson(std::span startAmountPerExchangePos, CurrencyCode targetCurrencyCode, + const MonetaryAmountPerExchange &conversionPerExchange) { + json in; + json inOpt; + + json fromAmounts; + + int publicExchangePos{}; + for (MonetaryAmount startAmount : startAmountPerExchangePos) { + if (!startAmount.isDefault()) { + fromAmounts.emplace(kSupportedExchanges[publicExchangePos], startAmount.str()); + } + ++publicExchangePos; + } + inOpt.emplace("sourceAmount", std::move(fromAmounts)); + + inOpt.emplace("targetCurrency", targetCurrencyCode.str()); + in.emplace("opt", std::move(inOpt)); + + json out = json::object(); + for (const auto &[e, convertedAmount] : conversionPerExchange) { + if (convertedAmount != 0) { + json conversionForExchange; + conversionForExchange.emplace("convertedAmount", convertedAmount.str()); + out.emplace(e->name(), std::move(conversionForExchange)); + } + } + + return ToJson(CoincenterCommandType::kConversion, std::move(in), std::move(out)); +} + json ConversionPathJson(Market mk, const ConversionPathPerExchange &conversionPathsPerExchange) { json in; json inOpt; @@ -1132,6 +1163,32 @@ void QueryResultPrinter::printConversion(MonetaryAmount amount, CurrencyCode tar logActivity(CoincenterCommandType::kConversion, jsonData); } +void QueryResultPrinter::printConversion(std::span startAmountPerExchangePos, + CurrencyCode targetCurrencyCode, + const MonetaryAmountPerExchange &conversionPerExchange) const { + json jsonData = ConversionJson(startAmountPerExchangePos, targetCurrencyCode, conversionPerExchange); + switch (_apiOutputType) { + case ApiOutputType::kFormattedTable: { + SimpleTable table; + table.reserve(1U + conversionPerExchange.size()); + table.emplace_back("Exchange", "From", "To"); + for (const auto &[e, convertedAmount] : conversionPerExchange) { + if (convertedAmount != 0) { + table.emplace_back(e->name(), startAmountPerExchangePos[e->publicExchangePos()].str(), convertedAmount.str()); + } + } + printTable(table); + break; + } + case ApiOutputType::kJson: + printJson(jsonData); + break; + case ApiOutputType::kNoPrint: + break; + } + logActivity(CoincenterCommandType::kConversion, jsonData); +} + void QueryResultPrinter::printConversionPath(Market mk, const ConversionPathPerExchange &conversionPathsPerExchange) const { json jsonData = ConversionPathJson(mk, conversionPathsPerExchange); diff --git a/src/engine/test/queryresultprinter_public_test.cpp b/src/engine/test/queryresultprinter_public_test.cpp index b966365f..4f99795b 100644 --- a/src/engine/test/queryresultprinter_public_test.cpp +++ b/src/engine/test/queryresultprinter_public_test.cpp @@ -3,6 +3,7 @@ #include #include "apioutputtype.hpp" +#include "cct_const.hpp" #include "coincentercommandtype.hpp" #include "currencycode.hpp" #include "currencyexchange.hpp" @@ -613,7 +614,7 @@ TEST_F(QueryResultPrinterMarketOrderBookTest, NoPrint) { expectNoStr(); } -class QueryResultPrinterConversionTest : public QueryResultPrinterTest { +class QueryResultPrinterConversionSingleAmountTest : public QueryResultPrinterTest { protected: MonetaryAmount fromAmount{34525, "SOL", 2}; CurrencyCode targetCurrencyCode{"KRW"}; @@ -622,7 +623,7 @@ class QueryResultPrinterConversionTest : public QueryResultPrinterTest { {&exchange2, MonetaryAmount{59000249, targetCurrencyCode}}}; }; -TEST_F(QueryResultPrinterConversionTest, FormattedTable) { +TEST_F(QueryResultPrinterConversionSingleAmountTest, FormattedTable) { basicQueryResultPrinter(ApiOutputType::kFormattedTable) .printConversion(fromAmount, targetCurrencyCode, monetaryAmountPerExchange); static constexpr std::string_view kExpected = R"( @@ -637,7 +638,7 @@ TEST_F(QueryResultPrinterConversionTest, FormattedTable) { expectStr(kExpected); } -TEST_F(QueryResultPrinterConversionTest, EmptyJson) { +TEST_F(QueryResultPrinterConversionSingleAmountTest, EmptyJson) { basicQueryResultPrinter(ApiOutputType::kJson) .printConversion(fromAmount, targetCurrencyCode, MonetaryAmountPerExchange{}); static constexpr std::string_view kExpected = R"( @@ -654,7 +655,7 @@ TEST_F(QueryResultPrinterConversionTest, EmptyJson) { expectJson(kExpected); } -TEST_F(QueryResultPrinterConversionTest, Json) { +TEST_F(QueryResultPrinterConversionSingleAmountTest, Json) { basicQueryResultPrinter(ApiOutputType::kJson) .printConversion(fromAmount, targetCurrencyCode, monetaryAmountPerExchange); static constexpr std::string_view kExpected = R"( @@ -681,12 +682,101 @@ TEST_F(QueryResultPrinterConversionTest, Json) { expectJson(kExpected); } -TEST_F(QueryResultPrinterConversionTest, NoPrint) { +TEST_F(QueryResultPrinterConversionSingleAmountTest, NoPrint) { basicQueryResultPrinter(ApiOutputType::kNoPrint) .printConversion(fromAmount, targetCurrencyCode, monetaryAmountPerExchange); expectNoStr(); } +class QueryResultPrinterConversionSeveralAmountTest : public QueryResultPrinterTest { + protected: + void SetUp() override { + fromAmounts[0] = MonetaryAmount{1, sourceCurrencyCode, 0}; + fromAmounts[2] = MonetaryAmount{11, sourceCurrencyCode, 1}; + fromAmounts[1] = MonetaryAmount{14, sourceCurrencyCode, 1}; + } + + CurrencyCode sourceCurrencyCode{"BTC"}; + CurrencyCode targetCurrencyCode{"KRW"}; + std::array fromAmounts; + MonetaryAmountPerExchange monetaryAmountPerExchange{{&exchange1, MonetaryAmount{41786641, targetCurrencyCode}}, + {&exchange3, MonetaryAmount{44487640, targetCurrencyCode}}, + {&exchange2, MonetaryAmount{59000249, targetCurrencyCode}}}; +}; + +TEST_F(QueryResultPrinterConversionSeveralAmountTest, FormattedTable) { + basicQueryResultPrinter(ApiOutputType::kFormattedTable) + .printConversion(fromAmounts, targetCurrencyCode, monetaryAmountPerExchange); + static constexpr std::string_view kExpected = R"( ++----------+---------+--------------+ +| Exchange | From | To | ++----------+---------+--------------+ +| binance | 1 BTC | 41786641 KRW | +| huobi | 1.1 BTC | 44487640 KRW | +| bithumb | 1.4 BTC | 59000249 KRW | ++----------+---------+--------------+ +)"; + expectStr(kExpected); +} + +TEST_F(QueryResultPrinterConversionSeveralAmountTest, EmptyJson) { + basicQueryResultPrinter(ApiOutputType::kJson) + .printConversion(fromAmounts, targetCurrencyCode, MonetaryAmountPerExchange{}); + static constexpr std::string_view kExpected = R"( +{ + "in": { + "opt": { + "sourceAmount": { + "binance": "1 BTC", + "bithumb": "1.4 BTC", + "huobi": "1.1 BTC" + }, + "targetCurrency": "KRW" + }, + "req": "Conversion" + }, + "out": {} +})"; + expectJson(kExpected); +} + +TEST_F(QueryResultPrinterConversionSeveralAmountTest, Json) { + basicQueryResultPrinter(ApiOutputType::kJson) + .printConversion(fromAmounts, targetCurrencyCode, monetaryAmountPerExchange); + static constexpr std::string_view kExpected = R"( +{ + "in": { + "opt": { + "sourceAmount": { + "binance": "1 BTC", + "bithumb": "1.4 BTC", + "huobi": "1.1 BTC" + }, + "targetCurrency": "KRW" + }, + "req": "Conversion" + }, + "out": { + "binance": { + "convertedAmount": "41786641 KRW" + }, + "bithumb": { + "convertedAmount": "59000249 KRW" + }, + "huobi": { + "convertedAmount": "44487640 KRW" + } + } +})"; + expectJson(kExpected); +} + +TEST_F(QueryResultPrinterConversionSeveralAmountTest, NoPrint) { + basicQueryResultPrinter(ApiOutputType::kNoPrint) + .printConversion(fromAmounts, targetCurrencyCode, monetaryAmountPerExchange); + expectNoStr(); +} + class QueryResultPrinterConversionPathTest : public QueryResultPrinterTest { protected: Market marketForPath{"XLM", "XRP"};