code.vegaprotocol.io/vega@v0.79.0/core/monitor/price/pricemonitoring.go (about)

     1  // Copyright (C) 2023 Gobalsky Labs Limited
     2  //
     3  // This program is free software: you can redistribute it and/or modify
     4  // it under the terms of the GNU Affero General Public License as
     5  // published by the Free Software Foundation, either version 3 of the
     6  // License, or (at your option) any later version.
     7  //
     8  // This program is distributed in the hope that it will be useful,
     9  // but WITHOUT ANY WARRANTY; without even the implied warranty of
    10  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    11  // GNU Affero General Public License for more details.
    12  //
    13  // You should have received a copy of the GNU Affero General Public License
    14  // along with this program.  If not, see <http://www.gnu.org/licenses/>.
    15  
    16  package price
    17  
    18  import (
    19  	"context"
    20  	"errors"
    21  	"log"
    22  	"sort"
    23  	"sync"
    24  	"time"
    25  
    26  	"code.vegaprotocol.io/vega/core/risk"
    27  	"code.vegaprotocol.io/vega/core/types"
    28  	"code.vegaprotocol.io/vega/core/types/statevar"
    29  	"code.vegaprotocol.io/vega/libs/num"
    30  	"code.vegaprotocol.io/vega/logging"
    31  )
    32  
    33  var (
    34  	// ErrNilRangeProvider signals that nil was supplied in place of RangeProvider.
    35  	ErrNilRangeProvider = errors.New("nil RangeProvider")
    36  	// ErrTimeSequence signals that time sequence is not in a non-decreasing order.
    37  	ErrTimeSequence = errors.New("received a time that's before the last received time")
    38  	// ErrExpiresAtNotSet indicates price monitoring auction is endless somehow.
    39  	ErrExpiresAtNotSet = errors.New("price monitoring auction with no end time")
    40  	// ErrNilPriceMonitoringSettings signals that nil was supplied in place of PriceMonitoringSettings.
    41  	ErrNilPriceMonitoringSettings = errors.New("nil PriceMonitoringSettings")
    42  )
    43  
    44  // can't make this one constant...
    45  var (
    46  	secondsPerYear = num.DecimalFromFloat(365.25 * 24 * 60 * 60)
    47  	tolerance, _   = num.DecimalFromString("1e-6")
    48  )
    49  
    50  //go:generate go run github.com/golang/mock/mockgen -destination mocks/auction_state_mock.go -package mocks code.vegaprotocol.io/vega/core/monitor/price AuctionState
    51  //nolint:interfacebloat
    52  type AuctionState interface {
    53  	// What is the current trading mode of the market, is it in auction
    54  	Mode() types.MarketTradingMode
    55  	InAuction() bool
    56  	// What type of auction are we dealing with
    57  	IsOpeningAuction() bool
    58  	IsPriceAuction() bool
    59  	IsPriceExtension() bool
    60  	IsFBA() bool
    61  	// is it the start/end of the auction
    62  	CanLeave() bool
    63  	AuctionStart() bool
    64  	// start a price-related auction, extend a current auction, or end it
    65  	StartPriceAuction(t time.Time, d *types.AuctionDuration)
    66  	ExtendAuctionPrice(delta types.AuctionDuration)
    67  	SetReadyToLeave()
    68  	// get parameters for current auction
    69  	Start() time.Time
    70  	Duration() types.AuctionDuration // currently not used - might be useful when extending an auction
    71  	ExpiresAt() *time.Time
    72  }
    73  
    74  // bound holds the limits for the valid price movement.
    75  type bound struct {
    76  	Active     bool
    77  	UpFactor   num.Decimal
    78  	DownFactor num.Decimal
    79  	Trigger    *types.PriceMonitoringTrigger
    80  }
    81  
    82  type boundFactors struct {
    83  	up   []num.Decimal
    84  	down []num.Decimal
    85  }
    86  
    87  var (
    88  	defaultDownFactor = num.MustDecimalFromString("0.9")
    89  	defaultUpFactor   = num.MustDecimalFromString("1.1")
    90  )
    91  
    92  type priceRange struct {
    93  	MinPrice       num.WrappedDecimal
    94  	MaxPrice       num.WrappedDecimal
    95  	ReferencePrice num.Decimal
    96  }
    97  
    98  type pastPrice struct {
    99  	Time         time.Time
   100  	AveragePrice num.Decimal
   101  }
   102  
   103  // RangeProvider provides the minimum and maximum future price corresponding to the current price level, horizon expressed as year fraction (e.g. 0.5 for 6 months) and probability level (e.g. 0.95 for 95%).
   104  //
   105  //go:generate go run github.com/golang/mock/mockgen -destination mocks/price_range_provider_mock.go -package mocks code.vegaprotocol.io/vega/core/monitor/price RangeProvider
   106  type RangeProvider interface {
   107  	PriceRange(price, yearFraction, probability num.Decimal) (num.Decimal, num.Decimal)
   108  }
   109  
   110  //go:generate go run github.com/golang/mock/mockgen -destination mocks/state_var_mock.go -package mocks code.vegaprotocol.io/vega/core/monitor/price StateVarEngine
   111  type StateVarEngine interface {
   112  	RegisterStateVariable(asset, market, name string, converter statevar.Converter, startCalculation func(string, statevar.FinaliseCalculation), trigger []statevar.EventType, result func(context.Context, statevar.StateVariableResult) error) error
   113  }
   114  
   115  // Engine allows tracking price changes and verifying them against the theoretical levels implied by the RangeProvider (risk model).
   116  type Engine struct {
   117  	log          *logging.Logger
   118  	riskModel    RangeProvider
   119  	auctionState AuctionState
   120  	minDuration  time.Duration
   121  
   122  	initialised bool
   123  	fpHorizons  map[int64]num.Decimal
   124  	now         time.Time
   125  	update      time.Time
   126  	pricesNow   []*num.Uint
   127  	pricesPast  []pastPrice
   128  	bounds      []*bound
   129  
   130  	priceRangeCacheTime time.Time
   131  	priceRangesCache    map[int]priceRange
   132  
   133  	refPriceCacheTime time.Time
   134  	refPriceCache     map[int64]num.Decimal
   135  	refPriceLock      sync.RWMutex
   136  
   137  	boundFactorsInitialised bool
   138  
   139  	stateChanged   bool
   140  	stateVarEngine StateVarEngine
   141  	market         string
   142  	asset          string
   143  }
   144  
   145  func (e *Engine) UpdateSettings(riskModel risk.Model, settings *types.PriceMonitoringSettings, as AuctionState) {
   146  	e.riskModel = riskModel
   147  	e.fpHorizons, e.bounds = computeBoundsAndHorizons(settings, as)
   148  	e.initialised = false
   149  	e.boundFactorsInitialised = false
   150  	e.priceRangesCache = make(map[int]priceRange, len(e.bounds)) // clear the cache
   151  	// reset reference cache
   152  	e.refPriceCacheTime = time.Time{}
   153  	e.refPriceCache = map[int64]num.Decimal{}
   154  	_ = e.getCurrentPriceRanges(true) // force bound recalc
   155  }
   156  
   157  // Initialised returns true if the engine already saw at least one price.
   158  func (e *Engine) Initialised() bool {
   159  	return e.initialised
   160  }
   161  
   162  // NewMonitor returns a new instance of PriceMonitoring.
   163  func NewMonitor(asset, mktID string, riskModel RangeProvider, auctionState AuctionState, settings *types.PriceMonitoringSettings, stateVarEngine StateVarEngine, log *logging.Logger) (*Engine, error) {
   164  	if riskModel == nil {
   165  		return nil, ErrNilRangeProvider
   166  	}
   167  	if settings == nil {
   168  		return nil, ErrNilPriceMonitoringSettings
   169  	}
   170  
   171  	// Other functions depend on this sorting
   172  	horizons, bounds := computeBoundsAndHorizons(settings, auctionState)
   173  
   174  	e := &Engine{
   175  		riskModel:               riskModel,
   176  		auctionState:            auctionState,
   177  		fpHorizons:              horizons,
   178  		bounds:                  bounds,
   179  		stateChanged:            true,
   180  		stateVarEngine:          stateVarEngine,
   181  		boundFactorsInitialised: false,
   182  		log:                     log,
   183  		market:                  mktID,
   184  		asset:                   asset,
   185  	}
   186  
   187  	stateVarEngine.RegisterStateVariable(asset, mktID, "bound-factors", boundFactorsConverter{}, e.startCalcPriceRanges, []statevar.EventType{statevar.EventTypeTimeTrigger, statevar.EventTypeAuctionEnded, statevar.EventTypeOpeningAuctionFirstUncrossingPrice}, e.updatePriceBounds)
   188  	return e, nil
   189  }
   190  
   191  func (e *Engine) SetMinDuration(d time.Duration) {
   192  	e.minDuration = d
   193  	e.stateChanged = true
   194  }
   195  
   196  // GetHorizonYearFractions returns horizons of all the triggers specified, expressed as year fraction, sorted in ascending order.
   197  func (e *Engine) GetHorizonYearFractions() []num.Decimal {
   198  	h := make([]num.Decimal, 0, len(e.bounds))
   199  	for _, v := range e.fpHorizons {
   200  		h = append(h, v)
   201  	}
   202  
   203  	sort.Slice(h, func(i, j int) bool { return h[i].LessThan(h[j]) })
   204  	return h
   205  }
   206  
   207  // GetValidPriceRange returns the range of prices that won't trigger the price monitoring auction.
   208  func (e *Engine) GetValidPriceRange() (num.WrappedDecimal, num.WrappedDecimal) {
   209  	min := num.NewWrappedDecimal(num.UintZero(), num.DecimalZero())
   210  	m := num.MaxUint()
   211  	max := num.NewWrappedDecimal(m, m.ToDecimal())
   212  	for _, pr := range e.getCurrentPriceRanges(false) {
   213  		if pr.MinPrice.Representation().GT(min.Representation()) {
   214  			min = pr.MinPrice
   215  		}
   216  		if !pr.MaxPrice.Representation().IsZero() && pr.MaxPrice.Representation().LT(max.Representation()) {
   217  			max = pr.MaxPrice
   218  		}
   219  	}
   220  	if min.Original().LessThan(num.DecimalZero()) {
   221  		min = num.NewWrappedDecimal(num.UintZero(), num.DecimalZero())
   222  	}
   223  	return min, max
   224  }
   225  
   226  // GetCurrentBounds returns a list of valid price ranges per price monitoring trigger. Note these are subject to change as the time progresses.
   227  func (e *Engine) GetCurrentBounds() []*types.PriceMonitoringBounds {
   228  	priceRanges := e.getCurrentPriceRanges(false)
   229  	ret := make([]*types.PriceMonitoringBounds, 0, len(priceRanges))
   230  	for ind, pr := range priceRanges {
   231  		b := e.bounds[ind]
   232  		if b.Active {
   233  			ret = append(ret,
   234  				&types.PriceMonitoringBounds{
   235  					MinValidPrice:  pr.MinPrice.Representation(),
   236  					MaxValidPrice:  pr.MaxPrice.Representation(),
   237  					Trigger:        b.Trigger,
   238  					ReferencePrice: pr.ReferencePrice,
   239  				})
   240  		}
   241  	}
   242  
   243  	sort.SliceStable(ret,
   244  		func(i, j int) bool {
   245  			if ret[i].Trigger.Horizon == ret[j].Trigger.Horizon {
   246  				return ret[i].Trigger.Probability.LessThan(ret[j].Trigger.Probability)
   247  			}
   248  			return ret[i].Trigger.Horizon < ret[j].Trigger.Horizon
   249  		})
   250  
   251  	return ret
   252  }
   253  
   254  // GetBounds returns a list of valid price ranges per price monitoring trigger. Note these are subject to change as the time progresses.
   255  func (e *Engine) GetBounds() []*types.PriceMonitoringBounds {
   256  	priceRanges := e.getCurrentPriceRanges(false)
   257  	ret := make([]*types.PriceMonitoringBounds, 0, len(priceRanges))
   258  	for ind, pr := range priceRanges {
   259  		ret = append(ret,
   260  			&types.PriceMonitoringBounds{
   261  				MinValidPrice:  pr.MinPrice.Representation(),
   262  				MaxValidPrice:  pr.MaxPrice.Representation(),
   263  				Trigger:        e.bounds[ind].Trigger,
   264  				ReferencePrice: pr.ReferencePrice,
   265  				Active:         e.bounds[ind].Active,
   266  			})
   267  	}
   268  
   269  	sort.SliceStable(ret,
   270  		func(i, j int) bool {
   271  			if ret[i].Trigger.Horizon == ret[j].Trigger.Horizon {
   272  				return ret[i].Trigger.Probability.LessThan(ret[j].Trigger.Probability)
   273  			}
   274  			return ret[i].Trigger.Horizon < ret[j].Trigger.Horizon
   275  		})
   276  
   277  	return ret
   278  }
   279  
   280  func (e *Engine) OnTimeUpdate(now time.Time) {
   281  	e.recordTimeChange(now)
   282  }
   283  
   284  // CheckPrice checks how current price, volume and time should impact the auction state and modifies it accordingly: start auction, end auction, extend ongoing auction,
   285  // "true" gets returned if non-persistent order should be rejected.
   286  func (e *Engine) CheckPrice(ctx context.Context, as AuctionState, price *num.Uint, persistent bool, recordPriceHistory bool) bool {
   287  	// market is not in auction, or in batch auction
   288  	if fba := as.IsFBA(); !as.InAuction() || fba {
   289  		bounds := e.checkBounds(price)
   290  		// no bounds violations - update price, and we're done (unless we initialised as part of this call, then price has alrady been updated)
   291  		if len(bounds) == 0 {
   292  			if recordPriceHistory {
   293  				e.recordPriceChange(price)
   294  			}
   295  			return false
   296  		}
   297  		if !persistent {
   298  			// we're going to stay in continuous trading, make sure we still have bounds
   299  			e.reactivateBounds()
   300  			return true
   301  		}
   302  		duration := types.AuctionDuration{}
   303  		for _, b := range bounds {
   304  			duration.Duration += b.AuctionExtension
   305  		}
   306  		// we're dealing with a batch auction that's about to end -> extend it?
   307  		if fba && as.CanLeave() {
   308  			// bounds were violated, based on the values in the bounds slice, we can calculate how long the auction should last
   309  			as.ExtendAuctionPrice(duration)
   310  			return false
   311  		}
   312  		if min := int64(e.minDuration / time.Second); duration.Duration < min {
   313  			duration.Duration = min
   314  		}
   315  
   316  		as.StartPriceAuction(e.now, &duration)
   317  		return false
   318  	}
   319  
   320  	bounds := e.checkBounds(price)
   321  	if len(bounds) == 0 {
   322  		end := as.ExpiresAt()
   323  		if !e.now.After(*end) {
   324  			return false
   325  		}
   326  		// auction can be terminated
   327  		as.SetReadyToLeave()
   328  		if recordPriceHistory {
   329  			e.ResetPriceHistory(price)
   330  		} else {
   331  			e.ResetPriceHistory(nil)
   332  		}
   333  		return false
   334  	}
   335  
   336  	var duration int64
   337  	for _, b := range bounds {
   338  		duration += b.AuctionExtension
   339  	}
   340  
   341  	// extend the current auction
   342  	as.ExtendAuctionPrice(types.AuctionDuration{
   343  		Duration: duration,
   344  	})
   345  
   346  	return false
   347  }
   348  
   349  // ResetPriceHistory deletes existing price history and starts it afresh with the supplied value.
   350  func (e *Engine) ResetPriceHistory(price *num.Uint) {
   351  	e.update = e.now
   352  	if price != nil && !price.IsZero() {
   353  		e.pricesNow = []*num.Uint{price.Clone()}
   354  		e.pricesPast = []pastPrice{}
   355  	} else {
   356  		// If there's a price history than use the most recent
   357  		if len(e.pricesPast) > 0 {
   358  			e.pricesPast = e.pricesPast[len(e.pricesPast)-1:]
   359  		} else { // Otherwise can't initialise
   360  			e.initialised = false
   361  			e.stateChanged = true
   362  			return
   363  		}
   364  	}
   365  	e.priceRangeCacheTime = time.Time{}
   366  	e.refPriceCacheTime = time.Time{}
   367  	// we're not reseetting the down/up factors - they will be updated as triggered by auction end/time
   368  	e.reactivateBounds()
   369  	e.stateChanged = true
   370  	e.initialised = true
   371  }
   372  
   373  // reactivateBounds reactivates all bounds.
   374  func (e *Engine) reactivateBounds() {
   375  	for _, b := range e.bounds {
   376  		if !b.Active {
   377  			e.stateChanged = true
   378  		}
   379  		b.Active = true
   380  	}
   381  	e.priceRangeCacheTime = time.Time{}
   382  }
   383  
   384  // recordPriceChange informs price monitoring module of a price change within the same instance as specified by the last call to UpdateTime.
   385  func (e *Engine) recordPriceChange(price *num.Uint) {
   386  	if price != nil && !price.IsZero() {
   387  		e.pricesNow = append(e.pricesNow, price.Clone())
   388  		e.stateChanged = true
   389  	}
   390  }
   391  
   392  // recordTimeChange updates the current time and moves prices from current prices to past prices by calculating their corresponding vwp.
   393  func (e *Engine) recordTimeChange(now time.Time) {
   394  	if now.Before(e.now) {
   395  		log.Panic("invalid state enecountered in price monitoring engine",
   396  			logging.Error(ErrTimeSequence))
   397  	}
   398  	if now.Equal(e.now) {
   399  		return
   400  	}
   401  
   402  	if len(e.pricesNow) > 0 {
   403  		priceSum, numObs := num.UintZero(), num.UintZero()
   404  		for _, p := range e.pricesNow {
   405  			numObs.AddSum(num.UintOne())
   406  			priceSum.AddSum(p)
   407  		}
   408  		e.pricesPast = append(e.pricesPast,
   409  			pastPrice{
   410  				Time:         e.now,
   411  				AveragePrice: priceSum.ToDecimal().Div(numObs.ToDecimal()),
   412  			})
   413  	}
   414  	e.pricesNow = e.pricesNow[:0]
   415  	e.now = now
   416  	e.clearStalePrices()
   417  	e.stateChanged = true
   418  }
   419  
   420  // checkBounds checks if the price is within price range for each of the bound and return trigger for each bound that it's not.
   421  func (e *Engine) checkBounds(price *num.Uint) []*types.PriceMonitoringTrigger {
   422  	ret := []*types.PriceMonitoringTrigger{} // returned price projections, empty if all good
   423  	if price == nil || price.IsZero() {
   424  		return ret
   425  	}
   426  	priceRanges := e.getCurrentPriceRanges(false)
   427  	if len(priceRanges) == 0 {
   428  		return ret
   429  	}
   430  	for i, b := range e.bounds {
   431  		if !b.Active {
   432  			continue
   433  		}
   434  		priceRange := priceRanges[i]
   435  		if price.LT(priceRange.MinPrice.Representation()) || price.GT(priceRange.MaxPrice.Representation()) {
   436  			ret = append(ret, b.Trigger)
   437  			// deactivate the bound that just got violated so it doesn't prevent auction from terminating
   438  			b.Active = false
   439  			// only allow breaking one bound at a time
   440  			return ret
   441  		}
   442  	}
   443  	return ret
   444  }
   445  
   446  // getCurrentPriceRanges calculates price ranges from current reference prices and bound down/up factors.
   447  func (e *Engine) getCurrentPriceRanges(force bool) map[int]priceRange {
   448  	if !force && e.priceRangeCacheTime == e.now && len(e.priceRangesCache) > 0 {
   449  		return e.priceRangesCache
   450  	}
   451  	ranges := make(map[int]priceRange, len(e.priceRangesCache))
   452  	if e.noHistory() {
   453  		return ranges
   454  	}
   455  	for i, b := range e.bounds {
   456  		if !b.Active {
   457  			continue
   458  		}
   459  		ref := e.getRefPrice(b.Trigger.Horizon, force)
   460  		var min, max num.Decimal
   461  
   462  		if e.boundFactorsInitialised {
   463  			min = ref.Mul(b.DownFactor)
   464  			max = ref.Mul(b.UpFactor)
   465  		} else {
   466  			min = ref.Mul(defaultDownFactor)
   467  			max = ref.Mul(defaultUpFactor)
   468  		}
   469  
   470  		ranges[i] = priceRange{
   471  			MinPrice:       wrapPriceRange(min, true),
   472  			MaxPrice:       wrapPriceRange(max, false),
   473  			ReferencePrice: ref,
   474  		}
   475  	}
   476  	e.priceRangesCache = ranges
   477  	e.priceRangeCacheTime = e.now
   478  	e.stateChanged = true
   479  	return e.priceRangesCache
   480  }
   481  
   482  // clearStalePrices updates the pricesPast slice to hold only as many prices as implied by the horizon.
   483  func (e *Engine) clearStalePrices() {
   484  	if e.now.Before(e.update) || len(e.bounds) == 0 || len(e.pricesPast) == 0 {
   485  		return
   486  	}
   487  
   488  	// Remove redundant average prices
   489  	minRequiredHorizon := e.now
   490  	if len(e.bounds) > 0 {
   491  		maxTau := e.bounds[len(e.bounds)-1].Trigger.Horizon
   492  		minRequiredHorizon = e.now.Add(time.Duration(-maxTau) * time.Second)
   493  	}
   494  
   495  	// Make sure at least one entry is left hence the "len(..) - 1"
   496  	for i := 0; i < len(e.pricesPast)-1; i++ {
   497  		if !e.pricesPast[i].Time.Before(minRequiredHorizon) {
   498  			e.pricesPast = e.pricesPast[i:]
   499  			return
   500  		}
   501  	}
   502  	e.pricesPast = e.pricesPast[len(e.pricesPast)-1:]
   503  }
   504  
   505  // getRefPrice caches and returns the ref price for a given horizon. The cache is invalidated when block changes.
   506  func (e *Engine) getRefPrice(horizon int64, force bool) num.Decimal {
   507  	e.refPriceLock.Lock()
   508  	defer e.refPriceLock.Unlock()
   509  	if e.refPriceCache == nil || e.refPriceCacheTime != e.now || force {
   510  		e.refPriceCache = make(map[int64]num.Decimal, len(e.refPriceCache))
   511  		e.stateChanged = true
   512  		e.refPriceCacheTime = e.now
   513  	}
   514  
   515  	if _, ok := e.refPriceCache[horizon]; !ok {
   516  		e.refPriceCache[horizon] = e.calculateRefPrice(horizon)
   517  		e.stateChanged = true
   518  	}
   519  	return e.refPriceCache[horizon]
   520  }
   521  
   522  func (e *Engine) getRefPriceNoUpdate(horizon int64) num.Decimal {
   523  	e.refPriceLock.RLock()
   524  	defer e.refPriceLock.RUnlock()
   525  	if e.refPriceCacheTime == e.now {
   526  		if _, ok := e.refPriceCache[horizon]; !ok {
   527  			return e.calculateRefPrice(horizon)
   528  		}
   529  		return e.refPriceCache[horizon]
   530  	}
   531  	return e.calculateRefPrice(horizon)
   532  }
   533  
   534  // calculateRefPrice returns theh last VolumeWeightedPrice with time preceding currentTime - horizon seconds. If there's only one price it returns the Price.
   535  func (e *Engine) calculateRefPrice(horizon int64) num.Decimal {
   536  	t := e.now.Add(time.Duration(-horizon) * time.Second)
   537  	if len(e.pricesPast) < 1 {
   538  		return e.pricesNow[0].ToDecimal()
   539  	}
   540  	ref := e.pricesPast[0].AveragePrice
   541  	for _, p := range e.pricesPast {
   542  		if p.Time.After(t) {
   543  			break
   544  		}
   545  		ref = p.AveragePrice
   546  	}
   547  	return ref
   548  }
   549  
   550  func (e *Engine) noHistory() bool {
   551  	return len(e.pricesPast) == 0 && len(e.pricesNow) == 0
   552  }
   553  
   554  func computeBoundsAndHorizons(settings *types.PriceMonitoringSettings, as AuctionState) (map[int64]num.Decimal, []*bound) {
   555  	// set bounds to inactive if we're in price monitoring auction
   556  	active := !as.IsPriceAuction()
   557  	parameters := make([]*types.PriceMonitoringTrigger, 0, len(settings.Parameters.Triggers))
   558  	for _, p := range settings.Parameters.Triggers {
   559  		p := *p
   560  		parameters = append(parameters, &p)
   561  	}
   562  	sort.Slice(parameters,
   563  		func(i, j int) bool {
   564  			return parameters[i].Horizon < parameters[j].Horizon &&
   565  				parameters[i].Probability.GreaterThanOrEqual(parameters[j].Probability)
   566  		})
   567  
   568  	horizons := map[int64]num.Decimal{}
   569  	bounds := make([]*bound, 0, len(parameters))
   570  	for _, p := range parameters {
   571  		bounds = append(bounds, &bound{
   572  			Active:  active,
   573  			Trigger: p,
   574  		})
   575  		if _, ok := horizons[p.Horizon]; !ok {
   576  			horizons[p.Horizon] = p.HorizonDec.Div(secondsPerYear)
   577  		}
   578  	}
   579  	return horizons, bounds
   580  }
   581  
   582  func wrapPriceRange(b num.Decimal, isMin bool) num.WrappedDecimal {
   583  	var r *num.Uint
   584  	if isMin {
   585  		r, _ = num.UintFromDecimal(b.Ceil())
   586  	} else {
   587  		r, _ = num.UintFromDecimal(b.Floor())
   588  	}
   589  	return num.NewWrappedDecimal(r, b)
   590  }