Currency: Add new forex provider exchangerate.host (#682)

* Add new forex provider ExchangeRateHost.io

* Fix linter paramTypeComine

* Add templates and README files

* Convert all times to UTC

* Fix cosmetic issue and address nits

* Add support for fx exchangerate.host engine override

* Address nit plus use remove plural
This commit is contained in:
Adrian Gallagher
2021-05-05 15:32:49 +10:00
committed by GitHub
parent 5d445991c7
commit 6ff453c364
20 changed files with 968 additions and 117 deletions

View File

@@ -6,6 +6,7 @@
+ Currency Layer support
+ Fixer.io support
+ Open Exchange Rates support
+ ExchangeRate.host support
### Please click GoDocs chevron above to view current GoDoc information for this package
{{template "contributions"}}

View File

@@ -0,0 +1,35 @@
{{define "currency forexprovider exchangerate.host" -}}
{{template "header" .}}
## Current Features for {{.Name}}
+ Fetches up to date curency data from [ExchangeRate.host API]("https://exchangerate.host")
### How to enable
+ [Enable via configuration](https://github.com/thrasher-corp/gocryptotrader/tree/master/config#enable-currency-via-config-example)
+ Individual package example below:
```go
import (
"github.com/thrasher-corp/gocryptotrader/currency/forexprovider/base"
"github.com/thrasher-corp/gocryptotrader/currency/forexprovider/exchangerate.host"
)
var c exchangeratehost.ExchangeRateHost
// Define configuration
newSettings := base.Settings{
Name: "ExchangeRateHost",
// ...
}
c.Setup(newSettings)
rates, err := c.GetRates("USD", "EUR,AUD")
// Handle error
```
### Please click GoDocs chevron above to view current GoDoc information for this package
{{template "contributions"}}
{{template "donations" .}}
{{- end}}

View File

@@ -1086,11 +1086,12 @@ func (c *Config) CheckCurrencyConfigValues() error {
count := 0
for i := range c.Currency.ForexProviders {
if c.Currency.ForexProviders[i].Enabled {
if c.Currency.ForexProviders[i].Name == "CurrencyConverter" &&
if (c.Currency.ForexProviders[i].Name == "CurrencyConverter" || c.Currency.ForexProviders[i].Name == "ExchangeRates") &&
c.Currency.ForexProviders[i].PrimaryProvider &&
(c.Currency.ForexProviders[i].APIKey == "" ||
c.Currency.ForexProviders[i].APIKey == DefaultUnsetAPIKey) {
log.Warnln(log.Global, "CurrencyConverter forex provider no longer supports unset API key requests. Switching to ExchangeRates FX provider..")
log.Warnf(log.Global, "%s forex provider no longer supports unset API key requests. Switching to %s FX provider..",
c.Currency.ForexProviders[i].Name, DefaultForexProviderExchangeRatesAPI)
c.Currency.ForexProviders[i].Enabled = false
c.Currency.ForexProviders[i].PrimaryProvider = false
c.Currency.ForexProviders[i].APIKey = DefaultUnsetAPIKey
@@ -1118,7 +1119,8 @@ func (c *Config) CheckCurrencyConfigValues() error {
if c.Currency.ForexProviders[x].Name == DefaultForexProviderExchangeRatesAPI {
c.Currency.ForexProviders[x].Enabled = true
c.Currency.ForexProviders[x].PrimaryProvider = true
log.Warnln(log.ConfigMgr, "Using ExchangeRatesAPI for default forex provider.")
log.Warnf(log.ConfigMgr, "No valid forex providers configured. Defaulting to %s.",
DefaultForexProviderExchangeRatesAPI)
}
}
}

View File

@@ -1209,7 +1209,7 @@ func TestGetForexProviders(t *testing.T) {
t.Error(err)
}
if r := cfg.GetForexProviders(); len(r) != 5 {
if r := cfg.GetForexProviders(); len(r) != 6 {
t.Error("unexpected length of forex providers")
}
}

View File

@@ -62,7 +62,7 @@ const (
DefaultUnsetAPIKey = "Key"
DefaultUnsetAPISecret = "Secret"
DefaultUnsetAccountPlan = "accountPlan"
DefaultForexProviderExchangeRatesAPI = "ExchangeRates"
DefaultForexProviderExchangeRatesAPI = "ExchangeRateHost"
)
// Variables here are used for configuration

View File

@@ -182,7 +182,7 @@ func TestGetRate(t *testing.T) {
c, err := NewConversion(from, to)
if err != nil {
t.Error(err)
t.Fatal(err)
}
rate, err := c.GetRate()
if err != nil {

View File

@@ -25,6 +25,7 @@ type BotOverrides struct {
FxCurrencyLayer bool
FxFixer bool
FxOpenExchangeRates bool
FxExchangeRateHost bool
}
// CoinmarketcapSettings refers to settings

View File

@@ -24,6 +24,7 @@ Join our slack to discuss all things related to GoCryptoTrader! [GoCryptoTrader
+ Currency Layer support
+ Fixer.io support
+ Open Exchange Rates support
+ ExchangeRate.host support
### Please click GoDocs chevron above to view current GoDoc information for this package

View File

@@ -0,0 +1,69 @@
# GoCryptoTrader package Exchangerate.Host
<img src="https://github.com/thrasher-corp/gocryptotrader/blob/master/web/src/assets/page-logo.png?raw=true" width="350px" height="350px" hspace="70">
[![Build Status](https://travis-ci.org/thrasher-corp/gocryptotrader.svg?branch=master)](https://travis-ci.org/thrasher-corp/gocryptotrader)
[![Software License](https://img.shields.io/badge/License-MIT-orange.svg?style=flat-square)](https://github.com/thrasher-corp/gocryptotrader/blob/master/LICENSE)
[![GoDoc](https://godoc.org/github.com/thrasher-corp/gocryptotrader?status.svg)](https://godoc.org/github.com/thrasher-corp/gocryptotrader/currency/forexprovider/exchangerate.host)
[![Coverage Status](http://codecov.io/github/thrasher-corp/gocryptotrader/coverage.svg?branch=master)](http://codecov.io/github/thrasher-corp/gocryptotrader?branch=master)
[![Go Report Card](https://goreportcard.com/badge/github.com/thrasher-corp/gocryptotrader)](https://goreportcard.com/report/github.com/thrasher-corp/gocryptotrader)
This exchangerate.host package is part of the GoCryptoTrader codebase.
## This is still in active development
You can track ideas, planned features and what's in progress on this Trello board: [https://trello.com/b/ZAhMhpOy/gocryptotrader](https://trello.com/b/ZAhMhpOy/gocryptotrader).
Join our slack to discuss all things related to GoCryptoTrader! [GoCryptoTrader Slack](https://join.slack.com/t/gocryptotrader/shared_invite/enQtNTQ5NDAxMjA2Mjc5LTc5ZDE1ZTNiOGM3ZGMyMmY1NTAxYWZhODE0MWM5N2JlZDk1NDU0YTViYzk4NTk3OTRiMDQzNGQ1YTc4YmRlMTk)
## Current Features for exchangerate.host
+ Fetches up to date curency data from [ExchangeRate.host API]("https://exchangerate.host")
### How to enable
+ [Enable via configuration](https://github.com/thrasher-corp/gocryptotrader/tree/master/config#enable-currency-via-config-example)
+ Individual package example below:
```go
import (
"github.com/thrasher-corp/gocryptotrader/currency/forexprovider/base"
"github.com/thrasher-corp/gocryptotrader/currency/forexprovider/exchangerate.host"
)
var c exchangeratehost.ExchangeRateHost
// Define configuration
newSettings := base.Settings{
Name: "ExchangeRateHost",
// ...
}
c.Setup(newSettings)
rates, err := c.GetRates("USD", "EUR,AUD")
// Handle error
```
### Please click GoDocs chevron above to view current GoDoc information for this package
## Contribution
Please feel free to submit any pull requests or suggest any desired features to be added.
When submitting a PR, please abide by our coding guidelines:
+ Code must adhere to the official Go [formatting](https://golang.org/doc/effective_go.html#formatting) guidelines (i.e. uses [gofmt](https://golang.org/cmd/gofmt/)).
+ Code must be documented adhering to the official Go [commentary](https://golang.org/doc/effective_go.html#commentary) guidelines.
+ Code must adhere to our [coding style](https://github.com/thrasher-corp/gocryptotrader/blob/master/doc/coding_style.md).
+ Pull requests need to be based on and opened against the `master` branch.
## Donations
<img src="https://github.com/thrasher-corp/gocryptotrader/blob/master/web/src/assets/donate.png?raw=true" hspace="70">
If this framework helped you in any way, or you would like to support the developers working on it, please donate Bitcoin to:
***bc1qk0jareu4jytc0cfrhr5wgshsq8282awpavfahc***

View File

@@ -0,0 +1,262 @@
package exchangeratehost
import (
"context"
"errors"
"net/http"
"net/url"
"strconv"
"time"
"github.com/thrasher-corp/gocryptotrader/common"
"github.com/thrasher-corp/gocryptotrader/currency/forexprovider/base"
"github.com/thrasher-corp/gocryptotrader/exchanges/request"
)
// A client for the exchangerate.host API. NOTE: The format and callback
// parameters aren't supported as they're not needed for this implementation.
// Furthermore, the source is set to "ECB" as default
const (
timeLayout = "2006-01-02"
exchangeRateHostURL = "https://api.exchangerate.host"
)
var (
// DefaultSource uses the ecb for forex rates
DefaultSource = "ecb"
)
// Setup sets up the ExchangeRateHost config
func (e *ExchangeRateHost) Setup(config base.Settings) error {
e.Name = config.Name
e.Enabled = config.Enabled
e.RESTPollingDelay = config.RESTPollingDelay
e.Verbose = config.Verbose
e.PrimaryProvider = config.PrimaryProvider
e.Requester = request.New(e.Name,
common.NewHTTPClientWithTimeout(base.DefaultTimeOut))
return nil
}
// GetLatestRates returns a list of forex rates based on the supplied params
func (e *ExchangeRateHost) GetLatestRates(baseCurrency, symbols string, amount float64, places int64, source string) (*LatestRates, error) {
v := url.Values{}
if baseCurrency != "" {
v.Set("base", baseCurrency)
}
if symbols != "" {
v.Set("symbols", symbols)
}
if amount != 0 {
v.Set("amount", strconv.FormatFloat(amount, 'f', -1, 64))
}
if places != 0 {
v.Set("places", strconv.FormatInt(places, 10))
}
targetSource := DefaultSource
if source != "" {
targetSource = source
}
v.Set("source", targetSource)
var l LatestRates
return &l, e.SendHTTPRequest("latest", v, &l)
}
// ConvertCurrency converts a currency based on the supplied params
func (e *ExchangeRateHost) ConvertCurrency(from, to, baseCurrency, symbols, source string, date time.Time, amount float64, places int64) (*ConvertCurrency, error) {
v := url.Values{}
if from != "" {
v.Set("from", from)
}
if to != "" {
v.Set("to", to)
}
if !date.IsZero() {
v.Set("date", date.UTC().Format(timeLayout))
}
if baseCurrency != "" {
v.Set("base", baseCurrency)
}
if symbols != "" {
v.Set("symbols", symbols)
}
if amount != 0 {
v.Set("amount", strconv.FormatFloat(amount, 'f', -1, 64))
}
if places != 0 {
v.Set("places", strconv.FormatInt(places, 10))
}
targetSource := DefaultSource
if source != "" {
targetSource = source
}
v.Set("source", targetSource)
var c ConvertCurrency
return &c, e.SendHTTPRequest("convert", v, &c)
}
// GetHistoricalRates returns a list of historical rates based on the supplied params
func (e *ExchangeRateHost) GetHistoricalRates(date time.Time, baseCurrency, symbols string, amount float64, places int64, source string) (*HistoricRates, error) {
v := url.Values{}
if date.IsZero() {
date = time.Now()
}
fmtDate := date.UTC().Format(timeLayout)
v.Set("date", fmtDate)
if baseCurrency != "" {
v.Set("base", baseCurrency)
}
if symbols != "" {
v.Set("symbols", symbols)
}
if amount != 0 {
v.Set("amount", strconv.FormatFloat(amount, 'f', -1, 64))
}
if places != 0 {
v.Set("places", strconv.FormatInt(places, 10))
}
targetSource := DefaultSource
if source != "" {
targetSource = source
}
v.Set("source", targetSource)
var h HistoricRates
return &h, e.SendHTTPRequest(fmtDate, v, &h)
}
// GetTimeSeries returns time series forex data based on the supplied params
func (e *ExchangeRateHost) GetTimeSeries(startDate, endDate time.Time, baseCurrency, symbols string, amount float64, places int64, source string) (*TimeSeries, error) {
if startDate.IsZero() || endDate.IsZero() {
return nil, errors.New("startDate and endDate must be set")
}
if startDate.After(endDate) || startDate.Equal(endDate) {
return nil, errors.New("startDate and endDate must be set correctly")
}
v := url.Values{}
v.Set("start_date", startDate.UTC().Format(timeLayout))
v.Set("end_date", endDate.UTC().Format(timeLayout))
if baseCurrency != "" {
v.Set("base", baseCurrency)
}
if symbols != "" {
v.Set("symbols", symbols)
}
if amount != 0 {
v.Set("amount", strconv.FormatFloat(amount, 'f', -1, 64))
}
if places != 0 {
v.Set("places", strconv.FormatInt(places, 10))
}
targetSource := DefaultSource
if source != "" {
targetSource = source
}
v.Set("source", targetSource)
var t TimeSeries
return &t, e.SendHTTPRequest("timeseries", v, &t)
}
// GetFluctuations returns a list of forex price fluctuations based on the supplied params
func (e *ExchangeRateHost) GetFluctuations(startDate, endDate time.Time, baseCurrency, symbols string, amount float64, places int64, source string) (*Fluctuations, error) {
if startDate.IsZero() || endDate.IsZero() {
return nil, errors.New("startDate and endDate must be set")
}
if startDate.After(endDate) || startDate.Equal(endDate) {
return nil, errors.New("startDate and endDate must be set correctly")
}
v := url.Values{}
v.Set("start_date", startDate.UTC().Format(timeLayout))
v.Set("end_date", endDate.UTC().Format(timeLayout))
if baseCurrency != "" {
v.Set("base", baseCurrency)
}
if symbols != "" {
v.Set("symbols", symbols)
}
if amount != 0 {
v.Set("amount", strconv.FormatFloat(amount, 'f', -1, 64))
}
if places != 0 {
v.Set("places", strconv.FormatInt(places, 10))
}
targetSource := DefaultSource
if source != "" {
targetSource = source
}
v.Set("source", targetSource)
var f Fluctuations
return &f, e.SendHTTPRequest("fluctuation", v, &f)
}
// GetSupportedSymbols returns a list of supported symbols
func (e *ExchangeRateHost) GetSupportedSymbols() (*SupportedSymbols, error) {
var s SupportedSymbols
return &s, e.SendHTTPRequest("symbols", url.Values{}, &s)
}
// GetSupportedCurrencies returns a list of supported currencies
func (e *ExchangeRateHost) GetSupportedCurrencies() ([]string, error) {
s, err := e.GetSupportedSymbols()
if err != nil {
return nil, err
}
var symbols []string
for x := range s.Symbols {
symbols = append(symbols, x)
}
return symbols, nil
}
// GetRates returns the forex rates based on the supplied base currency and symbols
func (e *ExchangeRateHost) GetRates(baseCurrency, symbols string) (map[string]float64, error) {
l, err := e.GetLatestRates(baseCurrency, symbols, 0, 0, "")
if err != nil {
return nil, err
}
rates := make(map[string]float64)
for k, v := range l.Rates {
rates[baseCurrency+k] = v
}
return rates, nil
}
// SendHTTPRequest sends a typical get request
func (e *ExchangeRateHost) SendHTTPRequest(endpoint string, v url.Values, result interface{}) error {
path := common.EncodeURLValues(exchangeRateHostURL+"/"+endpoint, v)
return e.Requester.SendPayload(context.Background(), &request.Item{
Method: http.MethodGet,
Path: path,
Result: &result,
Verbose: e.Verbose,
})
}

View File

@@ -0,0 +1,107 @@
package exchangeratehost
import (
"os"
"testing"
"time"
"github.com/thrasher-corp/gocryptotrader/currency/forexprovider/base"
)
var (
e ExchangeRateHost
testCurrencies = "USD,EUR,CZK"
)
func TestMain(t *testing.M) {
e.Setup(base.Settings{
Name: "ExchangeRateHost",
})
os.Exit(t.Run())
}
func TestGetLatestRates(t *testing.T) {
_, err := e.GetLatestRates("USD", testCurrencies, 1200, 2, "")
if err != nil {
t.Error(err)
}
}
func TestConvertCurrency(t *testing.T) {
_, err := e.ConvertCurrency("USD", "EUR", "", testCurrencies, "", time.Now(), 1200, 2)
if err != nil {
t.Error(err)
}
}
func TestGetHistoricRates(t *testing.T) {
_, err := e.GetHistoricalRates(time.Time{}, "AUD", testCurrencies, 1200, 2, "")
if err != nil {
t.Error(err)
}
}
func TestGetTimeSeriesRates(t *testing.T) {
_, err := e.GetTimeSeries(time.Time{}, time.Now(), "USD", testCurrencies, 1200, 2, "")
if err == nil {
t.Error("empty start time show throw an error")
}
tmNow := time.Now()
_, err = e.GetTimeSeries(tmNow, tmNow, "USD", testCurrencies, 1200, 2, "")
if err == nil {
t.Error("equal times show throw an error")
}
tmStart := tmNow.AddDate(0, -3, 0)
_, err = e.GetTimeSeries(tmStart, tmNow, "USD", testCurrencies, 1200, 2, "")
if err != nil {
t.Error(err)
}
}
func TestGetFluctuationData(t *testing.T) {
_, err := e.GetFluctuations(time.Time{}, time.Now(), "USD", testCurrencies, 1200, 2, "")
if err == nil {
t.Error("empty start time show throw an error")
}
tmNow := time.Now()
_, err = e.GetFluctuations(tmNow, tmNow, "USD", testCurrencies, 1200, 2, "")
if err == nil {
t.Error("equal times show throw an error")
}
tmStart := tmNow.AddDate(0, -3, 0)
_, err = e.GetFluctuations(tmStart, tmNow, "USD", testCurrencies, 1200, 2, "")
if err != nil {
t.Error(err)
}
}
func TestGetSupportedSymbols(t *testing.T) {
r, err := e.GetSupportedSymbols()
if err != nil {
t.Fatal(err)
}
_, ok := r.Symbols["AUD"]
if !ok {
t.Error("should contain AUD")
}
}
func TestGetGetSupportedCurrencies(t *testing.T) {
s, err := e.GetSupportedCurrencies()
if err != nil {
t.Fatal(err)
}
if len(s) == 0 {
t.Error("supported currencies should be greater than 0")
}
}
func TestGetRates(t *testing.T) {
r, err := e.GetRates("USD", "")
if err != nil {
t.Fatal(err)
}
if rate := r["USDAUD"]; rate == 0 {
t.Error("rate of USDAUD should be set")
}
}

View File

@@ -0,0 +1,91 @@
package exchangeratehost
import (
"github.com/thrasher-corp/gocryptotrader/currency/forexprovider/base"
"github.com/thrasher-corp/gocryptotrader/exchanges/request"
)
// ExchangeRateHost stores the struct for the exchangerate.host API
type ExchangeRateHost struct {
base.Base
Requester *request.Requester
}
// MessageOfTheDay stores the message of the day
type MessageOfTheDay struct {
Message string `json:"msg"`
DonationURL string `json:"url"`
}
// LatestRates stores the latest forex rates
type LatestRates struct {
MessageOfTheDay MessageOfTheDay `json:"motd"`
Success bool `json:"success"`
Base string `json:"base"`
Date string `json:"date"`
Rates map[string]float64 `json:"rates"`
}
// ConvertCurrency stores currency conversion data
type ConvertCurrency struct {
MessageOfTheDay MessageOfTheDay `json:"motd"`
Query struct {
From string `json:"from"`
To string `json:"to"`
Amount float64 `json:"amount"`
} `json:"query"`
Info struct {
Rate float64 `json:"rate"`
} `json:"info"`
Historical bool `json:"historical"`
Date string `json:"date"`
Result float64 `json:"result"`
}
// HistoricRates stores the hostoric rates
type HistoricRates struct {
LatestRates
Historical bool `json:"historical"`
}
// TimeSeries stores time series data
type TimeSeries struct {
MessageOfTheDay MessageOfTheDay `json:"motd"`
Success bool `json:"success"`
TimeSeries bool `json:"timeseries"`
Base string `json:"base"`
StartDate string `json:"start_date"`
EndDate string `json:"end_date"`
Rates map[string]map[string]float64 `json:"rates"`
}
// Fluctuation stores an individual rate flucutation
type Fluctuation struct {
StartRate float64 `json:"start_rate"`
EndRate float64 `json:"end_rate"`
Change float64 `json:"change"`
ChangePercentage float64 `json:"change_pct"`
}
// Fluctuations stores a collection of rate fluctuations
type Fluctuations struct {
MessageOfTheDay MessageOfTheDay `json:"motd"`
Success bool `json:"success"`
Flucutation bool `json:"fluctuation"`
StartDate string `json:"start_date"`
EndDate string `json:"end_date"`
Rates map[string]Fluctuation `json:"rate"`
}
// Symbol stores an individual symbol
type Symbol struct {
Description string `json:"description"`
Code string `json:"code"`
}
// SupportedSymbols store a collection of supported symbols
type SupportedSymbols struct {
MessageOfTheDay MessageOfTheDay `json:"motd"`
Success bool `json:"success"`
Symbols map[string]Symbol `json:"symbols"`
}

View File

@@ -6,7 +6,9 @@ import (
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/thrasher-corp/gocryptotrader/common"
"github.com/thrasher-corp/gocryptotrader/currency/forexprovider/base"
@@ -16,18 +18,31 @@ import (
// Setup sets appropriate values for CurrencyLayer
func (e *ExchangeRates) Setup(config base.Settings) error {
if config.APIKey == "" {
return errors.New("API key must be set")
}
e.Name = config.Name
e.Enabled = config.Enabled
e.RESTPollingDelay = config.RESTPollingDelay
e.Verbose = config.Verbose
e.PrimaryProvider = config.PrimaryProvider
e.APIKey = config.APIKey
e.APIKeyLvl = config.APIKeyLvl
e.Requester = request.New(e.Name,
common.NewHTTPClientWithTimeout(base.DefaultTimeOut),
request.WithLimiter(request.NewBasicRateLimit(rateLimitInterval, requestRate)))
return nil
}
func cleanCurrencies(baseCurrency, symbols string) string {
func (e *ExchangeRates) cleanCurrencies(baseCurrency, symbols string) string {
if len(e.supportedCurrencies) == 0 {
supportedCurrencies, err := e.GetSupportedCurrencies()
if err != nil {
log.Warnf(log.Global, "ExchangeRatesAPI unable to fetch supported currencies: %s", err)
} else {
e.supportedCurrencies = supportedCurrencies
}
}
var cleanedCurrencies []string
symbols = strings.Replace(symbols, "RUR", "RUB", -1)
var s = strings.Split(symbols, ",")
@@ -47,34 +62,45 @@ func cleanCurrencies(baseCurrency, symbols string) string {
}
// remove and warn about any unsupported currencies
if !strings.Contains(exchangeRatesSupportedCurrencies, x) { // nolint:gocritic
log.Warnf(log.Global,
"Forex provider ExchangeRatesAPI does not support currency %s, removing from forex rates query.\n", x)
continue
if len(e.supportedCurrencies) > 0 {
if !strings.Contains(strings.Join(e.supportedCurrencies, ","), x) {
log.Warnf(log.Global,
"Forex provider ExchangeRatesAPI does not support currency %s, removing from forex rates query.\n", x)
continue
}
}
cleanedCurrencies = append(cleanedCurrencies, x)
}
return strings.Join(cleanedCurrencies, ",")
}
// GetSymbols returns a list of supported symbols
func (e *ExchangeRates) GetSymbols() (map[string]string, error) {
resp := struct {
Symbols map[string]string `json:"symbols"`
}{}
return resp.Symbols, e.SendHTTPRequest("symbols", url.Values{}, &resp)
}
// GetLatestRates returns a map of forex rates based on the supplied params
// baseCurrency - USD [optional] The base currency to use for forex rates, defaults to EUR
// symbols - AUD,USD [optional] The symbols to query the forex rates for, default is
// all supported currencies
func (e *ExchangeRates) GetLatestRates(baseCurrency, symbols string) (Rates, error) {
func (e *ExchangeRates) GetLatestRates(baseCurrency, symbols string) (*Rates, error) {
vals := url.Values{}
if len(baseCurrency) > 0 {
if len(baseCurrency) > 0 && e.APIKeyLvl <= apiKeyFree && !strings.EqualFold("EUR", baseCurrency) {
return nil, errCannotSetBaseCurrencyOnFreePlan
} else if len(baseCurrency) > 0 {
vals.Set("base", baseCurrency)
}
if len(symbols) > 0 {
symbols = cleanCurrencies(baseCurrency, symbols)
symbols = e.cleanCurrencies(baseCurrency, symbols)
vals.Set("symbols", symbols)
}
var result Rates
return result, e.SendHTTPRequest(exchangeRatesLatest, vals, &result)
return &result, e.SendHTTPRequest(exchangeRatesLatest, vals, &result)
}
// GetHistoricalRates returns historical exchange rate data for all available or
@@ -83,20 +109,48 @@ func (e *ExchangeRates) GetLatestRates(baseCurrency, symbols string) (Rates, err
// baseCurrency - USD [optional] The base currency to use for forex rates, defaults to EUR
// symbols - AUD,USD [optional] The symbols to query the forex rates for, default is
// all supported currencies
func (e *ExchangeRates) GetHistoricalRates(date, baseCurrency string, symbols []string) (HistoricalRates, error) {
func (e *ExchangeRates) GetHistoricalRates(date time.Time, baseCurrency string, symbols []string) (*HistoricalRates, error) {
if date.IsZero() {
return nil, errors.New("a date must be specified")
}
var resp HistoricalRates
v := url.Values{}
if len(symbols) > 0 {
s := cleanCurrencies(baseCurrency, strings.Join(symbols, ","))
v.Set("symbols", s)
}
if len(baseCurrency) > 0 {
if len(baseCurrency) > 0 && e.APIKeyLvl <= apiKeyFree && !strings.EqualFold("EUR", baseCurrency) {
return nil, errCannotSetBaseCurrencyOnFreePlan
} else if len(baseCurrency) > 0 {
v.Set("base", baseCurrency)
}
return resp, e.SendHTTPRequest(date, v, &resp)
if len(symbols) > 0 {
s := e.cleanCurrencies(baseCurrency, strings.Join(symbols, ","))
v.Set("symbols", s)
}
return &resp, e.SendHTTPRequest(date.UTC().Format(timeLayout), v, &resp)
}
// ConvertCurrency converts a currency based on the supplied params
func (e *ExchangeRates) ConvertCurrency(from, to string, amount float64, date time.Time) (*ConvertCurrency, error) {
if e.APIKeyLvl <= apiKeyFree {
return nil, errAPIKeyLevelRestrictedAccess
}
vals := url.Values{}
if from == "" || to == "" || amount == 0 {
return nil, errors.New("from, to and amount must be set")
}
vals.Set("from", from)
vals.Set("to", to)
vals.Set("amount", strconv.FormatFloat(amount, 'e', -1, 64))
if !date.IsZero() {
vals.Set("date", date.UTC().Format(timeLayout))
}
var cc ConvertCurrency
return &cc, e.SendHTTPRequest(exchangeRatesConvert, vals, &cc)
}
// GetTimeSeriesRates returns daily historical exchange rate data between two
@@ -106,26 +160,63 @@ func (e *ExchangeRates) GetHistoricalRates(date, baseCurrency string, symbols []
// baseCurrency - USD [optional] The base currency to use for forex rates, defaults to EUR
// symbols - AUD,USD [optional] The symbols to query the forex rates for, default is
// all supported currencies
func (e *ExchangeRates) GetTimeSeriesRates(startDate, endDate, baseCurrency string, symbols []string) (TimeSeriesRates, error) {
var resp TimeSeriesRates
if startDate == "" || endDate == "" {
return resp, errors.New("startDate and endDate params must be set")
func (e *ExchangeRates) GetTimeSeriesRates(startDate, endDate time.Time, baseCurrency string, symbols []string) (*TimeSeriesRates, error) {
if e.APIKeyLvl <= apiKeyFree {
return nil, errAPIKeyLevelRestrictedAccess
}
if startDate.IsZero() || endDate.IsZero() {
return nil, errors.New("startDate and endDate params must be set")
}
if startDate.After(endDate) {
return nil, errors.New("startDate must be before endDate")
}
v := url.Values{}
v.Set("start_at", startDate)
v.Set("end_at", endDate)
v.Set("start_date", startDate.UTC().Format(timeLayout))
v.Set("end_date", endDate.UTC().Format(timeLayout))
if len(baseCurrency) > 0 {
if baseCurrency != "" {
v.Set("base", baseCurrency)
}
if len(symbols) > 0 {
s := cleanCurrencies(baseCurrency, strings.Join(symbols, ","))
s := e.cleanCurrencies(baseCurrency, strings.Join(symbols, ","))
v.Set("symbols", s)
}
return resp, e.SendHTTPRequest(exchangeRatesHistory, v, &resp)
var resp TimeSeriesRates
return &resp, e.SendHTTPRequest(exchangeRatesTimeSeries, v, &resp)
}
// GetFluctuations returns rate fluctuations based on the supplied params
func (e *ExchangeRates) GetFluctuations(startDate, endDate time.Time, baseCurrency, symbols string) (*Fluctuations, error) {
if e.APIKeyLvl <= apiKeyFree {
return nil, errAPIKeyLevelRestrictedAccess
}
if startDate.IsZero() || endDate.IsZero() {
return nil, errors.New("startDate and endDate must be set")
}
if startDate.After(endDate) {
return nil, errors.New("startDate must be before endDate")
}
v := url.Values{}
v.Set("start_date", startDate.UTC().Format(timeLayout))
v.Set("end_date", endDate.UTC().Format(timeLayout))
if baseCurrency != "" {
v.Set("base", baseCurrency)
}
if symbols != "" {
v.Set("symbols", symbols)
}
var f Fluctuations
return &f, e.SendHTTPRequest(exchangeRatesFluctuation, v, &f)
}
// GetRates is a wrapper function to return forex rates
@@ -146,19 +237,38 @@ func (e *ExchangeRates) GetRates(baseCurrency, symbols string) (map[string]float
// GetSupportedCurrencies returns the supported currency list
func (e *ExchangeRates) GetSupportedCurrencies() ([]string, error) {
return strings.Split(exchangeRatesSupportedCurrencies, ","), nil
symbols, err := e.GetSymbols()
if err != nil {
return nil, err
}
var supportedCurrencies []string
for x := range symbols {
supportedCurrencies = append(supportedCurrencies, x)
}
e.supportedCurrencies = supportedCurrencies
return supportedCurrencies, nil
}
// SendHTTPRequest sends a HTTPS request to the desired endpoint and returns the result
func (e *ExchangeRates) SendHTTPRequest(endPoint string, values url.Values, result interface{}) error {
path := common.EncodeURLValues(exchangeRatesAPI+"/"+endPoint, values)
if e.APIKey == "" {
return errors.New("api key must be set")
}
values.Set("access_key", e.APIKey)
protocolScheme := "https://"
if e.APIKeyLvl == apiKeyFree {
protocolScheme = "http://"
}
path := common.EncodeURLValues(protocolScheme+exchangeRatesAPI+"/v1/"+endPoint, values)
err := e.Requester.SendPayload(context.Background(), &request.Item{
Method: http.MethodGet,
Path: path,
Result: &result,
Verbose: e.Verbose})
Result: result,
Verbose: e.Verbose,
})
if err != nil {
return fmt.Errorf("exchangeRatesAPI SendHTTPRequest error %s with path %s",
return fmt.Errorf("exchangeRatesAPI: SendHTTPRequest error %s with path %s",
err,
path)
}

View File

@@ -1,45 +1,78 @@
package exchangerates
import (
"errors"
"os"
"testing"
"time"
"github.com/thrasher-corp/gocryptotrader/currency/forexprovider/base"
)
var e ExchangeRates
var initialSetup bool
const (
apiKey = ""
apiKeyLevel = apiKeyFree // Adjust this if your API key level is different
)
func setup() {
func TestMain(t *testing.M) {
e.Setup(base.Settings{
Name: "ExchangeRates",
Enabled: true,
Name: "ExchangeRates",
APIKey: apiKey,
APIKeyLvl: apiKeyLevel,
})
initialSetup = true
os.Exit(t.Run())
}
func isAPIKeySet() bool {
return e.APIKey != ""
}
func TestGetSymbols(t *testing.T) {
if !isAPIKeySet() {
t.Skip("API key not set, skipping test")
}
r, err := e.GetSymbols()
if err != nil {
t.Fatal(err)
}
if len(r) == 0 {
t.Error("expected rates map greater than 0")
}
}
func TestGetLatestRates(t *testing.T) {
if !initialSetup {
setup()
if !isAPIKeySet() {
t.Skip("API key not set, skipping test")
}
result, err := e.GetLatestRates("USD", "")
result, err := e.GetLatestRates("", "")
if err != nil {
t.Fatalf("failed to GetLatestRates. Err: %s", err)
t.Fatal(err)
}
if result.Base != "USD" {
t.Fatalf("unexepcted result. Base currency should be USD")
if result.Base != "EUR" {
t.Fatalf("unexepcted result. Base currency should be EUR")
}
if result.Rates["USD"] != 1 {
t.Fatalf("unexepcted result. USD value should be 1")
if result.Rates["EUR"] != 1 {
t.Fatalf("unexepcted result. EUR value should be 1")
}
if len(result.Rates) <= 1 {
t.Fatalf("unexepcted result. Rates map should be 1")
}
result, err = e.GetLatestRates("", "AUD")
if e.APIKeyLvl <= apiKeyFree {
_, err = e.GetLatestRates("USD", "")
if !errors.Is(err, errCannotSetBaseCurrencyOnFreePlan) {
t.Errorf("expected: %s, got %s", errCannotSetBaseCurrencyOnFreePlan, err)
}
}
result, err = e.GetLatestRates("EUR", "AUD")
if err != nil {
t.Fatalf("failed to GetLatestRates. Err: %s", err)
}
@@ -53,70 +86,143 @@ func TestGetLatestRates(t *testing.T) {
}
}
func TestGetHistoricalRates(t *testing.T) {
if !isAPIKeySet() {
t.Skip("API key not set, skipping test")
}
_, err := e.GetHistoricalRates(time.Time{}, "EUR", []string{"AUD"})
if err == nil {
t.Fatalf("invalid date should throw an error")
}
if e.APIKeyLvl <= apiKeyFree {
_, err = e.GetHistoricalRates(time.Now(), "USD", []string{"AUD"})
if !errors.Is(err, errCannotSetBaseCurrencyOnFreePlan) {
t.Errorf("expected: %s, got %s", errCannotSetBaseCurrencyOnFreePlan, err)
}
}
_, err = e.GetHistoricalRates(time.Now(), "EUR", []string{"AUD,USD"})
if err != nil {
t.Error(err)
}
}
func TestConvertCurrency(t *testing.T) {
if !isAPIKeySet() {
t.Skip("API key not set, skipping test")
}
if e.APIKeyLvl <= apiKeyFree {
_, err := e.ConvertCurrency("USD", "AUD", 1000, time.Time{})
if !errors.Is(err, errAPIKeyLevelRestrictedAccess) {
t.Errorf("expected: %s, got %s", errAPIKeyLevelRestrictedAccess, err)
}
return
}
_, err := e.ConvertCurrency("", "AUD", 1000, time.Time{})
if err == nil {
t.Errorf("no from currency should throw an error")
}
_, err = e.ConvertCurrency("USD", "AUD", 1000, time.Now())
if err != nil {
t.Error(err)
}
}
func TestGetTimeSeriesRates(t *testing.T) {
if !isAPIKeySet() {
t.Skip("API key not set, skipping test")
}
if e.APIKeyLvl <= apiKeyFree {
_, err := e.GetTimeSeriesRates(time.Time{}, time.Time{}, "EUR", []string{"EUR,USD"})
if !errors.Is(err, errAPIKeyLevelRestrictedAccess) {
t.Errorf("expected %s, got %s", errAPIKeyLevelRestrictedAccess, err)
}
return
}
_, err := e.GetTimeSeriesRates(time.Time{}, time.Time{}, "USD", []string{"EUR", "USD"})
if err == nil {
t.Fatal("empty startDate endDate params should throw an error")
}
tmNow := time.Now()
_, err = e.GetTimeSeriesRates(tmNow.AddDate(0, 1, 0), tmNow, "USD", []string{"EUR", "USD"})
if err == nil {
t.Fatal("future startTime should throw an error")
}
_, err = e.GetTimeSeriesRates(tmNow.AddDate(0, -1, 0), tmNow, "EUR", []string{"AUD,USD"})
if err != nil {
t.Error(err)
}
}
func TestGetFluctuation(t *testing.T) {
if !isAPIKeySet() {
t.Skip("API key not set, skipping test")
}
if e.APIKeyLvl <= apiKeyFree {
_, err := e.GetFluctuations(time.Time{}, time.Time{}, "EUR", "")
if !errors.Is(err, errAPIKeyLevelRestrictedAccess) {
t.Errorf("expected: %s, got %s", errAPIKeyLevelRestrictedAccess, err)
}
return
}
tmNow := time.Now()
_, err := e.GetFluctuations(tmNow.AddDate(0, -1, 0), tmNow, "EUR", "")
if err != nil {
t.Fatal(err)
}
}
func TestCleanCurrencies(t *testing.T) {
if !initialSetup {
setup()
if !isAPIKeySet() {
t.Skip("API key not set, skipping test")
}
result := cleanCurrencies("USD", "USD,AUD")
result := e.cleanCurrencies("EUR", "EUR,AUD")
if result != "AUD" {
t.Fatalf("unexpected result. AUD should be the only symbol")
t.Fatalf("AUD should be the only symbol")
}
result = cleanCurrencies("", "EUR,USD")
if result != "USD" {
t.Fatalf("unexpected result. USD should be the only symbol")
}
if cleanCurrencies("EUR", "RUR") != "RUB" {
if e.cleanCurrencies("EUR", "RUR") != "RUB" {
t.Fatalf("unexpected result. RUB should be the only symbol")
}
if cleanCurrencies("EUR", "AUD,BLA") != "AUD" {
t.Fatalf("unexpected result. AUD should be the only symbol")
if e.cleanCurrencies("EUR", "AUD,BLA") != "AUD" {
t.Fatalf("AUD should be the only symbol")
}
}
func TestGetRates(t *testing.T) {
if !initialSetup {
setup()
if !isAPIKeySet() {
t.Skip("API key not set, skipping test")
}
_, err := e.GetRates("USD", "AUD")
_, err := e.GetRates("EUR", "")
if err != nil {
t.Fatalf("failed to GetRates. Err: %s", err)
}
}
func TestGetHistoricalRates(t *testing.T) {
if !initialSetup {
setup()
}
_, err := e.GetHistoricalRates("-1", "USD", []string{"AUD"})
if err == nil {
t.Fatalf("unexpected result. Invalid date should throw an error")
func TestGetSupportedCurrencies(t *testing.T) {
if !isAPIKeySet() {
t.Skip("API key not set, skipping test")
}
_, err = e.GetHistoricalRates("2010-01-12", "USD", []string{"EUR,USD"})
r, err := e.GetSupportedCurrencies()
if err != nil {
t.Fatalf("failed to GetHistoricalRates. Err: %s", err)
}
}
func TestGetTimeSeriesRates(t *testing.T) {
if !initialSetup {
setup()
}
_, err := e.GetTimeSeriesRates("", "", "USD", []string{"EUR", "USD"})
if err == nil {
t.Fatal("unexpected result. Empty startDate endDate params should throw an error")
}
_, err = e.GetTimeSeriesRates("2018-01-01", "2018-09-01", "USD", []string{"EUR,USD"})
if err != nil {
t.Fatalf("failed to TestGetTimeSeriesRates. Err: %s", err)
}
_, err = e.GetTimeSeriesRates("-1", "-1", "USD", []string{"EUR,USD"})
if err == nil {
t.Fatal("unexpected result. Invalid date params should throw an error")
t.Fatal(err)
}
if len(r) == 0 {
t.Error("expected greater than zero supported symbols")
}
}

View File

@@ -1,6 +1,7 @@
package exchangerates
import (
"errors"
"time"
"github.com/thrasher-corp/gocryptotrader/currency/forexprovider/base"
@@ -8,37 +9,85 @@ import (
)
const (
exchangeRatesAPI = "https://api.exchangeratesapi.io"
exchangeRatesLatest = "latest"
exchangeRatesHistory = "history"
exchangeRatesSupportedCurrencies = "EUR,CHF,USD,BRL,ISK,PHP,KRW,BGN,MXN," +
"RON,CAD,SGD,NZD,THB,HKD,JPY,NOK,HRK,ILS,GBP,DKK,HUF,MYR,RUB,TRY,IDR," +
"ZAR,INR,AUD,CZK,SEK,CNY,PLN"
exchangeRatesAPI = "api.exchangeratesapi.io"
exchangeRatesLatest = "latest"
exchangeRatesTimeSeries = "timeseries"
exchangeRatesConvert = "convert"
exchangeRatesFluctuation = "fluctuation"
rateLimitInterval = time.Second * 10
requestRate = 10
timeLayout = "2006-01-02"
apiKeyFree = iota
apiKeyBasic
apiKeyProfessional
apiKeyBusiness
)
var (
errCannotSetBaseCurrencyOnFreePlan = errors.New("base currency cannot be set on the free plan")
errAPIKeyLevelRestrictedAccess = errors.New("apiKey level function access denied")
)
// ExchangeRates stores the struct for the ExchangeRatesAPI API
type ExchangeRates struct {
base.Base
Requester *request.Requester
supportedCurrencies []string
Requester *request.Requester
}
// Rates holds the latest forex rates info
type Rates struct {
Base string `json:"base"`
Date string `json:"date"`
Rates map[string]float64 `json:"rates"`
Base string `json:"base"`
Timestamp int64 `json:"timestamp"`
Date string `json:"date"`
Rates map[string]float64 `json:"rates"`
}
// HistoricalRates stores the historical rate info
type HistoricalRates Rates
type HistoricalRates struct {
Historical bool `json:"historical"`
Rates
}
// ConvertCurrency stores the converted currency info
type ConvertCurrency struct {
Query struct {
From string `json:"from"`
To string `json:"to"`
Amount float64 `json:"amount"`
} `json:"query"`
Info struct {
Timestamp int64 `json:"timestamp"`
Rate float64 `json:"rate"`
} `json:"info"`
Historical bool `json:"historical"`
Result float64 `json:"result"`
}
// TimeSeriesRates stores time series rate info
type TimeSeriesRates struct {
Base string `json:"base"`
StartAt string `json:"start_at"`
EndAt string `json:"end_at"`
Rates map[string]interface{} `json:"rates"`
Timeseries bool `json:"timeseries"`
StartDate string `json:"start_date"`
EndDate string `json:"end_date"`
Base string `json:"base"`
Rates map[string]map[string]float64 `json:"rates"`
}
// FlucutationItem stores an individual rate fluctuation
type FlucutationItem struct {
StartRate float64 `json:"start_rate"`
EndRate float64 `json:"end_rate"`
Change float64 `json:"change"`
ChangePercentage float64 `json:"change_pct"`
}
// Fluctuations stores a collection of rate fluctuations
type Fluctuations struct {
Fluctuation bool `json:"fluctuation"`
StartDate string `json:"start_date"`
EndDate string `json:"end_date"`
Base string `json:"base"`
Rates map[string]FlucutationItem `json:"rates"`
}

View File

@@ -8,6 +8,7 @@ import (
"github.com/thrasher-corp/gocryptotrader/currency/forexprovider/base"
currencyconverter "github.com/thrasher-corp/gocryptotrader/currency/forexprovider/currencyconverterapi"
"github.com/thrasher-corp/gocryptotrader/currency/forexprovider/currencylayer"
exchangeratehost "github.com/thrasher-corp/gocryptotrader/currency/forexprovider/exchangerate.host"
exchangerates "github.com/thrasher-corp/gocryptotrader/currency/forexprovider/exchangeratesapi.io"
fixer "github.com/thrasher-corp/gocryptotrader/currency/forexprovider/fixer.io"
"github.com/thrasher-corp/gocryptotrader/currency/forexprovider/openexchangerates"
@@ -15,21 +16,24 @@ import (
// GetSupportedForexProviders returns a list of supported forex providers
func GetSupportedForexProviders() []string {
return []string{"CurrencyConverter",
return []string{
"CurrencyConverter",
"CurrencyLayer",
"ExchangeRates",
"Fixer",
"OpenExchangeRates"}
"OpenExchangeRates",
"ExchangeRateHost",
}
}
// NewDefaultFXProvider returns the default forex provider (currencyconverterAPI)
func NewDefaultFXProvider() *ForexProviders {
handler := new(ForexProviders)
provider := new(exchangerates.ExchangeRates)
provider := new(exchangeratehost.ExchangeRateHost)
err := provider.Setup(base.Settings{
PrimaryProvider: true,
Enabled: true,
Name: "ExchangeRates",
Name: "ExchangeRateHost",
})
if err != nil {
panic(err)

View File

@@ -139,6 +139,13 @@ func (s *Storage) RunUpdater(overrides BotOverrides, settings *MainConfiguration
fxSettings = append(fxSettings,
base.Settings(settings.ForexProviders[i]))
}
case "ExchangeRateHost":
if overrides.FxExchangeRateHost || settings.ForexProviders[i].Enabled {
settings.ForexProviders[i].Enabled = true
fxSettings = append(fxSettings,
base.Settings(settings.ForexProviders[i]))
}
}
}

View File

@@ -313,6 +313,7 @@ func PrintSettings(s *Settings) {
gctlog.Debugf(gctlog.Global, "\t Enable currency layer: %v", s.EnableCurrencyLayer)
gctlog.Debugf(gctlog.Global, "\t Enable fixer: %v", s.EnableFixer)
gctlog.Debugf(gctlog.Global, "\t Enable OpenExchangeRates: %v", s.EnableOpenExchangeRates)
gctlog.Debugf(gctlog.Global, "\t Enable ExchangeRateHost: %v", s.EnableExchangeRateHost)
gctlog.Debugf(gctlog.Global, "- EXCHANGE SETTINGS:")
gctlog.Debugf(gctlog.Global, "\t Enable exchange auto pair updates: %v", s.EnableExchangeAutoPairUpdates)
gctlog.Debugf(gctlog.Global, "\t Disable all exchange auto pair updates: %v", s.DisableExchangeAutoPairUpdates)
@@ -412,13 +413,15 @@ func (bot *Engine) Start() error {
bot.Settings.EnableCurrencyConverter ||
bot.Settings.EnableCurrencyLayer ||
bot.Settings.EnableFixer ||
bot.Settings.EnableOpenExchangeRates {
bot.Settings.EnableOpenExchangeRates ||
bot.Settings.EnableExchangeRateHost {
err = currency.RunStorageUpdater(currency.BotOverrides{
Coinmarketcap: bot.Settings.EnableCoinmarketcapAnalysis,
FxCurrencyConverter: bot.Settings.EnableCurrencyConverter,
FxCurrencyLayer: bot.Settings.EnableCurrencyLayer,
FxFixer: bot.Settings.EnableFixer,
FxOpenExchangeRates: bot.Settings.EnableOpenExchangeRates,
FxExchangeRateHost: bot.Settings.EnableExchangeRateHost,
},
&currency.MainConfiguration{
ForexProviders: bot.Config.GetForexProviders(),
@@ -563,7 +566,8 @@ func (bot *Engine) Stop() {
bot.Settings.EnableCurrencyConverter ||
bot.Settings.EnableCurrencyLayer ||
bot.Settings.EnableFixer ||
bot.Settings.EnableOpenExchangeRates {
bot.Settings.EnableOpenExchangeRates ||
bot.Settings.EnableExchangeRateHost {
if err := currency.ShutdownStorageUpdater(); err != nil {
gctlog.Errorf(gctlog.Global, "ExchangeSettings storage system. Error: %v", err)
}

View File

@@ -52,6 +52,7 @@ type Settings struct {
EnableCurrencyLayer bool
EnableFixer bool
EnableOpenExchangeRates bool
EnableExchangeRateHost bool
// Exchange tuning settings
EnableExchangeHTTPRateLimiter bool

View File

@@ -73,6 +73,7 @@ func main() {
flag.BoolVar(&settings.EnableCurrencyLayer, "currencylayer", false, "overrides config and sets up foreign exchange Currency Layer")
flag.BoolVar(&settings.EnableFixer, "fixer", false, "overrides config and sets up foreign exchange Fixer.io")
flag.BoolVar(&settings.EnableOpenExchangeRates, "openexchangerates", false, "overrides config and sets up foreign exchange Open Exchange Rates")
flag.BoolVar(&settings.EnableExchangeRateHost, "exchangeratehost", false, "overrides config and sets up foreign exchange ExchangeRate.host")
// Exchange tuning settings
flag.BoolVar(&settings.EnableExchangeAutoPairUpdates, "exchangeautopairupdates", false, "enables automatic available currency pair updates for supported exchanges")