Currency Conversion with ECB Exchange Rates in Java

Building a currency conversion system using European Central Bank (ECB) exchange rates involves fetching real-time or historical forex data, parsing XML responses, and performing accurate currency calculations.


Understanding ECB Exchange Rate Data

The ECB provides daily reference exchange rates in XML format:

  • Base currency: EUR (Euro)
  • Update frequency: Daily around 16:00 CET
  • Format: XML with cube structure
  • URL: https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml

1. Basic Currency Conversion Setup

Domain Models

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.*;
public class CurrencyModel {
// Currency pair representation
public record CurrencyPair(String base, String target) {
public static CurrencyPair of(String base, String target) {
return new CurrencyPair(base.toUpperCase(), target.toUpperCase());
}
@Override
public String toString() {
return base + "/" + target;
}
}
// Exchange rate record
public record ExchangeRate(String currency, BigDecimal rate, LocalDate date) {
public ExchangeRate {
Objects.requireNonNull(currency, "Currency cannot be null");
Objects.requireNonNull(rate, "Rate cannot be null");
Objects.requireNonNull(date, "Date cannot be null");
}
@Override
public String toString() {
return String.format("%s: %.4f (%s)", currency, rate, date);
}
}
// Exchange rate response
public static class ExchangeRateResponse {
private final LocalDate date;
private final Map<String, ExchangeRate> rates;
public ExchangeRateResponse(LocalDate date, Map<String, ExchangeRate> rates) {
this.date = date;
this.rates = new HashMap<>(rates);
}
public LocalDate getDate() { return date; }
public Map<String, ExchangeRate> getRates() { return Collections.unmodifiableMap(rates); }
public Optional<ExchangeRate> getRate(String currency) { 
return Optional.ofNullable(rates.get(currency.toUpperCase())); 
}
public boolean containsCurrency(String currency) {
return rates.containsKey(currency.toUpperCase());
}
}
}

2. ECB XML Parser Implementation

