decred.org/dcrdex@v1.0.3/client/mm/price_oracle.go (about)

     1  // This code is available on the terms of the project LICENSE.md file,
     2  // also available online at https://blueoakcouncil.org/license/1.0.0.
     3  
     4  package mm
     5  
     6  import (
     7  	"context"
     8  	"encoding/json"
     9  	"errors"
    10  	"fmt"
    11  	"net/http"
    12  	"net/url"
    13  	"strings"
    14  	"sync"
    15  	"sync/atomic"
    16  	"time"
    17  
    18  	"decred.org/dcrdex/client/asset"
    19  	"decred.org/dcrdex/dex"
    20  	"decred.org/dcrdex/dex/dexnet"
    21  	"decred.org/dcrdex/dex/fiatrates"
    22  )
    23  
    24  const (
    25  	oraclePriceExpiration = time.Minute * 10
    26  	oracleRecheckInterval = time.Minute * 3
    27  
    28  	// If the total USD volume of all oracles is less than
    29  	// minimumUSDVolumeForOraclesAvg, the oracles will be ignored for
    30  	// pricing averages.
    31  	minimumUSDVolumeForOraclesAvg = 100_000
    32  )
    33  
    34  // MarketReport contains a market's rates on various exchanges and the fiat
    35  // rates of the base/quote assets.
    36  type MarketReport struct {
    37  	Price         float64         `json:"price"`
    38  	Oracles       []*OracleReport `json:"oracles"`
    39  	BaseFiatRate  float64         `json:"baseFiatRate"`
    40  	QuoteFiatRate float64         `json:"quoteFiatRate"`
    41  	BaseFees      *LotFeeRange    `json:"baseFees"`
    42  	QuoteFees     *LotFeeRange    `json:"quoteFees"`
    43  }
    44  
    45  // OracleReport is a summary of a market on an exchange.
    46  type OracleReport struct {
    47  	Host     string  `json:"host"`
    48  	USDVol   float64 `json:"usdVol"`
    49  	BestBuy  float64 `json:"bestBuy"`
    50  	BestSell float64 `json:"bestSell"`
    51  }
    52  
    53  // stampedPrice is used for caching price data that can expire.
    54  type cachedPrice struct {
    55  	stamp   time.Time
    56  	price   float64
    57  	oracles []*OracleReport
    58  }
    59  
    60  type marketPair struct {
    61  	baseID, quoteID uint32
    62  }
    63  
    64  func (m marketPair) String() string {
    65  	return fmt.Sprintf("%s-%s", dex.BipIDSymbol(m.baseID), dex.BipIDSymbol(m.quoteID))
    66  }
    67  
    68  type syncedMarket struct {
    69  	numSubscribers uint32
    70  	stopSync       context.CancelFunc
    71  }
    72  
    73  type priceOracle struct {
    74  	ctx context.Context
    75  	log dex.Logger
    76  
    77  	syncedMarketsMtx sync.RWMutex
    78  	syncedMarkets    map[marketPair]*syncedMarket
    79  
    80  	cachedPricesMtx sync.RWMutex
    81  	cachedPrices    map[marketPair]*cachedPrice
    82  }
    83  
    84  func newPriceOracle(ctx context.Context, log dex.Logger) *priceOracle {
    85  	oracle := &priceOracle{
    86  		ctx:           ctx,
    87  		cachedPrices:  make(map[marketPair]*cachedPrice),
    88  		syncedMarkets: make(map[marketPair]*syncedMarket),
    89  		log:           log,
    90  	}
    91  
    92  	go func() {
    93  		<-ctx.Done()
    94  		oracle.syncedMarketsMtx.Lock()
    95  		defer oracle.syncedMarketsMtx.Unlock()
    96  		for mkt, syncedMarket := range oracle.syncedMarkets {
    97  			syncedMarket.stopSync()
    98  			delete(oracle.syncedMarkets, mkt)
    99  		}
   100  	}()
   101  
   102  	return oracle
   103  }
   104  
   105  type oracle interface {
   106  	getMarketPrice(baseID, quoteID uint32) float64
   107  }
   108  
   109  var _ oracle = (*priceOracle)(nil)
   110  
   111  // getMarketPrice returns the volume weighted market rate for the specified
   112  // base/quote pair. This market rate is used as the "oracleRate" in the
   113  // basic market making strategy.
   114  func (o *priceOracle) getMarketPrice(baseID, quoteID uint32) float64 {
   115  	price, _, err := o.getOracleInfo(baseID, quoteID)
   116  	if err != nil {
   117  		return 0
   118  	}
   119  	return price
   120  }
   121  
   122  func (o *priceOracle) getCachedPrice(baseID, quoteID uint32) *cachedPrice {
   123  	o.cachedPricesMtx.RLock()
   124  	defer o.cachedPricesMtx.RUnlock()
   125  	return o.cachedPrices[marketPair{baseID, quoteID}]
   126  }
   127  
   128  // getOracleInfo returns the volume weighted market rate for a given base/quote pair
   129  // and details about the market on each available exchange that was used to determine
   130  // the market rate. This market rate is used as the "oracleRate" in the basic market
   131  // making strategy.
   132  func (o *priceOracle) getOracleInfo(baseID, quoteID uint32) (float64, []*OracleReport, error) {
   133  	cachedPrice := o.getCachedPrice(baseID, quoteID)
   134  	isAutoSyncing := o.marketIsAutoSyncing(baseID, quoteID)
   135  
   136  	if isAutoSyncing {
   137  		if cachedPrice == nil || time.Since(cachedPrice.stamp) > oraclePriceExpiration {
   138  			return 0, nil, fmt.Errorf("auto-synced market has an expired price")
   139  		}
   140  		o.log.Tracef("Returning cached price of synced market %s", marketPair{baseID, quoteID})
   141  		return cachedPrice.price, cachedPrice.oracles, nil
   142  	}
   143  
   144  	if cachedPrice != nil && time.Since(cachedPrice.stamp) < oracleRecheckInterval {
   145  		o.log.Tracef("Returning cached price of non synced market %s", marketPair{baseID, quoteID})
   146  		return cachedPrice.price, cachedPrice.oracles, nil
   147  	}
   148  
   149  	return o.syncMarket(baseID, quoteID)
   150  }
   151  
   152  func (o *priceOracle) marketIsAutoSyncing(baseID, quoteID uint32) bool {
   153  	o.syncedMarketsMtx.RLock()
   154  	defer o.syncedMarketsMtx.RUnlock()
   155  	_, found := o.syncedMarkets[marketPair{baseID, quoteID}]
   156  	return found
   157  }
   158  
   159  func (o *priceOracle) startAutoSyncingMarket(baseID, quoteID uint32) error {
   160  	mkt := marketPair{baseID, quoteID}
   161  
   162  	o.syncedMarketsMtx.Lock()
   163  	defer o.syncedMarketsMtx.Unlock()
   164  
   165  	if syncedMarket, found := o.syncedMarkets[mkt]; found {
   166  		syncedMarket.numSubscribers++
   167  		return nil
   168  	}
   169  
   170  	_, _, err := o.syncMarket(baseID, quoteID)
   171  	if err != nil {
   172  		return err
   173  	}
   174  
   175  	ctx, stopSync := context.WithCancel(o.ctx)
   176  	go func() {
   177  		timer := time.After(0)
   178  		for {
   179  			select {
   180  			case <-timer:
   181  				_, _, err := o.syncMarket(baseID, quoteID)
   182  				if err != nil {
   183  					o.log.Errorf("Error syncing market %s: %v", mkt, err)
   184  					timer = time.After(30 * time.Second)
   185  				} else {
   186  					timer = time.After(oracleRecheckInterval)
   187  				}
   188  			case <-ctx.Done():
   189  				return
   190  			}
   191  		}
   192  	}()
   193  
   194  	o.syncedMarkets[mkt] = &syncedMarket{
   195  		numSubscribers: 1,
   196  		stopSync:       stopSync,
   197  	}
   198  
   199  	return nil
   200  }
   201  
   202  func (o *priceOracle) stopAutoSyncingMarket(baseID, quoteID uint32) {
   203  	mkt := marketPair{baseID, quoteID}
   204  
   205  	o.syncedMarketsMtx.Lock()
   206  	defer o.syncedMarketsMtx.Unlock()
   207  
   208  	if syncedMarket, found := o.syncedMarkets[mkt]; found {
   209  		syncedMarket.numSubscribers--
   210  		if syncedMarket.numSubscribers == 0 {
   211  			syncedMarket.stopSync()
   212  			delete(o.syncedMarkets, mkt)
   213  		}
   214  	}
   215  }
   216  
   217  func (o *priceOracle) syncMarket(baseID, quoteID uint32) (float64, []*OracleReport, error) {
   218  	mkt := marketPair{baseID, quoteID}
   219  	price, oracles, err := fetchMarketPrice(o.ctx, baseID, quoteID, o.log)
   220  	if err != nil {
   221  		return 0, nil, fmt.Errorf("error fetching market price for %s: %v", mkt, err)
   222  	}
   223  
   224  	o.cachedPricesMtx.Lock()
   225  	defer o.cachedPricesMtx.Unlock()
   226  
   227  	o.cachedPrices[mkt] = &cachedPrice{
   228  		stamp:   time.Now(),
   229  		price:   price,   // Might be zero
   230  		oracles: oracles, // might be empty
   231  	}
   232  
   233  	return price, oracles, nil
   234  }
   235  
   236  func coinpapAsset(assetID uint32) (*fiatrates.CoinpaprikaAsset, error) {
   237  	if tkn := asset.TokenInfo(assetID); tkn != nil {
   238  		symbol := dex.BipIDSymbol(assetID)
   239  		symbol = strings.Split(symbol, ".")[0]
   240  		return &fiatrates.CoinpaprikaAsset{
   241  			AssetID: assetID,
   242  			Name:    tkn.Name,
   243  			Symbol:  symbol,
   244  		}, nil
   245  	}
   246  	a := asset.Asset(assetID)
   247  	if a == nil {
   248  		return nil, fmt.Errorf("unknown asset ID %d", assetID)
   249  	}
   250  	return &fiatrates.CoinpaprikaAsset{
   251  		AssetID: assetID,
   252  		Name:    a.Info.Name,
   253  		Symbol:  a.Symbol,
   254  	}, nil
   255  }
   256  
   257  func fetchMarketPrice(ctx context.Context, baseID, quoteID uint32, log dex.Logger) (float64, []*OracleReport, error) {
   258  	b, err := coinpapAsset(baseID)
   259  	if err != nil {
   260  		return 0, nil, err
   261  	}
   262  
   263  	q, err := coinpapAsset(quoteID)
   264  	if err != nil {
   265  		return 0, nil, err
   266  	}
   267  
   268  	oracles, err := oracleMarketReport(ctx, b, q, log)
   269  	if err != nil {
   270  		return 0, nil, err
   271  	}
   272  
   273  	price, usdVolume, err := oracleAverage(oracles, log)
   274  	if err != nil {
   275  		return 0, nil, err
   276  	}
   277  	if usdVolume < minimumUSDVolumeForOraclesAvg {
   278  		log.Meter("oracle_low_volume_"+b.Symbol+"_"+q.Symbol, 12*time.Hour).Infof(
   279  			"Rejecting oracle average price for %s. not enough volume (%.2f USD < %.2f)",
   280  			b.Symbol+"_"+q.Symbol, usdVolume, float32(minimumUSDVolumeForOraclesAvg),
   281  		)
   282  		return 0, oracles, nil
   283  	}
   284  	return price, oracles, err
   285  }
   286  
   287  func oracleAverage(mkts []*OracleReport, log dex.Logger) (rate, usdVolume float64, _ error) {
   288  	var weightedSum float64
   289  	var n int
   290  	for _, mkt := range mkts {
   291  		n++
   292  		weightedSum += mkt.USDVol * (mkt.BestBuy + mkt.BestSell) / 2
   293  		usdVolume += mkt.USDVol
   294  	}
   295  	if usdVolume == 0 {
   296  		return 0, 0, nil // No markets have data. OK.
   297  	}
   298  
   299  	rate = weightedSum / usdVolume
   300  	// TODO: Require a minimum USD volume?
   301  	log.Tracef("marketAveragedPrice: price calculated from %d markets: rate = %f, USD volume = %f", n, rate, usdVolume)
   302  	return rate, usdVolume, nil
   303  }
   304  
   305  func getRates(ctx context.Context, url string, thing any) (err error) {
   306  	return dexnet.Get(ctx, url, thing, dexnet.WithSizeLimit(1<<22))
   307  }
   308  
   309  func getHTTPWithCode(ctx context.Context, url string, thing any) (int, error) {
   310  	var code int
   311  	return code, dexnet.Get(ctx, url, thing, dexnet.WithSizeLimit(1<<22), dexnet.WithStatusFunc(func(c int) { code = c }))
   312  }
   313  
   314  // Truncates the URL to the domain name and TLD.
   315  func shortHost(addr string) (string, error) {
   316  	u, err := url.Parse(addr)
   317  	if u == nil {
   318  		return "", fmt.Errorf("error parsing URL %q: %v", addr, err)
   319  	}
   320  	// remove subdomains
   321  	parts := strings.Split(u.Host, ".")
   322  	if len(parts) < 2 {
   323  		return "", fmt.Errorf("not enough URL parts: %q", u.Host)
   324  	}
   325  	return parts[len(parts)-2] + "." + parts[len(parts)-1], nil
   326  }
   327  
   328  // spread fetches market data and returns the best buy and sell prices.
   329  // TODO: We may be able to do better. We could pull a small amount of market
   330  // book data and do a VWAP-like integration of, say, 1 DEX lot's worth.
   331  func spread(ctx context.Context, addr string, baseSymbol, quoteSymbol string, log dex.Logger) (sell, buy float64) {
   332  	host, err := shortHost(addr)
   333  	if err != nil {
   334  		log.Error(err)
   335  		return
   336  	}
   337  	s := spreaders[host]
   338  	if s == nil {
   339  		return 0, 0
   340  	}
   341  	sell, buy, err = s(ctx, baseSymbol, quoteSymbol, log)
   342  	if err != nil {
   343  		log.Meter("spread_"+addr, time.Hour*12).Errorf("Error getting spread from %q: %v", addr, err)
   344  		return 0, 0
   345  	}
   346  	return sell, buy
   347  }
   348  
   349  // oracleMarketReport fetches oracle price, spread, and volume data for known
   350  // exchanges for a market. This is done by fetching the market data from
   351  // coinpaprika, looking for known exchanges in the results, then pulling the
   352  // data directly from the exchange's public data API.
   353  func oracleMarketReport(ctx context.Context, b, q *fiatrates.CoinpaprikaAsset, log dex.Logger) (oracles []*OracleReport, err error) {
   354  	// They're going to return the quote prices in terms of USD, which is
   355  	// sort of nonsense for a non-USD market like DCR-BTC.
   356  	baseSlug := fiatrates.CoinpapSlug(b.Name, b.Symbol)
   357  	quoteSlug := fiatrates.CoinpapSlug(q.Name, q.Symbol)
   358  
   359  	type coinpapQuote struct {
   360  		Price  float64 `json:"price"`
   361  		Volume float64 `json:"volume_24h"`
   362  	}
   363  
   364  	type coinpapMarket struct {
   365  		BaseCurrencyID  string                   `json:"base_currency_id"`
   366  		QuoteCurrencyID string                   `json:"quote_currency_id"`
   367  		MarketURL       string                   `json:"market_url"`
   368  		LastUpdated     time.Time                `json:"last_updated"`
   369  		TrustScore      string                   `json:"trust_score"` // TrustScore appears to be deprecated?
   370  		Outlier         bool                     `json:"outlier"`
   371  		Quotes          map[string]*coinpapQuote `json:"quotes"`
   372  	}
   373  
   374  	var rawMarkets []*coinpapMarket
   375  	url := fmt.Sprintf("https://api.coinpaprika.com/v1/coins/%s/markets", baseSlug)
   376  	if err := getRates(ctx, url, &rawMarkets); err != nil {
   377  		return nil, err
   378  	}
   379  
   380  	convertIfNecessary := func(addr, slug string) string {
   381  		s, _ := shortHost(addr)
   382  		switch s {
   383  		case "coinbase.com":
   384  			switch slug {
   385  			case "usd-us-dollars":
   386  				return "usdc-usd-coin"
   387  			}
   388  		}
   389  		return slug
   390  	}
   391  
   392  	// Create filter for desirable matches.
   393  	marketMatches := func(mkt *coinpapMarket) bool {
   394  		if mkt.TrustScore != "high" || mkt.Outlier {
   395  			return false
   396  		}
   397  
   398  		if mkt.MarketURL == "" {
   399  			return false
   400  		}
   401  
   402  		if time.Since(mkt.LastUpdated) > time.Minute*30 {
   403  			return false
   404  		}
   405  
   406  		return (mkt.BaseCurrencyID == baseSlug && mkt.QuoteCurrencyID == quoteSlug) ||
   407  			(mkt.BaseCurrencyID == quoteSlug && mkt.QuoteCurrencyID == baseSlug)
   408  	}
   409  
   410  	var filteredResults []*coinpapMarket
   411  	for _, mkt := range rawMarkets {
   412  		mkt.BaseCurrencyID = convertIfNecessary(mkt.MarketURL, mkt.BaseCurrencyID)
   413  		mkt.QuoteCurrencyID = convertIfNecessary(mkt.MarketURL, mkt.QuoteCurrencyID)
   414  		if marketMatches(mkt) {
   415  			filteredResults = append(filteredResults, mkt)
   416  		}
   417  	}
   418  
   419  	addMarket := func(mkt *coinpapMarket, buy, sell float64) {
   420  		host, err := shortHost(mkt.MarketURL)
   421  		if err != nil {
   422  			log.Error(err)
   423  			return
   424  		}
   425  		oracle := &OracleReport{
   426  			Host:     host,
   427  			BestBuy:  buy,
   428  			BestSell: sell,
   429  		}
   430  		oracles = append(oracles, oracle)
   431  		usdQuote, found := mkt.Quotes["USD"]
   432  		if found {
   433  			oracle.USDVol = usdQuote.Volume
   434  		}
   435  	}
   436  
   437  	for _, mkt := range filteredResults {
   438  		if mkt.BaseCurrencyID == baseSlug {
   439  			buy, sell := spread(ctx, mkt.MarketURL, b.Symbol, q.Symbol, log)
   440  			if buy > 0 && sell > 0 {
   441  				// buy = 0, sell = 0 for any unknown markets
   442  				addMarket(mkt, buy, sell)
   443  			}
   444  		} else {
   445  			buy, sell := spread(ctx, mkt.MarketURL, q.Symbol, b.Symbol, log) // base and quote switched
   446  			if buy > 0 && sell > 0 {
   447  				addMarket(mkt, 1/sell, 1/buy) // inverted
   448  			}
   449  		}
   450  	}
   451  
   452  	return
   453  }
   454  
   455  // Spreader is a function that can generate market spread data for a known
   456  // exchange.
   457  type Spreader func(ctx context.Context, baseSymbol, quoteSymbol string, log dex.Logger) (sell, buy float64, err error)
   458  
   459  var spreaders = map[string]Spreader{
   460  	"binance.com":  fetchBinanceGlobalSpread,
   461  	"binance.us":   fetchBinanceUSSpread,
   462  	"coinbase.com": fetchCoinbaseSpread,
   463  	"bittrex.com":  fetchBittrexSpread,
   464  	"hitbtc.com":   fetchHitBTCSpread,
   465  	"exmo.com":     fetchEXMOSpread,
   466  }
   467  
   468  var binanceGlobalIs451, binanceUSIs451 atomic.Bool
   469  
   470  func fetchBinanceGlobalSpread(ctx context.Context, baseSymbol, quoteSymbol string, log dex.Logger) (sell, buy float64, err error) {
   471  	if binanceGlobalIs451.Load() {
   472  		return 0, 0, nil
   473  	}
   474  	return fetchBinanceSpread(ctx, baseSymbol, quoteSymbol, false, log)
   475  }
   476  
   477  func fetchBinanceUSSpread(ctx context.Context, baseSymbol, quoteSymbol string, log dex.Logger) (sell, buy float64, err error) {
   478  	if binanceUSIs451.Load() {
   479  		return 0, 0, nil
   480  	}
   481  	return fetchBinanceSpread(ctx, baseSymbol, quoteSymbol, true, log)
   482  }
   483  
   484  func fetchBinanceSpread(ctx context.Context, baseSymbol, quoteSymbol string, isUS bool, log dex.Logger) (sell, buy float64, err error) {
   485  	slug := fmt.Sprintf("%s%s", strings.ToUpper(baseSymbol), strings.ToUpper(quoteSymbol))
   486  	var url string
   487  	if isUS {
   488  		url = fmt.Sprintf("https://api.binance.us/api/v3/ticker/bookTicker?symbol=%s", slug)
   489  	} else {
   490  		url = fmt.Sprintf("https://api.binance.com/api/v3/ticker/bookTicker?symbol=%s", slug)
   491  	}
   492  
   493  	var resp struct {
   494  		BidPrice float64 `json:"bidPrice,string"`
   495  		AskPrice float64 `json:"askPrice,string"`
   496  	}
   497  
   498  	code, err := getHTTPWithCode(ctx, url, &resp)
   499  	if err != nil {
   500  		if code == http.StatusUnavailableForLegalReasons {
   501  			if isUS && binanceUSIs451.CompareAndSwap(false, true) {
   502  				log.Debugf("Binance U.S. responded with a 451. Disabling")
   503  			} else if !isUS && binanceGlobalIs451.CompareAndSwap(false, true) {
   504  				log.Debugf("Binance Global responded with a 451. Disabling")
   505  			}
   506  			return 0, 0, nil
   507  		}
   508  		return 0, 0, err
   509  	}
   510  
   511  	return resp.AskPrice, resp.BidPrice, nil
   512  }
   513  
   514  func fetchCoinbaseSpread(ctx context.Context, baseSymbol, quoteSymbol string, _ dex.Logger) (sell, buy float64, err error) {
   515  	slugSymbol := func(symbol string) string {
   516  		switch symbol {
   517  		case "usdc":
   518  			return "USD"
   519  		}
   520  		return strings.ToUpper(symbol)
   521  	}
   522  	slug := fmt.Sprintf("%s-%s", slugSymbol(baseSymbol), slugSymbol(quoteSymbol))
   523  	url := fmt.Sprintf("https://api.exchange.coinbase.com/products/%s/ticker", slug)
   524  
   525  	var resp struct {
   526  		Ask float64 `json:"ask,string"`
   527  		Bid float64 `json:"bid,string"`
   528  	}
   529  
   530  	return resp.Ask, resp.Bid, getRates(ctx, url, &resp)
   531  }
   532  
   533  func fetchBittrexSpread(ctx context.Context, baseSymbol, quoteSymbol string, _ dex.Logger) (sell, buy float64, err error) {
   534  	slug := fmt.Sprintf("%s-%s", strings.ToUpper(baseSymbol), strings.ToUpper(quoteSymbol))
   535  	url := fmt.Sprintf("https://api.bittrex.com/v3/markets/%s/ticker", slug)
   536  	var resp struct {
   537  		AskRate float64 `json:"askRate,string"`
   538  		BidRate float64 `json:"bidRate,string"`
   539  	}
   540  	return resp.AskRate, resp.BidRate, getRates(ctx, url, &resp)
   541  }
   542  
   543  func fetchHitBTCSpread(ctx context.Context, baseSymbol, quoteSymbol string, _ dex.Logger) (sell, buy float64, err error) {
   544  	slug := fmt.Sprintf("%s%s", strings.ToUpper(baseSymbol), strings.ToUpper(quoteSymbol))
   545  	url := fmt.Sprintf("https://api.hitbtc.com/api/3/public/orderbook/%s?depth=1", slug)
   546  
   547  	var resp struct {
   548  		Ask [][2]json.Number `json:"ask"`
   549  		Bid [][2]json.Number `json:"bid"`
   550  	}
   551  	if err := getRates(ctx, url, &resp); err != nil {
   552  		return 0, 0, err
   553  	}
   554  	if len(resp.Ask) < 1 || len(resp.Bid) < 1 {
   555  		return 0, 0, fmt.Errorf("not enough orders")
   556  	}
   557  
   558  	ask, err := resp.Ask[0][0].Float64()
   559  	if err != nil {
   560  		return 0, 0, fmt.Errorf("failed to decode ask price %q", resp.Ask[0][0])
   561  	}
   562  
   563  	bid, err := resp.Bid[0][0].Float64()
   564  	if err != nil {
   565  		return 0, 0, fmt.Errorf("failed to decode bid price %q", resp.Bid[0][0])
   566  	}
   567  
   568  	return ask, bid, nil
   569  }
   570  
   571  func fetchEXMOSpread(ctx context.Context, baseSymbol, quoteSymbol string, _ dex.Logger) (sell, buy float64, err error) {
   572  	slug := fmt.Sprintf("%s_%s", strings.ToUpper(baseSymbol), strings.ToUpper(quoteSymbol))
   573  	url := fmt.Sprintf("https://api.exmo.com/v1.1/order_book?pair=%s&limit=1", slug)
   574  
   575  	var resp map[string]*struct {
   576  		AskTop float64 `json:"ask_top,string"`
   577  		BidTop float64 `json:"bid_top,string"`
   578  	}
   579  
   580  	if err := getRates(ctx, url, &resp); err != nil {
   581  		return 0, 0, err
   582  	}
   583  
   584  	mkt := resp[slug]
   585  	if mkt == nil {
   586  		return 0, 0, errors.New("slug not in response")
   587  	}
   588  
   589  	return mkt.AskTop, mkt.BidTop, nil
   590  }