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 }