From 5470ffafe69e6116b109400c658c8ad0bfbd8df2 Mon Sep 17 00:00:00 2001 From: Oleksandr Zhevedenko Date: Tue, 2 Apr 2024 11:28:12 -0400 Subject: [PATCH] Feature: Cross-currency conversion capability (#3084) * Added cross-currency conversion capability and updated precision determination * Small update to test --- .../currency/CurrencyConversionService.java | 41 ++++++++++++-- .../server/functional/model/Currency.groovy | 2 +- .../functional/tests/CurrencySpec.groovy | 56 +++++++++++++++++-- .../CurrencyConversionServiceTest.java | 34 ++++++++++- 4 files changed, 121 insertions(+), 12 deletions(-) diff --git a/src/main/java/org/prebid/server/currency/CurrencyConversionService.java b/src/main/java/org/prebid/server/currency/CurrencyConversionService.java index cc63eb5e927..87f1916c640 100644 --- a/src/main/java/org/prebid/server/currency/CurrencyConversionService.java +++ b/src/main/java/org/prebid/server/currency/CurrencyConversionService.java @@ -26,6 +26,7 @@ import java.time.Duration; import java.time.ZonedDateTime; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.Objects; @@ -272,7 +273,13 @@ private static BigDecimal getConversionRate(Map> return conversionRate; } - return findIntermediateConversionRate(directCurrencyRates, reverseCurrencyRates); + final BigDecimal intermediateConversionRate = findIntermediateConversionRate(directCurrencyRates, + reverseCurrencyRates); + if (intermediateConversionRate != null) { + return intermediateConversionRate; + } + + return findCrossConversionRate(currencyConversionRates, fromCurrency, toCurrency); } /** @@ -286,7 +293,8 @@ private static BigDecimal findReverseConversionRate(Map curr : null; return reverseConversionRate != null - ? BigDecimal.ONE.divide(reverseConversionRate, reverseConversionRate.precision(), + ? BigDecimal.ONE.divide(reverseConversionRate, + getRatePrecision(reverseConversionRate), RoundingMode.HALF_EVEN) : null; } @@ -310,15 +318,38 @@ private static BigDecimal findIntermediateConversionRate(Map final BigDecimal reverseCurrencyRateIntermediate = reverseCurrencyRates.get(sharedCurrency); conversionRate = directCurrencyRateIntermediate.divide(reverseCurrencyRateIntermediate, // chose largest precision among intermediate rates - reverseCurrencyRateIntermediate.compareTo(directCurrencyRateIntermediate) > 0 - ? reverseCurrencyRateIntermediate.precision() - : directCurrencyRateIntermediate.precision(), + getRatePrecision(directCurrencyRateIntermediate, reverseCurrencyRateIntermediate), RoundingMode.HALF_EVEN); } } return conversionRate; } + private static BigDecimal findCrossConversionRate(Map> currencyConversionRates, + String fromCurrency, + String toCurrency) { + for (Map rates : currencyConversionRates.values()) { + final BigDecimal fromRate = rates.get(fromCurrency); + final BigDecimal toRate = rates.get(toCurrency); + if (fromRate != null && toRate != null) { + return toRate.divide(fromRate, + getRatePrecision(fromRate, toRate), + RoundingMode.HALF_EVEN); + } + } + + return null; + } + + private static int getRatePrecision(BigDecimal... rates) { + final int precision = Arrays.stream(rates) + .map(BigDecimal::precision) + .max(Integer::compareTo) + .orElse(DEFAULT_PRICE_PRECISION); + + return Math.max(precision, DEFAULT_PRICE_PRECISION); + } + private boolean isRatesStale() { if (lastUpdated == null) { return false; diff --git a/src/test/groovy/org/prebid/server/functional/model/Currency.groovy b/src/test/groovy/org/prebid/server/functional/model/Currency.groovy index cd3360510fa..fb48fc4a6d1 100644 --- a/src/test/groovy/org/prebid/server/functional/model/Currency.groovy +++ b/src/test/groovy/org/prebid/server/functional/model/Currency.groovy @@ -4,7 +4,7 @@ import com.fasterxml.jackson.annotation.JsonValue enum Currency { - USD, EUR, GBP, JPY, BOGUS + USD, EUR, GBP, JPY, CHF, CAD, BOGUS @JsonValue String getValue() { diff --git a/src/test/groovy/org/prebid/server/functional/tests/CurrencySpec.groovy b/src/test/groovy/org/prebid/server/functional/tests/CurrencySpec.groovy index ab22cb65cc2..568f202be55 100644 --- a/src/test/groovy/org/prebid/server/functional/tests/CurrencySpec.groovy +++ b/src/test/groovy/org/prebid/server/functional/tests/CurrencySpec.groovy @@ -9,6 +9,8 @@ import org.prebid.server.functional.testcontainers.scaffolding.CurrencyConversio import java.math.RoundingMode +import static org.prebid.server.functional.model.Currency.CAD +import static org.prebid.server.functional.model.Currency.CHF import static org.prebid.server.functional.model.Currency.EUR import static org.prebid.server.functional.model.Currency.JPY import static org.prebid.server.functional.model.Currency.USD @@ -18,8 +20,11 @@ class CurrencySpec extends BaseSpec { private static final Currency DEFAULT_CURRENCY = USD private static final int PRICE_PRECISION = 3 - private static final Map> DEFAULT_CURRENCY_RATES = [(USD): [(EUR): 0.8872327211427558, - (JPY): 114.12], + private static final Map> DEFAULT_CURRENCY_RATES = [(USD): [(USD): 1, + (EUR): 0.9249838127832763, + (CHF): 0.9033391915641477, + (JPY): 151.1886041994265, + (CAD): 1.357136250115623], (EUR): [(USD): 1.3429368029739777]] private static final CurrencyConversion currencyConversion = new CurrencyConversion(networkServiceContainer).tap { setCurrencyConversionRatesResponse(CurrencyConversionRatesResponse.getDefaultCurrencyConversionRatesResponse(DEFAULT_CURRENCY_RATES)) @@ -113,6 +118,34 @@ class CurrencySpec extends BaseSpec { JPY || USD } + def "PBS should use cross currency conversion when direct, reverse and intermediate conversion is not available"() { + given: "Default BidRequest with #requestCurrency currency" + def bidRequest = BidRequest.defaultBidRequest.tap { cur = [requestCurrency] } + + and: "Default Bid with a #bidCurrency currency" + def bidderResponse = BidResponse.getDefaultBidResponse(bidRequest).tap { cur = bidCurrency } + bidder.setResponse(bidRequest.id, bidderResponse) + + when: "PBS processes auction request" + def bidResponse = pbsService.sendAuctionRequest(bidRequest) + + then: "Auction response should contain bid in #requestCurrency currency" + assert bidResponse.cur == requestCurrency + def bidPrice = bidResponse.seatbid[0].bid[0].price + assert bidPrice == convertCurrency(bidderResponse.seatbid[0].bid[0].price, bidCurrency, requestCurrency) + assert bidResponse.seatbid[0].bid[0].ext.origbidcpm == bidderResponse.seatbid[0].bid[0].price + assert bidResponse.seatbid[0].bid[0].ext.origbidcur == bidCurrency + + where: + requestCurrency || bidCurrency + CHF || JPY + JPY || CHF + CAD || JPY + JPY || CAD + EUR || CHF + CHF || EUR + } + private static Map getExternalCurrencyConverterConfig() { ["auction.ad-server-currency" : DEFAULT_CURRENCY as String, "currency-converter.external-rates.enabled" : "true", @@ -129,11 +162,26 @@ class CurrencySpec extends BaseSpec { def conversionRate if (fromCurrency == toCurrency) { conversionRate = 1 - } else if (fromCurrency in DEFAULT_CURRENCY_RATES) { + } else if (toCurrency in DEFAULT_CURRENCY_RATES?[fromCurrency]) { conversionRate = DEFAULT_CURRENCY_RATES[fromCurrency][toCurrency] - } else { + } else if (fromCurrency in DEFAULT_CURRENCY_RATES?[toCurrency]) { conversionRate = 1 / DEFAULT_CURRENCY_RATES[toCurrency][fromCurrency] + } else { + conversionRate = getCrossConversionRate(fromCurrency, toCurrency) } conversionRate } + + private static BigDecimal getCrossConversionRate(Currency fromCurrency, Currency toCurrency) { + for (Map rates : DEFAULT_CURRENCY_RATES.values()) { + def fromRate = rates?[fromCurrency] + def toRate = rates?[toCurrency] + + if (fromRate && toRate) { + return toRate / fromRate + } + } + + null + } } diff --git a/src/test/java/org/prebid/server/auction/CurrencyConversionServiceTest.java b/src/test/java/org/prebid/server/auction/CurrencyConversionServiceTest.java index 8714a70c42c..643e2b53301 100644 --- a/src/test/java/org/prebid/server/auction/CurrencyConversionServiceTest.java +++ b/src/test/java/org/prebid/server/auction/CurrencyConversionServiceTest.java @@ -206,7 +206,7 @@ public void convertCurrencyShouldUseLatestRatesIfRequestRatesIsNull() { BigDecimal.ONE, givenBidRequestWithCurrencies(null, false), EUR, GBP); // then - assertThat(price).isEqualByComparingTo(BigDecimal.valueOf(0.770)); + assertThat(price).isEqualByComparingTo(BigDecimal.valueOf(0.769)); } @Test @@ -220,7 +220,7 @@ public void convertCurrencyShouldUseConversionRateFromServerIfusepbsratesIsTrue( givenBidRequestWithCurrencies(requestConversionRates, true), EUR, GBP); // then - assertThat(price).isEqualByComparingTo(BigDecimal.valueOf(0.770)); + assertThat(price).isEqualByComparingTo(BigDecimal.valueOf(0.769)); } @Test @@ -251,6 +251,36 @@ public void convertCurrencyShouldUseLatestRatesIfMultiplierWasNotFoundInRequestR assertThat(price).isEqualByComparingTo(BigDecimal.valueOf(5)); } + @Test + public void convertCurrencyShouldUseCrossRateIfOtherRatesAreNotAvailable() { + // given + final Map> requestConversionRates = new HashMap<>(); + requestConversionRates.put(USD, Map.of(GBP, BigDecimal.valueOf(2), + EUR, BigDecimal.valueOf(0.5))); + + // when + final BigDecimal price = currencyService.convertCurrency(BigDecimal.ONE, + givenBidRequestWithCurrencies(requestConversionRates, false), GBP, EUR); + + // then + assertThat(price).isEqualByComparingTo(BigDecimal.valueOf(0.25)); + } + + @Test + public void convertCurrencyShouldUseCrossRateIfOtherRatesAreNotAvailableReversed() { + // given + final Map> requestConversionRates = new HashMap<>(); + requestConversionRates.put(USD, Map.of(GBP, BigDecimal.valueOf(2), + EUR, BigDecimal.valueOf(0.5))); + + // when + final BigDecimal price = currencyService.convertCurrency(BigDecimal.ONE, + givenBidRequestWithCurrencies(requestConversionRates, false), EUR, GBP); + + // then + assertThat(price).isEqualByComparingTo(BigDecimal.valueOf(4)); + } + @Test public void convertCurrencyShouldReturnSamePriceIfBidCurrencyIsNullAndServerCurrencyUSD() { // when