From 56ca927c404da61b5e035697f2ba738474a1469a Mon Sep 17 00:00:00 2001 From: Riordan Panayides Date: Sat, 22 Apr 2023 18:35:57 +0100 Subject: [PATCH 1/5] Use client library for orderbook --- components/trade/Orderbook.tsx | 246 ++++++++++++++++++++++++--------- package.json | 2 + yarn.lock | 14 +- 3 files changed, 194 insertions(+), 68 deletions(-) diff --git a/components/trade/Orderbook.tsx b/components/trade/Orderbook.tsx index fbf5ae52..a1d868c2 100644 --- a/components/trade/Orderbook.tsx +++ b/components/trade/Orderbook.tsx @@ -29,6 +29,7 @@ import useSelectedMarket from 'hooks/useSelectedMarket' import { INITIAL_ANIMATION_SETTINGS } from '@components/settings/AnimationSettings' import { ArrowPathIcon } from '@heroicons/react/20/solid' import { sleep } from 'utils' +import { OrderbookFeed } from '@blockworks-foundation/mango-feeds' const getMarket = () => { const group = mangoStore.getState().group @@ -212,6 +213,7 @@ const Orderbook = () => { const [tickSize, setTickSize] = useState(0) const [showBids, setShowBids] = useState(true) const [showAsks, setShowAsks] = useState(true) + const [useOrderbookFeed, setUseOrderbookFeed] = useState(true) const currentOrderbookData = useRef() const orderbookElRef = useRef(null) @@ -373,88 +375,198 @@ const Orderbook = () => { const market = getMarket() if (!group || !market) return - let bidSubscriptionId: number | undefined = undefined - let askSubscriptionId: number | undefined = undefined - const bidsPk = new PublicKey(bidAccountAddress) - if (bidsPk) { - connection - .getAccountInfoAndContext(bidsPk) - .then(({ context, value: info }) => { - if (!info) return - const decodedBook = decodeBook(client, market, info, 'bids') - set((state) => { - state.selectedMarket.lastSeenSlot.bids = context.slot - state.selectedMarket.bidsAccount = decodedBook - state.selectedMarket.orderbook.bids = decodeBookL2(decodedBook) - }) + if (useOrderbookFeed) { + let hasConnected = false + const orderbookFeed = new OrderbookFeed(`ws://localhost:8080`, { + reconnectionIntervalMs: 5_000, + reconnectionMaxAttempts: 6, + }) + orderbookFeed.onConnect(() => { + console.log('[OrderbookFeed] connected') + hasConnected = true + orderbookFeed.subscribe({ + marketId: market.publicKey.toBase58(), }) - bidSubscriptionId = connection.onAccountChange( - bidsPk, - (info, context) => { - const lastSeenSlot = - mangoStore.getState().selectedMarket.lastSeenSlot.bids - if (context.slot > lastSeenSlot) { - const market = getMarket() - if (!market) return - const decodedBook = decodeBook(client, market, info, 'bids') - if (decodedBook instanceof BookSide) { - updatePerpMarketOnGroup(decodedBook, 'bids') + }) + + orderbookFeed.onDisconnect((reconnectionAttemptsExhausted) => { + // fallback to rpc if we couldn't reconnect or if we never connected + if (reconnectionAttemptsExhausted || !hasConnected) { + console.warn('[OrderbookFeed] disconnected') + setUseOrderbookFeed(false) + } else { + console.log('[OrderbookFeed] reconnecting...') + } + }) + + let lastWriteVersion = 0 + orderbookFeed.onL2Update((update) => { + const selectedMarket = mangoStore.getState().selectedMarket + if (!selectedMarket) return + + // ensure updates are applied in the correct order by checking slot and writeVersion + const lastSeenSlot = + update.side == 'bid' + ? mangoStore.getState().selectedMarket.lastSeenSlot.bids + : mangoStore.getState().selectedMarket.lastSeenSlot.asks + if (update.slot < lastSeenSlot) return + if ( + update.slot == lastSeenSlot && + update.writeVersion < lastWriteVersion + ) + return + lastWriteVersion = update.writeVersion + + const bookside = + update.side == 'bid' + ? selectedMarket.orderbook.bids + : selectedMarket.orderbook.asks + const new_bookside = Array.from(bookside) + + for (const diff of update.update) { + // find existing level for each update + const levelIndex = new_bookside.findIndex( + (level) => level && level.length && level[0] === diff[0] + ) + if (diff[1] > 0) { + // level being added or updated + if (levelIndex !== -1) { + new_bookside[levelIndex] = diff + } else { + // add new level and resort + new_bookside.push(diff) + new_bookside.sort((a, b) => { + return update.side == 'bid' ? b[0] - a[0] : a[0] - b[0] + }) } + } else { + // level being removed if zero size + if (levelIndex !== -1) { + new_bookside.splice(levelIndex, 1) + } else { + console.warn('tried to remove missing level') + } + } + } + set((state) => { + if (update.side == 'bid') { + state.selectedMarket.bidsAccount = undefined + state.selectedMarket.orderbook.bids = new_bookside + state.selectedMarket.lastSeenSlot.bids = update.slot + } else { + state.selectedMarket.asksAccount = undefined + state.selectedMarket.orderbook.asks = new_bookside + state.selectedMarket.lastSeenSlot.asks = update.slot + } + }) + }) + + orderbookFeed.onL2Checkpoint((checkpoint) => { + if (checkpoint.market !== market.publicKey.toBase58()) return + set((state) => { + state.selectedMarket.lastSeenSlot.bids = checkpoint.slot + state.selectedMarket.lastSeenSlot.asks = checkpoint.slot + state.selectedMarket.bidsAccount = undefined + state.selectedMarket.asksAccount = undefined + state.selectedMarket.orderbook.bids = checkpoint.bids + state.selectedMarket.orderbook.asks = checkpoint.asks + }) + }) + orderbookFeed.onStatus((update) => { + console.log('[OrderbookFeed] status', update) + }) + + return () => { + console.log('[OrderbookFeed] unsubscribe') + orderbookFeed.unsubscribe(market.publicKey.toBase58()) + } + } else { + console.log('using rpc orderbook feed') + + let bidSubscriptionId: number | undefined = undefined + let askSubscriptionId: number | undefined = undefined + const bidsPk = new PublicKey(bidAccountAddress) + if (bidsPk) { + connection + .getAccountInfoAndContext(bidsPk) + .then(({ context, value: info }) => { + if (!info) return + const decodedBook = decodeBook(client, market, info, 'bids') set((state) => { + state.selectedMarket.lastSeenSlot.bids = context.slot state.selectedMarket.bidsAccount = decodedBook state.selectedMarket.orderbook.bids = decodeBookL2(decodedBook) - state.selectedMarket.lastSeenSlot.bids = context.slot }) - } - }, - 'processed' - ) - } - - const asksPk = new PublicKey(askAccountAddress) - if (asksPk) { - connection - .getAccountInfoAndContext(asksPk) - .then(({ context, value: info }) => { - if (!info) return - const decodedBook = decodeBook(client, market, info, 'asks') - set((state) => { - state.selectedMarket.asksAccount = decodedBook - state.selectedMarket.orderbook.asks = decodeBookL2(decodedBook) - state.selectedMarket.lastSeenSlot.asks = context.slot }) - }) - askSubscriptionId = connection.onAccountChange( - asksPk, - (info, context) => { - const lastSeenSlot = - mangoStore.getState().selectedMarket.lastSeenSlot.asks - if (context.slot > lastSeenSlot) { - const market = getMarket() - if (!market) return - const decodedBook = decodeBook(client, market, info, 'asks') - if (decodedBook instanceof BookSide) { - updatePerpMarketOnGroup(decodedBook, 'asks') + bidSubscriptionId = connection.onAccountChange( + bidsPk, + (info, context) => { + const lastSeenSlot = + mangoStore.getState().selectedMarket.lastSeenSlot.bids + if (context.slot > lastSeenSlot) { + const market = getMarket() + if (!market) return + const decodedBook = decodeBook(client, market, info, 'bids') + if (decodedBook instanceof BookSide) { + updatePerpMarketOnGroup(decodedBook, 'bids') + } + set((state) => { + state.selectedMarket.bidsAccount = decodedBook + state.selectedMarket.orderbook.bids = decodeBookL2(decodedBook) + state.selectedMarket.lastSeenSlot.bids = context.slot + }) } + }, + 'processed' + ) + } + + const asksPk = new PublicKey(askAccountAddress) + if (asksPk) { + connection + .getAccountInfoAndContext(asksPk) + .then(({ context, value: info }) => { + if (!info) return + const decodedBook = decodeBook(client, market, info, 'asks') set((state) => { state.selectedMarket.asksAccount = decodedBook state.selectedMarket.orderbook.asks = decodeBookL2(decodedBook) state.selectedMarket.lastSeenSlot.asks = context.slot }) - } - }, - 'processed' - ) - } - return () => { - if (typeof bidSubscriptionId !== 'undefined') { - connection.removeAccountChangeListener(bidSubscriptionId) + }) + askSubscriptionId = connection.onAccountChange( + asksPk, + (info, context) => { + const lastSeenSlot = + mangoStore.getState().selectedMarket.lastSeenSlot.asks + if (context.slot > lastSeenSlot) { + const market = getMarket() + if (!market) return + const decodedBook = decodeBook(client, market, info, 'asks') + if (decodedBook instanceof BookSide) { + updatePerpMarketOnGroup(decodedBook, 'asks') + } + set((state) => { + state.selectedMarket.asksAccount = decodedBook + state.selectedMarket.orderbook.asks = decodeBookL2(decodedBook) + state.selectedMarket.lastSeenSlot.asks = context.slot + }) + } + }, + 'processed' + ) } - if (typeof askSubscriptionId !== 'undefined') { - connection.removeAccountChangeListener(askSubscriptionId) + return () => { + console.log('rpc orderbook unsubscribe') + if (typeof bidSubscriptionId !== 'undefined') { + connection.removeAccountChangeListener(bidSubscriptionId) + } + if (typeof askSubscriptionId !== 'undefined') { + connection.removeAccountChangeListener(askSubscriptionId) + } } } - }, [bidAccountAddress, askAccountAddress, connection]) + }, [bidAccountAddress, askAccountAddress, connection, useOrderbookFeed]) useEffect(() => { window.addEventListener('resize', verticallyCenterOrderbook) @@ -685,7 +797,7 @@ const OrderbookRow = ({ const formattedSize = useMemo(() => { return minOrderSize && !isNaN(size) ? floorToDecimal(size, getDecimalCount(minOrderSize)) - : new Decimal(size) + : new Decimal(size ?? -1) }, [size, minOrderSize]) const formattedPrice = useMemo(() => { diff --git a/package.json b/package.json index 74435a5e..0d94452d 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "postinstall": "tar -xzC public -f vendor/charting_library.tgz;tar -xzC public -f vendor/datafeeds.tgz" }, "dependencies": { + "@blockworks-foundation/mango-feeds": "0.1.5", "@blockworks-foundation/mango-v4": "^0.9.18", "@headlessui/react": "1.6.6", "@heroicons/react": "2.0.10", @@ -62,6 +63,7 @@ "recharts": "2.1.14", "tsparticles": "2.2.4", "walktour": "5.1.1", + "webpack-node-externals": "3.0.0", "zustand": "4.1.3" }, "peerDependencies": { diff --git a/yarn.lock b/yarn.lock index b73d6e72..8b51d823 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14,6 +14,13 @@ dependencies: regenerator-runtime "^0.13.11" +"@blockworks-foundation/mango-feeds@0.1.5": + version "0.1.5" + resolved "https://registry.yarnpkg.com/@blockworks-foundation/mango-feeds/-/mango-feeds-0.1.5.tgz#c31132e4d71265e0cee4bd06acebd891001a0d5e" + integrity sha512-ZAiKoXk4ULy6/GBB8AIoI6sIAtgdqLNo1JzsweSaiYfhOFOMs4/BG0WGAk+hoRYjpzJMikzqR3Jw4Wn+stmVyw== + dependencies: + ws "^8.13.0" + "@blockworks-foundation/mango-v4@^0.9.18": version "0.9.18" resolved "https://registry.yarnpkg.com/@blockworks-foundation/mango-v4/-/mango-v4-0.9.18.tgz#93174de932a4c6a6372e7f7dce93939762e3bf13" @@ -8539,6 +8546,11 @@ webidl-conversions@^3.0.0: resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== +webpack-node-externals@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/webpack-node-externals/-/webpack-node-externals-3.0.0.tgz#1a3407c158d547a9feb4229a9e3385b7b60c9917" + integrity sha512-LnL6Z3GGDPht/AigwRh2dvL9PQPFQ8skEpVrWZXLWBYmqcaojHNN0onvHzie6rq7EWKrrBfPYqNEzTJgiwEQDQ== + webrtc-adapter@^7.2.1: version "7.7.1" resolved "https://registry.yarnpkg.com/webrtc-adapter/-/webrtc-adapter-7.7.1.tgz#b2c227a6144983b35057df67bd984a7d4bfd17f1" @@ -8663,7 +8675,7 @@ ws@^7.2.0, ws@^7.4.0, ws@^7.4.5, ws@^7.5.1: resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.9.tgz#54fa7db29f4c7cec68b1ddd3a89de099942bb591" integrity sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q== -ws@^8.5.0: +ws@^8.13.0, ws@^8.5.0: version "8.13.0" resolved "https://registry.yarnpkg.com/ws/-/ws-8.13.0.tgz#9a9fb92f93cf41512a0735c8f4dd09b8a1211cd0" integrity sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA== From f75188202608555028c36bb6ae8ad8a6bc5aca3b Mon Sep 17 00:00:00 2001 From: Riordan Panayides Date: Mon, 24 Apr 2023 19:03:11 +0100 Subject: [PATCH 2/5] Compute funding rate from L2 data --- components/trade/PerpFundingRate.tsx | 82 +++++++++++++++++++++++++--- 1 file changed, 74 insertions(+), 8 deletions(-) diff --git a/components/trade/PerpFundingRate.tsx b/components/trade/PerpFundingRate.tsx index 8911fd67..ba6a5bf0 100644 --- a/components/trade/PerpFundingRate.tsx +++ b/components/trade/PerpFundingRate.tsx @@ -8,6 +8,7 @@ import { MANGO_DATA_API_URL } from 'utils/constants' import Tooltip from '@components/shared/Tooltip' import { useTranslation } from 'next-i18next' import mangoStore from '@store/mangoStore' +import { OrderbookL2 } from 'types' const fetchFundingRate = async (groupPk: string | undefined) => { const res = await fetch( @@ -38,6 +39,57 @@ export const formatFunding = Intl.NumberFormat('en', { style: 'percent', }) +function getImpactPriceL2( + bookside: number[][], + baseDepth: number +): number | undefined { + let total = 0 + for (const level of bookside) { + total += level[1] + if (total >= baseDepth) { + return level[0] + } + } + return undefined +} + +function getInstantaneousFundingRateL2( + market: PerpMarket, + orderbook: OrderbookL2 +) { + const MIN_FUNDING = market.minFunding.toNumber() + const MAX_FUNDING = market.maxFunding.toNumber() + + const bid = getImpactPriceL2( + orderbook.bids, + market.baseLotsToUi(market.impactQuantity) + ) + console.log(bid) + const ask = getImpactPriceL2( + orderbook.asks, + market.baseLotsToUi(market.impactQuantity) + ) + console.log(ask) + const indexPrice = market._uiPrice + + let funding + if (bid !== undefined && ask !== undefined) { + const bookPrice = (bid + ask) / 2 + funding = Math.min( + Math.max(bookPrice / indexPrice - 1, MIN_FUNDING), + MAX_FUNDING + ) + } else if (bid !== undefined) { + funding = MAX_FUNDING + } else if (ask !== undefined) { + funding = MIN_FUNDING + } else { + funding = 0 + } + + return funding +} + const PerpFundingRate = () => { const { selectedMarket } = useSelectedMarket() const rate = usePerpFundingRate() @@ -45,6 +97,7 @@ const PerpFundingRate = () => { const bids = mangoStore((s) => s.selectedMarket.bidsAccount) const asks = mangoStore((s) => s.selectedMarket.asksAccount) + const orderbook = mangoStore((s) => s.selectedMarket.orderbook) const fundingRate = useMemo(() => { if (rate.isSuccess && selectedMarket instanceof PerpMarket) { @@ -56,6 +109,25 @@ const PerpFundingRate = () => { } }, [rate, selectedMarket]) + const instantaneousRate = useMemo(() => { + if (!(selectedMarket instanceof PerpMarket)) return undefined + if (bids instanceof BookSide && asks instanceof BookSide) { + return selectedMarket.getInstantaneousFundingRateUi(bids, asks).toFixed(4) + } + + console.log(orderbook.bids.length, orderbook.asks.length) + if (orderbook.asks.length && orderbook.bids.length) { + console.log( + getInstantaneousFundingRateL2(selectedMarket, orderbook).toFixed(4) + ) + return ( + getInstantaneousFundingRateL2(selectedMarket, orderbook) * 100 + ).toFixed(4) + } + + return undefined + }, [orderbook, bids, asks, selectedMarket]) + return ( <>
@@ -76,15 +148,9 @@ const PerpFundingRate = () => { {formatFunding.format(fundingRate * 8760)}.
) : null} - {selectedMarket instanceof PerpMarket && - bids instanceof BookSide && - asks instanceof BookSide ? ( + {instantaneousRate ? (
- The latest instantaneous rate is{' '} - {selectedMarket - .getInstantaneousFundingRateUi(bids, asks) - .toFixed(4)} - % + The latest instantaneous rate is {instantaneousRate}%
) : null} From 43c3368c257c2c2c35535a4c2508dbceb44ac70a Mon Sep 17 00:00:00 2001 From: Riordan Panayides Date: Mon, 24 Apr 2023 19:23:12 +0100 Subject: [PATCH 3/5] Use production url --- components/trade/Orderbook.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/trade/Orderbook.tsx b/components/trade/Orderbook.tsx index a1d868c2..6c9e3e3c 100644 --- a/components/trade/Orderbook.tsx +++ b/components/trade/Orderbook.tsx @@ -377,7 +377,7 @@ const Orderbook = () => { if (useOrderbookFeed) { let hasConnected = false - const orderbookFeed = new OrderbookFeed(`ws://localhost:8080`, { + const orderbookFeed = new OrderbookFeed(`wss://api.mngo.cloud/orderbook/v1/`, { reconnectionIntervalMs: 5_000, reconnectionMaxAttempts: 6, }) From d3b08eb08d4d66086bccad6fea85cf69b24f9f7a Mon Sep 17 00:00:00 2001 From: Riordan Panayides Date: Tue, 25 Apr 2023 16:25:35 +0100 Subject: [PATCH 4/5] Update mango-feeds --- components/trade/Orderbook.tsx | 11 +++++++---- package.json | 2 +- yarn.lock | 8 ++++---- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/components/trade/Orderbook.tsx b/components/trade/Orderbook.tsx index 6c9e3e3c..797bd8a4 100644 --- a/components/trade/Orderbook.tsx +++ b/components/trade/Orderbook.tsx @@ -377,10 +377,13 @@ const Orderbook = () => { if (useOrderbookFeed) { let hasConnected = false - const orderbookFeed = new OrderbookFeed(`wss://api.mngo.cloud/orderbook/v1/`, { - reconnectionIntervalMs: 5_000, - reconnectionMaxAttempts: 6, - }) + const orderbookFeed = new OrderbookFeed( + `wss://api.mngo.cloud/orderbook/v1/`, + { + reconnectionIntervalMs: 5_000, + reconnectionMaxAttempts: 6, + } + ) orderbookFeed.onConnect(() => { console.log('[OrderbookFeed] connected') hasConnected = true diff --git a/package.json b/package.json index 0d94452d..a3021e6a 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "postinstall": "tar -xzC public -f vendor/charting_library.tgz;tar -xzC public -f vendor/datafeeds.tgz" }, "dependencies": { - "@blockworks-foundation/mango-feeds": "0.1.5", + "@blockworks-foundation/mango-feeds": "0.1.6", "@blockworks-foundation/mango-v4": "^0.9.18", "@headlessui/react": "1.6.6", "@heroicons/react": "2.0.10", diff --git a/yarn.lock b/yarn.lock index 8b51d823..0ad9dbd6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14,10 +14,10 @@ dependencies: regenerator-runtime "^0.13.11" -"@blockworks-foundation/mango-feeds@0.1.5": - version "0.1.5" - resolved "https://registry.yarnpkg.com/@blockworks-foundation/mango-feeds/-/mango-feeds-0.1.5.tgz#c31132e4d71265e0cee4bd06acebd891001a0d5e" - integrity sha512-ZAiKoXk4ULy6/GBB8AIoI6sIAtgdqLNo1JzsweSaiYfhOFOMs4/BG0WGAk+hoRYjpzJMikzqR3Jw4Wn+stmVyw== +"@blockworks-foundation/mango-feeds@0.1.6": + version "0.1.6" + resolved "https://registry.yarnpkg.com/@blockworks-foundation/mango-feeds/-/mango-feeds-0.1.6.tgz#ae82a0f1cb131633b7edd76e2397cfd486cd21c0" + integrity sha512-vsulzORXp8Qyd74ogm809UxxiiL2Gr9UmcxHMeYpd8tohVHLcFF6+SGKmX/9ImSElBrw+zEKIzY5O50w/QM7mQ== dependencies: ws "^8.13.0" From 0c6960dd9df5af4b475c8c1a5293bca091dfd7de Mon Sep 17 00:00:00 2001 From: Riordan Panayides Date: Tue, 25 Apr 2023 16:40:45 +0100 Subject: [PATCH 5/5] Remove errant logs --- components/trade/PerpFundingRate.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/components/trade/PerpFundingRate.tsx b/components/trade/PerpFundingRate.tsx index ba6a5bf0..6d5ba36d 100644 --- a/components/trade/PerpFundingRate.tsx +++ b/components/trade/PerpFundingRate.tsx @@ -115,11 +115,7 @@ const PerpFundingRate = () => { return selectedMarket.getInstantaneousFundingRateUi(bids, asks).toFixed(4) } - console.log(orderbook.bids.length, orderbook.asks.length) if (orderbook.asks.length && orderbook.bids.length) { - console.log( - getInstantaneousFundingRateL2(selectedMarket, orderbook).toFixed(4) - ) return ( getInstantaneousFundingRateL2(selectedMarket, orderbook) * 100 ).toFixed(4)