import org.w3c.dom.*;
import javax.xml.parsers.*;
import java.io.*;
import java.math.BigDecimal;
import java.net.HttpURLConnection;
import java.net.URL;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.*;
public class ECBRateParser {
private static final String ECB_DAILY_RATES_URL = 
"https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml";
private static final String ECB_HISTORICAL_RATES_URL = 
"https://www.ecb.europa.eu/stats/eurofxref/eurofxref-hist.xml";
private final DocumentBuilder documentBuilder;
public ECBRateParser() {
try {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
// Security: disable external entity processing
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
factory.setXIncludeAware(false);
factory.setExpandEntityReferences(false);
this.documentBuilder = factory.newDocumentBuilder();
} catch (Exception e) {
throw new RuntimeException("Failed to create XML parser", e);
}
}
public CurrencyModel.ExchangeRateResponse fetchDailyRates() throws RateFetchException {
try {
String xmlContent = fetchXmlContent(ECB_DAILY_RATES_URL);
return parseRatesFromXml(xmlContent);
} catch (Exception e) {
throw new RateFetchException("Failed to fetch daily rates", e);
}
}
public Map<LocalDate, CurrencyModel.ExchangeRateResponse> fetchHistoricalRates() throws RateFetchException {
try {
String xmlContent = fetchXmlContent(ECB_HISTORICAL_RATES_URL);
return parseHistoricalRatesFromXml(xmlContent);
} catch (Exception e) {
throw new RateFetchException("Failed to fetch historical rates", e);
}
}
private String fetchXmlContent(String urlString) throws IOException {
HttpURLConnection connection = null;
try {
URL url = new URL(urlString);
connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("GET");
connection.setConnectTimeout(10000);
connection.setReadTimeout(10000);
connection.setRequestProperty("User-Agent", 
"Mozilla/5.0 (Java Currency Converter)");
int responseCode = connection.getResponseCode();
if (responseCode != 200) {
throw new IOException("HTTP error code: " + responseCode);
}
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(connection.getInputStream()))) {
StringBuilder content = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
content.append(line);
}
return content.toString();
}
} finally {
if (connection != null) {
connection.disconnect();
}
}
}
private CurrencyModel.ExchangeRateResponse parseRatesFromXml(String xmlContent) throws Exception {
Document document = documentBuilder.parse(new ByteArrayInputStream(xmlContent.getBytes()));
document.getDocumentElement().normalize();
// Find the latest time attribute
NodeList cubeNodes = document.getElementsByTagName("Cube");
LocalDate rateDate = null;
Map<String, CurrencyModel.ExchangeRate> rates = new HashMap<>();
for (int i = 0; i < cubeNodes.getLength(); i++) {
Node node = cubeNodes.item(i);
if (node.getNodeType() == Node.ELEMENT_NODE) {
Element element = (Element) node;
// Extract date from time attribute
if (element.hasAttribute("time")) {
String timeStr = element.getAttribute("time");
rateDate = LocalDate.parse(timeStr, DateTimeFormatter.ISO_LOCAL_DATE);
}
// Extract currency rates
if (element.hasAttribute("currency") && element.hasAttribute("rate")) {
String currency = element.getAttribute("currency");
BigDecimal rate = new BigDecimal(element.getAttribute("rate"));
rates.put(currency, new CurrencyModel.ExchangeRate(currency, rate, rateDate));
}
}
}
if (rateDate == null || rates.isEmpty()) {
throw new RateFetchException("No valid rate data found in XML");
}
return new CurrencyModel.ExchangeRateResponse(rateDate, rates);
}
private Map<LocalDate, CurrencyModel.ExchangeRateResponse> parseHistoricalRatesFromXml(String xmlContent) 
throws Exception {
Document document = documentBuilder.parse(new ByteArrayInputStream(xmlContent.getBytes()));
document.getDocumentElement().normalize();
Map<LocalDate, CurrencyModel.ExchangeRateResponse> historicalRates = new HashMap<>();
LocalDate currentDate = null;
Map<String, CurrencyModel.ExchangeRate> currentRates = new HashMap<>();
NodeList cubeNodes = document.getElementsByTagName("Cube");
for (int i = 0; i < cubeNodes.getLength(); i++) {
Node node = cubeNodes.item(i);
if (node.getNodeType() == Node.ELEMENT_NODE) {
Element element = (Element) node;
if (element.hasAttribute("time")) {
// Save previous day's rates if we have them
if (currentDate != null && !currentRates.isEmpty()) {
historicalRates.put(currentDate, 
new CurrencyModel.ExchangeRateResponse(currentDate, currentRates));
}
// Start new day
String timeStr = element.getAttribute("time");
currentDate = LocalDate.parse(timeStr, DateTimeFormatter.ISO_LOCAL_DATE);
currentRates = new HashMap<>();
}
if (element.hasAttribute("currency") && element.hasAttribute("rate") && currentDate != null) {
String currency = element.getAttribute("currency");
BigDecimal rate = new BigDecimal(element.getAttribute("rate"));
currentRates.put(currency, 
new CurrencyModel.ExchangeRate(currency, rate, currentDate));
}
}
}
// Don't forget the last day
if (currentDate != null && !currentRates.isEmpty()) {
historicalRates.put(currentDate, 
new CurrencyModel.ExchangeRateResponse(currentDate, currentRates));
}
return historicalRates;
}
public static class RateFetchException extends Exception {
public RateFetchException(String message) {
super(message);
}
public RateFetchException(String message, Throwable cause) {
super(message, cause);
}
}
}

