github.com/diadata-org/diadata@v1.4.593/pkg/dia/scraper/exchange-scrapers/ECBScraper.go (about) 1 package scrapers 2 3 import ( 4 "encoding/xml" 5 "errors" 6 "fmt" 7 "net/http" 8 "strconv" 9 "sync" 10 "time" 11 12 "github.com/diadata-org/diadata/pkg/dia" 13 models "github.com/diadata-org/diadata/pkg/model" 14 ) 15 16 const ( 17 refreshDelay = time.Second * 20 * 60 18 ecbRSSURL = "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml" 19 ) 20 21 type ( 22 XMLHistoricalEnvelope struct { 23 XMLName xml.Name `xml:"GenericData"` 24 Obs []XMLObs `xml:"DataSet>Series>Obs"` 25 } 26 27 XMLObs struct { 28 XMLName xml.Name `xml:"Obs"` 29 Timestamp XMLObsDimension `xml:"ObsDimension"` 30 Price XMLObsValue `xml:"ObsValue"` 31 } 32 33 XMLObsDimension struct { 34 XMLName xml.Name `xml:"ObsDimension"` 35 Value string `xml:"value,attr"` 36 } 37 38 XMLObsValue struct { 39 XMLName xml.Name `xml:"ObsValue"` 40 Value string `xml:"value,attr"` 41 } 42 ) 43 44 type ( 45 // rssDocument defines the fields associated with the rss document. 46 47 XMLCube struct { 48 XMLName xml.Name `xml:"Cube"` 49 Currency string `xml:"currency,attr"` 50 Rate string `xml:"rate,attr"` 51 } 52 53 XMLCubeTime struct { 54 XMLName xml.Name `xml:"Cube"` 55 Time string `xml:"time,attr"` 56 Cube []XMLCube `xml:"Cube"` 57 } 58 59 XMLEnvelope struct { 60 XMLName xml.Name `xml:"Envelope"` 61 CubeTime []XMLCubeTime `xml:"Cube>Cube"` 62 } 63 ) 64 65 type ECBScraper struct { 66 // signaling channels 67 shutdown chan nothing 68 shutdownDone chan nothing 69 // error handling; to read error or closed, first acquire read lock 70 // only cleanup method should hold write lock 71 errorLock sync.RWMutex 72 error error 73 closed bool 74 pairScrapers map[string]*ECBPairScraper // dia.ExchangePair -> pairScraperSet 75 ticker *time.Ticker 76 datastore models.Datastore 77 chanTrades chan *dia.Trade 78 } 79 80 // SpawnECBScraper returns a new ECBScraper initialized with default values. 81 // The instance is asynchronously scraping as soon as it is created. 82 func SpawnECBScraper(datastore models.Datastore) *ECBScraper { 83 s := &ECBScraper{ 84 shutdown: make(chan nothing), 85 shutdownDone: make(chan nothing), 86 pairScrapers: make(map[string]*ECBPairScraper), 87 error: nil, 88 ticker: time.NewTicker(refreshDelay), 89 datastore: datastore, 90 chanTrades: make(chan *dia.Trade), 91 } 92 93 log.Info("Scraper is built and initiated") 94 go s.mainLoop() 95 return s 96 } 97 98 // mainLoop runs in a goroutine until channel s is closed. 99 func (s *ECBScraper) mainLoop() { 100 err := s.Update() 101 if err != nil { 102 log.Error(err) 103 } 104 for { 105 select { 106 case <-s.ticker.C: 107 err := s.Update() 108 if err != nil { 109 log.Error(err) 110 } 111 case <-s.shutdown: // user requested shutdown 112 log.Println("ECBScraper shutting down") 113 s.cleanup(nil) 114 return 115 } 116 } 117 } 118 119 // Update performs a HTTP Get request for the rss feed and decodes the results. 120 func (s *ECBScraper) Update() error { 121 122 log.Printf("Executing ECBScraper update") 123 124 // Retrieve the rss feed document from the web. 125 resp, err := http.Get(ecbRSSURL) //nolint:noctx,gosec 126 if err != nil { 127 return err 128 } 129 130 // Close the response once we return from the function. 131 defer func() { 132 err = resp.Body.Close() 133 if err != nil { 134 log.Error(err) 135 } 136 }() 137 138 // Check the status code for a 200 so we know we have received a 139 // proper response. 140 if resp.StatusCode != 200 { 141 return fmt.Errorf("HTTP Response Error %d", resp.StatusCode) 142 } 143 144 // Decode the rss feed document into our struct type. 145 // We don't need to check for errors, the caller can do this. 146 var document XMLEnvelope 147 err = xml.NewDecoder(resp.Body).Decode(&document) 148 if err != nil { 149 fmt.Println(err) 150 } 151 152 for _, valueCubeTime := range document.CubeTime { 153 change := &models.Change{ 154 USD: []models.CurrencyChange{}, 155 } 156 157 euroDollar := 1.0 158 for _, valueCube := range valueCubeTime.Cube { 159 if valueCube.Currency == "USD" { 160 euroDollar, err = strconv.ParseFloat(valueCube.Rate, 64) 161 if err != nil { 162 return fmt.Errorf("error parsing rate %s: %w", valueCube.Rate, err) 163 } 164 } 165 } 166 167 for _, valueCube := range valueCubeTime.Cube { 168 pair := string("EUR" + valueCube.Currency) 169 ps := s.pairScrapers[pair] 170 if ps != nil { 171 var rate float64 172 var timestamp time.Time 173 rate, err = strconv.ParseFloat(valueCube.Rate, 64) 174 if err != nil { 175 return fmt.Errorf("error parsing rate %s: %w", valueCube.Rate, err) 176 } 177 timestamp, err = time.Parse("2006-01-02", valueCubeTime.Time) 178 if err != nil { 179 return fmt.Errorf("error parsing time %s: %w", valueCubeTime.Time, err) 180 } 181 182 t := &dia.Trade{ 183 Pair: pair, 184 Symbol: pair, 185 Price: rate, 186 Volume: 0, 187 Time: timestamp, 188 Source: "ECB", 189 } 190 191 log.Printf("writing trade %#v ", t.Pair) 192 193 s.chanTrades <- t 194 c := valueCube.Currency 195 if c == "USD" { 196 change.USD = append(change.USD, models.CurrencyChange{ 197 Symbol: "EUR", 198 Rate: 1.0 / euroDollar, 199 RateYesterday: 1.0 / euroDollar, // TOFIX 200 }) 201 } else { 202 // list for coinhub 203 if (c == "JPY") || c == "GBP" || c == "SEK" || c == "CHF" || c == "NOK" || c == "AUD" || c == "CAD" || c == "CNY" || c == "KRW" { 204 change.USD = append(change.USD, models.CurrencyChange{ 205 Symbol: c, 206 Rate: rate / euroDollar, 207 RateYesterday: rate / euroDollar, // TOFIX 208 }) 209 } 210 } 211 } 212 } 213 err = s.datastore.SetCurrencyChange(change) 214 if err != nil { 215 return err 216 } 217 } 218 err = s.datastore.ExecuteRedisPipe() 219 if err != nil { 220 log.Error("execute redis pipe: ", err) 221 } 222 223 err = s.datastore.FlushRedisPipe() 224 if err != nil { 225 log.Error("flush redis pipe: ", err) 226 } 227 log.Info("Update done") 228 return err 229 } 230 231 // Populate fetches historical daily datas from 1999 until today and saves them on the database 232 func Populate(datastore *models.DB, rdb *models.RelDB, pairs []string) { 233 // Start with USD to have conversion reference 234 xmlEurusd := populateCurrency(datastore, rdb, "USD", nil) 235 236 // Populate every other currency 237 for _, p := range pairs { 238 currency := p[3:] 239 if currency != "USD" { 240 populateCurrency(datastore, rdb, currency, xmlEurusd) 241 } 242 } 243 } 244 245 func populateCurrency(datastore *models.DB, rdb *models.RelDB, currency string, xmlEurusd *XMLHistoricalEnvelope) *XMLHistoricalEnvelope { 246 var asset dia.Asset 247 var err error 248 if currency == "USD" { 249 // TO DO: fiat assets have yet to be filled into the asset table 250 // by adding an asset source for fiat currencies in the asset service. 251 asset, err = rdb.GetFiatAssetBySymbol("EUR") 252 if err != nil { 253 log.Errorf("fetching fiat asset %s: %v", "EUR", err) 254 return &XMLHistoricalEnvelope{} 255 } 256 } else { 257 asset, err = rdb.GetFiatAssetBySymbol(currency) 258 if err != nil { 259 log.Errorf("fetching fiat asset %s: %v", currency, err) 260 return &XMLHistoricalEnvelope{} 261 } 262 } 263 log.Printf("Historical prices population starting for %s\n", asset.Symbol) 264 time.Sleep(5 * time.Second) 265 266 // Fetch URL 267 resp, err := http.Get(fmt.Sprintf("https://sdw-wsrest.ecb.europa.eu/service/data/EXR/D.%s.EUR.SP00.A", currency)) //nolint:noctx,gosec 268 269 if err != nil { 270 log.Errorf("error fetching url %v\n", err) 271 } 272 defer func() { 273 err = resp.Body.Close() 274 if err != nil { 275 log.Error(err) 276 } 277 }() 278 279 // Parse XML in response 280 var xmlSheet XMLHistoricalEnvelope 281 err = xml.NewDecoder(resp.Body).Decode(&xmlSheet) 282 if err != nil { 283 log.Errorf("error parsing xml %v\n", err) 284 } 285 286 // Format each value as a fiatQuotation struct and put them into the fqs slice 287 var quotations []*models.AssetQuotation 288 for _, o := range xmlSheet.Obs { 289 if o.Price.Value == "NaN" { 290 continue 291 } 292 var timestamp time.Time 293 var price float64 294 timestamp, err = time.Parse("2006-01-02", o.Timestamp.Value) 295 if err != nil { 296 log.Errorf("error formating timestamp %v\n", err) 297 } 298 price, err = strconv.ParseFloat(o.Price.Value, 64) 299 if err != nil { 300 log.Errorf("error parsing price %v %v", o.Price.Value, err) 301 } 302 303 if currency != "USD" { 304 // If other than USD, conversion from EUR as a quote currency to USD as base currency is made 305 var usdFor1Euro float64 306 for _, eurusdObs := range xmlEurusd.Obs { 307 if eurusdObs.Timestamp.Value == o.Timestamp.Value { 308 usdFor1Euro, err = strconv.ParseFloat(eurusdObs.Price.Value, 64) 309 if err != nil { 310 log.Errorf("error parsing price %v %v", eurusdObs.Price.Value, err) 311 } 312 } 313 } 314 if usdFor1Euro == 0 { 315 continue 316 } 317 price = usdFor1Euro / price 318 } 319 320 assetquotation := models.AssetQuotation{ 321 Asset: asset, 322 Price: price, 323 Source: dia.Diadata, 324 Time: timestamp, 325 } 326 quotations = append(quotations, &assetquotation) 327 } 328 err = datastore.AddAssetQuotationsToBatch(quotations) 329 if err != nil { 330 log.Errorf("add quotation to batch: %v", err) 331 } 332 // Write quotations on influxdb 333 err = datastore.WriteBatchInflux() 334 if err != nil { 335 log.Errorf("asset quotations batch write: %v", err) 336 } else { 337 log.Printf("historical prices for %s successfully populated\n", currency) 338 } 339 340 return &xmlSheet 341 } 342 343 // closes all connected PairScrapers 344 // must only be called from mainLoop 345 func (s *ECBScraper) cleanup(err error) { 346 347 s.errorLock.Lock() 348 defer s.errorLock.Unlock() 349 350 s.ticker.Stop() 351 352 if err != nil { 353 s.error = err 354 } 355 s.closed = true 356 357 close(s.shutdownDone) // signal that shutdown is complete 358 } 359 360 // Close closes any existing API connections, as well as channels of 361 // PairScrapers from calls to ScrapePair 362 func (s *ECBScraper) Close() error { 363 if s.closed { 364 return errors.New("ECBScraper: Already closed") 365 } 366 close(s.shutdown) 367 <-s.shutdownDone 368 s.errorLock.RLock() 369 defer s.errorLock.RUnlock() 370 return s.error 371 } 372 373 // ECBPairScraper implements PairScraper for ECB 374 type ECBPairScraper struct { 375 parent *ECBScraper 376 pair dia.ExchangePair 377 closed bool 378 } 379 380 // ScrapePair returns a PairScraper that can be used to get trades for a single pair from 381 // this APIScraper 382 func (s *ECBScraper) ScrapePair(pair dia.ExchangePair) (PairScraper, error) { 383 384 s.errorLock.RLock() 385 defer s.errorLock.RUnlock() 386 if s.error != nil { 387 return nil, s.error 388 } 389 if s.closed { 390 return nil, errors.New("ECBScraper: Call ScrapePair on closed scraper") 391 } 392 ps := &ECBPairScraper{ 393 parent: s, 394 pair: pair, 395 } 396 397 s.pairScrapers[pair.Symbol] = ps 398 399 return ps, nil 400 } 401 402 // Channel returns a channel that can be used to receive trades/pricing information 403 func (ps *ECBScraper) Channel() chan *dia.Trade { 404 return ps.chanTrades 405 } 406 407 func (ps *ECBPairScraper) Close() error { 408 ps.closed = true 409 return nil 410 } 411 412 // Error returns an error when the channel Channel() is closed 413 // and nil otherwise 414 func (ps *ECBPairScraper) Error() error { 415 s := ps.parent 416 s.errorLock.RLock() 417 defer s.errorLock.RUnlock() 418 return s.error 419 } 420 421 // Pair returns the pair this scraper is subscribed to 422 func (ps *ECBPairScraper) Pair() dia.ExchangePair { 423 return ps.pair 424 }