Skip to content

Commit

Permalink
Roman/price refactor (#104)
Browse files Browse the repository at this point in the history
* separate a computeAndRankRoutesByDirectQuote method

* refactor: better pricing

* more tests and impl

* lint

* updates

* lint

* updates
  • Loading branch information
p0mvn authored Mar 14, 2024
1 parent 357a14d commit 39f385e
Show file tree
Hide file tree
Showing 9 changed files with 345 additions and 70 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ Ref: https://keepachangelog.com/en/1.0.0/
## v0.9.0

- Support all asset list v1 tokens
- Use spot price in pricing

## v0.8.4

Expand Down
1 change: 0 additions & 1 deletion router/usecase/pools/routable_cw_pool.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,6 @@ func (r *routableCosmWasmPoolImpl) SetTokenOutDenom(tokenOutDenom string) {

// CalcSpotPrice implements sqsdomain.RoutablePool.
func (r *routableCosmWasmPoolImpl) CalcSpotPrice(ctx context.Context, baseDenom string, quoteDenom string) (osmomath.BigDec, error) {

var request msg.SpotPriceQueryMsg
// HACK:
// AStroport PCL pool has the quote and base asset denoms reversed
Expand Down
53 changes: 53 additions & 0 deletions router/usecase/precompute.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package usecase

import "github.com/osmosis-labs/osmosis/osmomath"

var (
TenE9 = osmomath.NewInt(1_000_000_000)
TenE8 = osmomath.NewInt(100_000_000)
TenE7 = osmomath.NewInt(10_000_000)
TenE6 = osmomath.NewInt(1_000_000)
TenE5 = osmomath.NewInt(100_000)
TenE4 = osmomath.NewInt(10_000)
TenE3 = osmomath.NewInt(1_000)
TenE2 = osmomath.NewInt(100)
TenE1 = osmomath.NewInt(10)
)

// GetPrecomputeOrderOfMagnitude returns the order of magnitude of the given amount.
// Uses look up table for precomputed order of magnitudes.
func GetPrecomputeOrderOfMagnitude(amount osmomath.Int) int {
if amount.GT(TenE9) {
a := amount.Quo(TenE9)
return 9 + GetPrecomputeOrderOfMagnitude(a)
}
if amount.GTE(TenE9) {
return 9
}
if amount.GTE(TenE8) {
return 8
}
if amount.GTE(TenE7) {
return 7
}
if amount.GTE(TenE6) {
return 6
}
if amount.GTE(TenE5) {
return 5
}
if amount.GTE(TenE4) {
return 4
}
if amount.GTE(TenE3) {
return 3
}
if amount.GTE(TenE2) {
return 2
}
if amount.GTE(TenE1) {
return 1
}

return 0
}
70 changes: 70 additions & 0 deletions router/usecase/precompute_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package usecase_test

import (
"testing"

"github.com/osmosis-labs/osmosis/osmomath"
"github.com/osmosis-labs/sqs/router/usecase"
)

var (
testAmount = osmomath.NewInt(1234567890323344555)
)

func (s *RouterTestSuite) TestGetPrecomputeOrderOfMagnitude() {

tests := map[string]struct {
amount osmomath.Int
}{
"0 = 0": {
amount: osmomath.ZeroInt(),
},
"1 = 0": {
amount: osmomath.OneInt(),
},
"9.99 = 0": {
amount: osmomath.NewInt(9),
},
"10^9 - 1": {
amount: usecase.TenE9.Sub(osmomath.OneInt()),
},
"10^9": {
amount: usecase.TenE9,
},
"10^9 +1": {
amount: usecase.TenE9.Add(osmomath.OneInt()),
},
"10^18 +1": {
amount: usecase.TenE9.Mul(usecase.TenE9).Add(osmomath.OneInt()),
},
"10^15 +5": {
amount: usecase.TenE9.Mul(usecase.TenE6).Add(osmomath.OneInt()),
},
}

for name, tc := range tests {
s.Run(name, func() {

actual := usecase.GetPrecomputeOrderOfMagnitude(tc.amount)

expected := osmomath.OrderOfMagnitude(tc.amount.ToLegacyDec())

s.Require().Equal(expected, actual)
})
}

}

// go test -benchmem -run=^$ -bench ^BenchmarkGetPrecomputeOrderOfMagnitude$ github.com/osmosis-labs/sqs/router/usecase -count=6
func BenchmarkGetPrecomputeOrderOfMagnitude(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = usecase.GetPrecomputeOrderOfMagnitude(testAmount)
}
}

// go test -benchmem -run=^$ -bench ^BenchmarkOrderOfMagnitude$ github.com/osmosis-labs/sqs/router/usecase -count=6 > old
func BenchmarkOrderOfMagnitude(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = osmomath.OrderOfMagnitude(testAmount.ToLegacyDec())
}
}
111 changes: 58 additions & 53 deletions router/usecase/router_usecase.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,71 +136,29 @@ func (r *routerUseCaseImpl) GetOptimalQuote(ctx context.Context, tokenIn sdk.Coi
func (r *routerUseCaseImpl) GetOptimalQuoteFromConfig(ctx context.Context, tokenIn sdk.Coin, tokenOutDenom string, config domain.RouterConfig) (domain.Quote, error) {
// Get an order of magnitude for the token in amount
// This is used for caching ranked routes as these might differ depending on the amount swapped in.
tokenInOrderOfMagnitude := osmomath.OrderOfMagnitude(tokenIn.Amount.ToLegacyDec())
tokenInOrderOfMagnitude := GetPrecomputeOrderOfMagnitude(tokenIn.Amount)

candidateRankedRoutes, err := r.GetCachedRankedRoutes(ctx, tokenIn.Denom, tokenOutDenom, tokenInOrderOfMagnitude)
if err != nil {
return nil, err
}

router := r.initializeRouter(config)

var (
topSingleRouteQuote domain.Quote
rankedRoutes []route.RouteImpl
)

router := r.initializeRouter(config)

// Preferred route in this context is either an overwrite or a cached ranked route.
// If an overwrite exists, it is always used over the ranked route.
// If cached ranked routes are not present, compute and rank routes by direct quote
if len(candidateRankedRoutes.Routes) == 0 {
// If top routes are not present in cache, retrieve unranked candidate routes
candidateRoutes, err := r.handleCandidateRoutes(ctx, router, tokenIn.Denom, tokenOutDenom)
if err != nil {
r.logger.Error("error handling routes", zap.Error(err))
return nil, err
}

// Get request path for metrics
requestURLPath, err := domain.GetURLPathFromContext(ctx)
if err != nil {
return nil, err
}

if len(candidateRoutes.Routes) > 0 {
cacheWrite.WithLabelValues(requestURLPath, candidateRouteCacheLabel, tokenIn.Denom, tokenOutDenom, noOrderOfMagnitude).Inc()

r.candidateRouteCache.Set(formatCandidateRouteCacheKey(tokenIn.Denom, tokenOutDenom), candidateRoutes, time.Duration(config.CandidateRouteCacheExpirySeconds)*time.Second)
}

// Rank candidate routes by estimating direct quotes
topSingleRouteQuote, rankedRoutes, err = r.rankRoutesByDirectQuote(ctx, router, candidateRoutes, tokenIn, tokenOutDenom)
if err != nil {
r.logger.Error("error getting top routes", zap.Error(err))
return nil, err
}

if len(rankedRoutes) == 0 {
return nil, fmt.Errorf("no ranked routes found")
}

// Update ranked routes with filtered ranked routes
rankedRoutes = filterDuplicatePoolIDRoutes(rankedRoutes)

// Convert ranked routes back to candidate for caching
candidateRoutes = convertRankedToCandidateRoutes(rankedRoutes)

if len(rankedRoutes) > 0 {
cacheWrite.WithLabelValues(requestURLPath, rankedRouteCacheLabel, tokenIn.Denom, tokenOutDenom, strconv.FormatInt(int64(tokenInOrderOfMagnitude), 10)).Inc()

r.rankedRouteCache.Set(formatRankedRouteCacheKey(tokenIn.Denom, tokenOutDenom, tokenInOrderOfMagnitude), candidateRoutes, time.Duration(config.RankedRouteCacheExpirySeconds)*time.Second)
}
topSingleRouteQuote, rankedRoutes, err = r.computeAndRankRoutesByDirectQuote(ctx, router, tokenIn, tokenOutDenom)
} else {
// Rank candidate routes by estimating direct quotes
// Otherwise, simply compute quotes over cached ranked routes
topSingleRouteQuote, rankedRoutes, err = r.rankRoutesByDirectQuote(ctx, router, candidateRankedRoutes, tokenIn, tokenOutDenom)
if err != nil {
r.logger.Error("error getting top routes", zap.Error(err))
return nil, err
}
}
if err != nil {
return nil, err
}

if len(rankedRoutes) == 1 || router.config.MaxSplitRoutes == domain.DisableSplitRoutes {
Expand All @@ -221,8 +179,6 @@ func (r *routerUseCaseImpl) GetOptimalQuoteFromConfig(ctx context.Context, token
return nil, err
}

// TODO: Cache split route proportions

finalQuote := topSingleRouteQuote

// If the split route quote is better than the single route quote, return the split route quote
Expand Down Expand Up @@ -317,6 +273,55 @@ func (r *routerUseCaseImpl) rankRoutesByDirectQuote(ctx context.Context, router
return topQuote, routes, nil
}

// computeAndRankRoutesByDirectQuote computes candidate routes and ranks them by token out after estimating direct quotes.
func (r *routerUseCaseImpl) computeAndRankRoutesByDirectQuote(ctx context.Context, router *Router, tokenIn sdk.Coin, tokenOutDenom string) (domain.Quote, []route.RouteImpl, error) {
tokenInOrderOfMagnitude := GetPrecomputeOrderOfMagnitude(tokenIn.Amount)

// If top routes are not present in cache, retrieve unranked candidate routes
candidateRoutes, err := r.handleCandidateRoutes(ctx, router, tokenIn.Denom, tokenOutDenom)
if err != nil {
r.logger.Error("error handling routes", zap.Error(err))
return nil, nil, err
}

// Get request path for metrics
requestURLPath, err := domain.GetURLPathFromContext(ctx)
if err != nil {
return nil, nil, err
}

if len(candidateRoutes.Routes) > 0 {
cacheWrite.WithLabelValues(requestURLPath, candidateRouteCacheLabel, tokenIn.Denom, tokenOutDenom, noOrderOfMagnitude).Inc()

r.candidateRouteCache.Set(formatCandidateRouteCacheKey(tokenIn.Denom, tokenOutDenom), candidateRoutes, time.Duration(router.config.CandidateRouteCacheExpirySeconds)*time.Second)
}

// Rank candidate routes by estimating direct quotes
topSingleRouteQuote, rankedRoutes, err := r.rankRoutesByDirectQuote(ctx, router, candidateRoutes, tokenIn, tokenOutDenom)
if err != nil {
r.logger.Error("error getting ranked routes", zap.Error(err))
return nil, nil, err
}

if len(rankedRoutes) == 0 {
return nil, nil, fmt.Errorf("no ranked routes found")
}

// Update ranked routes with filtered ranked routes
rankedRoutes = filterDuplicatePoolIDRoutes(rankedRoutes)

// Convert ranked routes back to candidate for caching
candidateRoutes = convertRankedToCandidateRoutes(rankedRoutes)

if len(rankedRoutes) > 0 {
cacheWrite.WithLabelValues(requestURLPath, rankedRouteCacheLabel, tokenIn.Denom, tokenOutDenom, strconv.FormatInt(int64(tokenInOrderOfMagnitude), 10)).Inc()

r.rankedRouteCache.Set(formatRankedRouteCacheKey(tokenIn.Denom, tokenOutDenom, tokenInOrderOfMagnitude), candidateRoutes, time.Duration(router.config.RankedRouteCacheExpirySeconds)*time.Second)
}

return topSingleRouteQuote, rankedRoutes, nil
}

// estimateDirectQuote estimates and returns the direct quote for the given routes, token in and token out denom.
// Also, returns the routes ranked by amount out in decreasing order.
// Returns error if:
Expand Down
25 changes: 25 additions & 0 deletions router/usecase/routertesting/suite.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,24 @@ var (
TransmuterCodeIDs: []uint64{148, 254},
GeneralCosmWasmCodeIDs: []uint64{},
}

DefaultPricingRouterConfig = domain.RouterConfig{
PreferredPoolIDs: []uint64{},
MaxRoutes: 5,
MaxPoolsPerRoute: 3,
MaxSplitRoutes: 3,
MinOSMOLiquidity: 50,
RouteCacheEnabled: true,
}

DefaultPricingConfig = domain.PricingConfig{
DefaultSource: domain.ChainPricingSource,
CacheExpiryMs: 2000,
DefaultQuoteHumanDenom: "usdc",
MaxPoolsPerRoute: 4,
MaxRoutes: 5,
MinOSMOLiquidity: 50,
}
)

func init() {
Expand Down Expand Up @@ -306,3 +324,10 @@ func (s *RouterTestHelper) SetupRouterAndPoolsUsecase(router *routerusecase.Rout
Tokens: tokensUsecase,
}
}

// helper to convert any to BigDec
func (s *RouterTestHelper) ConvertAnyToBigDec(any any) osmomath.BigDec {
bigDec, ok := any.(osmomath.BigDec)
s.Require().True(ok)
return bigDec
}
Loading

0 comments on commit 39f385e

Please sign in to comment.