github.com/diadata-org/diadata@v1.4.593/pkg/model/quotation.go (about)

     1  package models
     2  
     3  import (
     4  	"context"
     5  	"database/sql"
     6  	"encoding/json"
     7  	"errors"
     8  	"fmt"
     9  	"sort"
    10  	"time"
    11  
    12  	"github.com/diadata-org/diadata/pkg/dia"
    13  	"github.com/diadata-org/diadata/pkg/utils"
    14  	"github.com/go-redis/redis"
    15  	clientInfluxdb "github.com/influxdata/influxdb1-client/v2"
    16  	"github.com/jackc/pgx/v4"
    17  )
    18  
    19  const (
    20  	WindowYesterday             = 24 * 60 * 60
    21  	Window1h                    = 60 * 60
    22  	Window7d                    = 7 * 24 * 60 * 60
    23  	Window14d                   = 7 * 24 * 60 * 60
    24  	Window30d                   = 30 * 24 * 60 * 60
    25  	Window2                     = 24 * 60 * 60 * 8
    26  	BufferTTL                   = 60 * 60
    27  	BiggestWindow               = Window2
    28  	TimeOutRedis                = time.Duration(time.Second*BiggestWindow + time.Second*BufferTTL)
    29  	TimeOutAssetQuotation       = time.Duration(time.Second * WindowYesterday)
    30  	assetQuotationLookbackHours = 24 * 7
    31  )
    32  
    33  func getKeyQuotation(value string) string {
    34  	return "dia_quotation_USD_" + value
    35  }
    36  
    37  func getKeyAssetQuotation(blockchain, address string) string {
    38  	return "dia_assetquotation_USD_" + blockchain + "_" + address
    39  }
    40  
    41  // ------------------------------------------------------------------------------
    42  // ASSET EXCHANGE RATES (WIP)
    43  // ------------------------------------------------------------------------------
    44  
    45  // SetAssetPriceUSD stores the price of @asset in influx and the caching layer.
    46  // The latter only holds the most recent price point.
    47  func (datastore *DB) SetAssetPriceUSD(asset dia.Asset, price float64, timestamp time.Time) error {
    48  	return datastore.SetAssetQuotation(&AssetQuotation{
    49  		Asset:  asset,
    50  		Price:  price,
    51  		Source: dia.Diadata,
    52  		Time:   timestamp,
    53  	})
    54  }
    55  
    56  // GetAssetPriceUSDLatest returns the latest price of @asset.
    57  func (datastore *DB) GetAssetPriceUSDLatest(asset dia.Asset) (price float64, err error) {
    58  	assetQuotation, err := datastore.GetAssetQuotationLatest(asset, time.Now().Add(time.Duration(assetQuotationLookbackHours)*time.Hour))
    59  	if err != nil {
    60  		return
    61  	}
    62  	price = assetQuotation.Price
    63  	return
    64  }
    65  
    66  // GetAssetPriceUSD returns the latest USD price of @asset before @timestamp.
    67  func (datastore *DB) GetAssetPriceUSD(asset dia.Asset, starttime time.Time, endtime time.Time) (price float64, err error) {
    68  	assetQuotation, err := datastore.GetAssetQuotation(asset, starttime, endtime)
    69  	if err != nil {
    70  		return
    71  	}
    72  	price = assetQuotation.Price
    73  	return
    74  }
    75  
    76  // AddAssetQuotationsToBatch is a helper function that adds a slice of
    77  // quotations to an influx batch.
    78  func (datastore *DB) AddAssetQuotationsToBatch(quotations []*AssetQuotation) error {
    79  	for _, quotation := range quotations {
    80  		tags := map[string]string{
    81  			"symbol":     EscapeReplacer.Replace(quotation.Asset.Symbol),
    82  			"name":       EscapeReplacer.Replace(quotation.Asset.Name),
    83  			"address":    quotation.Asset.Address,
    84  			"blockchain": quotation.Asset.Blockchain,
    85  		}
    86  		fields := map[string]interface{}{
    87  			"price": quotation.Price,
    88  		}
    89  		pt, err := clientInfluxdb.NewPoint(influxDBAssetQuotationsTable, tags, fields, quotation.Time)
    90  		if err != nil {
    91  			log.Errorln("addAssetQuotationsToBatch:", err)
    92  			return err
    93  		}
    94  		datastore.addPoint(pt)
    95  	}
    96  	return nil
    97  }
    98  
    99  // SetAssetQuotation stores the full quotation of @asset into influx and cache.
   100  func (datastore *DB) SetAssetQuotation(quotation *AssetQuotation) error {
   101  	// Write to influx
   102  	tags := map[string]string{
   103  		"symbol":     EscapeReplacer.Replace(quotation.Asset.Symbol),
   104  		"name":       EscapeReplacer.Replace(quotation.Asset.Name),
   105  		"address":    quotation.Asset.Address,
   106  		"blockchain": quotation.Asset.Blockchain,
   107  	}
   108  	fields := map[string]interface{}{
   109  		"price": quotation.Price,
   110  	}
   111  
   112  	pt, err := clientInfluxdb.NewPoint(influxDBAssetQuotationsTable, tags, fields, quotation.Time)
   113  	if err != nil {
   114  		log.Errorln("SetAssetQuotation:", err)
   115  	} else {
   116  		datastore.addPoint(pt)
   117  	}
   118  
   119  	// Write latest point to redis cache
   120  	// log.Printf("write to cache: %s", quotation.Asset.Symbol)
   121  	_, err = datastore.SetAssetQuotationCache(quotation, false)
   122  	return err
   123  
   124  }
   125  
   126  // GetAssetQuotation returns the latest full quotation for @asset.
   127  func (datastore *DB) GetAssetQuotationLatest(asset dia.Asset, starttime time.Time) (*AssetQuotation, error) {
   128  	endtime := time.Now()
   129  
   130  	// First attempt to get latest quotation from redis cache
   131  	quotation, err := datastore.GetAssetQuotationCache(asset)
   132  	if err == nil {
   133  		log.Infof("got asset quotation for %s from cache: %v", asset.Symbol, quotation)
   134  		return quotation, nil
   135  	}
   136  
   137  	// if not in cache, get quotation from influx
   138  	log.Infof("asset %s not in cache. Query influx for range %v -- %v ...", asset.Symbol, starttime, endtime)
   139  
   140  	return datastore.GetAssetQuotation(asset, starttime, endtime)
   141  
   142  }
   143  
   144  // GetAssetQuotation returns the latest full quotation for @asset in the range (@starttime,@endtime].
   145  func (datastore *DB) GetAssetQuotation(asset dia.Asset, starttime time.Time, endtime time.Time) (*AssetQuotation, error) {
   146  
   147  	quotation := AssetQuotation{}
   148  	q := fmt.Sprintf(`SELECT price FROM %s WHERE address='%s' AND blockchain='%s' AND time>%d AND time<=%d ORDER BY DESC LIMIT 1`,
   149  		influxDBAssetQuotationsTable,
   150  		asset.Address,
   151  		asset.Blockchain,
   152  		starttime.UnixNano(),
   153  		endtime.UnixNano(),
   154  	)
   155  
   156  	res, err := queryInfluxDB(datastore.influxClient, q)
   157  	if err != nil {
   158  		return &quotation, err
   159  	}
   160  
   161  	if len(res) > 0 && len(res[0].Series) > 0 {
   162  		if len(res[0].Series[0].Values) > 0 {
   163  			quotation.Time, err = time.Parse(time.RFC3339, res[0].Series[0].Values[0][0].(string))
   164  			if err != nil {
   165  				return &quotation, err
   166  			}
   167  			quotation.Price, err = res[0].Series[0].Values[0][1].(json.Number).Float64()
   168  			if err != nil {
   169  				return &quotation, err
   170  			}
   171  			log.Infof("queried price for %s: %v", asset.Symbol, quotation.Price)
   172  		} else {
   173  			return &quotation, errors.New("no assetQuotation in DB")
   174  		}
   175  	} else {
   176  		return &quotation, errors.New("no assetQuotation in DB")
   177  	}
   178  	quotation.Asset = asset
   179  	quotation.Source = dia.Diadata
   180  	return &quotation, nil
   181  }
   182  
   183  // GetAssetQuotations returns all assetQuotations for @asset in the given time-range.
   184  func (datastore *DB) GetAssetQuotations(asset dia.Asset, starttime time.Time, endtime time.Time) ([]AssetQuotation, error) {
   185  
   186  	quotations := []AssetQuotation{}
   187  	q := fmt.Sprintf(
   188  		"SELECT price FROM %s WHERE address='%s' AND blockchain='%s' AND time>%d AND time<=%d ORDER BY DESC",
   189  		influxDBAssetQuotationsTable,
   190  		asset.Address,
   191  		asset.Blockchain,
   192  		starttime.UnixNano(),
   193  		endtime.UnixNano(),
   194  	)
   195  
   196  	res, err := queryInfluxDB(datastore.influxClient, q)
   197  	if err != nil {
   198  		return quotations, err
   199  	}
   200  
   201  	if len(res) > 0 && len(res[0].Series) > 0 {
   202  		for i := range res[0].Series[0].Values {
   203  			var quotation AssetQuotation
   204  			quotation.Time, err = time.Parse(time.RFC3339, res[0].Series[0].Values[i][0].(string))
   205  			if err != nil {
   206  				return quotations, err
   207  			}
   208  			quotation.Price, err = res[0].Series[0].Values[i][1].(json.Number).Float64()
   209  			if err != nil {
   210  				return quotations, err
   211  			}
   212  			quotation.Asset = asset
   213  			quotation.Source = dia.Diadata
   214  			quotations = append(quotations, quotation)
   215  		}
   216  	} else {
   217  		return quotations, errors.New("no assetQuotation in DB")
   218  	}
   219  
   220  	return quotations, nil
   221  }
   222  
   223  // SetAssetQuotationCache stores @quotation in redis cache.
   224  // If @check is true, it checks for a more recent quotation first.
   225  func (datastore *DB) SetAssetQuotationCache(quotation *AssetQuotation, check bool) (bool, error) {
   226  	if check {
   227  		// fetch current state of cache
   228  		cachestate, err := datastore.GetAssetQuotationCache(quotation.Asset)
   229  		if err != nil && !errors.Is(err, redis.Nil) {
   230  			return false, err
   231  		}
   232  		// Do not write to cache if more recent entry exists
   233  		if (quotation.Time).Before(cachestate.Time) {
   234  			return false, nil
   235  		}
   236  	}
   237  	// Otherwise write to cache
   238  	key := getKeyAssetQuotation(quotation.Asset.Blockchain, quotation.Asset.Address)
   239  	return true, datastore.redisPipe.Set(key, quotation, TimeOutAssetQuotation).Err()
   240  }
   241  
   242  // GetAssetQuotationCache returns the latest quotation for @asset from the redis cache.
   243  func (datastore *DB) GetAssetQuotationCache(asset dia.Asset) (*AssetQuotation, error) {
   244  	key := getKeyAssetQuotation(asset.Blockchain, asset.Address)
   245  	// log.Infof("get asset quotation from cache for asset %s with address %s using key as %s ", asset.Symbol, asset.Address, key)
   246  
   247  	quotation := &AssetQuotation{}
   248  
   249  	err := datastore.redisClient.Get(key).Scan(quotation)
   250  	if err != nil {
   251  		if !errors.Is(err, redis.Nil) {
   252  			log.Errorf("GetAssetQuotationCache on %s: %v\n", asset.Name, err)
   253  		}
   254  		return quotation, err
   255  	}
   256  	return quotation, nil
   257  }
   258  
   259  // GetAssetPriceUSDCache returns the latest price of @asset from the cache.
   260  func (datastore *DB) GetAssetPriceUSDCache(asset dia.Asset) (price float64, err error) {
   261  	quotation, err := datastore.GetAssetQuotationCache(asset)
   262  	if err != nil {
   263  		log.Errorf("get asset quotation for %s from cache: %v", asset.Symbol, err)
   264  		return
   265  	}
   266  	price = quotation.Price
   267  	return
   268  }
   269  
   270  // GetSortedQuotations returns quotations for all assets in @assets, sorted by 24h volume
   271  // in descending order.
   272  func (datastore *DB) GetSortedAssetQuotations(assets []dia.Asset) ([]AssetQuotation, error) {
   273  	var quotations []AssetQuotation
   274  	var volumes []float64
   275  	for _, asset := range assets {
   276  		var quotation *AssetQuotation
   277  		var volume *float64
   278  		var err error
   279  		quotation, err = datastore.GetAssetQuotationLatest(asset, time.Now().Add(time.Duration(assetQuotationLookbackHours)*time.Hour))
   280  		if err != nil {
   281  			log.Errorf("get quotation for symbol %s with address %s on blockchain %s: %v", asset.Symbol, asset.Address, asset.Blockchain, err)
   282  			continue
   283  		}
   284  		volume, err = datastore.Get24HoursAssetVolume(asset)
   285  		if err != nil {
   286  			log.Errorf("get volume for symbol %s with address %s on blockchain %s: %v", asset.Symbol, asset.Address, asset.Blockchain, err)
   287  			continue
   288  		}
   289  		quotations = append(quotations, *quotation)
   290  		volumes = append(volumes, *volume)
   291  	}
   292  	if len(quotations) == 0 {
   293  		return quotations, errors.New("no quotations available")
   294  	}
   295  
   296  	var quotationsSorted []AssetQuotation
   297  	volumesSorted := utils.NewFloat64Slice(sort.Float64Slice(volumes))
   298  	sort.Sort(volumesSorted)
   299  	for _, ind := range volumesSorted.Ind() {
   300  		quotationsSorted = append([]AssetQuotation{quotations[ind]}, quotationsSorted...)
   301  	}
   302  	return quotationsSorted, nil
   303  }
   304  
   305  func (datastore *DB) GetOldestQuotation(asset dia.Asset) (quotation AssetQuotation, err error) {
   306  
   307  	q := fmt.Sprintf(`
   308  	SELECT price FROM %s WHERE address='%s' AND blockchain='%s' ORDER BY ASC LIMIT 1`,
   309  		influxDBAssetQuotationsTable,
   310  		asset.Address,
   311  		asset.Blockchain,
   312  	)
   313  	res, err := queryInfluxDB(datastore.influxClient, q)
   314  	if err != nil {
   315  		return
   316  	}
   317  
   318  	if len(res) > 0 && len(res[0].Series) > 0 {
   319  		if len(res[0].Series[0].Values) > 0 {
   320  			quotation.Time, err = time.Parse(time.RFC3339, res[0].Series[0].Values[0][0].(string))
   321  			if err != nil {
   322  				return quotation, err
   323  			}
   324  			quotation.Price, err = res[0].Series[0].Values[0][1].(json.Number).Float64()
   325  			if err != nil {
   326  				return
   327  			}
   328  			log.Infof("queried price for %s: %v", asset.Symbol, quotation.Price)
   329  		} else {
   330  			err = errors.New("no assetQuotation in DB")
   331  			return
   332  		}
   333  	} else {
   334  		err = errors.New("no assetQuotation in DB")
   335  		return
   336  	}
   337  	quotation.Asset = asset
   338  	quotation.Source = dia.Diadata
   339  	return
   340  }
   341  
   342  // ------------------------------------------------------------------------------
   343  // HISTORICAL QUOTES
   344  // ------------------------------------------------------------------------------
   345  
   346  // SetHistoricalQuote stores a historical quote for an asset symbol at a specific time into postgres.
   347  func (rdb *RelDB) SetHistoricalQuotation(quotation AssetQuotation) error {
   348  	queryString := `
   349  	INSERT INTO %s (asset_id,price,quote_time,source) 
   350  	VALUES ((SELECT asset_id FROM %s WHERE address=$1 AND blockchain=$2),$3,$4,$5) 
   351  	ON CONFLICT (asset_id,quote_time,source) DO NOTHING
   352  	`
   353  	query := fmt.Sprintf(queryString, historicalQuotationTable, assetTable)
   354  	_, err := rdb.postgresClient.Exec(
   355  		context.Background(),
   356  		query,
   357  		quotation.Asset.Address,
   358  		quotation.Asset.Blockchain,
   359  		quotation.Price,
   360  		quotation.Time,
   361  		quotation.Source,
   362  	)
   363  	if err != nil {
   364  		log.Error("insert historical quotation: ", err)
   365  		return err
   366  	}
   367  	return nil
   368  }
   369  
   370  // GetHistoricalQuotations returns all historical quotations of @asset in the given time range.
   371  func (rdb *RelDB) GetHistoricalQuotations(asset dia.Asset, starttime time.Time, endtime time.Time) (quotations []AssetQuotation, err error) {
   372  	query := fmt.Sprintf(`
   373  	SELECT hq.price,hq.quote_time,hq.source,a.decimals 
   374  	FROM %s hq
   375  	INNER JOIN %s a
   376  	ON hq.asset_id=a.asset_id
   377  	WHERE a.address=$1 AND a.blockchain=$2
   378  	AND hq.quote_time>to_timestamp($3)
   379  	AND hq.quote_time<to_timestamp($4)
   380  	ORDER BY hq.quote_time ASC
   381  	`,
   382  		historicalQuotationTable,
   383  		assetTable,
   384  	)
   385  	var rows pgx.Rows
   386  	rows, err = rdb.postgresClient.Query(context.Background(), query, asset.Address, asset.Blockchain, starttime.Unix(), endtime.Unix())
   387  	if err != nil {
   388  		return
   389  	}
   390  	defer rows.Close()
   391  
   392  	for rows.Next() {
   393  		var (
   394  			price     sql.NullFloat64
   395  			source    sql.NullString
   396  			quotation AssetQuotation
   397  			decimals  sql.NullInt64
   398  		)
   399  		err = rows.Scan(
   400  			&price,
   401  			&quotation.Time,
   402  			&quotation.Source,
   403  			&decimals,
   404  		)
   405  		if err != nil {
   406  			return
   407  		}
   408  		quotation.Asset = asset
   409  		if decimals.Valid {
   410  			quotation.Asset.Decimals = uint8(decimals.Int64)
   411  		} else {
   412  			err = errors.New("cannot parse decimals")
   413  			return
   414  		}
   415  		if price.Valid {
   416  			quotation.Price = price.Float64
   417  		}
   418  		if source.Valid {
   419  			quotation.Source = source.String
   420  		}
   421  		quotations = append(quotations, quotation)
   422  	}
   423  	return
   424  }
   425  
   426  // GetLastHistoricalQuoteTimestamp returns the timestamp of the last historical quote for asset symbol.
   427  func (rdb *RelDB) GetLastHistoricalQuotationTimestamp(asset dia.Asset) (timestamp time.Time, err error) {
   428  	query := fmt.Sprintf(`
   429  	SELECT quote_time 
   430  	FROM %s hq
   431  	INNER JOIN %s a
   432  	ON hq.asset_id=a.asset_id
   433  	WHERE a.address=$1 
   434  	AND a.blockchain=$2 
   435  	ORDER BY hq.quote_time DESC 
   436  	LIMIT 1
   437  	`,
   438  		historicalQuotationTable,
   439  		assetTable,
   440  	)
   441  	var t sql.NullTime
   442  	err = rdb.postgresClient.QueryRow(context.Background(), query, asset.Address, asset.Blockchain).Scan(&t)
   443  	if err != nil {
   444  		return
   445  	}
   446  	if t.Valid {
   447  		timestamp = t.Time
   448  	}
   449  	return
   450  }
   451  
   452  // ------------------------------------------------------------------------------
   453  // MARKET MEASURES
   454  // ------------------------------------------------------------------------------
   455  
   456  // GetAssetsMarketCap returns the actual market cap of @asset.
   457  func (datastore *DB) GetAssetsMarketCap(asset dia.Asset) (float64, error) {
   458  	price, err := datastore.GetAssetPriceUSDLatest(asset)
   459  	if err != nil {
   460  		return 0, err
   461  	}
   462  	supply, err := datastore.GetSupplyCache(asset)
   463  	if err != nil {
   464  		return 0, err
   465  	}
   466  	return price * supply.CirculatingSupply, nil
   467  }
   468  
   469  // GetTopAssetByVolume returns the asset with highest volume among all assets with symbol @symbol.
   470  // This method allows us to use all API endpoints called on a symbol.
   471  func (datastore *DB) GetTopAssetByVolume(symbol string, relDB *RelDB) (topAsset dia.Asset, err error) {
   472  	assets, err := relDB.GetAssets(symbol)
   473  	if err != nil {
   474  		return
   475  	}
   476  	if len(assets) == 0 {
   477  		err = errors.New("no matching asset")
   478  		return
   479  	}
   480  	var volume float64
   481  	for _, asset := range assets {
   482  		var value *float64
   483  		value, err = datastore.Get24HoursAssetVolume(asset)
   484  		if err != nil {
   485  			log.Error(err)
   486  			continue
   487  		}
   488  		if value == nil {
   489  			continue
   490  		}
   491  		if *value > volume {
   492  			volume = *value
   493  			topAsset = asset
   494  		}
   495  	}
   496  	if volume == 0 {
   497  		err = errors.New("no quotation for symbol")
   498  	} else {
   499  		err = nil
   500  	}
   501  	return
   502  }
   503  
   504  // GetTopAssetByMcap returns the asset with highest market cap among all assets with symbol @symbol.
   505  func (datastore *DB) GetTopAssetByMcap(symbol string, relDB *RelDB) (topAsset dia.Asset, err error) {
   506  	assets, err := relDB.GetAssets(symbol)
   507  	if err != nil {
   508  		return
   509  	}
   510  	if len(assets) == 0 {
   511  		err = errors.New("no matching asset")
   512  		return
   513  	}
   514  	var mcap float64
   515  	for _, asset := range assets {
   516  		var value float64
   517  		value, err = datastore.GetAssetsMarketCap(asset)
   518  		if err != nil {
   519  			log.Error(err)
   520  			continue
   521  		}
   522  		if value == 0 {
   523  			continue
   524  		}
   525  		if value > mcap {
   526  			mcap = value
   527  			topAsset = asset
   528  		}
   529  	}
   530  	if mcap == 0 {
   531  		err = errors.New("no quotation for symbol")
   532  	} else {
   533  		err = nil
   534  	}
   535  	return
   536  }