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 "ation, 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 "ation, err 166 } 167 quotation.Price, err = res[0].Series[0].Values[0][1].(json.Number).Float64() 168 if err != nil { 169 return "ation, err 170 } 171 log.Infof("queried price for %s: %v", asset.Symbol, quotation.Price) 172 } else { 173 return "ation, errors.New("no assetQuotation in DB") 174 } 175 } else { 176 return "ation, errors.New("no assetQuotation in DB") 177 } 178 quotation.Asset = asset 179 quotation.Source = dia.Diadata 180 return "ation, 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 "ation.Time, 402 "ation.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 }