From 1bfd54674505b0f604710d8bfd4b92dcd9620631 Mon Sep 17 00:00:00 2001 From: wwestgarth Date: Wed, 4 Sep 2024 14:38:10 +0100 Subject: [PATCH] fix: a selection of fixes to stablise AMM's during fuzz runs --- core/execution/amm/engine.go | 14 +- core/execution/amm/engine_test.go | 37 +++- core/execution/amm/pool.go | 31 ++++ core/execution/amm/pool_test.go | 64 +++++++ core/execution/amm/shape.go | 16 +- core/execution/future/market.go | 16 +- .../amm/0090-VAMM-market-ticks.feature | 163 ++++++++++++++++++ 7 files changed, 335 insertions(+), 6 deletions(-) create mode 100644 core/integration/features/amm/0090-VAMM-market-ticks.feature diff --git a/core/execution/amm/engine.go b/core/execution/amm/engine.go index c5dde61038..1a0a70ce1e 100644 --- a/core/execution/amm/engine.go +++ b/core/execution/amm/engine.go @@ -347,8 +347,18 @@ func (e *Engine) GetVolumeAtPrice(price *num.Uint, side types.Side) uint64 { vol := uint64(0) for _, pool := range e.poolsCpy { // get the pool's current price - fp := pool.BestPrice(nil) - volume := pool.TradableVolumeInRange(side, fp, price) + best := pool.BestPrice(&types.Order{Price: price, Side: side}) + + // make sure price is in tradable range + if side == types.SideBuy && best.GT(price) { + continue + } + + if side == types.SideSell && best.LT(price) { + continue + } + + volume := pool.TradableVolumeForPrice(side, price) vol += volume } return vol diff --git a/core/execution/amm/engine_test.go b/core/execution/amm/engine_test.go index f387988a8f..1ac548f31b 100644 --- a/core/execution/amm/engine_test.go +++ b/core/execution/amm/engine_test.go @@ -63,6 +63,7 @@ func TestAmendAMM(t *testing.T) { t.Run("test amend AMM which doesn't exist", testAmendAMMWhichDoesntExist) t.Run("test amend AMM with sparse amend", testAmendAMMSparse) t.Run("test amend AMM insufficient commitment", testAmendInsufficientCommitment) + t.Run("test amend AMM when position to large", testAmendWhenPositionLarge) } func TestClosingAMM(t *testing.T) { @@ -125,6 +126,7 @@ func testAmendAMMSparse(t *testing.T) { amend.Parameters.UpperBound.AddSum(num.UintOne()) amend.Parameters.LowerBound.AddSum(num.UintOne()) + ensurePosition(t, tst.pos, 0, nil) updated, _, err := tst.engine.Amend(ctx, amend, riskFactors, scalingFactors, slippage) require.NoError(t, err) @@ -158,6 +160,39 @@ func testAmendInsufficientCommitment(t *testing.T) { assert.Equal(t, poolID, tst.engine.poolsCpy[0].ID) } +func testAmendWhenPositionLarge(t *testing.T) { + ctx := context.Background() + tst := getTestEngine(t) + + party, subAccount := getParty(t, tst) + submit := getPoolSubmission(t, party, tst.marketID) + expectSubaccountCreation(t, tst, party, subAccount) + whenAMMIsSubmitted(t, tst, submit) + + poolID := tst.engine.poolsCpy[0].ID + + amend := getPoolAmendment(t, party, tst.marketID) + + // lower commitment so that the AMM's position at the same price bounds will be less + amend.CommitmentAmount = num.NewUint(50000000000) + + expectBalanceChecks(t, tst, party, subAccount, 100000000000) + ensurePosition(t, tst.pos, 20000000, nil) + _, _, err := tst.engine.Amend(ctx, amend, riskFactors, scalingFactors, slippage) + require.ErrorContains(t, err, "current position is outside of amended bounds") + + // check that the original pool still exists + assert.Equal(t, poolID, tst.engine.poolsCpy[0].ID) + + expectBalanceChecks(t, tst, party, subAccount, 100000000000) + ensurePosition(t, tst.pos, -20000000, nil) + _, _, err = tst.engine.Amend(ctx, amend, riskFactors, scalingFactors, slippage) + require.ErrorContains(t, err, "current position is outside of amended bounds") + + // check that the original pool still exists + assert.Equal(t, poolID, tst.engine.poolsCpy[0].ID) +} + func testBasicSubmitOrder(t *testing.T) { tst := getTestEngine(t) @@ -559,7 +594,7 @@ func testAmendMakesClosingPoolActive(t *testing.T) { amend := getPoolAmendment(t, party, tst.marketID) expectBalanceChecks(t, tst, party, subAccount, amend.CommitmentAmount.Uint64()) - + ensurePosition(t, tst.pos, 0, num.UintZero()) updated, _, err := tst.engine.Amend(ctx, amend, riskFactors, scalingFactors, slippage) require.NoError(t, err) tst.engine.Confirm(ctx, updated) diff --git a/core/execution/amm/pool.go b/core/execution/amm/pool.go index 70a754f684..ba10a3a1a7 100644 --- a/core/execution/amm/pool.go +++ b/core/execution/amm/pool.go @@ -316,6 +316,21 @@ func (p *Pool) IntoProto() *snapshotpb.PoolMapEntry_Pool { } } +// checkPosition will return false if its position exists outside of the curve boundaries and so the AMM +// is invalid. +func (p *Pool) checkPosition() bool { + pos := p.getPosition() + if pos > p.lower.pv.IntPart() { + return false + } + + if -pos > p.upper.pv.IntPart() { + return false + } + + return true +} + // Update returns a copy of the give pool but with its curves and parameters update as specified by `amend`. func (p *Pool) Update( amend *types.AmendAMM, @@ -372,6 +387,11 @@ func (p *Pool) Update( if err := updated.setCurves(rf, sf, linearSlippage); err != nil { return nil, err } + + if !updated.checkPosition() { + return nil, errors.New("AMM's current position is outside of amended bounds - reduce position first") + } + return updated, nil } @@ -661,6 +681,17 @@ func (p *Pool) TradableVolumeInRange(side types.Side, price1 *num.Uint, price2 * return num.MinV(uint64(stP-ndP), uint64(num.AbsV(pos))) } +// TrableVolumeForPrice returns the volume available between the AMM's fair-price and the given +// price and side of an incoming order. It is a special case of TradableVolumeInRange with +// the benefit of accurately using the AMM's position instead of having to calculate the hop +// from fair-price -> position. +func (p *Pool) TradableVolumeForPrice(side types.Side, price *num.Uint) uint64 { + if side == types.SideSell { + return p.TradableVolumeInRange(side, price, nil) + } + return p.TradableVolumeInRange(side, nil, price) +} + // getBalance returns the total balance of the pool i.e it's general account + it's margin account. func (p *Pool) getBalance() *num.Uint { general, err := p.collateral.GetPartyGeneralAccount(p.AMMParty, p.asset) diff --git a/core/execution/amm/pool_test.go b/core/execution/amm/pool_test.go index a46d83876d..e20f2c6e77 100644 --- a/core/execution/amm/pool_test.go +++ b/core/execution/amm/pool_test.go @@ -34,6 +34,8 @@ import ( func TestAMMPool(t *testing.T) { t.Run("test volume between prices", testTradeableVolumeInRange) + t.Run("test volume between prices when AMM closing", testTradeableVolumeInRangeClosing) + t.Run("test tradable volume at price", testTradableVolumeAtPrice) t.Run("test best price", testBestPrice) t.Run("test pool logic with position factor", testPoolPositionFactor) t.Run("test one sided pool", testOneSidedPool) @@ -255,6 +257,64 @@ func testTradeableVolumeInRangeClosing(t *testing.T) { } } +func testTradableVolumeAtPrice(t *testing.T) { + p := newTestPool(t) + defer p.ctrl.Finish() + + tests := []struct { + name string + price *num.Uint + position int64 + side types.Side + expectedVolume uint64 + }{ + { + name: "full volume upper curve", + price: num.NewUint(2200), + side: types.SideBuy, + expectedVolume: 635, + }, + { + name: "full volume lower curve", + price: num.NewUint(1800), + side: types.SideSell, + expectedVolume: 702, + }, + { + name: "no volume upper, wrong side", + price: num.NewUint(2200), + side: types.SideSell, + expectedVolume: 0, + }, + { + name: "no volume lower, wrong side", + price: num.NewUint(1800), + side: types.SideBuy, + expectedVolume: 0, + }, + { + name: "no volume at fair-price buy", + price: num.NewUint(2000), + side: types.SideBuy, + expectedVolume: 0, + }, + { + name: "no volume at fair-price sell", + price: num.NewUint(2000), + side: types.SideSell, + expectedVolume: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ensurePositionN(t, p.pos, tt.position, num.UintZero(), 1) + volume := p.pool.TradableVolumeForPrice(tt.side, tt.price) + assert.Equal(t, int(tt.expectedVolume), int(volume)) + }) + } +} + func TestTradeableVolumeWhenAtBoundary(t *testing.T) { // from ticket 11389 this replicates a scenario found during fuzz testing submit := &types.SubmitAMM{ @@ -488,6 +548,10 @@ func newBasicPoolWithSubmit(t *testing.T, submit *types.SubmitAMM) (*Pool, error col := mocks.NewMockCollateral(ctrl) pos := mocks.NewMockPosition(ctrl) + pos.EXPECT().GetPositionsByParty(gomock.Any()).AnyTimes().Return( + []events.MarketPosition{&marketPosition{size: 0, averageEntry: nil}}, + ) + sqrter := &Sqrter{cache: map[string]num.Decimal{}} return NewPool( diff --git a/core/execution/amm/shape.go b/core/execution/amm/shape.go index 91196581af..837000785a 100644 --- a/core/execution/amm/shape.go +++ b/core/execution/amm/shape.go @@ -93,9 +93,21 @@ func (sm *shapeMaker) makeBoundaryOrder(st, nd *num.Uint) *types.Order { cu = sm.pool.upper } + // if one of the boundaries it equal to the fair-price then the equivalent position + // if the AMM's current position and checking removes the risk of precision loss + stPosition := sm.pos + if st.NEQ(sm.fairPrice) { + stPosition = cu.positionAtPrice(sm.pool.sqrt, st) + } + + ndPosition := sm.pos + if nd.NEQ(sm.fairPrice) { + ndPosition = cu.positionAtPrice(sm.pool.sqrt, nd) + } + volume := num.DeltaV( - cu.positionAtPrice(sm.pool.sqrt, st), - cu.positionAtPrice(sm.pool.sqrt, nd), + stPosition, + ndPosition, ) if st.GTE(sm.fairPrice) { diff --git a/core/execution/future/market.go b/core/execution/future/market.go index 06f60dc622..89b32f8402 100644 --- a/core/execution/future/market.go +++ b/core/execution/future/market.go @@ -1442,6 +1442,20 @@ func (m *Market) getNewPeggedPrice(order *types.Order) (*num.Uint, error) { // we're converting both offset and tick size to asset decimals so we can adjust the price (in asset) directly priceInMarket, _ := num.UintFromDecimal(price.ToDecimal().Div(m.priceFactor)) + + // if the pegged offset is zero and the reference price is non-tick size (from an AMM) then we have to move it so it + // is otherwise the pegged will cross. + if order.PeggedOrder.Offset.IsZero() { + if mod := num.UintZero().Mod(priceInMarket, m.mkt.TickSize); !mod.IsZero() { + if order.Side == types.SideBuy { + priceInMarket.Sub(priceInMarket, mod) + } else { + d := num.UintOne().Sub(m.mkt.TickSize, mod) + priceInMarket.AddSum(d) + } + } + } + if order.Side == types.SideSell { priceInMarket.AddSum(order.PeggedOrder.Offset) // this can only happen when pegged to mid, in which case we want to round to the nearest *better* tick size @@ -5362,7 +5376,7 @@ func (m *Market) getRebasingOrder( Walk: for { // get the tradable volume necessary to move the AMM's position from fair-price -> price - required := pool.TradableVolumeInRange(types.OtherSide(side), fairPrice, price) + required := pool.TradableVolumeForPrice(types.OtherSide(side), price) // AMM is close enough to the target that is has no volume between, so we do not need to rebase if required == 0 { diff --git a/core/integration/features/amm/0090-VAMM-market-ticks.feature b/core/integration/features/amm/0090-VAMM-market-ticks.feature new file mode 100644 index 0000000000..f615f5e819 --- /dev/null +++ b/core/integration/features/amm/0090-VAMM-market-ticks.feature @@ -0,0 +1,163 @@ +Feature: vAMM rebasing when created or amended + + Background: + Given the average block duration is "1" + And the margin calculator named "margin-calculator-1": + | search factor | initial factor | release factor | + | 1.2 | 1.5 | 1.7 | + And the log normal risk model named "log-normal-risk-model": + | risk aversion | tau | mu | r | sigma | + | 0.001 | 0.0011407711613050422 | 0 | 0.9 | 3.0 | + And the liquidity monitoring parameters: + | name | triggering ratio | time window | scaling factor | + | lqm-params | 1.00 | 20s | 1 | + + And the following network parameters are set: + | name | value | + | market.value.windowLength | 60s | + | network.markPriceUpdateMaximumFrequency | 0s | + | limits.markets.maxPeggedOrders | 6 | + | market.auction.minimumDuration | 1 | + | market.fee.factors.infrastructureFee | 0.001 | + | market.fee.factors.makerFee | 0.004 | + | spam.protection.max.stopOrdersPerMarket | 5 | + | market.liquidity.equityLikeShareFeeFraction | 1 | + | market.amm.minCommitmentQuantum | 1 | + | market.liquidity.bondPenaltyParameter | 0.2 | + | market.liquidity.stakeToCcyVolume | 1 | + | market.liquidity.successorLaunchWindowLength | 1h | + | market.liquidity.sla.nonPerformanceBondPenaltySlope | 0 | + | market.liquidity.sla.nonPerformanceBondPenaltyMax | 0.6 | + | validators.epoch.length | 10s | + | market.liquidity.earlyExitPenalty | 0.25 | + | market.liquidity.maximumLiquidityFeeFactorLevel | 0.25 | + #risk factor short:3.5569036 + #risk factor long:0.801225765 + And the following assets are registered: + | id | decimal places | + | USD | 0 | + And the fees configuration named "fees-config-1": + | maker fee | infrastructure fee | + | 0.0004 | 0.001 | + + And the liquidity sla params named "SLA-22": + | price range | commitment min time fraction | performance hysteresis epochs | sla competition factor | + | 0.5 | 0.6 | 1 | 1.0 | + + And the oracle spec for settlement data filtering data from "0xCAFECAFE19" named "termination-oracle": + | property | type | binding | decimals | + | prices.ETH.value | TYPE_INTEGER | settlement data | 0 | + + And the oracle spec for trading termination filtering data from "0xCAFECAFE19" named "termination-oracle": + | property | type | binding | + | trading.terminated | TYPE_BOOLEAN | trading termination | + + + # tick size is + And the markets: + | id | quote name | asset | liquidity monitoring | risk model | margin calculator | auction duration | fees | price monitoring | data source config | linear slippage factor | quadratic slippage factor | sla params | tick size | + | ETH/MAR22 | USD | USD | lqm-params | log-normal-risk-model | margin-calculator-1 | 2 | fees-config-1 | default-none | termination-oracle | 1e0 | 0 | SLA-22 | 7 | + + # Setting up the accounts and vAMM submission now is part of the background, because we'll be running scenarios 0090-VAMM-006 through 0090-VAMM-014 on this setup + Given the parties deposit on asset's general account the following amount: + | party | asset | amount | + | lp1 | USD | 10000000 | + | lp2 | USD | 10000000 | + | lp3 | USD | 10000000 | + | party1 | USD | 10000000 | + | party2 | USD | 10000000 | + | party3 | USD | 10000000 | + | party4 | USD | 10000000 | + | party5 | USD | 10000000 | + | vamm1 | USD | 1000000000 | + | vamm2 | USD | 1000000000 | + + + And the parties place the following orders: + | party | market id | side | volume | price | resulting trades | type | tif | reference | + | lp1 | ETH/MAR22 | buy | 20 | 42 | 0 | TYPE_LIMIT | TIF_GTC | lp1-b | + | party5 | ETH/MAR22 | buy | 20 | 70 | 0 | TYPE_LIMIT | TIF_GTC | lp1-b | + | party1 | ETH/MAR22 | buy | 1 | 77 | 0 | TYPE_LIMIT | TIF_GTC | | + | party2 | ETH/MAR22 | sell | 1 | 77 | 0 | TYPE_LIMIT | TIF_GTC | | + | party3 | ETH/MAR22 | sell | 10 | 140 | 0 | TYPE_LIMIT | TIF_GTC | | + | lp1 | ETH/MAR22 | sell | 10 | 140 | 0 | TYPE_LIMIT | TIF_GTC | lp1-s | + When the opening auction period ends for market "ETH/MAR22" + Then the following trades should be executed: + | buyer | price | size | seller | + | party1 | 77 | 1 | party2 | + + + Then the parties submit the following AMM: + | party | market id | amount | slippage | base | lower bound | upper bound | proposed fee | + | vamm1 | ETH/MAR22 | 100000 | 0.05 | 100 | 90 | 110 | 0.03 | + Then the AMM pool status should be: + | party | market id | amount | status | base | lower bound | upper bound | + | vamm1 | ETH/MAR22 | 100000 | STATUS_ACTIVE | 100 | 90 | 110 | + + And set the following AMM sub account aliases: + | party | market id | alias | + | vamm1 | ETH/MAR22 | vamm1-id | + + And the market data for the market "ETH/MAR22" should be: + | mark price | trading mode | best bid price | best offer price | best bid volume | best offer volume | + | 77 | TRADING_MODE_CONTINUOUS | 99 | 101 | 51 | 45 | + + @VAMM + Scenario: AMM exists and trades outside of market tick sizes + + # AMM's has a BUY at 99 so a SELL at that price should match + When the parties place the following orders: + | party | market id | side | volume | price | resulting trades | type | tif | reference | + | party1 | ETH/MAR22 | sell | 1 | 77 | 1 | TYPE_LIMIT | TIF_GTC | | + + # trade was made outside of tick sizes, great + Then the following trades should be executed: + | buyer | price | size | seller | is amm | + | vamm1-id | 99 | 1 | party1 | true | + + + @VAMM + Scenario: pegged orders pegged to non-tick size AMM's + + Then the parties place the following pegged orders: + | party | market id | side | volume | pegged reference | offset | reference | + | lp3 | ETH/MAR22 | sell | 10 | ASK | 7 | peg-ask | + | lp3 | ETH/MAR22 | buy | 10 | BID | 7 | peg-bid | + | lp3 | ETH/MAR22 | buy | 5 | MID | 14 | peg-mid-bid | + | lp3 | ETH/MAR22 | sell | 5 | MID | 14 | peg-mid-ask | + + # check that the pegged orders are priced on market ticks, moving *towards* the + Then the order book should have the following volumes for market "ETH/MAR22": + | side | price | volume | + | buy | 91 | 5 | + | buy | 98 | 10 | + | sell | 105 | 10 | + | sell | 112 | 5 | + + @VAMM + Scenario: Reference price is AMM not at market-tick and pegged offset is 0 + + When the parties cancel the following AMM: + | party | market id | method | + | vamm1 | ETH/MAR22 | METHOD_IMMEDIATE | + + + # have pegged offset at 0 and at a size of the market tick, they should both get pegged to the same price + When the parties place the following pegged iceberg orders: + | party | market id | side | volume | peak size | minimum visible size | pegged reference | offset | + | lp1 | ETH/MAR22 | buy | 100 | 10 | 2 | BID | 0 | + | lp1 | ETH/MAR22 | buy | 100 | 10 | 2 | BID | 7 | + | lp1 | ETH/MAR22 | sell | 100 | 10 | 2 | ASK | 0 | + | lp1 | ETH/MAR22 | sell | 100 | 10 | 2 | ASK | 7 | + # create an AMM outside of tick prices and force a recalculate + Then the parties submit the following AMM: + | party | market id | amount | slippage | base | lower bound | upper bound | proposed fee | + | vamm1 | ETH/MAR22 | 100000 | 0.05 | 100 | 90 | 110 | 0.03 | + + And the network moves ahead "1" blocks + Then the order book should have the following volumes for market "ETH/MAR22": + | side | price | volume | + | buy | 98 | 20 | + | sell | 105 | 20 | + + \ No newline at end of file