Skip to content

Commit

Permalink
ExchangeRateService: Support aggregate rates
Browse files Browse the repository at this point in the history
Add support for aggregate rates in the ExchangeRateService. If multiple
ExchangeRateProviders contain rates for the same currency, then these
rates will be automatically aggregated (averaged) into one.

This allows the service to transparently scale to multiple providers for
 any specific currency.

The clients index the rates received from the pricenode by currency
code, which means they expect at most a single rate per currency. By
aggregating rates from multiple providers into one per currency, the
ExchangeRateService provides more accurate price data. At the same time,
the service API data structure remains intact, thus preserving backward
compatibility with all clients.
  • Loading branch information
cd2357 committed Jun 16, 2020
1 parent ad8b695 commit ff732aa
Show file tree
Hide file tree
Showing 2 changed files with 194 additions and 9 deletions.
79 changes: 74 additions & 5 deletions pricenode/src/main/java/bisq/price/spot/ExchangeRateService.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,23 @@

import org.springframework.stereotype.Service;

import java.math.BigDecimal;

import java.util.ArrayList;
import java.util.Comparator;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.OptionalDouble;
import java.util.Set;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import static java.util.Arrays.asList;

/**
* High-level {@link ExchangeRate} data operations.
*/
Expand All @@ -53,26 +60,88 @@ public ExchangeRateService(List<ExchangeRateProvider> providers) {

public Map<String, Object> getAllMarketPrices() {
Map<String, Object> metadata = new LinkedHashMap<>();
Map<String, ExchangeRate> allExchangeRates = new LinkedHashMap<>();
Map<String, ExchangeRate> aggregateExchangeRates = getAggregateExchangeRates();

providers.forEach(p -> {
Set<ExchangeRate> exchangeRates = p.get();

// Specific metadata fields for specific providers are expected by the client, mostly for historical reasons
// Therefore, add metadata fields for all known providers
// Rates are encapsulated in the "data" map below
metadata.putAll(getMetadata(p, exchangeRates));
exchangeRates.forEach(e ->
allExchangeRates.put(e.getCurrency(), e)
);
});

return new LinkedHashMap<String, Object>() {{
putAll(metadata);
// Use a sorted list by currency code to make comparision of json data between different
// price nodes easier
List<ExchangeRate> values = new ArrayList<>(allExchangeRates.values());
List<ExchangeRate> values = new ArrayList<>(aggregateExchangeRates.values());
values.sort(Comparator.comparing(ExchangeRate::getCurrency));
put("data", values);
}};
}

/**
* 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 aggregate price = average of retrieved prices.
* If a single provider has rates for the currency, then aggregate price = the rate from that provider.
*
* @return Aggregate {@link ExchangeRate}s based on info from all providers, indexed by currency code
*/
private Map<String, ExchangeRate> getAggregateExchangeRates() {
Map<String, ExchangeRate> aggregateExchangeRates = new HashMap<>();

// Query all known providers and collect all exchange rates, grouped by currency code
Map<String, List<ExchangeRate>> currencyCodeToExchangeRates = getCurrencyCodeToExchangeRates();

// For each currency code, calculate aggregate rate
currencyCodeToExchangeRates.forEach((currencyCode, exchangeRateList) -> {
ExchangeRate aggregateExchangeRate;
if (exchangeRateList.size() == 1) {
// If a single provider has rates for this currency, then aggregate = rate from that provider
aggregateExchangeRate = exchangeRateList.get(0);
}
else if (exchangeRateList.size() > 1) {
// If multiple providers have rates for this currency, then aggregate = average of the rates
OptionalDouble opt = exchangeRateList.stream().mapToDouble(ExchangeRate::getPrice).average();
double priceAvg = opt.orElseThrow(IllegalStateException::new); // List size > 1, so opt is always set

aggregateExchangeRate = new ExchangeRate(
currencyCode,
BigDecimal.valueOf(priceAvg),
new Date(), // timestamp = time when avg is calculated
"Bisq-Aggregate");
}
else {
// If the map was built incorrectly and this currency points to an empty list of rates, skip it
return;
}
aggregateExchangeRates.put(aggregateExchangeRate.getCurrency(), aggregateExchangeRate);
});

return aggregateExchangeRates;
}

/**
* @return All {@link ExchangeRate}s from all providers, grouped by currency code
*/
private Map<String, List<ExchangeRate>> getCurrencyCodeToExchangeRates() {
Map<String, List<ExchangeRate>> currencyCodeToExchangeRates = new HashMap<>();
for (ExchangeRateProvider p : providers) {
for (ExchangeRate exchangeRate : p.get()) {
String currencyCode = exchangeRate.getCurrency();
if (currencyCodeToExchangeRates.containsKey(currencyCode)) {
List<ExchangeRate> l = new ArrayList<>(currencyCodeToExchangeRates.get(currencyCode));
l.add(exchangeRate);
currencyCodeToExchangeRates.put(currencyCode, l);
} else {
currencyCodeToExchangeRates.put(currencyCode, asList(exchangeRate));
}
}
}
return currencyCodeToExchangeRates;
}

private Map<String, Object> getMetadata(ExchangeRateProvider provider, Set<ExchangeRate> exchangeRates) {
Map<String, Object> metadata = new LinkedHashMap<>();

Expand Down
124 changes: 120 additions & 4 deletions pricenode/src/test/java/bisq/price/spot/ExchangeRateServiceTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,20 @@

package bisq.price.spot;

import com.google.common.collect.Sets;

import org.apache.commons.lang3.RandomStringUtils;
import org.apache.commons.lang3.RandomUtils;

import java.time.Duration;

import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.OptionalDouble;
import java.util.Set;
import java.util.stream.Collectors;

Expand Down Expand Up @@ -108,7 +114,7 @@ public void getAllMarketPrices_withSingleExchangeRate() {
}

@Test
public void getAllMarketPrices_withMultipleProviders() {
public void getAllMarketPrices_withMultipleProviders_differentCurrencyCodes() {
int numberOfCurrencyPairsOnExchange = 1;
ExchangeRateProvider dummyProvider1 = buildDummyExchangeRateProvider(numberOfCurrencyPairsOnExchange);
ExchangeRateProvider dummyProvider2 = buildDummyExchangeRateProvider(numberOfCurrencyPairsOnExchange);
Expand All @@ -124,6 +130,31 @@ public void getAllMarketPrices_withMultipleProviders() {
assertNotEquals(0L, retrievedData.get(dummyProvider2.getPrefix() + "Ts"));
}

/**
* Tests the scenario when multiple providers have rates for the same currencies
*/
@Test
public void getAllMarketPrices_withMultipleProviders_overlappingCurrencyCodes() {

// List of currencies for which multiple providers will have exchange rates
Set<String> 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);

ExchangeRateService service = new ExchangeRateService(asList(dummyProvider1, dummyProvider2));

Map<String, Object> retrievedData = service.getAllMarketPrices();

doSanityChecksForRetrievedDataMultipleProviders(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
assertNotEquals(0L, retrievedData.get(dummyProvider1.getPrefix() + "Ts"));
assertNotEquals(0L, retrievedData.get(dummyProvider2.getPrefix() + "Ts"));
}

/**
* Performs generic sanity checks on the response format and contents.
*
Expand Down Expand Up @@ -163,11 +194,61 @@ private void doSanityChecksForRetrievedDataMultipleProviders(Map<String, Object>
assertNotNull(retrievedData.get(providerPrefix + "Ts"));
assertNotNull(retrievedData.get(providerPrefix + "Count"));
}
assertNotNull(retrievedData.get("data"));

// TODO Add checks for the case when rates for the same currency pair is retrieved from multiple providers
// Check validity of the data field
List<ExchangeRate> retrievedRates = (List<ExchangeRate>) retrievedData.get("data");
assertNotNull(retrievedRates);

// It should contain no duplicate ExchangeRate objects
int uniqueRates = Sets.newHashSet(retrievedRates).size();
int totalRates = retrievedRates.size();
assertEquals(uniqueRates, totalRates, "Found duplicate rates in data field");

// There should be only one ExchangeRate per currency
// In other words, even if multiple providers return rates for the same currency, the ExchangeRateService
// should expose only one (aggregate) ExchangeRate for that currency
Map<String, ExchangeRate> currencyCodeToExchangeRateFromService = retrievedRates.stream()
.collect(Collectors.toMap(
ExchangeRate::getCurrency, exchangeRate -> exchangeRate
));
int uniqueCurrencyCodes = currencyCodeToExchangeRateFromService.keySet().size();
assertEquals(uniqueCurrencyCodes, uniqueRates, "Found currency code with multiple exchange rates");

// Collect all ExchangeRates from all providers and group them by currency code
Map<String, List<ExchangeRate>> currencyCodeToExchangeRatesFromProviders = new HashMap<>();
for (ExchangeRateProvider p : providers) {
for (ExchangeRate exchangeRate : p.get()) {
String currencyCode = exchangeRate.getCurrency();
if (currencyCodeToExchangeRatesFromProviders.containsKey(currencyCode)) {
List<ExchangeRate> l = new ArrayList<>(currencyCodeToExchangeRatesFromProviders.get(currencyCode));
l.add(exchangeRate);
currencyCodeToExchangeRatesFromProviders.put(currencyCode, l);
} else {
currencyCodeToExchangeRatesFromProviders.put(currencyCode, asList(exchangeRate));
}
}
}

// For each ExchangeRate which is covered by multiple providers, ensure the rate 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 one, as well as many, providers for a specific currency
assertEquals(priceFromService, priceAvgFromProviders, "Service returned incorrect aggregate rate");
});
}

/**
* @param numberOfRatesAvailable Number of exchange rates this provider returns
* @return Dummy {@link ExchangeRateProvider} providing rates for "numberOfRatesAvailable" random currency codes
*/
private ExchangeRateProvider buildDummyExchangeRateProvider(int numberOfRatesAvailable) {
ExchangeRateProvider dummyProvider = new ExchangeRateProvider(
"ExchangeName-" + getRandomAlphaNumericString(5),
Expand All @@ -187,7 +268,42 @@ protected Set<ExchangeRate> doGet() {
for (int i = 0; i < numberOfRatesAvailable; i++) {
exchangeRates.add(new ExchangeRate(
"DUM-" + getRandomAlphaNumericString(3), // random symbol, avoid duplicates
0,
RandomUtils.nextDouble(1, 1000), // random price
System.currentTimeMillis(),
getName())); // ExchangeRateProvider name
}

return exchangeRates;
}
};

// Initialize provider
dummyProvider.start();
dummyProvider.stop();

return dummyProvider;
}

private ExchangeRateProvider buildDummyExchangeRateProvider(Set<String> rateCurrencyCodes) {
ExchangeRateProvider dummyProvider = new ExchangeRateProvider(
"ExchangeName-" + getRandomAlphaNumericString(5),
"EXCH-" + getRandomAlphaNumericString(3),
Duration.ofDays(1)) {

@Override
public boolean isRunning() {
return true;
}

@Override
protected Set<ExchangeRate> doGet() {
HashSet<ExchangeRate> exchangeRates = new HashSet<>();

// Simulate the required amount of rates
for (String rateCurrencyCode : rateCurrencyCodes) {
exchangeRates.add(new ExchangeRate(
rateCurrencyCode,
RandomUtils.nextDouble(1, 1000), // random price
System.currentTimeMillis(),
getName())); // ExchangeRateProvider name
}
Expand Down

0 comments on commit ff732aa

Please sign in to comment.