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 }