3. Currency Conversion Engine

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.*;
public class CurrencyConverter {
private final ECBRateParser rateParser;
private CurrencyModel.ExchangeRateResponse currentRates;
private final Map<LocalDate, CurrencyModel.ExchangeRateResponse> historicalRates;
private final Set<String> supportedCurrencies;
public CurrencyConverter() {
this.rateParser = new ECBRateParser();
this.historicalRates = new HashMap<>();
this.supportedCurrencies = new HashSet<>();
initializeSupportedCurrencies();
}
private void initializeSupportedCurrencies() {
// ECB always uses EUR as base, plus all quoted currencies
supportedCurrencies.add("EUR");
supportedCurrencies.addAll(Arrays.asList(
"USD", "JPY", "BGN", "CZK", "DKK", "GBP", "HUF", "PLN", "RON", 
"SEK", "CHF", "ISK", "NOK", "HRK", "RUB", "TRY", "AUD", "BRL", 
"CAD", "CNY", "HKD", "IDR", "ILS", "INR", "KRW", "MXN", "MYR", 
"NZD", "PHP", "SGD", "THB", "ZAR"
));
}
public void refreshRates() throws ECBRateParser.RateFetchException {
this.currentRates = rateParser.fetchDailyRates();
// Add EUR to EUR rate (1:1)
currentRates.getRates().put("EUR", 
new CurrencyModel.ExchangeRate("EUR", BigDecimal.ONE, currentRates.getDate()));
}
public void loadHistoricalData() throws ECBRateParser.RateFetchException {
historicalRates.clear();
historicalRates.putAll(rateParser.fetchHistoricalRates());
}
public BigDecimal convert(BigDecimal amount, String fromCurrency, String toCurrency) 
throws ConversionException {
return convert(amount, fromCurrency, toCurrency, LocalDate.now());
}
public BigDecimal convert(BigDecimal amount, String fromCurrency, String toCurrency, LocalDate date) 
throws ConversionException {
validateInput(amount, fromCurrency, toCurrency);
if (fromCurrency.equalsIgnoreCase(toCurrency)) {
return amount.setScale(4, RoundingMode.HALF_UP);
}
CurrencyModel.ExchangeRateResponse rates = getRatesForDate(date);
return performConversion(amount, fromCurrency, toCurrency, rates);
}
private void validateInput(BigDecimal amount, String fromCurrency, String toCurrency) 
throws ConversionException {
if (amount == null || amount.compareTo(BigDecimal.ZERO) < 0) {
throw new ConversionException("Amount must be non-negative");
}
if (!isCurrencySupported(fromCurrency)) {
throw new ConversionException("Unsupported source currency: " + fromCurrency);
}
if (!isCurrencySupported(toCurrency)) {
throw new ConversionException("Unsupported target currency: " + toCurrency);
}
}
private CurrencyModel.ExchangeRateResponse getRatesForDate(LocalDate date) throws ConversionException {
if (date.isEqual(LocalDate.now()) && currentRates != null) {
return currentRates;
}
if (historicalRates.containsKey(date)) {
return historicalRates.get(date);
}
throw new ConversionException("No exchange rate data available for date: " + date);
}
private BigDecimal performConversion(BigDecimal amount, String fromCurrency, String toCurrency,
CurrencyModel.ExchangeRateResponse rates) throws ConversionException {
try {
// Since ECB uses EUR as base, we need to convert through EUR
BigDecimal fromRate = getRateToEUR(fromCurrency, rates);
BigDecimal toRate = getRateToEUR(toCurrency, rates);
// Convert: fromCurrency → EUR → toCurrency
BigDecimal amountInEUR = fromCurrency.equalsIgnoreCase("EUR") 
? amount 
: amount.divide(fromRate, 10, RoundingMode.HALF_UP);
BigDecimal result = toCurrency.equalsIgnoreCase("EUR")
? amountInEUR
: amountInEUR.multiply(toRate);
return result.setScale(4, RoundingMode.HALF_UP);
} catch (ArithmeticException e) {
throw new ConversionException("Conversion calculation error", e);
}
}
private BigDecimal getRateToEUR(String currency, CurrencyModel.ExchangeRateResponse rates) 
throws ConversionException {
if (currency.equalsIgnoreCase("EUR")) {
return BigDecimal.ONE;
}
CurrencyModel.ExchangeRate rate = rates.getRate(currency)
.orElseThrow(() -> new ConversionException("Exchange rate not found for currency: " + currency));
return rate.rate();
}
public boolean isCurrencySupported(String currency) {
return supportedCurrencies.contains(currency.toUpperCase());
}
public Set<String> getSupportedCurrencies() {
return Collections.unmodifiableSet(supportedCurrencies);
}
public LocalDate getLatestRateDate() {
return currentRates != null ? currentRates.getDate() : null;
}
public Map<String, BigDecimal> getAllRates() {
if (currentRates == null) {
return Collections.emptyMap();
}
Map<String, BigDecimal> rates = new HashMap<>();
currentRates.getRates().forEach((currency, rate) -> {
rates.put(currency, rate.rate());
});
return rates;
}
public static class ConversionException extends Exception {
public ConversionException(String message) {
super(message);
}
public ConversionException(String message, Throwable cause) {
super(message, cause);
}
}
}

4. Advanced Features and Caching

