diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ffd2ebab..45a2c49cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ - feat: Moved MangroveJsDeploy from mangrove-strats to this package. Renamed script to EmptyChainDeployer - fix: rounding when deducing tick from price for LiquidityProvider +- feat: Add spread to market +- feat: Add getBest to semibook # 2.0.0 diff --git a/src/market.ts b/src/market.ts index efd4c75c4..c9fdfd4f0 100644 --- a/src/market.ts +++ b/src/market.ts @@ -18,6 +18,7 @@ for more on big.js vs decimals.js vs. bignumber.js (which is *not* ethers's BigN */ import Big from "big.js"; import { Density } from "./util/Density"; +import TickPriceHelper from "./util/tickPriceHelper"; let canConstructMarket = false; @@ -778,6 +779,42 @@ class Market { }; } + /** + * Gets the absolute, relative, and tick spread between bids and asks on the market. + */ + async spread() { + const { asks, bids } = this.getBook(); + + const bestAsk = await asks.getBest(); + const bestBid = await bids.getBest(); + + return Market.spread(this, bestAsk, bestBid); + } + + /** + * Gets the absolute, relative, and tick spread between a bid and an ask on the market. + */ + static spread( + market: Market.KeyResolvedForCalculation, + bestAsk?: { price: Bigish; tick: number }, + bestBid?: { price: Bigish; tick: number }, + ) { + if (!bestAsk || !bestBid) { + return {}; + } + const lowestAskPrice = Big(bestAsk.price); + const highestBidPrice = Big(bestBid.price); + const absoluteSpread = lowestAskPrice.sub(highestBidPrice); + const tickSpread = bestAsk.tick + bestBid.tick; + // Intentionally using raw ratio as we do not want decimals scaling + // Rounding is irrelevant as ticks already respects tick spacing + const relativeSpread = new TickPriceHelper("asks", market) + .rawRatioFromTick(tickSpread, "roundUp") + .sub(1); + + return { absoluteSpread, relativeSpread, tickSpread }; + } + /** * Is the market active? * @returns Whether the market is active, i.e., whether both the asks and bids semibooks are active. diff --git a/src/semibook.ts b/src/semibook.ts index f55a730e3..e64b785cd 100644 --- a/src/semibook.ts +++ b/src/semibook.ts @@ -415,6 +415,30 @@ class Semibook return state.bestBinInCache?.firstOfferId; } + /** Returns the best offer if any */ + async getBest(): Promise { + const state = this.getLatestState(); + const result = await this.#foldLeftUntil<{ + offer: Market.Offer | undefined; + }>( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.lastSeenEventBlock!, + state, + { + offer: undefined, + }, + (acc) => { + return acc.offer !== undefined; + }, + (cur, acc) => { + acc.offer = cur; + return acc; + }, + ); + + return result.offer; + } + /** Returns an iterator over the offers in the cache. */ [Symbol.iterator](): Semibook.CacheIterator { const state = this.getLatestState(); diff --git a/test/integration/market.integration.test.ts b/test/integration/market.integration.test.ts index 883566c4a..6ed7668c1 100644 --- a/test/integration/market.integration.test.ts +++ b/test/integration/market.integration.test.ts @@ -110,6 +110,78 @@ describe("Market integration tests suite", () => { }); }); + describe("spread", () => { + let market: Market; + const createBid = async () => { + const { response } = await market.buy({ + limitPrice: 2, + total: 1, + restingOrder: {}, + }); + const tx = await waitForTransaction(response); + await waitForBlock(market.mgv, tx.blockNumber); + }; + + const createAsk = async () => { + const { response } = await market.sell({ + limitPrice: 3, + volume: 1, + restingOrder: {}, + }); + const tx = await waitForTransaction(response); + await waitForBlock(market.mgv, tx.blockNumber); + }; + + beforeEach(async function () { + market = await mgv.market({ + base: "TokenB", + quote: "TokenA", + tickSpacing: 1, + }); + + // Approve router + const orderLogic = mgv.offerLogic(mgv.orderContract.address); + const routerAddress = (await orderLogic.router())!.address; + await waitForTransaction(market.base.approve(routerAddress)); + await waitForTransaction(market.quote.approve(routerAddress)); + }); + + it("with offers", async () => { + // Arrange + await createBid(); + await createAsk(); + + // Act + const { absoluteSpread, relativeSpread, tickSpread } = + await market.spread(); + + // Assert + helpers.assertApproxEqRel(absoluteSpread, 1, 0.0003); + helpers.assertApproxEqRel(relativeSpread, 0.5, 0.0004); + assert.equal(tickSpread, 4056); + }); + + ["bids", "asks", "none"].forEach((ba) => { + it(`with ${ba} on book`, async () => { + // Arrange + if (ba === "bids") { + await createBid(); + } else if (ba === "asks") { + await createAsk(); + } + + // Act + const { absoluteSpread, relativeSpread, tickSpread } = + await market.spread(); + + // Assert + assert.equal(absoluteSpread, undefined); + assert.equal(relativeSpread, undefined); + assert.equal(tickSpread, undefined); + }); + }); + }); + describe("getOutboundInbound", () => { it("returns base as outbound and quote as inbound, when asks", async function () { //Arrange @@ -336,7 +408,7 @@ describe("Market integration tests suite", () => { expect(result).to.be.equal(true); }); - it("returns false, when gives is less than 1", async function () { + it("returns false, when gives is 0", async function () { // Arrange const market = await mgv.market({ base: "TokenB", diff --git a/test/integration/semibook.integration.test.ts b/test/integration/semibook.integration.test.ts index e06957538..d56c25ef0 100644 --- a/test/integration/semibook.integration.test.ts +++ b/test/integration/semibook.integration.test.ts @@ -893,6 +893,25 @@ describe("Semibook integration tests suite", function () { const semibook = market.getSemibook("asks"); expect(semibook.size()).to.equal(0); expect(semibook.getLatestState().isComplete).to.equal(true); + expect(await semibook.getBest()).to.equal(undefined); + }); + + it("does not fail with empty incomplete cache", async function () { + await createOffers(2); + const market = await mgv.market({ + base: "TokenA", + quote: "TokenB", + tickSpacing: 1, + bookOptions: { + targetNumberOfTicks: 0, + chunkSize: 1, + }, + }); + const semibook = market.getSemibook("asks"); + expect(semibook.size()).to.equal(0); + expect(semibook.getLatestState().isComplete).to.equal(false); + const best = await semibook.getBest(); + expect(best?.tick).to.equal(1); }); it("fetches only one chunk if the first contains the target number of ticks", async function () { @@ -947,6 +966,8 @@ describe("Semibook integration tests suite", function () { expect(semibook.size()).to.equal(2); expect(semibook.getLatestState().binCache.size).to.equal(2); expect(semibook.getLatestState().isComplete).to.equal(false); + const best = await semibook.getBest(); + expect(best?.tick).to.equal(1); }); it("fetches multiple chunks until at least target number of ticks have been fetched, then stops, ignoring partially fetched extra ticks", async function () { diff --git a/test/util/helpers.ts b/test/util/helpers.ts index 70790cf3d..f86080a52 100644 --- a/test/util/helpers.ts +++ b/test/util/helpers.ts @@ -73,16 +73,19 @@ export const assertApproxEqAbs = ( }; export const assertApproxEqRel = ( - actual: Bigish, + actual: Bigish | undefined, expected: Bigish, deltaRel: Bigish, message?: string, ) => { - if (!Big(actual).sub(Big(expected)).abs().div(expected).lte(Big(deltaRel))) { + const diff = actual + ? Big(actual).sub(Big(expected)).abs().div(expected) + : undefined; + if (!diff || !diff.lte(Big(deltaRel))) { assert.fail( `${ message ? message + ": " : "" - }expected actual=${actual} to be within relative ${deltaRel} of expected=${expected}`, + }expected actual=${actual} to be within relative ${deltaRel} of expected=${expected} but was ${diff}`, ); } };