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  }