import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.*;
import java.util.concurrent.*;
public class AdvancedCurrencyConverter {
private final CurrencyConverter baseConverter;
private final Map<LocalDate, CurrencyModel.ExchangeRateResponse> rateCache;
private final ScheduledExecutorService scheduler;
private final long cacheDurationMinutes;
public AdvancedCurrencyConverter() {
this.baseConverter = new CurrencyConverter();
this.rateCache = new ConcurrentHashMap<>();
this.scheduler = Executors.newScheduledThreadPool(1);
this.cacheDurationMinutes = 60; // Refresh every hour
startScheduledRefresh();
}
private void startScheduledRefresh() {
scheduler.scheduleAtFixedRate(this::refreshCache, 0, cacheDurationMinutes, TimeUnit.MINUTES);
}
private void refreshCache() {
try {
baseConverter.refreshRates();
rateCache.clear();
System.out.println("Exchange rates refreshed at: " + new Date());
} catch (Exception e) {
System.err.println("Failed to refresh exchange rates: " + e.getMessage());
}
}
public BigDecimal convertWithFee(BigDecimal amount, String fromCurrency, String toCurrency, 
BigDecimal feePercent) throws CurrencyConverter.ConversionException {
BigDecimal convertedAmount = baseConverter.convert(amount, fromCurrency, toCurrency);
BigDecimal fee = convertedAmount.multiply(feePercent).divide(BigDecimal.valueOf(100), 4, RoundingMode.HALF_UP);
return convertedAmount.subtract(fee);
}
public Map<String, BigDecimal> convertToMultiple(BigDecimal amount, String fromCurrency, 
Set<String> toCurrencies) throws CurrencyConverter.ConversionException {
Map<String, BigDecimal> results = new HashMap<>();
for (String toCurrency : toCurrencies) {
try {
BigDecimal converted = baseConverter.convert(amount, fromCurrency, toCurrency);
results.put(toCurrency, converted);
} catch (CurrencyConverter.ConversionException e) {
System.err.println("Failed to convert to " + toCurrency + ": " + e.getMessage());
}
}
return results;
}
public BigDecimal calculatePercentageChange(String currency, LocalDate startDate, LocalDate endDate) 
throws CurrencyConverter.ConversionException {
BigDecimal startRate = getHistoricalRate(currency, startDate);
BigDecimal endRate = getHistoricalRate(currency, endDate);
return endRate.subtract(startRate)
.divide(startRate, 4, RoundingMode.HALF_UP)
.multiply(BigDecimal.valueOf(100));
}
private BigDecimal getHistoricalRate(String currency, LocalDate date) throws CurrencyConverter.ConversionException {
if (!rateCache.containsKey(date)) {
// Load historical data if not in cache
try {
baseConverter.loadHistoricalData();
} catch (ECBRateParser.RateFetchException e) {
throw new CurrencyConverter.ConversionException("Failed to load historical data", e);
}
}
CurrencyModel.ExchangeRateResponse rates = rateCache.get(date);
if (rates == null) {
throw new CurrencyConverter.ConversionException("No rates available for date: " + date);
}
return rates.getRate(currency)
.orElseThrow(() -> new CurrencyConverter.ConversionException("Rate not found for " + currency))
.rate();
}
public void shutdown() {
scheduler.shutdown();
try {
if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) {
scheduler.shutdownNow();
}
} catch (InterruptedException e) {
scheduler.shutdownNow();
Thread.currentThread().interrupt();
}
}
}

5. Usage Examples and Testing

import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Map;
import java.util.Set;
public class CurrencyConverterDemo {
public static void main(String[] args) {
try {
AdvancedCurrencyConverter converter = new AdvancedCurrencyConverter();
// Basic conversion
demoBasicConversion(converter);
// Multiple currency conversion
demoMultipleConversion(converter);
// Historical analysis
demoHistoricalAnalysis(converter);
// With fees
demoConversionWithFees(converter);
converter.shutdown();
} catch (Exception e) {
System.err.println("Demo failed: " + e.getMessage());
e.printStackTrace();
}
}
private static void demoBasicConversion(AdvancedCurrencyConverter converter) {
try {
System.out.println("=== Basic Currency Conversion ===");
BigDecimal amount = new BigDecimal("100.00");
BigDecimal converted = converter.convertWithFee(amount, "USD", "EUR", BigDecimal.ZERO);
System.out.printf("%s USD = %s EUR%n", amount, converted);
System.out.println("Latest rate date: " + converter.getLatestRateDate());
} catch (CurrencyConverter.ConversionException e) {
System.err.println("Conversion error: " + e.getMessage());
}
}
private static void demoMultipleConversion(AdvancedCurrencyConverter converter) {
try {
System.out.println("\n=== Multiple Currency Conversion ===");
BigDecimal amount = new BigDecimal("1000.00");
Set<String> targetCurrencies = Set.of("GBP", "JPY", "CAD", "AUD", "CHF");
Map<String, BigDecimal> results = converter.convertToMultiple(amount, "EUR", targetCurrencies);
System.out.printf("%s EUR converts to:%n", amount);
results.forEach((currency, value) -> 
System.out.printf("  %s: %s%n", currency, value));
} catch (CurrencyConverter.ConversionException e) {
System.err.println("Multiple conversion error: " + e.getMessage());
}
}
private static void demoHistoricalAnalysis(AdvancedCurrencyConverter converter) {
try {
System.out.println("\n=== Historical Analysis ===");
LocalDate startDate = LocalDate.now().minusMonths(1);
LocalDate endDate = LocalDate.now();
BigDecimal change = converter.calculatePercentageChange("USD", startDate, endDate);
System.out.printf("USD/EUR change from %s to %s: %.2f%%%n", 
startDate, endDate, change);
} catch (CurrencyConverter.ConversionException e) {
System.err.println("Historical analysis error: " + e.getMessage());
}
}
private static void demoConversionWithFees(AdvancedCurrencyConverter converter) {
try {
System.out.println("\n=== Conversion with Fees ===");
BigDecimal amount = new BigDecimal("500.00");
BigDecimal feePercent = new BigDecimal("1.5"); // 1.5% fee
BigDecimal withoutFee = converter.convertWithFee(amount, "GBP", "USD", BigDecimal.ZERO);
BigDecimal withFee = converter.convertWithFee(amount, "GBP", "USD", feePercent);
System.out.printf("%s GBP = %s USD (without fee)%n", amount, withoutFee);
System.out.printf("%s GBP = %s USD (with %.2f%% fee)%n", amount, withFee, feePercent);
System.out.printf("Fee amount: %s USD%n", withoutFee.subtract(withFee));
} catch (CurrencyConverter.ConversionException e) {
System.err.println("Fee conversion error: " + e.getMessage());
}
}
}

