From 61623e35504b53f96c1b24d92e5b2d74ba5baf54 Mon Sep 17 00:00:00 2001 From: Maxence Raballand Date: Fri, 6 Sep 2024 16:14:46 +0200 Subject: [PATCH] fix: market order simulation (#130) * fix: market order simulation * chore: format --------- Co-authored-by: maxencerb --- .changeset/gold-fireants-smoke.md | 5 + src/lib/market-order-simulation.test.ts | 191 ++++++++++++++++++++++++ src/lib/market-order-simulation.ts | 51 ++++--- 3 files changed, 228 insertions(+), 19 deletions(-) create mode 100644 .changeset/gold-fireants-smoke.md create mode 100644 src/lib/market-order-simulation.test.ts diff --git a/.changeset/gold-fireants-smoke.md b/.changeset/gold-fireants-smoke.md new file mode 100644 index 0000000..72ecb71 --- /dev/null +++ b/.changeset/gold-fireants-smoke.md @@ -0,0 +1,5 @@ +--- +"@mangrovedao/mgv": patch +--- + +Fixed market order simulation and added tests diff --git a/src/lib/market-order-simulation.test.ts b/src/lib/market-order-simulation.test.ts new file mode 100644 index 0000000..c0f2c89 --- /dev/null +++ b/src/lib/market-order-simulation.test.ts @@ -0,0 +1,191 @@ +import { parseEther, parseUnits } from 'viem' +import { beforeAll, describe, expect, inject, it } from 'vitest' +import { getBook } from '~mgv/actions/book.js' +import { simulatePopulate } from '~mgv/actions/kandel/populate.js' +import { simulateSow } from '~mgv/actions/kandel/sow.js' +import { validateKandelParams } from '~mgv/index.js' +import { getClient } from '~test/src/client.js' +import { mintAndApprove } from '~test/src/contracts/index.js' +import type { Book } from '../types/index.js' +import { BS } from './enums.js' +import { marketOrderSimulation } from './market-order-simulation.js' +import { inboundFromOutbound, outboundFromInbound } from './tick.js' + +const client = getClient() +const actionParams = inject('mangrove') +const kandelSeeder = inject('kandel') +const { wethUSDC } = inject('markets') + +const KANDEL_GASREQ = 128_000n + +describe('marketOrderSimulation', () => { + let book: Book + + beforeAll(async () => { + // Get the book + book = await getBook(client, actionParams, wethUSDC) + + const { params, minProvision } = validateKandelParams({ + minPrice: 2990, + midPrice: 3000, + maxPrice: 3010, + pricePoints: 5n, + market: wethUSDC, + baseAmount: parseEther('10'), + quoteAmount: parseUnits('30000', 6), + stepSize: 1n, + gasreq: KANDEL_GASREQ, + factor: 3, + asksLocalConfig: book.asksConfig, + bidsLocalConfig: book.bidsConfig, + marketConfig: book.marketConfig, + deposit: true, + }) + + const { request: sowReq, result: kandel } = await simulateSow( + client, + wethUSDC, + kandelSeeder.kandelSeeder, + { + account: client.account.address, + }, + ) + const sowTx = await client.writeContract(sowReq) + await client.waitForTransactionReceipt({ hash: sowTx }) + + await mintAndApprove( + client, + wethUSDC.base.address, + client.account.address, + params.baseAmount || 0n, + kandel, + ) + await mintAndApprove( + client, + wethUSDC.quote.address, + client.account.address, + params.quoteAmount || 0n, + kandel, + ) + + const { request: populateReq } = await simulatePopulate(client, kandel, { + ...params, + account: client.account.address, + value: minProvision, + }) + const populateTx = await client.writeContract(populateReq) + await client.waitForTransactionReceipt({ hash: populateTx }) + + book = await getBook(client, actionParams, wethUSDC) + }) + + it('should simulate a buy market order', () => { + const baseAmount = parseEther('4') + const quoteAmount = inboundFromOutbound( + book.asks[0]!.offer.tick, + baseAmount, + ) + const fee = (baseAmount * book.asksConfig.fee) / 10_000n + + const result = marketOrderSimulation({ + book, + bs: BS.buy, + base: baseAmount, // 5 tokens + }) + + expect(result.baseAmount).toBe(baseAmount - fee) + expect(result.quoteAmount).toBe(quoteAmount) + expect(result.gas).toBe(KANDEL_GASREQ + book.asksConfig.offer_gasbase) + expect(result.feePaid).toBe(fee) + expect(result.maxTickEncountered).toBe(book.asks[0]?.offer.tick) + expect(result.minSlippage).toBe(0) + expect(result.fillWants).toBe(true) + expect(result.rawPrice).approximately(3000 / 1e12, 10e-12) + expect(result.fillVolume).toBe(baseAmount) + }) + + it('should simulate a sell market order', () => { + const baseAmount = parseEther('4') + const quoteAmount = outboundFromInbound( + book.bids[0]!.offer.tick, + baseAmount, + ) + const fee = (quoteAmount * book.bidsConfig.fee) / 10_000n + + const result = marketOrderSimulation({ + book, + bs: BS.sell, + base: baseAmount, + }) + + expect(result.baseAmount).toBe(baseAmount) + expect(result.quoteAmount).toBe(quoteAmount - fee) + expect(result.gas).toBe(KANDEL_GASREQ + book.bidsConfig.offer_gasbase) + expect(result.feePaid).toBe(fee) + expect(result.maxTickEncountered).toBe(book.bids[0]?.offer.tick) + expect(result.minSlippage).toBe(0) + expect(result.fillWants).toBe(false) + expect(result.rawPrice).approximately(3000 / 1e12, 10e-12) + expect(result.fillVolume).toBe(baseAmount) + }) + + it('should simulate a buy market order with quote amount', () => { + const quoteAmount = parseUnits('12000', 6) // 12000 USDC + const baseAmount = outboundFromInbound( + book.asks[0]!.offer.tick, + quoteAmount, + ) + const fee = (baseAmount * book.asksConfig.fee) / 10_000n + + const result = marketOrderSimulation({ + book, + bs: BS.buy, + quote: quoteAmount, // 12000 USDC + }) + + expect(result.baseAmount).toBe(baseAmount - fee) + expect(result.quoteAmount).toBe(quoteAmount) + expect(result.gas).toBe(KANDEL_GASREQ + book.asksConfig.offer_gasbase) + expect(result.feePaid).toBe(fee) + expect(result.maxTickEncountered).toBe(book.asks[0]!.offer.tick) + expect(result.minSlippage).toBe(0) + expect(result.fillWants).toBe(false) + expect(result.rawPrice).approximately(3000 / 1e12, 10e-12) + expect(result.fillVolume).toBe(quoteAmount) + }) + + it('should simulate a sell order with quote amount', () => { + const quoteAmount = parseUnits('12000', 6) // 12000 USDC + const baseAmount = inboundFromOutbound( + book.bids[0]!.offer.tick, + quoteAmount, + ) + const fee = (quoteAmount * book.bidsConfig.fee) / 10_000n + + const result = marketOrderSimulation({ + book, + bs: BS.sell, + quote: quoteAmount, // 12000 USDC + }) + + expect(result.baseAmount).toBe(baseAmount) + expect(result.quoteAmount).toBe(quoteAmount - fee) + expect(result.gas).toBe(KANDEL_GASREQ + book.bidsConfig.offer_gasbase) + expect(result.feePaid).toBe(fee) + expect(result.maxTickEncountered).toBe(book.bids[0]!.offer.tick) + expect(result.minSlippage).toBe(0) + expect(result.fillWants).toBe(true) + expect(result.rawPrice).approximately(3000 / 1e12, 10e-12) + expect(result.fillVolume).toBe(quoteAmount) + }) + + it('should throw an error if neither base nor quote is specified', () => { + expect(() => + // @ts-expect-error + marketOrderSimulation({ + book, + bs: BS.buy, + }), + ).toThrow('either base or quote must be specified') + }) +}) diff --git a/src/lib/market-order-simulation.ts b/src/lib/market-order-simulation.ts index f4a50c4..24d0161 100644 --- a/src/lib/market-order-simulation.ts +++ b/src/lib/market-order-simulation.ts @@ -55,20 +55,15 @@ export type RawMarketOrderSimulationResult = { export function rawMarketOrderSimulation( params: RawMarketOrderSimulationParams, ): RawMarketOrderSimulationResult { - const { + let { orderBook, - fillVolume: _fillVolume, + fillVolume, localConfig, globalConfig, fillWants = true, maxTick = MAX_TICK, } = params - // if fillWants is true, then we need to multiply the fillVolume by 10_000n / (10_000n - fee) in order to account for the fee - let fillVolume = fillWants - ? (_fillVolume * 10_000n) / (10_000n - localConfig.fee) - : _fillVolume - const result: RawMarketOrderSimulationResult = { totalGot: 0n, totalGave: 0n, @@ -84,18 +79,36 @@ export function rawMarketOrderSimulation( i < globalConfig.maxRecursionDepth; i++ ) { - const offer = orderBook[i]! - if (offer.offer.tick > maxTick) break - const maxGot = fillWants - ? fillVolume - : outboundFromInbound(offer.offer.tick, fillVolume) - const got = maxGot < offer.offer.gives ? maxGot : offer.offer.gives - const gave = inboundFromOutbound(offer.offer.tick, got) - result.totalGot += got - result.totalGave += gave - result.gas += localConfig.offer_gasbase + offer.detail.gasreq - result.maxTickEncountered = offer.offer.tick - fillVolume -= fillWants ? got : gave + if (orderBook[i]!.offer.tick > maxTick) break + + const offerGives = orderBook[i]!.offer.gives + const offerWants = inboundFromOutbound(orderBook[i]!.offer.tick, offerGives) + + result.gas += localConfig.offer_gasbase + orderBook[i]!.detail.gasreq + result.maxTickEncountered = orderBook[i]!.offer.tick + + let takerWants = 0n + let takerGives = 0n + + if ( + (fillWants && offerGives <= fillVolume) || + (!fillWants && offerWants <= fillVolume) + ) { + // We can take the entire offer + takerWants = offerGives + takerGives = offerWants + } else if (fillWants) { + takerWants = fillVolume + takerGives = inboundFromOutbound(orderBook[i]!.offer.tick, fillVolume) + } else { + takerWants = outboundFromInbound(orderBook[i]!.offer.tick, fillVolume) + takerGives = fillVolume + } + + result.totalGot += takerWants + result.totalGave += takerGives + + fillVolume -= fillWants ? takerWants : takerGives } result.feePaid = (result.totalGot * localConfig.fee) / 10_000n