From dd296bd78a9fbc2c93e286ab4fe796bb9f97708c Mon Sep 17 00:00:00 2001 From: Derrick Hammer Date: Sun, 10 Mar 2024 12:41:42 -0400 Subject: [PATCH] feat: add initial price tracker --- config/sia.go | 39 +++++- db/models/sc_price_history.go | 17 +++ renter/price_tracker.go | 238 ++++++++++++++++++++++++++++++++++ renter/renter.go | 21 +++ 4 files changed, 313 insertions(+), 2 deletions(-) create mode 100644 db/models/sc_price_history.go create mode 100644 renter/price_tracker.go diff --git a/config/sia.go b/config/sia.go index 2d194db..e635476 100644 --- a/config/sia.go +++ b/config/sia.go @@ -3,10 +3,24 @@ package config import "errors" var _ Validator = (*SiaConfig)(nil) +var _ Defaults = (*SiaConfig)(nil) type SiaConfig struct { - Key string `mapstructure:"key"` - URL string `mapstructure:"url"` + Key string `mapstructure:"key"` + URL string `mapstructure:"url"` + PriceHistoryDays uint64 `mapstructure:"price_history_days"` + MaxUploadPrice float64 `mapstructure:"max_upload_price"` + MaxDownloadPrice float64 `mapstructure:"max_download_price"` + MaxStoragePrice float64 `mapstructure:"max_storage_price"` + MaxContractPrice float64 `mapstructure:"max_contract_price"` + MaxRPCPrice float64 `mapstructure:"max_rpc_price"` +} + +func (s SiaConfig) Defaults() map[string]interface{} { + return map[string]interface{}{ + "max_rpc_price": 1, + "price_history_days": 90, + } } func (s SiaConfig) Validate() error { @@ -16,5 +30,26 @@ func (s SiaConfig) Validate() error { if s.URL == "" { return errors.New("core.storage.sia.url is required") } + + if s.MaxUploadPrice <= 0 { + return errors.New("core.storage.sia.max_upload_price must be greater than 0") + } + + if s.MaxDownloadPrice <= 0 { + return errors.New("core.storage.sia.max_download_price must be greater than 0") + } + + if s.MaxStoragePrice <= 0 { + return errors.New("core.storage.sia.max_storage_price must be greater than 0") + } + + if s.MaxContractPrice <= 0 { + return errors.New("core.storage.sia.max_contract_price must be greater than 0") + } + + if s.MaxRPCPrice <= 0 { + return errors.New("core.storage.sia.max_rpc_price must be greater than 0") + } + return nil } diff --git a/db/models/sc_price_history.go b/db/models/sc_price_history.go new file mode 100644 index 0000000..8fae07e --- /dev/null +++ b/db/models/sc_price_history.go @@ -0,0 +1,17 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +func init() { + registerModel(&SCPriceHistory{}) +} + +type SCPriceHistory struct { + gorm.Model + CreatedAt time.Time `gorm:"index:idx_rate"` + Rate float64 `gorm:"index:idx_rate"` +} diff --git a/renter/price_tracker.go b/renter/price_tracker.go new file mode 100644 index 0000000..1919aff --- /dev/null +++ b/renter/price_tracker.go @@ -0,0 +1,238 @@ +package renter + +import ( + "context" + "errors" + "math/big" + "time" + + "git.lumeweb.com/LumeWeb/portal/db/models" + + "github.com/siacentral/apisdkgo" + + "git.lumeweb.com/LumeWeb/portal/config" + "git.lumeweb.com/LumeWeb/portal/cron" + "github.com/go-co-op/gocron/v2" + siasdk "github.com/siacentral/apisdkgo/sia" + "go.sia.tech/core/types" + "go.uber.org/fx" + "go.uber.org/zap" + "gorm.io/gorm" +) + +var _ cron.CronableService = (*PriceTracker)(nil) + +const usdSymbol = "usd" + +type PriceTracker struct { + config *config.Manager + logger *zap.Logger + cron *cron.CronServiceDefault + db *gorm.DB + renter *RenterDefault + api *siasdk.APIClient +} + +func (p PriceTracker) LoadInitialTasks(cron cron.CronService) error { + job := gocron.DurationJob(time.Minute) + _, err := cron.Scheduler().NewJob( + job, + gocron.NewTask(p.recordRate), + ) + if err != nil { + return err + } + + return err +} + +func (p PriceTracker) recordRate() { + rate, _, err := p.api.GetExchangeRate() + if err != nil { + p.logger.Error("failed to get exchange rate", zap.Error(err)) + return + } + + siaPrice, ok := rate[usdSymbol] + if !ok { + p.logger.Error("exchange rate not found") + return + } + + var history models.SCPriceHistory + + history.Rate = siaPrice + + if tx := p.db.Create(&history); tx.Error != nil { + p.logger.Error("failed to save price history", zap.Error(tx.Error)) + } + + if err := p.updatePrices(); err != nil { + p.logger.Error("failed to update prices", zap.Error(err)) + } +} + +func (p PriceTracker) updatePrices() error { + var averageRate float64 + x := 1 + sql := ` +SELECT AVG(rate) as average_rate FROM ( + SELECT rate FROM ( + SELECT rate, ROW_NUMBER() OVER (PARTITION BY DATE(created_at) ORDER BY created_at DESC) as rn + FROM sc_price_histories + WHERE created_at >= NOW() - INTERVAL ? day + ) tmp WHERE rn = 1 +) final; +` + err := p.db.Raw(sql, x).Scan(&averageRate).Error + if err != nil { + p.logger.Error("failed to fetch average rate", zap.Error(err), zap.Int("days", x)) + return err + } + + if averageRate == 0 { + p.logger.Error("average rate is 0") + return errors.New("average rate is 0") + } + + gouge, err := p.renter.GougingSettings(context.Background()) + + if err != nil { + p.logger.Error("failed to fetch gouging settings", zap.Error(err)) + return err + } + + gouge.MaxDownloadPrice, err = siacoinsFromFloat(p.config.Config().Core.Sia.MaxDownloadPrice / averageRate) + if err != nil { + return err + } + + gouge.MaxUploadPrice, err = siacoinsFromFloat(p.config.Config().Core.Sia.MaxUploadPrice / averageRate) + if err != nil { + return err + } + + gouge.MaxContractPrice, err = siacoinsFromFloat(p.config.Config().Core.Sia.MaxContractPrice / averageRate) + if err != nil { + return err + } + + gouge.MaxStoragePrice, err = siacoinsFromFloat(p.config.Config().Core.Sia.MaxStoragePrice / averageRate) + if err != nil { + return err + } + + gouge.MaxRPCPrice, err = siacoinsFromFloat(p.config.Config().Core.Sia.MaxRPCPrice / averageRate) + if err != nil { + return err + } + + err = p.renter.UpdateGougingSettings(context.Background(), gouge) + if err != nil { + return err + } + + return nil +} + +func (p PriceTracker) importPrices() error { + var count int64 + + // Query to count the number of historical records + err := p.db.Model(&models.SCPriceHistory{}).Count(&count).Error + if err != nil { + p.logger.Error("failed to count historical records", zap.Error(err)) + return err + } + + daysOfHistory := p.config.Config().Core.Sia.PriceHistoryDays + + // Check if the count is less than x + if uint64(count) < daysOfHistory { + // Calculate how many records need to be fetched and created + missingRecords := daysOfHistory - uint64(count) + for i := uint64(0); i < missingRecords; i++ { + currentDate := time.Now().UTC().AddDate(0, 0, int(-i)) + timestamp := time.Date(currentDate.Year(), currentDate.Month(), currentDate.Day(), 0, 0, 0, 0, time.UTC) + // Fetch the historical exchange rate for the calculated timestamp + rates, err := p.api.GetHistoricalExchangeRate(timestamp) + if err != nil { + p.logger.Error("failed to fetch historical exchange rate", zap.Error(err)) + return err + } + + // Assuming you want to store rates for a specific currency, say "USD" + rate, exists := rates[usdSymbol] + if !exists { + p.logger.Error("USD rate not found for timestamp", zap.String("timestamp", timestamp.String())) + return errors.New("USD rate not found for timestamp") + } + + // Create a new record in the database for each fetched rate + priceRecord := &models.SCPriceHistory{ + Rate: rate, + CreatedAt: timestamp, + } + + err = p.db.Create(&priceRecord).Error + if err != nil { + p.logger.Error("failed to create historical record", zap.Error(err)) + return err + } + } + } + + return nil +} + +type PriceTrackerParams struct { + fx.In + Config *config.Manager + Logger *zap.Logger + Cron *cron.CronServiceDefault + Db *gorm.DB + Renter *RenterDefault + PriceApi *siasdk.APIClient +} + +func (p PriceTracker) init() error { + p.cron.RegisterService(p) + p.api = apisdkgo.NewSiaClient() + + err := p.importPrices() + if err != nil { + p.logger.Error("failed to import prices", zap.Error(err)) + return err + } + + err = p.updatePrices() + if err != nil { + p.logger.Error("failed to update prices", zap.Error(err)) + return err + } + + return nil +} + +func NewPriceTracker(params PriceTrackerParams) *PriceTracker { + return &PriceTracker{ + config: params.Config, + logger: params.Logger, + cron: params.Cron, + db: params.Db, + renter: params.Renter, + api: params.PriceApi, + } +} + +func siacoinsFromFloat(f float64) (types.Currency, error) { + r := new(big.Rat).SetFloat64(f) + r.Mul(r, new(big.Rat).SetInt(types.HastingsPerSiacoin.Big())) + i := new(big.Int).Div(r.Num(), r.Denom()) + if i.Sign() < 0 { + return types.ZeroCurrency, errors.New("value cannot be negative") + } else if i.BitLen() > 128 { + return types.ZeroCurrency, errors.New("value overflows Currency representation") + } + return types.NewCurrency(i.Uint64(), new(big.Int).Rsh(i, 64).Uint64()), nil +} diff --git a/renter/renter.go b/renter/renter.go index b87f48d..c67c10f 100644 --- a/renter/renter.go +++ b/renter/renter.go @@ -15,6 +15,7 @@ import ( "git.lumeweb.com/LumeWeb/portal/config" "git.lumeweb.com/LumeWeb/portal/cron" + sia "github.com/siacentral/apisdkgo" rhpv2 "go.sia.tech/core/rhp/v2" "go.sia.tech/renterd/api" busClient "go.sia.tech/renterd/bus/client" @@ -55,9 +56,14 @@ type MultiPartUploadParams struct { var Module = fx.Module("renter", fx.Options( fx.Provide(NewRenterService), + fx.Provide(sia.NewSiaClient), + fx.Provide(NewPriceTracker), fx.Invoke(func(r *RenterDefault) error { return r.init() }), + fx.Invoke(func(r *PriceTracker) error { + return r.init() + }), ), ) @@ -257,3 +263,18 @@ func (r *RenterDefault) UploadObjectMultipart(ctx context.Context, params *Multi func (r *RenterDefault) DeleteObject(ctx context.Context, bucket string, fileName string) error { return r.workerClient.DeleteObject(ctx, bucket, fileName, api.DeleteObjectOptions{}) } + +func (r *RenterDefault) UpdateGougingSettings(ctx context.Context, settings api.GougingSettings) error { + return r.busClient.UpdateSetting(ctx, api.SettingGouging, settings) +} + +func (r *RenterDefault) GougingSettings(ctx context.Context) (api.GougingSettings, error) { + var settings api.GougingSettings + err := r.GetSetting(ctx, api.SettingGouging, &settings) + + if err != nil { + return api.GougingSettings{}, err + } + + return settings, nil +}