6. Error Handling and Validation

import java.math.BigDecimal;
import java.util.regex.Pattern;
public class CurrencyValidation {
private static final Pattern CURRENCY_PATTERN = Pattern.compile("[A-Z]{3}");
private static final Set<String> VALID_CURRENCIES = Set.of(
"EUR", "USD", "JPY", "GBP", "CHF", "CAD", "AUD", "CNY", "HKD", "NZD"
);
public static void validateCurrency(String currency) throws ValidationException {
if (currency == null || currency.trim().isEmpty()) {
throw new ValidationException("Currency code cannot be null or empty");
}
if (!CURRENCY_PATTERN.matcher(currency).matches()) {
throw new ValidationException("Invalid currency code format: " + currency);
}
if (!VALID_CURRENCIES.contains(currency.toUpperCase())) {
throw new ValidationException("Unsupported currency: " + currency);
}
}
public static void validateAmount(BigDecimal amount) throws ValidationException {
if (amount == null) {
throw new ValidationException("Amount cannot be null");
}
if (amount.compareTo(BigDecimal.ZERO) < 0) {
throw new ValidationException("Amount cannot be negative: " + amount);
}
if (amount.scale() > 4) {
throw new ValidationException("Amount has too many decimal places: " + amount);
}
}
public static void validateDateRange(LocalDate start, LocalDate end) throws ValidationException {
if (start == null || end == null) {
throw new ValidationException("Dates cannot be null");
}
if (start.isAfter(end)) {
throw new ValidationException("Start date cannot be after end date");
}
if (start.isAfter(LocalDate.now())) {
throw new ValidationException("Start date cannot be in the future");
}
}
public static class ValidationException extends Exception {
public ValidationException(String message) {
super(message);
}
}
}

7. Maven Dependencies

<dependencies>
<!-- For XML parsing -->
<dependency>
<groupId>xerces</groupId>
<artifactId>xercesImpl</artifactId>
<version>2.12.2</version>
</dependency>
<!-- For HTTP client (alternative to HttpURLConnection) -->
<dependency>
<groupId>org.apache.httpcomponents.client5</groupId>
<artifactId>httpclient5</artifactId>
<version>5.1.3</version>
</dependency>
<!-- For JSON support (if needed) -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.14.2</version>
</dependency>
<!-- For testing -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.9.2</version>
<scope>test</scope>
</dependency>
</dependencies>

Key Features Summary

  1. Real-time ECB Rates: Fetches daily exchange rates from ECB
  2. Historical Data: Supports historical rate analysis
  3. Accurate Calculations: Uses BigDecimal for precise financial calculations
  4. Caching: Implements caching for performance
  5. Error Handling: Comprehensive exception handling
  6. Thread Safety: Designed for concurrent usage
  7. Flexible API: Supports single and multiple currency conversions
  8. Fee Calculation: Built-in support for conversion fees
  9. Validation: Input validation and sanitization
  10. Scheduled Updates: Automatic rate refresh

This implementation provides a robust foundation for currency conversion applications that require accurate, up-to-date exchange rates from the European Central Bank.

Leave a Reply

Your email address will not be published. Required fields are marked *


Macro Nepal Helper