From d86f1505130ee7d06165b42ca0098f7bda6de611 Mon Sep 17 00:00:00 2001 From: Rodrigo Varela Date: Sun, 6 Aug 2023 11:59:49 +1000 Subject: [PATCH 1/3] Add redundancy to ARS/BTC pricenode: - Remove ban over ARS fiat - ExchangeRateProvider by default does not consider rates with blue markets. CryptoYimplementation does. - ExchangeRateService uses ubiquous place for generating the rates / consider bluemarket prices / update tests accordingly - avoid procesing of blue markets if its already handled by EP / move bluelytics to its own util package - bluelytics util is singleton and updates gap async less frequently (1hr) - make sure getMarkets won't gel blocked even on the first use of blue gap - naming conventions and docs --- bisq | 2 +- .../bisq/price/spot/ExchangeRateProvider.java | 29 +++++ .../bisq/price/spot/ExchangeRateService.java | 59 ++++++++- .../bisq/price/spot/providers/CoinGecko.java | 28 ++--- .../bisq/price/spot/providers/CryptoYa.java | 10 ++ .../price/util/bluelytics/BlueLyticsDto.java | 49 ++++++++ .../util/bluelytics/BlueLyticsService.java | 117 ++++++++++++++++++ src/main/resources/application.properties | 2 +- .../AbstractExchangeRateProviderTest.java | 6 +- .../price/spot/ExchangeRateServiceTest.java | 53 ++++++-- .../price/spot/providers/CoinGeckoTest.java | 1 - 11 files changed, 320 insertions(+), 36 deletions(-) create mode 100644 src/main/java/bisq/price/util/bluelytics/BlueLyticsDto.java create mode 100644 src/main/java/bisq/price/util/bluelytics/BlueLyticsService.java diff --git a/bisq b/bisq index 17abc0b..a2dca3e 160000 --- a/bisq +++ b/bisq @@ -1 +1 @@ -Subproject commit 17abc0bc8dc93ab836040662017f81a5c391c436 +Subproject commit a2dca3e4d44b59cdf6d4ceb19f280cfc9f2efb98 diff --git a/src/main/java/bisq/price/spot/ExchangeRateProvider.java b/src/main/java/bisq/price/spot/ExchangeRateProvider.java index 2081c65..8c9c266 100644 --- a/src/main/java/bisq/price/spot/ExchangeRateProvider.java +++ b/src/main/java/bisq/price/spot/ExchangeRateProvider.java @@ -32,7 +32,9 @@ import org.knowm.xchange.service.marketdata.params.Params; import org.springframework.core.env.Environment; +import javax.annotation.Nullable; import java.io.IOException; +import java.math.BigDecimal; import java.time.Duration; import java.util.*; import java.util.function.Predicate; @@ -332,4 +334,31 @@ protected long getMarketDataCallDelay() { protected boolean requiresFilterDuringBulkTickerRetrieval() { return false; } + + /** + * + * @return true if the implementation of this ExchangeRateProvider already consider currencies + * blue markets if any. Defaults to false. + */ + public boolean alreadyConsidersBlueMarkets() { + return false; + } + + /** + * @param originalRate original official rate for a certain currency. E.g. rate for ARS for Argentina PESO + * @return a new exchange rate if the implementation of this price provider already tackles real vs official + * market prices for the given currency. + * e.g. for FIAT ARS official rates are not the ones using in the free trading world. + * Most currencies won't need this, so defaults to null. + */ + public @Nullable ExchangeRate maybeUpdateBlueMarketPriceGapForRate(ExchangeRate originalRate, Double blueMarketGapForCurrency) { + if ("ARS".equalsIgnoreCase(originalRate.getCurrency())) { + double blueRate = originalRate.getPrice() * blueMarketGapForCurrency; + return new ExchangeRate(originalRate.getCurrency(), + BigDecimal.valueOf(blueRate), + new Date(originalRate.getTimestamp()), + originalRate.getProvider()); + } + return null; + } } diff --git a/src/main/java/bisq/price/spot/ExchangeRateService.java b/src/main/java/bisq/price/spot/ExchangeRateService.java index 3f20382..8e9f940 100644 --- a/src/main/java/bisq/price/spot/ExchangeRateService.java +++ b/src/main/java/bisq/price/spot/ExchangeRateService.java @@ -21,6 +21,8 @@ import bisq.core.util.InlierUtil; +import bisq.price.util.bluelytics.BlueLyticsService; +import com.google.common.collect.Sets; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.core.env.Environment; @@ -36,7 +38,6 @@ @Service class ExchangeRateService { protected final Logger log = LoggerFactory.getLogger(this.getClass()); - private final Environment env; private final List providers; @@ -64,6 +65,7 @@ public Map getAllMarketPrices() { metadata.putAll(getMetadata(p)); }); + LinkedHashMap result = new LinkedHashMap<>(metadata); // Use a sorted list by currency code to make comparison of json data between // different price nodes easier @@ -74,6 +76,56 @@ public Map getAllMarketPrices() { return result; } + /** + * Please do not call provider.get(), use this method instead to consider currencies with blue markets + * @param provider to get the rates from + * @return the exchange rates for the different currencies the provider supports considering bluemarket rates if any + */ + public Set providerCurrentExchangeRates(ExchangeRateProvider provider) { + Map blueMarketGapForCurrency = this.getBlueMarketGapForCurrencies(); + Set originalExchangeRates = provider.get(); + if (originalExchangeRates == null) + return null; + + Set exchangeRates = new HashSet<>(); + boolean noOverlapBetweenCurrencies = Sets.intersection(originalExchangeRates.stream().map(ExchangeRate::getCurrency) + .collect(Collectors.toSet()), blueMarketGapForCurrency.keySet()) + .isEmpty(); + + if (provider.alreadyConsidersBlueMarkets() || noOverlapBetweenCurrencies) { + exchangeRates.addAll(originalExchangeRates); + } else { + this.addRatesUpdatingBlueGaps(blueMarketGapForCurrency, provider, exchangeRates, originalExchangeRates); + } + return exchangeRates; + } + + private void addRatesUpdatingBlueGaps(Map blueMarketGapForCurrency, ExchangeRateProvider provider, Set exchangeRates, Set originalExchangeRates) { + originalExchangeRates.forEach(er -> { + // default to original rate + ExchangeRate exchangeRateToUse = er; + if (blueMarketGapForCurrency.containsKey(er.getCurrency())) { + ExchangeRate updatedExchangeRate = provider.maybeUpdateBlueMarketPriceGapForRate(er, blueMarketGapForCurrency.get(er.getCurrency())); + if (updatedExchangeRate != null) { + exchangeRateToUse = updatedExchangeRate; +// this.log.info(String.format("Replaced original %s rate of $%s to $%s", er.getCurrency(), BigDecimal.valueOf(er.getPrice()).toEngineeringString(), BigDecimal.valueOf(updatedExchangeRate.getPrice()).toEngineeringString())); + } + } + exchangeRates.add(exchangeRateToUse); + }); + } + + private Map getBlueMarketGapForCurrencies() { + Map blueMarketGapForCurrencies = new HashMap<>(); + // ARS + Double arsBlueMultiplier = BlueLyticsService.getInstance().blueGapMultiplier(); + if (!arsBlueMultiplier.isNaN()) { +// this.log.info("Updated ARS/USD multiplier is " + arsBlueMultiplier); + blueMarketGapForCurrencies.put("ARS", arsBlueMultiplier); + } + return blueMarketGapForCurrencies; + } + /** * For each currency, create an aggregate {@link ExchangeRate} based on the currency's * rates from all providers. If multiple providers have rates for the currency, then @@ -160,9 +212,10 @@ private double getOutlierStdDeviation() { private Map> getCurrencyCodeToExchangeRates() { Map> currencyCodeToExchangeRates = new HashMap<>(); for (ExchangeRateProvider p : providers) { - if (p.get() == null) + Set exchangeRates = providerCurrentExchangeRates(p); + if (exchangeRates == null) continue; - for (ExchangeRate exchangeRate : p.get()) { + for (ExchangeRate exchangeRate : exchangeRates) { String currencyCode = exchangeRate.getCurrency(); if (currencyCodeToExchangeRates.containsKey(currencyCode)) { List l = new ArrayList<>(currencyCodeToExchangeRates.get(currencyCode)); diff --git a/src/main/java/bisq/price/spot/providers/CoinGecko.java b/src/main/java/bisq/price/spot/providers/CoinGecko.java index 556001f..5f70fda 100644 --- a/src/main/java/bisq/price/spot/providers/CoinGecko.java +++ b/src/main/java/bisq/price/spot/providers/CoinGecko.java @@ -17,10 +17,14 @@ package bisq.price.spot.providers; +import bisq.asset.Coin; import bisq.price.spot.ExchangeRate; import bisq.price.spot.ExchangeRateProvider; import bisq.price.util.coingecko.CoinGeckoMarketData; +import bisq.price.util.coingecko.CoinGeckoTicker; +import lombok.Getter; +import lombok.Setter; import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.env.Environment; import org.springframework.http.RequestEntity; @@ -33,16 +37,13 @@ import java.math.BigDecimal; import java.math.RoundingMode; -import java.util.Date; -import java.util.HashSet; -import java.util.Map; -import java.util.Set; +import java.util.*; import java.util.function.Predicate; import java.util.stream.Collectors; @Component class CoinGecko extends ExchangeRateProvider { - + private static final String GET_EXCHANGE_RATES_URL = "https://api.coingecko.com/api/v3/exchange_rates"; private final RestTemplate restTemplate = new RestTemplate(); public CoinGecko(Environment env) { @@ -60,18 +61,16 @@ public Set doGet() { Predicate isDesiredFiatPair = t -> getSupportedFiatCurrencies().contains(t.getKey()); Predicate isDesiredCryptoPair = t -> getSupportedCryptoCurrencies().contains(t.getKey()); - getMarketData().getRates().entrySet().stream() + Map rates = getMarketData().getRates(); + rates.entrySet().stream() .filter(isDesiredFiatPair.or(isDesiredCryptoPair)) .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)) .forEach((key, ticker) -> { - boolean useInverseRate = false; - if (getSupportedCryptoCurrencies().contains(key)) { - // Use inverse rate for alts, because the API returns the - // conversion rate in the opposite direction than what we need - // API returns the BTC/Alt rate, we need the Alt/BTC rate - useInverseRate = true; - } + boolean useInverseRate = getSupportedCryptoCurrencies().contains(key); + // Use inverse rate for alts, because the API returns the + // conversion rate in the opposite direction than what we need + // API returns the BTC/Alt rate, we need the Alt/BTC rate BigDecimal rate = ticker.getValue(); // Find the inverse rate, while using enough decimals to reflect very @@ -87,7 +86,6 @@ public Set doGet() { this.getName() )); }); - return result; } @@ -95,7 +93,7 @@ private CoinGeckoMarketData getMarketData() { return restTemplate.exchange( RequestEntity .get(UriComponentsBuilder - .fromUriString("https://api.coingecko.com/api/v3/exchange_rates").build() + .fromUriString(CoinGecko.GET_EXCHANGE_RATES_URL).build() .toUri()) .build(), new ParameterizedTypeReference() { diff --git a/src/main/java/bisq/price/spot/providers/CryptoYa.java b/src/main/java/bisq/price/spot/providers/CryptoYa.java index b12f418..8dbbbaa 100644 --- a/src/main/java/bisq/price/spot/providers/CryptoYa.java +++ b/src/main/java/bisq/price/spot/providers/CryptoYa.java @@ -49,6 +49,16 @@ public CryptoYa(Environment env) { super(env, "CRYPTOYA", "cryptoya", Duration.ofMinutes(1)); } + @Override + public boolean alreadyConsidersBlueMarkets() { + return true; + } + + /** + * + * @return average price buy/sell price averaging different providers suported by cryptoya api + * which uses the free market (or blue, or unofficial) ARS price for BTC + */ @Override public Set doGet() { CryptoYaMarketData cryptoYaMarketData = fetchArsBlueMarketData(); diff --git a/src/main/java/bisq/price/util/bluelytics/BlueLyticsDto.java b/src/main/java/bisq/price/util/bluelytics/BlueLyticsDto.java new file mode 100644 index 0000000..4668397 --- /dev/null +++ b/src/main/java/bisq/price/util/bluelytics/BlueLyticsDto.java @@ -0,0 +1,49 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + + +package bisq.price.util.bluelytics; + +import lombok.Getter; +import lombok.Setter; + +import java.util.Date; + +@Getter +@Setter +public class BlueLyticsDto { + @Getter + @Setter + public static class USDRate { + Double value_avg; + Double value_sell; + Double value_buy; + } + + BlueLyticsDto.USDRate oficial; + BlueLyticsDto.USDRate blue; + Date last_update; + + /** + * + * @return the sell multiplier to go from oficial to blue market for ARS/USD + * if its not available, returns NaN + */ + public Double gapSellMultiplier() { + return this.blue.value_sell / this.oficial.value_sell; + } +} diff --git a/src/main/java/bisq/price/util/bluelytics/BlueLyticsService.java b/src/main/java/bisq/price/util/bluelytics/BlueLyticsService.java new file mode 100644 index 0000000..c72fca3 --- /dev/null +++ b/src/main/java/bisq/price/util/bluelytics/BlueLyticsService.java @@ -0,0 +1,117 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.price.util.bluelytics; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.RequestEntity; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import java.time.Instant; +import java.util.Date; +import java.util.Objects; + +/** + * Util singleton object to update and provider ARS/USD blue market gap against the oficial rate. + * This is useful for example, to estimate ARS/BTC blue (real) market rate in a country with heavy currency controls + */ +public final class BlueLyticsService { + private static final long MIN_REFRESH_WINDOW = 3600000; // 1hr + private static final String GET_USD_EXCHANGE_RATES_ARG_URL = "https://api.bluelytics.com.ar/v2/latest"; + private static final BlueLyticsService instance = new BlueLyticsService(); + private final Logger logger = LoggerFactory.getLogger(getClass()); + private final RestTemplate restTemplate = new RestTemplate(); + private Double lastBlueGap; + private Long lastRefresh; + private Thread refreshJob; + + private BlueLyticsService() { + lastRefresh = null; + lastBlueGap = null; + refreshJob = null; + } + + public static BlueLyticsService getInstance() { + return BlueLyticsService.instance; + } + + /** + * + * @return current ARS/USD gap multiplier to get from official rate to free market rate. + * If not available returns Nan + */ + public Double blueGapMultiplier() { + maybeLaunchAsyncRefresh(); + return Objects.requireNonNullElse(lastBlueGap, Double.NaN); + } + + /** + * if enough time {@see BlueLyticsUSDRate.MIN_FRESH_WINDOW} has pass from the last refresh or + * no refresh has been done before: launch async refresh + */ + private void maybeLaunchAsyncRefresh() { + long nowTimestamp = Date.from(Instant.now()).getTime(); + if (refreshJob != null) { + logger.info("Skipping ARS/USD multiplier refresh since its already running"); + } else { + if (lastRefresh == null || + nowTimestamp > (lastRefresh + BlueLyticsService.MIN_REFRESH_WINDOW)) { + if (lastRefresh == null) { + logger.info("Refreshing for the first time"); + } else { + logger.info(String.format("should refresh? %s with last refresh %s and now time %s", (lastRefresh + BlueLyticsService.MIN_REFRESH_WINDOW), lastRefresh, nowTimestamp)); + } + launchAsyncRefresh(); + } + } + } + + private synchronized void launchAsyncRefresh() { + logger.info("Launching async refresh of blue ARS/USD rate"); + refreshJob = new Thread(this::refreshBlueGap); + refreshJob.start(); + } + + private void refreshBlueGap() { + try { + // the last_update value is different than the last one and also launch the update if 1 hour passed ? + lastBlueGap = Objects.requireNonNull(restTemplate.exchange( + RequestEntity + .get(UriComponentsBuilder + .fromUriString(BlueLyticsService.GET_USD_EXCHANGE_RATES_ARG_URL).build() + .toUri()) + .build(), + new ParameterizedTypeReference() { + } + ).getBody()).gapSellMultiplier(); + lastRefresh = new Date().getTime(); + logger.info(String.format("New blue gap is %s and refresh was at epoch %s", lastBlueGap, lastRefresh)); + } catch (Exception e) { + logger.error("Failed to fetch updated bluelytics gap multiplier", e); + } finally { + refreshJob = null; + } + } + + @Override + protected Object clone() throws CloneNotSupportedException { + throw new CloneNotSupportedException("Cannot clone Singleton"); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 9564a1d..399ab37 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -7,7 +7,7 @@ bisq.price.mining.providers.mempoolHostname.2=mempool.emzy.de bisq.price.mining.providers.mempoolHostname.3=mempool.ninja bisq.price.mining.providers.mempoolHostname.4=mempool.bisq.services # bisq.price.mining.providers.mempoolHostname.5=someHostOrIP -bisq.price.fiatcurrency.excluded=LBP,ARS +bisq.price.fiatcurrency.excluded=LBP bisq.price.fiatcurrency.excludedByProvider=HUOBI:BRL bisq.price.cryptocurrency.excluded= bisq.price.outlierStdDeviation=2.2 diff --git a/src/test/java/bisq/price/AbstractExchangeRateProviderTest.java b/src/test/java/bisq/price/AbstractExchangeRateProviderTest.java index 3040be4..8e25845 100644 --- a/src/test/java/bisq/price/AbstractExchangeRateProviderTest.java +++ b/src/test/java/bisq/price/AbstractExchangeRateProviderTest.java @@ -46,12 +46,12 @@ private void checkProviderCurrencyPairs(ExchangeRateProvider exchangeProvider, S .collect(Collectors.toSet()); Set supportedFiatCurrenciesRetrieved = exchangeProvider.getSupportedFiatCurrencies().stream() - .filter(f -> retrievedRatesCurrencies.contains(f)) + .filter(retrievedRatesCurrencies::contains) .collect(Collectors.toCollection(TreeSet::new)); log.info("Retrieved rates for supported fiat currencies: " + supportedFiatCurrenciesRetrieved); Set supportedCryptoCurrenciesRetrieved = exchangeProvider.getSupportedCryptoCurrencies().stream() - .filter(c -> retrievedRatesCurrencies.contains(c)) + .filter(retrievedRatesCurrencies::contains) .collect(Collectors.toCollection(TreeSet::new)); log.info("Retrieved rates for supported altcoins: " + supportedCryptoCurrenciesRetrieved); @@ -59,7 +59,7 @@ private void checkProviderCurrencyPairs(ExchangeRateProvider exchangeProvider, S exchangeProvider.getSupportedCryptoCurrencies(), exchangeProvider.getSupportedFiatCurrencies()); - Set unsupportedCurrencies = Sets.difference(retrievedRatesCurrencies, supportedCurrencies); + Set unsupportedCurrencies = Sets.difference(retrievedRatesCurrencies, supportedCurrencies); assertTrue(unsupportedCurrencies.isEmpty(), "Retrieved exchange rates contain unsupported currencies: " + unsupportedCurrencies); } diff --git a/src/test/java/bisq/price/spot/ExchangeRateServiceTest.java b/src/test/java/bisq/price/spot/ExchangeRateServiceTest.java index 5e8c50a..96761ff 100644 --- a/src/test/java/bisq/price/spot/ExchangeRateServiceTest.java +++ b/src/test/java/bisq/price/spot/ExchangeRateServiceTest.java @@ -18,6 +18,7 @@ package bisq.price.spot; import bisq.core.locale.CurrencyUtil; +import bisq.price.util.bluelytics.BlueLyticsService; import ch.qos.logback.classic.Level; import ch.qos.logback.classic.Logger; import ch.qos.logback.classic.spi.ILoggingEvent; @@ -31,6 +32,7 @@ import org.springframework.core.env.Environment; import org.springframework.core.env.StandardEnvironment; +import javax.annotation.Nullable; import java.time.Duration; import java.util.*; import java.util.stream.Collectors; @@ -54,12 +56,16 @@ public class ExchangeRateServiceTest { static void setup() { // Get the logger object for logs in ExchangeRateService exchangeRateServiceLogger = (Logger) LoggerFactory.getLogger(ExchangeRateService.class); + exchangeRateServiceLogger.info("Setup started"); + // trigger the first init + BlueLyticsService.getInstance().blueGapMultiplier(); // Initiate and append a ListAppender, which allows us to programmatically inspect // log messages ListAppender listAppender = new ListAppender<>(); listAppender.setName(LIST_APPENDER_NAME); listAppender.start(); + exchangeRateServiceLogger.info("Setup finished"); exchangeRateServiceLogger.addAppender(listAppender); } @@ -71,7 +77,7 @@ public void getAllMarketPrices_withNoExchangeRates_logs_Exception() { Map retrievedData = service.getAllMarketPrices(); - doSanityChecksForRetrievedDataSingleProvider(retrievedData, dummyProvider, numberOfCurrencyPairsOnExchange); + doSanityChecksForRetrievedDataSingleProvider(service, retrievedData, dummyProvider, numberOfCurrencyPairsOnExchange); // No exchange rates provided by this exchange, two things should happen // A) the timestamp should be set to 0 @@ -97,7 +103,7 @@ public void getAllMarketPrices_withSingleExchangeRate() { Map retrievedData = service.getAllMarketPrices(); - doSanityChecksForRetrievedDataSingleProvider(retrievedData, dummyProvider, numberOfCurrencyPairsOnExchange); + doSanityChecksForRetrievedDataSingleProvider(service, retrievedData, dummyProvider, numberOfCurrencyPairsOnExchange); // One rate was provided by this provider, so the timestamp should not be 0 assertNotEquals(0L, retrievedData.get(dummyProvider.getPrefix() + "Ts")); @@ -112,7 +118,7 @@ public void getAllMarketPrices_withMultipleProviders_differentCurrencyCodes() { Map retrievedData = service.getAllMarketPrices(); - doSanityChecksForRetrievedDataMultipleProviders(retrievedData, asList(dummyProvider1, dummyProvider2)); + doSanityChecksForRetrievedDataMultipleProviders(service, retrievedData, asList(dummyProvider1, dummyProvider2)); // One rate was provided by each provider in this service, so the timestamp // (for both providers) should not be 0 @@ -120,6 +126,27 @@ public void getAllMarketPrices_withMultipleProviders_differentCurrencyCodes() { assertNotEquals(0L, retrievedData.get(dummyProvider2.getPrefix() + "Ts")); } + /** + * Test scenario + */ + @Test + public void getAllMarketPrices_oneProvider_considerBlueUpdates() { + String excludedCcvString = "LBP"; + Set rateCurrencyCodes = Sets.newHashSet("ARS", "USD", "LBP", "EUR"); + String providerExcludedCcvString = "HUOBI:BRL,BINANCE:GBP,BINANCE:SEK"; + Environment mockedEnvironment = mock(Environment.class); + when(mockedEnvironment.getProperty(eq("bisq.price.cryptocurrency.excluded"), anyString())).thenReturn(""); + when(mockedEnvironment.getProperty(eq("bisq.price.fiatcurrency.excluded"), anyString())).thenReturn(excludedCcvString); + when(mockedEnvironment.getProperty(eq("bisq.price.fiatcurrency.excludedByProvider"), anyString())).thenReturn(providerExcludedCcvString); + ExchangeRateProvider dummyProvider = buildDummyExchangeRateProvider(rateCurrencyCodes, mockedEnvironment); + ExchangeRateService service = new ExchangeRateService(mockedEnvironment, List.of(dummyProvider)); + + Map retrievedData = service.getAllMarketPrices(); + + doSanityChecksForRetrievedDataMultipleProviders(service, retrievedData, List.of(dummyProvider)); + + } + /** * Tests the scenario when multiple providers have rates for the same currencies */ @@ -130,14 +157,14 @@ public void getAllMarketPrices_withMultipleProviders_overlappingCurrencyCodes() Set rateCurrencyCodes = Sets.newHashSet("CURRENCY-1", "CURRENCY-2", "CURRENCY-3"); // Create several dummy providers, each providing their own rates for the same set of currencies - ExchangeRateProvider dummyProvider1 = buildDummyExchangeRateProvider(rateCurrencyCodes); - ExchangeRateProvider dummyProvider2 = buildDummyExchangeRateProvider(rateCurrencyCodes); + ExchangeRateProvider dummyProvider1 = buildDummyExchangeRateProvider(rateCurrencyCodes, null); + ExchangeRateProvider dummyProvider2 = buildDummyExchangeRateProvider(rateCurrencyCodes, null); ExchangeRateService service = new ExchangeRateService(new StandardEnvironment(), asList(dummyProvider1, dummyProvider2)); Map retrievedData = service.getAllMarketPrices(); - doSanityChecksForRetrievedDataMultipleProviders(retrievedData, asList(dummyProvider1, dummyProvider2)); + doSanityChecksForRetrievedDataMultipleProviders(service, retrievedData, asList(dummyProvider1, dummyProvider2)); // At least one rate was provided by each provider in this service, so the // timestamp (for both providers) should not be 0 @@ -216,17 +243,18 @@ public Set doGet() { /** * Performs generic sanity checks on the response format and contents. * + * @param service being tested {@link ExchangeRateService} * @param retrievedData Response data retrieved from the {@link ExchangeRateService} * @param provider {@link ExchangeRateProvider} available to the * {@link ExchangeRateService} * @param numberOfCurrencyPairsOnExchange Number of currency pairs this exchange was * initiated with */ - private void doSanityChecksForRetrievedDataSingleProvider(Map retrievedData, + private void doSanityChecksForRetrievedDataSingleProvider(ExchangeRateService service, Map retrievedData, ExchangeRateProvider provider, int numberOfCurrencyPairsOnExchange) { // Check response structure - doSanityChecksForRetrievedDataMultipleProviders(retrievedData, asList(provider)); + doSanityChecksForRetrievedDataMultipleProviders(service, retrievedData, asList(provider)); // Check that the amount of provided exchange rates matches expected value // For one provider, the amount of rates of that provider should be the total @@ -238,11 +266,12 @@ private void doSanityChecksForRetrievedDataSingleProvider(Map re /** * Performs generic sanity checks on the response format and contents. * + * @param service being tested {@link ExchangeRateService} * @param retrievedData Response data retrieved from the {@link ExchangeRateService} * @param providers List of all {@link ExchangeRateProvider#getPrefix()} the * {@link ExchangeRateService} uses */ - private void doSanityChecksForRetrievedDataMultipleProviders(Map retrievedData, + private void doSanityChecksForRetrievedDataMultipleProviders(ExchangeRateService service, Map retrievedData, List providers) { // Check the correct amount of entries were present in the service response: // The timestamp and the count fields are per provider, so N providers means N @@ -279,7 +308,7 @@ private void doSanityChecksForRetrievedDataMultipleProviders(Map // Collect all ExchangeRates from all providers and group them by currency code Map> currencyCodeToExchangeRatesFromProviders = new HashMap<>(); for (ExchangeRateProvider p : providers) { - for (ExchangeRate exchangeRate : p.get()) { + for (ExchangeRate exchangeRate : service.providerCurrentExchangeRates(p)) { String currencyCode = exchangeRate.getCurrency(); if (currencyCodeToExchangeRatesFromProviders.containsKey(currencyCode)) { List l = new ArrayList<>(currencyCodeToExchangeRatesFromProviders.get(currencyCode)); @@ -355,9 +384,9 @@ protected Set doGet() { return dummyProvider; } - private ExchangeRateProvider buildDummyExchangeRateProvider(Set rateCurrencyCodes) { + private ExchangeRateProvider buildDummyExchangeRateProvider(Set rateCurrencyCodes, @Nullable Environment env) { ExchangeRateProvider dummyProvider = new ExchangeRateProvider( - new StandardEnvironment(), + env == null ? new StandardEnvironment() : env, "ExchangeName-" + getRandomAlphaNumericString(5), "EXCH-" + getRandomAlphaNumericString(3), Duration.ofDays(1)) { diff --git a/src/test/java/bisq/price/spot/providers/CoinGeckoTest.java b/src/test/java/bisq/price/spot/providers/CoinGeckoTest.java index ca453c2..8d13e21 100644 --- a/src/test/java/bisq/price/spot/providers/CoinGeckoTest.java +++ b/src/test/java/bisq/price/spot/providers/CoinGeckoTest.java @@ -29,5 +29,4 @@ public class CoinGeckoTest extends AbstractExchangeRateProviderTest { public void doGet_successfulCall() { doGet_successfulCall(new CoinGecko(new StandardEnvironment())); } - } From c70ea6b2aecf9f80fa513d335541378f1dee56f3 Mon Sep 17 00:00:00 2001 From: Rodrigo Varela Date: Thu, 13 Jul 2023 16:45:53 +1000 Subject: [PATCH 2/3] Add support for ARS free market price - setup cryptoya api price provider - add cryptoya pricenode provider - algorithm to decide rate based on fetched tickers - add test for new exchange rate provider - code review suggestions --- src/main/java/bisq/price/spot/providers/CryptoYa.java | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/main/java/bisq/price/spot/providers/CryptoYa.java b/src/main/java/bisq/price/spot/providers/CryptoYa.java index 8dbbbaa..9e471b8 100644 --- a/src/main/java/bisq/price/spot/providers/CryptoYa.java +++ b/src/main/java/bisq/price/spot/providers/CryptoYa.java @@ -49,11 +49,6 @@ public CryptoYa(Environment env) { super(env, "CRYPTOYA", "cryptoya", Duration.ofMinutes(1)); } - @Override - public boolean alreadyConsidersBlueMarkets() { - return true; - } - /** * * @return average price buy/sell price averaging different providers suported by cryptoya api From d67f1e240485b8916fedcc2cdc95031634dd3da0 Mon Sep 17 00:00:00 2001 From: Alva Swanson Date: Mon, 4 Sep 2023 18:14:14 +0200 Subject: [PATCH 3/3] Add redundancy to ARS price node - Implement ExchangeRateTransformer (@alvasw) - Implement BlueLyticsApi (@alvasw) - Implement ArsBlueMarketGapProvider (@alvasw) - Implement ArsBlueRateTransformer (@alvasw) - merge @alvasw improvements - fixed bug lossing prices after merge - fix tests, all green - delete unused code - comment most important methods/classes --- bisq | 2 +- .../price/spot/ArsBlueRateTransformer.java | 61 +++++++++ .../bisq/price/spot/ExchangeRateProvider.java | 27 ---- .../bisq/price/spot/ExchangeRateService.java | 94 +++++--------- .../price/spot/ExchangeRateTransformer.java | 31 +++++ .../spot/providers/BlueRateProvider.java | 24 ++++ .../bisq/price/spot/providers/CryptoYa.java | 2 +- .../bluelytics/ArsBlueMarketGapProvider.java | 56 +++++++++ .../price/util/bluelytics/BlueLyticsApi.java | 41 ++++++ .../price/util/bluelytics/BlueLyticsDto.java | 23 ++-- .../util/bluelytics/BlueLyticsService.java | 117 ------------------ .../price/spot/ExchangeRateServiceTest.java | 43 ++++--- 12 files changed, 282 insertions(+), 239 deletions(-) create mode 100644 src/main/java/bisq/price/spot/ArsBlueRateTransformer.java create mode 100644 src/main/java/bisq/price/spot/ExchangeRateTransformer.java create mode 100644 src/main/java/bisq/price/spot/providers/BlueRateProvider.java create mode 100644 src/main/java/bisq/price/util/bluelytics/ArsBlueMarketGapProvider.java create mode 100644 src/main/java/bisq/price/util/bluelytics/BlueLyticsApi.java delete mode 100644 src/main/java/bisq/price/util/bluelytics/BlueLyticsService.java diff --git a/bisq b/bisq index a2dca3e..17abc0b 160000 --- a/bisq +++ b/bisq @@ -1 +1 @@ -Subproject commit a2dca3e4d44b59cdf6d4ceb19f280cfc9f2efb98 +Subproject commit 17abc0bc8dc93ab836040662017f81a5c391c436 diff --git a/src/main/java/bisq/price/spot/ArsBlueRateTransformer.java b/src/main/java/bisq/price/spot/ArsBlueRateTransformer.java new file mode 100644 index 0000000..5923256 --- /dev/null +++ b/src/main/java/bisq/price/spot/ArsBlueRateTransformer.java @@ -0,0 +1,61 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.price.spot; + +import bisq.price.spot.providers.BlueRateProvider; +import bisq.price.util.bluelytics.ArsBlueMarketGapProvider; +import org.springframework.stereotype.Component; + +import java.util.Optional; +import java.util.OptionalDouble; + +@Component +public class ArsBlueRateTransformer implements ExchangeRateTransformer { + private final ArsBlueMarketGapProvider blueMarketGapProvider; + + public ArsBlueRateTransformer(ArsBlueMarketGapProvider blueMarketGapProvider) { + this.blueMarketGapProvider = blueMarketGapProvider; + } + + @Override + public Optional apply(ExchangeRateProvider provider, ExchangeRate originalExchangeRate) { + if (provider instanceof BlueRateProvider) { + return Optional.of(originalExchangeRate); + } + + OptionalDouble sellGapMultiplier = blueMarketGapProvider.get(); + if (sellGapMultiplier.isEmpty()) { + return Optional.empty(); + } + + double blueRate = originalExchangeRate.getPrice() * sellGapMultiplier.getAsDouble(); + + ExchangeRate newExchangeRate = new ExchangeRate( + originalExchangeRate.getCurrency(), + blueRate, + originalExchangeRate.getTimestamp(), + originalExchangeRate.getProvider() + ); + return Optional.of(newExchangeRate); + } + + @Override + public String supportedCurrency() { + return "ARS"; + } +} diff --git a/src/main/java/bisq/price/spot/ExchangeRateProvider.java b/src/main/java/bisq/price/spot/ExchangeRateProvider.java index 8c9c266..fd81f3b 100644 --- a/src/main/java/bisq/price/spot/ExchangeRateProvider.java +++ b/src/main/java/bisq/price/spot/ExchangeRateProvider.java @@ -334,31 +334,4 @@ protected long getMarketDataCallDelay() { protected boolean requiresFilterDuringBulkTickerRetrieval() { return false; } - - /** - * - * @return true if the implementation of this ExchangeRateProvider already consider currencies - * blue markets if any. Defaults to false. - */ - public boolean alreadyConsidersBlueMarkets() { - return false; - } - - /** - * @param originalRate original official rate for a certain currency. E.g. rate for ARS for Argentina PESO - * @return a new exchange rate if the implementation of this price provider already tackles real vs official - * market prices for the given currency. - * e.g. for FIAT ARS official rates are not the ones using in the free trading world. - * Most currencies won't need this, so defaults to null. - */ - public @Nullable ExchangeRate maybeUpdateBlueMarketPriceGapForRate(ExchangeRate originalRate, Double blueMarketGapForCurrency) { - if ("ARS".equalsIgnoreCase(originalRate.getCurrency())) { - double blueRate = originalRate.getPrice() * blueMarketGapForCurrency; - return new ExchangeRate(originalRate.getCurrency(), - BigDecimal.valueOf(blueRate), - new Date(originalRate.getTimestamp()), - originalRate.getProvider()); - } - return null; - } } diff --git a/src/main/java/bisq/price/spot/ExchangeRateService.java b/src/main/java/bisq/price/spot/ExchangeRateService.java index 8e9f940..7b4fcbc 100644 --- a/src/main/java/bisq/price/spot/ExchangeRateService.java +++ b/src/main/java/bisq/price/spot/ExchangeRateService.java @@ -18,11 +18,7 @@ package bisq.price.spot; import bisq.common.util.Tuple2; - import bisq.core.util.InlierUtil; - -import bisq.price.util.bluelytics.BlueLyticsService; -import com.google.common.collect.Sets; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.core.env.Environment; @@ -40,17 +36,22 @@ class ExchangeRateService { protected final Logger log = LoggerFactory.getLogger(this.getClass()); private final Environment env; private final List providers; + private final List transformers; /** * Construct an {@link ExchangeRateService} with a list of all * {@link ExchangeRateProvider} implementations discovered via classpath scanning. * - * @param providers all {@link ExchangeRateProvider} implementations in ascending - * order of precedence + * @param providers all {@link ExchangeRateProvider} implementations in ascending + * order of precedence + * @param transformers all {@link ExchangeRateTransformer} implementations */ - public ExchangeRateService(Environment env, List providers) { + public ExchangeRateService(Environment env, + List providers, + List transformers) { this.env = env; this.providers = providers; + this.transformers = transformers; } public Map getAllMarketPrices() { @@ -76,56 +77,6 @@ public Map getAllMarketPrices() { return result; } - /** - * Please do not call provider.get(), use this method instead to consider currencies with blue markets - * @param provider to get the rates from - * @return the exchange rates for the different currencies the provider supports considering bluemarket rates if any - */ - public Set providerCurrentExchangeRates(ExchangeRateProvider provider) { - Map blueMarketGapForCurrency = this.getBlueMarketGapForCurrencies(); - Set originalExchangeRates = provider.get(); - if (originalExchangeRates == null) - return null; - - Set exchangeRates = new HashSet<>(); - boolean noOverlapBetweenCurrencies = Sets.intersection(originalExchangeRates.stream().map(ExchangeRate::getCurrency) - .collect(Collectors.toSet()), blueMarketGapForCurrency.keySet()) - .isEmpty(); - - if (provider.alreadyConsidersBlueMarkets() || noOverlapBetweenCurrencies) { - exchangeRates.addAll(originalExchangeRates); - } else { - this.addRatesUpdatingBlueGaps(blueMarketGapForCurrency, provider, exchangeRates, originalExchangeRates); - } - return exchangeRates; - } - - private void addRatesUpdatingBlueGaps(Map blueMarketGapForCurrency, ExchangeRateProvider provider, Set exchangeRates, Set originalExchangeRates) { - originalExchangeRates.forEach(er -> { - // default to original rate - ExchangeRate exchangeRateToUse = er; - if (blueMarketGapForCurrency.containsKey(er.getCurrency())) { - ExchangeRate updatedExchangeRate = provider.maybeUpdateBlueMarketPriceGapForRate(er, blueMarketGapForCurrency.get(er.getCurrency())); - if (updatedExchangeRate != null) { - exchangeRateToUse = updatedExchangeRate; -// this.log.info(String.format("Replaced original %s rate of $%s to $%s", er.getCurrency(), BigDecimal.valueOf(er.getPrice()).toEngineeringString(), BigDecimal.valueOf(updatedExchangeRate.getPrice()).toEngineeringString())); - } - } - exchangeRates.add(exchangeRateToUse); - }); - } - - private Map getBlueMarketGapForCurrencies() { - Map blueMarketGapForCurrencies = new HashMap<>(); - // ARS - Double arsBlueMultiplier = BlueLyticsService.getInstance().blueGapMultiplier(); - if (!arsBlueMultiplier.isNaN()) { -// this.log.info("Updated ARS/USD multiplier is " + arsBlueMultiplier); - blueMarketGapForCurrencies.put("ARS", arsBlueMultiplier); - } - return blueMarketGapForCurrencies; - } - /** * For each currency, create an aggregate {@link ExchangeRate} based on the currency's * rates from all providers. If multiple providers have rates for the currency, then @@ -212,20 +163,43 @@ private double getOutlierStdDeviation() { private Map> getCurrencyCodeToExchangeRates() { Map> currencyCodeToExchangeRates = new HashMap<>(); for (ExchangeRateProvider p : providers) { - Set exchangeRates = providerCurrentExchangeRates(p); + Set exchangeRates = p.get(); if (exchangeRates == null) continue; for (ExchangeRate exchangeRate : exchangeRates) { String currencyCode = exchangeRate.getCurrency(); + + List transformedExchangeRates = transformers.stream() + .filter(transformer -> transformer.supportedCurrency() + .equalsIgnoreCase(currencyCode) + ) + .map(t -> t.apply(p, exchangeRate)) + .filter(Optional::isPresent) + .map(Optional::get) + .collect(Collectors.toList()); + + if (!transformedExchangeRates.isEmpty()) { + log.info(String.format("%s transformed from %s to %s", currencyCode, exchangeRate.getPrice(), transformedExchangeRates.get(0).getPrice())); + } + if (currencyCodeToExchangeRates.containsKey(currencyCode)) { List l = new ArrayList<>(currencyCodeToExchangeRates.get(currencyCode)); - l.add(exchangeRate); + if (transformedExchangeRates.isEmpty()) { + l.add(exchangeRate); + } else { + l.addAll(transformedExchangeRates); + } currencyCodeToExchangeRates.put(currencyCode, l); } else { - currencyCodeToExchangeRates.put(currencyCode, List.of(exchangeRate)); + if (transformedExchangeRates.isEmpty()) { + currencyCodeToExchangeRates.put(currencyCode, List.of(exchangeRate)); + } else { + currencyCodeToExchangeRates.put(currencyCode, transformedExchangeRates); + } } } } + return currencyCodeToExchangeRates; } diff --git a/src/main/java/bisq/price/spot/ExchangeRateTransformer.java b/src/main/java/bisq/price/spot/ExchangeRateTransformer.java new file mode 100644 index 0000000..a2ae5c4 --- /dev/null +++ b/src/main/java/bisq/price/spot/ExchangeRateTransformer.java @@ -0,0 +1,31 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.price.spot; + +import java.util.Optional; + +/** + * An ExchangeRateTransformer allows to apply a transformation on a particular ExchangeRate + * for particular supported currencies. This is useful for countries with currency controls + * that have a "blue" market in place for real/free trades. + */ +public interface ExchangeRateTransformer { + Optional apply(ExchangeRateProvider provider, ExchangeRate exchangeRate); + + String supportedCurrency(); +} diff --git a/src/main/java/bisq/price/spot/providers/BlueRateProvider.java b/src/main/java/bisq/price/spot/providers/BlueRateProvider.java new file mode 100644 index 0000000..0550f59 --- /dev/null +++ b/src/main/java/bisq/price/spot/providers/BlueRateProvider.java @@ -0,0 +1,24 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.price.spot.providers; + +/** + * Tag for providers that provide a "blue" market exchange rate (unofficial real/free traded) + */ +public interface BlueRateProvider { +} diff --git a/src/main/java/bisq/price/spot/providers/CryptoYa.java b/src/main/java/bisq/price/spot/providers/CryptoYa.java index 9e471b8..43d1b3c 100644 --- a/src/main/java/bisq/price/spot/providers/CryptoYa.java +++ b/src/main/java/bisq/price/spot/providers/CryptoYa.java @@ -39,7 +39,7 @@ * This ExchangeRateProvider provides a real market rate (black or "blue") for ARS/BTC */ @Component -class CryptoYa extends ExchangeRateProvider { +class CryptoYa extends ExchangeRateProvider implements BlueRateProvider { private static final String CRYPTO_YA_BTC_ARS_API_URL = "https://criptoya.com/api/btc/ars/0.1"; diff --git a/src/main/java/bisq/price/util/bluelytics/ArsBlueMarketGapProvider.java b/src/main/java/bisq/price/util/bluelytics/ArsBlueMarketGapProvider.java new file mode 100644 index 0000000..baa0304 --- /dev/null +++ b/src/main/java/bisq/price/util/bluelytics/ArsBlueMarketGapProvider.java @@ -0,0 +1,56 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.price.util.bluelytics; + +import bisq.price.PriceProvider; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.Optional; +import java.util.OptionalDouble; + +@Slf4j +@Component +public class ArsBlueMarketGapProvider extends PriceProvider { + public interface Listener { + void onUpdate(OptionalDouble sellGapMultiplier); + } + + private static final Duration REFRESH_INTERVAL = Duration.ofHours(1); + + private final BlueLyticsApi blueLyticsApi = new BlueLyticsApi(); + private final Optional onUpdateListener; + + public ArsBlueMarketGapProvider() { + super(REFRESH_INTERVAL); + this.onUpdateListener = Optional.empty(); + } + + public ArsBlueMarketGapProvider(Listener onUpdateListener) { + super(REFRESH_INTERVAL); + this.onUpdateListener = Optional.of(onUpdateListener); + } + + @Override + protected OptionalDouble doGet() { + OptionalDouble sellGapMultiplier = blueLyticsApi.getSellGapMultiplier(); + onUpdateListener.ifPresent(listener -> listener.onUpdate(sellGapMultiplier)); + return sellGapMultiplier; + } +} diff --git a/src/main/java/bisq/price/util/bluelytics/BlueLyticsApi.java b/src/main/java/bisq/price/util/bluelytics/BlueLyticsApi.java new file mode 100644 index 0000000..13ebcd8 --- /dev/null +++ b/src/main/java/bisq/price/util/bluelytics/BlueLyticsApi.java @@ -0,0 +1,41 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.price.util.bluelytics; + +import org.springframework.http.MediaType; +import org.springframework.web.reactive.function.client.WebClient; + +import java.time.Duration; +import java.time.temporal.ChronoUnit; +import java.util.OptionalDouble; + +public class BlueLyticsApi { + private static final String API_URL = "https://api.bluelytics.com.ar/v2/latest"; + private final WebClient webClient = WebClient.create(); + + public OptionalDouble getSellGapMultiplier() { + BlueLyticsDto blueLyticsDto = webClient.get() + .uri(API_URL) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .bodyToMono(BlueLyticsDto.class) + .block(Duration.of(30, ChronoUnit.SECONDS)); + + return blueLyticsDto == null ? OptionalDouble.empty() : blueLyticsDto.gapSellMultiplier(); + } +} diff --git a/src/main/java/bisq/price/util/bluelytics/BlueLyticsDto.java b/src/main/java/bisq/price/util/bluelytics/BlueLyticsDto.java index 4668397..5c41c20 100644 --- a/src/main/java/bisq/price/util/bluelytics/BlueLyticsDto.java +++ b/src/main/java/bisq/price/util/bluelytics/BlueLyticsDto.java @@ -22,6 +22,7 @@ import lombok.Setter; import java.util.Date; +import java.util.OptionalDouble; @Getter @Setter @@ -29,21 +30,17 @@ public class BlueLyticsDto { @Getter @Setter public static class USDRate { - Double value_avg; - Double value_sell; - Double value_buy; + double value_avg; + double value_sell; + double value_buy; } - BlueLyticsDto.USDRate oficial; - BlueLyticsDto.USDRate blue; - Date last_update; + private BlueLyticsDto.USDRate oficial; + private BlueLyticsDto.USDRate blue; + private Date last_update; - /** - * - * @return the sell multiplier to go from oficial to blue market for ARS/USD - * if its not available, returns NaN - */ - public Double gapSellMultiplier() { - return this.blue.value_sell / this.oficial.value_sell; + public OptionalDouble gapSellMultiplier() { + double sellMultiplier = blue.value_sell / oficial.value_sell; + return Double.isNaN(sellMultiplier) ? OptionalDouble.empty() : OptionalDouble.of(sellMultiplier); } } diff --git a/src/main/java/bisq/price/util/bluelytics/BlueLyticsService.java b/src/main/java/bisq/price/util/bluelytics/BlueLyticsService.java deleted file mode 100644 index c72fca3..0000000 --- a/src/main/java/bisq/price/util/bluelytics/BlueLyticsService.java +++ /dev/null @@ -1,117 +0,0 @@ -/* - * This file is part of Bisq. - * - * Bisq is free software: you can redistribute it and/or modify it - * under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or (at - * your option) any later version. - * - * Bisq is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public - * License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with Bisq. If not, see . - */ - -package bisq.price.util.bluelytics; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.core.ParameterizedTypeReference; -import org.springframework.http.RequestEntity; -import org.springframework.web.client.RestTemplate; -import org.springframework.web.util.UriComponentsBuilder; - -import java.time.Instant; -import java.util.Date; -import java.util.Objects; - -/** - * Util singleton object to update and provider ARS/USD blue market gap against the oficial rate. - * This is useful for example, to estimate ARS/BTC blue (real) market rate in a country with heavy currency controls - */ -public final class BlueLyticsService { - private static final long MIN_REFRESH_WINDOW = 3600000; // 1hr - private static final String GET_USD_EXCHANGE_RATES_ARG_URL = "https://api.bluelytics.com.ar/v2/latest"; - private static final BlueLyticsService instance = new BlueLyticsService(); - private final Logger logger = LoggerFactory.getLogger(getClass()); - private final RestTemplate restTemplate = new RestTemplate(); - private Double lastBlueGap; - private Long lastRefresh; - private Thread refreshJob; - - private BlueLyticsService() { - lastRefresh = null; - lastBlueGap = null; - refreshJob = null; - } - - public static BlueLyticsService getInstance() { - return BlueLyticsService.instance; - } - - /** - * - * @return current ARS/USD gap multiplier to get from official rate to free market rate. - * If not available returns Nan - */ - public Double blueGapMultiplier() { - maybeLaunchAsyncRefresh(); - return Objects.requireNonNullElse(lastBlueGap, Double.NaN); - } - - /** - * if enough time {@see BlueLyticsUSDRate.MIN_FRESH_WINDOW} has pass from the last refresh or - * no refresh has been done before: launch async refresh - */ - private void maybeLaunchAsyncRefresh() { - long nowTimestamp = Date.from(Instant.now()).getTime(); - if (refreshJob != null) { - logger.info("Skipping ARS/USD multiplier refresh since its already running"); - } else { - if (lastRefresh == null || - nowTimestamp > (lastRefresh + BlueLyticsService.MIN_REFRESH_WINDOW)) { - if (lastRefresh == null) { - logger.info("Refreshing for the first time"); - } else { - logger.info(String.format("should refresh? %s with last refresh %s and now time %s", (lastRefresh + BlueLyticsService.MIN_REFRESH_WINDOW), lastRefresh, nowTimestamp)); - } - launchAsyncRefresh(); - } - } - } - - private synchronized void launchAsyncRefresh() { - logger.info("Launching async refresh of blue ARS/USD rate"); - refreshJob = new Thread(this::refreshBlueGap); - refreshJob.start(); - } - - private void refreshBlueGap() { - try { - // the last_update value is different than the last one and also launch the update if 1 hour passed ? - lastBlueGap = Objects.requireNonNull(restTemplate.exchange( - RequestEntity - .get(UriComponentsBuilder - .fromUriString(BlueLyticsService.GET_USD_EXCHANGE_RATES_ARG_URL).build() - .toUri()) - .build(), - new ParameterizedTypeReference() { - } - ).getBody()).gapSellMultiplier(); - lastRefresh = new Date().getTime(); - logger.info(String.format("New blue gap is %s and refresh was at epoch %s", lastBlueGap, lastRefresh)); - } catch (Exception e) { - logger.error("Failed to fetch updated bluelytics gap multiplier", e); - } finally { - refreshJob = null; - } - } - - @Override - protected Object clone() throws CloneNotSupportedException { - throw new CloneNotSupportedException("Cannot clone Singleton"); - } -} diff --git a/src/test/java/bisq/price/spot/ExchangeRateServiceTest.java b/src/test/java/bisq/price/spot/ExchangeRateServiceTest.java index 96761ff..c9ff1c7 100644 --- a/src/test/java/bisq/price/spot/ExchangeRateServiceTest.java +++ b/src/test/java/bisq/price/spot/ExchangeRateServiceTest.java @@ -18,7 +18,6 @@ package bisq.price.spot; import bisq.core.locale.CurrencyUtil; -import bisq.price.util.bluelytics.BlueLyticsService; import ch.qos.logback.classic.Level; import ch.qos.logback.classic.Logger; import ch.qos.logback.classic.spi.ILoggingEvent; @@ -57,8 +56,6 @@ static void setup() { // Get the logger object for logs in ExchangeRateService exchangeRateServiceLogger = (Logger) LoggerFactory.getLogger(ExchangeRateService.class); exchangeRateServiceLogger.info("Setup started"); - // trigger the first init - BlueLyticsService.getInstance().blueGapMultiplier(); // Initiate and append a ListAppender, which allows us to programmatically inspect // log messages @@ -73,7 +70,8 @@ static void setup() { public void getAllMarketPrices_withNoExchangeRates_logs_Exception() { int numberOfCurrencyPairsOnExchange = 0; ExchangeRateProvider dummyProvider = buildDummyExchangeRateProvider(numberOfCurrencyPairsOnExchange); - ExchangeRateService service = new ExchangeRateService(new StandardEnvironment(), Collections.singletonList(dummyProvider)); + ExchangeRateService service = new ExchangeRateService(new StandardEnvironment(), + Collections.singletonList(dummyProvider), Collections.emptyList()); Map retrievedData = service.getAllMarketPrices(); @@ -99,7 +97,8 @@ public void getAllMarketPrices_withNoExchangeRates_logs_Exception() { public void getAllMarketPrices_withSingleExchangeRate() { int numberOfCurrencyPairsOnExchange = 1; ExchangeRateProvider dummyProvider = buildDummyExchangeRateProvider(numberOfCurrencyPairsOnExchange); - ExchangeRateService service = new ExchangeRateService(new StandardEnvironment(), Collections.singletonList(dummyProvider)); + ExchangeRateService service = new ExchangeRateService(new StandardEnvironment(), + Collections.singletonList(dummyProvider), Collections.emptyList()); Map retrievedData = service.getAllMarketPrices(); @@ -114,7 +113,8 @@ public void getAllMarketPrices_withMultipleProviders_differentCurrencyCodes() { int numberOfCurrencyPairsOnExchange = 1; ExchangeRateProvider dummyProvider1 = buildDummyExchangeRateProvider(numberOfCurrencyPairsOnExchange); ExchangeRateProvider dummyProvider2 = buildDummyExchangeRateProvider(numberOfCurrencyPairsOnExchange); - ExchangeRateService service = new ExchangeRateService(new StandardEnvironment(), asList(dummyProvider1, dummyProvider2)); + ExchangeRateService service = new ExchangeRateService(new StandardEnvironment(), + asList(dummyProvider1, dummyProvider2), Collections.emptyList()); Map retrievedData = service.getAllMarketPrices(); @@ -139,7 +139,7 @@ public void getAllMarketPrices_oneProvider_considerBlueUpdates() { when(mockedEnvironment.getProperty(eq("bisq.price.fiatcurrency.excluded"), anyString())).thenReturn(excludedCcvString); when(mockedEnvironment.getProperty(eq("bisq.price.fiatcurrency.excludedByProvider"), anyString())).thenReturn(providerExcludedCcvString); ExchangeRateProvider dummyProvider = buildDummyExchangeRateProvider(rateCurrencyCodes, mockedEnvironment); - ExchangeRateService service = new ExchangeRateService(mockedEnvironment, List.of(dummyProvider)); + ExchangeRateService service = new ExchangeRateService(mockedEnvironment, List.of(dummyProvider), List.of()); Map retrievedData = service.getAllMarketPrices(); @@ -160,7 +160,8 @@ public void getAllMarketPrices_withMultipleProviders_overlappingCurrencyCodes() ExchangeRateProvider dummyProvider1 = buildDummyExchangeRateProvider(rateCurrencyCodes, null); ExchangeRateProvider dummyProvider2 = buildDummyExchangeRateProvider(rateCurrencyCodes, null); - ExchangeRateService service = new ExchangeRateService(new StandardEnvironment(), asList(dummyProvider1, dummyProvider2)); + ExchangeRateService service = new ExchangeRateService(new StandardEnvironment(), + asList(dummyProvider1, dummyProvider2), Collections.emptyList()); Map retrievedData = service.getAllMarketPrices(); @@ -308,7 +309,7 @@ private void doSanityChecksForRetrievedDataMultipleProviders(ExchangeRateService // Collect all ExchangeRates from all providers and group them by currency code Map> currencyCodeToExchangeRatesFromProviders = new HashMap<>(); for (ExchangeRateProvider p : providers) { - for (ExchangeRate exchangeRate : service.providerCurrentExchangeRates(p)) { + for (ExchangeRate exchangeRate : p.get()) { String currencyCode = exchangeRate.getCurrency(); if (currencyCodeToExchangeRatesFromProviders.containsKey(currencyCode)) { List l = new ArrayList<>(currencyCodeToExchangeRatesFromProviders.get(currencyCode)); @@ -324,17 +325,19 @@ private void doSanityChecksForRetrievedDataMultipleProviders(ExchangeRateService // value is an average currencyCodeToExchangeRatesFromProviders.forEach((currencyCode, exchangeRateList) -> { ExchangeRate rateFromService = currencyCodeToExchangeRateFromService.get(currencyCode); - double priceFromService = rateFromService.getPrice(); - - OptionalDouble opt = exchangeRateList.stream().mapToDouble(ExchangeRate::getPrice).average(); - double priceAvgFromProviders = opt.getAsDouble(); - - // Ensure that the ExchangeRateService correctly aggregates exchange rates - // from multiple providers. If multiple providers contain rates for a - // currency, the service should return a single aggregate rate - // Expected value for aggregate rate = avg(provider rates) - // This formula works for any number of providers for a specific currency - assertEquals(priceFromService, priceAvgFromProviders, "Service returned incorrect aggregate rate"); + if (rateFromService != null) { + double priceFromService = rateFromService.getPrice(); + + OptionalDouble opt = exchangeRateList.stream().mapToDouble(ExchangeRate::getPrice).average(); + double priceAvgFromProviders = opt.getAsDouble(); + + // Ensure that the ExchangeRateService correctly aggregates exchange rates + // from multiple providers. If multiple providers contain rates for a + // currency, the service should return a single aggregate rate + // Expected value for aggregate rate = avg(provider rates) + // This formula works for any number of providers for a specific currency + assertEquals(priceFromService, priceAvgFromProviders, "Service returned incorrect aggregate rate"); + } }); }