decred.org/dcrdex@v1.0.5/dex/fiatrates/oracle.go (about)

     1  package fiatrates
     2  
     3  import (
     4  	"context"
     5  	"errors"
     6  	"fmt"
     7  	"strings"
     8  	"sync"
     9  	"time"
    10  
    11  	"decred.org/dcrdex/dex"
    12  )
    13  
    14  const (
    15  	// FiatRateDataExpiry : Any data older than FiatRateDataExpiry will be
    16  	// discarded.
    17  	FiatRateDataExpiry = 60 * time.Minute
    18  
    19  	// averageRateRefreshInterval is how long it'll take before a fresh fiat
    20  	// average rate is calculated.
    21  	averageRateRefreshInterval = defaultRefreshInterval + time.Minute
    22  )
    23  
    24  // Oracle manages and retrieves fiat rate information from all enabled rate
    25  // sources.
    26  type Oracle struct {
    27  	log      dex.Logger
    28  	sources  []*source
    29  	ratesMtx sync.RWMutex
    30  	rates    map[string]*FiatRateInfo
    31  
    32  	listenersMtx sync.RWMutex
    33  	listeners    map[string]chan<- map[string]*FiatRateInfo
    34  }
    35  
    36  func NewFiatOracle(cfg Config, tickerSymbols string, log dex.Logger) (*Oracle, error) {
    37  	fiatOracle := &Oracle{
    38  		log:       log,
    39  		rates:     make(map[string]*FiatRateInfo),
    40  		sources:   fiatSources(cfg),
    41  		listeners: make(map[string]chan<- map[string]*FiatRateInfo),
    42  	}
    43  
    44  	tickers := strings.Split(tickerSymbols, ",")
    45  	for _, ticker := range tickers {
    46  		_, ok := dex.BipSymbolID(strings.ToLower(ticker))
    47  		if !ok {
    48  			return nil, fmt.Errorf("unknown asset %s", ticker)
    49  		}
    50  
    51  		// Initialize entry for this asset.
    52  		fiatOracle.rates[parseTicker(ticker)] = new(FiatRateInfo)
    53  	}
    54  
    55  	if len(fiatOracle.rates) == 0 {
    56  		return nil, errors.New("a minimum of one ticker is expected to configure fiat oracle")
    57  	}
    58  
    59  	return fiatOracle, nil
    60  }
    61  
    62  // tickers retrieves all tickers that data can be fetched for.
    63  func (o *Oracle) tickers() []string {
    64  	o.ratesMtx.RLock()
    65  	defer o.ratesMtx.RUnlock()
    66  	var tickers []string
    67  	for ticker := range o.rates {
    68  		tickers = append(tickers, ticker)
    69  	}
    70  	return tickers
    71  }
    72  
    73  // Rates returns the current fiat rates. Returns an empty map if there are no
    74  // valid rates.
    75  func (o *Oracle) Rates() map[string]*FiatRateInfo {
    76  	o.ratesMtx.RLock()
    77  	defer o.ratesMtx.RUnlock()
    78  	rates := make(map[string]*FiatRateInfo, len(o.rates))
    79  	for ticker, rate := range o.rates {
    80  		if rate.Value > 0 && time.Since(rate.LastUpdate) < FiatRateDataExpiry {
    81  			r := *rate
    82  			rates[ticker] = &r
    83  		}
    84  	}
    85  	return rates
    86  }
    87  
    88  // Run starts goroutines that refresh fiat rates every source.refreshInterval.
    89  // This should be called in a goroutine as it's blocking.
    90  func (o *Oracle) Run(ctx context.Context) {
    91  	var wg sync.WaitGroup
    92  	var sourcesEnabled int
    93  	for i := range o.sources {
    94  		fiatSource := o.sources[i]
    95  		if fiatSource.isDisabled() {
    96  			o.log.Infof("Fiat rate source %q is disabled...", fiatSource.name)
    97  			continue
    98  		}
    99  
   100  		o.fetchFromSource(ctx, fiatSource, &wg)
   101  		sourcesEnabled++
   102  
   103  		// Fetch rates now.
   104  		newRates, err := fiatSource.getRates(ctx, o.tickers(), o.log)
   105  		if err != nil {
   106  			o.log.Errorf("failed to retrieve rate from %s: %v", fiatSource.name, err)
   107  			continue
   108  		}
   109  
   110  		fiatSource.mtx.Lock()
   111  		fiatSource.rates = newRates
   112  		fiatSource.lastRefresh = time.Now()
   113  		fiatSource.mtx.Unlock()
   114  	}
   115  
   116  	// Calculate average fiat rate now.
   117  	o.calculateAverageRate()
   118  
   119  	if sourcesEnabled > 0 {
   120  		// Start a goroutine to generate an average fiat rate based on fresh
   121  		// data from all enabled sources. This is done every
   122  		// averageRateRefreshInterval.
   123  		wg.Add(1)
   124  		go func() {
   125  			defer wg.Done()
   126  			ticker := time.NewTicker(averageRateRefreshInterval)
   127  			defer ticker.Stop()
   128  
   129  			for {
   130  				select {
   131  				case <-ctx.Done():
   132  					return
   133  				case <-ticker.C:
   134  					reActivatedSources := o.calculateAverageRate()
   135  					for _, index := range reActivatedSources {
   136  						s := o.sources[index]
   137  						// Start a new goroutine for this source.
   138  						o.fetchFromSource(ctx, s, &wg)
   139  					}
   140  				}
   141  			}
   142  		}()
   143  	}
   144  
   145  	wg.Wait()
   146  
   147  	o.listenersMtx.Lock()
   148  	for id, rateChan := range o.listeners {
   149  		close(rateChan) // we are done sending fiat rates
   150  		delete(o.listeners, id)
   151  	}
   152  	o.listenersMtx.Unlock()
   153  }
   154  
   155  // AddFiatRateListener adds a new fiat rate listener for the provided uniqueID.
   156  // Overrides existing rateChan if uniqueID already exists.
   157  func (o *Oracle) AddFiatRateListener(uniqueID string, ratesChan chan<- map[string]*FiatRateInfo) {
   158  	o.listenersMtx.Lock()
   159  	defer o.listenersMtx.Unlock()
   160  	o.listeners[uniqueID] = ratesChan
   161  }
   162  
   163  // RemoveFiatRateListener removes a fiat rate listener. no-op if there's no
   164  // listener for the provided uniqueID. The fiat rate chan will be closed to
   165  // signal to readers that we are done sending.
   166  func (o *Oracle) RemoveFiatRateListener(uniqueID string) {
   167  	o.listenersMtx.Lock()
   168  	defer o.listenersMtx.Unlock()
   169  	rateChan, ok := o.listeners[uniqueID]
   170  	if !ok {
   171  		return
   172  	}
   173  
   174  	delete(o.listeners, uniqueID)
   175  	close(rateChan) // we are done sending.
   176  }
   177  
   178  // notifyListeners sends the provided rates to all listener.
   179  func (o *Oracle) notifyListeners(rates map[string]*FiatRateInfo) {
   180  	o.listenersMtx.RLock()
   181  	defer o.listenersMtx.RUnlock()
   182  	for _, rateChan := range o.listeners {
   183  		rateChan <- rates
   184  	}
   185  }
   186  
   187  // calculateAverageRate is a shared function to support fiat average rate
   188  // calculations before and after averageRateRefreshInterval.
   189  func (o *Oracle) calculateAverageRate() []int {
   190  	var reActivatedSourceIndexes []int
   191  	newRatesInfo := make(map[string]*fiatRateAndSourceCount)
   192  	for i := range o.sources {
   193  		s := o.sources[i]
   194  		if s.isDisabled() {
   195  			if s.checkIfSourceCanReactivate() {
   196  				reActivatedSourceIndexes = append(reActivatedSourceIndexes, i)
   197  			}
   198  			continue
   199  		}
   200  
   201  		s.mtx.RLock()
   202  		sourceRates := s.rates
   203  		s.mtx.RUnlock()
   204  
   205  		for ticker, rate := range sourceRates {
   206  			if rate == 0 {
   207  				continue
   208  			}
   209  
   210  			info, ok := newRatesInfo[ticker]
   211  			if !ok {
   212  				info = new(fiatRateAndSourceCount)
   213  				newRatesInfo[ticker] = info
   214  			}
   215  
   216  			info.sources++
   217  			info.totalFiatRate += rate
   218  		}
   219  	}
   220  
   221  	now := time.Now()
   222  	broadcastRates := make(map[string]*FiatRateInfo)
   223  	o.ratesMtx.Lock()
   224  	for ticker := range o.rates {
   225  		rateInfo := newRatesInfo[ticker]
   226  		if rateInfo != nil {
   227  			newRate := rateInfo.totalFiatRate / float64(rateInfo.sources)
   228  			if newRate > 0 {
   229  				o.rates[ticker].Value = newRate
   230  				o.rates[ticker].LastUpdate = now
   231  				rate := *o.rates[ticker] // copy
   232  				broadcastRates[ticker] = &rate
   233  			}
   234  		}
   235  	}
   236  	o.ratesMtx.Unlock()
   237  
   238  	if len(broadcastRates) > 0 {
   239  		o.notifyListeners(broadcastRates)
   240  	}
   241  
   242  	return reActivatedSourceIndexes
   243  }
   244  
   245  // fetchFromSource starts a goroutine that retrieves fiat rate from the provided
   246  // source.
   247  func (o *Oracle) fetchFromSource(ctx context.Context, s *source, wg *sync.WaitGroup) {
   248  	wg.Add(1)
   249  	go func(s *source) {
   250  		defer wg.Done()
   251  		ticker := time.NewTicker(s.requestInterval)
   252  		defer ticker.Stop()
   253  
   254  		for {
   255  			select {
   256  			case <-ctx.Done():
   257  				return
   258  			case <-ticker.C:
   259  				if s.isDisabled() { // nothing to fetch.
   260  					continue
   261  				}
   262  
   263  				if s.hasTicker() && s.isExpired() {
   264  					s.deactivate()
   265  					o.log.Errorf("Fiat rate source %q has been disabled due to lack of fresh data. It will be re-enabled after %d hours.", s.name, reactivateDuration.Hours())
   266  					return
   267  				}
   268  
   269  				newRates, err := s.getRates(ctx, o.tickers(), o.log)
   270  				if err != nil {
   271  					o.log.Errorf("%s.getRates error: %v", s.name, err)
   272  					continue
   273  				}
   274  
   275  				s.mtx.Lock()
   276  				s.rates = newRates
   277  				s.lastRefresh = time.Now()
   278  				s.mtx.Unlock()
   279  			}
   280  		}
   281  	}(s)
   282  }