import { AccountInfo, PublicKey } from '@solana/web3.js' import Big from 'big.js' import mangoStore from '@store/mangoStore' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Market, Orderbook as SpotOrderBook } from '@project-serum/serum' import isEqual from 'lodash/isEqual' import useLocalStorageState from 'hooks/useLocalStorageState' import { floorToDecimal, formatNumericValue, getDecimalCount, } from 'utils/numbers' import { ANIMATION_SETTINGS_KEY } from 'utils/constants' import { useTranslation } from 'next-i18next' import Decimal from 'decimal.js' import { OrderbookL2 } from 'types' import OrderbookIcon from '@components/icons/OrderbookIcon' import Tooltip from '@components/shared/Tooltip' import GroupSize from './GroupSize' import { breakpoints } from '../../utils/theme' import { useViewport } from 'hooks/useViewport' import { BookSide, BookSideType, MangoClient, PerpMarket, } from '@blockworks-foundation/mango-v4' 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 sizeCompacter = Intl.NumberFormat('en', { maximumFractionDigits: 6, notation: 'compact', }) const SHOW_EXPONENTIAL_THRESHOLD = 0.00001 const getMarket = () => { const group = mangoStore.getState().group const selectedMarket = mangoStore.getState().selectedMarket.current if (!group || !selectedMarket) return return selectedMarket instanceof PerpMarket ? selectedMarket : group?.getSerum3ExternalMarket(selectedMarket.serumMarketExternal) } export const decodeBookL2 = (book: SpotOrderBook | BookSide): number[][] => { const depth = 300 if (book instanceof SpotOrderBook) { return book.getL2(depth).map(([price, size]) => [price, size]) } else if (book instanceof BookSide) { return book.getL2Ui(depth) } return [] } export function decodeBook( client: MangoClient, market: Market | PerpMarket, accInfo: AccountInfo, side: 'bids' | 'asks' ): SpotOrderBook | BookSide { if (market instanceof Market) { const book = SpotOrderBook.decode(market, accInfo.data) return book } else { const decodedAcc = client.program.coder.accounts.decode( 'bookSide', accInfo.data ) const book = BookSide.from( client, market, side === 'bids' ? BookSideType.bids : BookSideType.asks, decodedAcc ) return book } } type cumOrderbookSide = { price: number size: number cumulativeSize: number sizePercent: number maxSizePercent: number cumulativeSizePercent: number isUsersOrder: boolean } const getCumulativeOrderbookSide = ( orders: number[][], totalSize: number, maxSize: number, depth: number, usersOpenOrderPrices: number[], grouping: number, isGrouped: boolean ): cumOrderbookSide[] => { let cumulativeSize = 0 return orders.slice(0, depth).map(([price, size]) => { cumulativeSize += size return { price: Number(price), size, cumulativeSize, sizePercent: Math.round((cumulativeSize / (totalSize || 1)) * 100), cumulativeSizePercent: Math.round((size / (cumulativeSize || 1)) * 100), maxSizePercent: Math.round((size / (maxSize || 1)) * 100), isUsersOrder: hasOpenOrderForPriceGroup( usersOpenOrderPrices, price, grouping, isGrouped ), } }) } const groupBy = ( ordersArray: number[][], market: PerpMarket | Market, grouping: number, isBids: boolean ) => { if (!ordersArray || !market || !grouping || grouping == market?.tickSize) { return ordersArray || [] } const groupFloors: Record = {} for (let i = 0; i < ordersArray.length; i++) { if (typeof ordersArray[i] == 'undefined') { break } const bigGrouping = Big(grouping) const bigOrder = Big(ordersArray[i][0]) const floor = isBids ? bigOrder .div(bigGrouping) .round(0, Big.roundDown) .times(bigGrouping) .toNumber() : bigOrder .div(bigGrouping) .round(0, Big.roundUp) .times(bigGrouping) .toNumber() if (typeof groupFloors[floor] == 'undefined') { groupFloors[floor] = ordersArray[i][1] } else { groupFloors[floor] = ordersArray[i][1] + groupFloors[floor] } } const sortedGroups = Object.entries(groupFloors) .map((entry) => { return [ +parseFloat(entry[0]).toFixed(getDecimalCount(grouping)), entry[1], ] }) .sort((a: number[], b: number[]) => { if (!a || !b) { return -1 } return isBids ? b[0] - a[0] : a[0] - b[0] }) return sortedGroups } const hasOpenOrderForPriceGroup = ( openOrderPrices: number[], price: number, grouping: number, isGrouped: boolean ) => { if (!isGrouped) { return !!openOrderPrices.find((ooPrice) => { return ooPrice === price }) } return !!openOrderPrices.find((ooPrice) => { return ooPrice >= price - grouping && ooPrice <= price + grouping }) } const updatePerpMarketOnGroup = (book: BookSide, side: 'bids' | 'asks') => { const group = mangoStore.getState().group const perpMarket = group?.getPerpMarketByMarketIndex( book.perpMarket.perpMarketIndex ) if (perpMarket) { perpMarket[`_${side}`] = book // mangoStore.getState().actions.fetchOpenOrders() } } type OrderbookData = { bids: cumOrderbookSide[] asks: cumOrderbookSide[] spread: number spreadPercentage: number } const Orderbook = () => { const { t } = useTranslation(['common', 'trade']) const { serumOrPerpMarket: market, baseSymbol, quoteSymbol, } = useSelectedMarket() const connection = mangoStore((s) => s.connection) const [isScrolled, setIsScrolled] = useState(false) const [orderbookData, setOrderbookData] = useState(null) const [grouping, setGrouping] = useState(0.01) 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) const { width } = useViewport() const isMobile = width ? width < breakpoints.md : false const depth = useMemo(() => { return isMobile ? 9 : 40 }, [isMobile]) const depthArray: number[] = useMemo(() => { return Array(depth).fill(0) }, [depth]) useEffect(() => { if (market && market.tickSize !== tickSize) { setTickSize(market.tickSize) setGrouping(market.tickSize) } }, [market, tickSize]) const verticallyCenterOrderbook = useCallback(() => { const element = orderbookElRef.current if (element) { if (element.scrollHeight > window.innerHeight) { element.scrollTop = (element.scrollHeight - element.scrollHeight) / 2 + (element.scrollHeight - window.innerHeight) / 2 + 94 } else { element.scrollTop = (element.scrollHeight - element.offsetHeight) / 2 } } }, []) useEffect( () => mangoStore.subscribe( (state) => state.selectedMarket.orderbook, (newOrderbook) => { if ( newOrderbook && market && !isEqual(currentOrderbookData.current, newOrderbook) ) { // check if user has open orders so we can highlight them on orderbook const openOrders = mangoStore.getState().mangoAccount.openOrders const marketPk = market.publicKey.toString() const bids2 = mangoStore.getState().selectedMarket.bidsAccount const asks2 = mangoStore.getState().selectedMarket.asksAccount const mangoAccount = mangoStore.getState().mangoAccount.current let usersOpenOrderPrices: number[] = [] if ( mangoAccount && bids2 && asks2 && bids2 instanceof BookSide && asks2 instanceof BookSide ) { usersOpenOrderPrices = [...bids2.items(), ...asks2.items()] .filter((order) => order.owner.equals(mangoAccount.publicKey)) .map((order) => order.price) } else { usersOpenOrderPrices = marketPk && openOrders[marketPk]?.length ? openOrders[marketPk]?.map((order) => order.price) : [] } // updated orderbook data const bids = groupBy(newOrderbook?.bids, market, grouping, true) || [] const asks = groupBy(newOrderbook?.asks, market, grouping, false) || [] const sum = (total: number, [, size]: number[], index: number) => index < depth ? total + size : total const totalSize = bids.reduce(sum, 0) + asks.reduce(sum, 0) const maxSize = Math.max( ...bids.map((b: number[]) => { return b[1] }) ) + Math.max( ...asks.map((a: number[]) => { return a[1] }) ) const isGrouped = grouping !== market.tickSize const bidsToDisplay = getCumulativeOrderbookSide( bids, totalSize, maxSize, depth, usersOpenOrderPrices, grouping, isGrouped ) const asksToDisplay = getCumulativeOrderbookSide( asks, totalSize, maxSize, depth, usersOpenOrderPrices, grouping, isGrouped ) currentOrderbookData.current = newOrderbook if (bidsToDisplay[0] || asksToDisplay[0]) { const bid = bidsToDisplay[0]?.price const ask = asksToDisplay[0]?.price let spread = 0, spreadPercentage = 0 if (bid && ask) { spread = parseFloat( (ask - bid).toFixed(getDecimalCount(market.tickSize)) ) spreadPercentage = (spread / ask) * 100 } setOrderbookData({ bids: bidsToDisplay, asks: asksToDisplay.reverse(), spread, spreadPercentage, }) if (!isScrolled) { verticallyCenterOrderbook() } } else { setOrderbookData(null) } } } ), [grouping, market, isScrolled, verticallyCenterOrderbook] ) const bidAccountAddress = useMemo(() => { if (!market) return '' const bidsPk = market instanceof Market ? market['_decoded'].bids : market.bids return bidsPk.toString() }, [market]) const askAccountAddress = useMemo(() => { if (!market) return '' const asksPk = market instanceof Market ? market['_decoded'].asks : market.asks return asksPk.toString() }, [market]) // subscribe to the bids and asks orderbook accounts useEffect(() => { console.log('setting up orderbook websockets') const set = mangoStore.getState().set const client = mangoStore.getState().client const group = mangoStore.getState().group const market = getMarket() if (!group || !market) return if (useOrderbookFeed) { let hasConnected = false const orderbookFeed = new OrderbookFeed( `wss://api.mngo.cloud/orderbook/v1/`, { reconnectionIntervalMs: 5_000, reconnectionMaxAttempts: 6, } ) orderbookFeed.onConnect(() => { console.log('[OrderbookFeed] connected') hasConnected = true orderbookFeed.subscribe({ marketId: market.publicKey.toBase58(), }) }) 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) }) }) 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 }) }) 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' ) } return () => { console.log('rpc orderbook unsubscribe') if (typeof bidSubscriptionId !== 'undefined') { connection.removeAccountChangeListener(bidSubscriptionId) } if (typeof askSubscriptionId !== 'undefined') { connection.removeAccountChangeListener(askSubscriptionId) } } } }, [bidAccountAddress, askAccountAddress, connection, useOrderbookFeed]) useEffect(() => { window.addEventListener('resize', verticallyCenterOrderbook) }, [verticallyCenterOrderbook]) const resetOrderbook = useCallback(async () => { setShowBids(true) setShowAsks(true) await sleep(300) verticallyCenterOrderbook() }, [verticallyCenterOrderbook]) const onGroupSizeChange = useCallback((groupSize: number) => { setGrouping(groupSize) }, []) const handleScroll = useCallback(() => { setIsScrolled(true) }, []) const toggleSides = (side: string) => { if (side === 'bids') { setShowBids(true) setShowAsks(false) } else { setShowBids(false) setShowAsks(true) } } return (
{market ? ( <>

{t('trade:grouping')}:

) : null}
{t('trade:size')} ({baseSymbol})
{t('price')} ({quoteSymbol})
{showAsks ? depthArray.map((_x, idx) => { let index = idx const reverse = showAsks && !showBids if (orderbookData?.asks && !reverse) { const lengthDiff = depthArray.length - orderbookData.asks.length if (lengthDiff > 0) { index = index < lengthDiff ? -1 : Math.abs(lengthDiff - index) } } return (
{!!orderbookData?.asks[index] && market ? ( ) : null}
) }) : null} {showBids && showAsks ? (
{t('trade:spread')}
{orderbookData?.spreadPercentage.toFixed(2)}%
{orderbookData?.spread ? orderbookData.spread < SHOW_EXPONENTIAL_THRESHOLD ? orderbookData.spread.toExponential() : formatNumericValue( orderbookData.spread, market ? getDecimalCount(market.tickSize) : undefined ) : null}
) : null} {showBids ? depthArray.map((_x, index) => (
{!!orderbookData?.bids[index] && market ? ( ) : null}
)) : null}
) } const OrderbookRow = ({ side, price, size, sizePercent, // invert, hasOpenOrder, minOrderSize, cumulativeSizePercent, tickSize, grouping, }: { side: 'buy' | 'sell' price: number size: number sizePercent: number cumulativeSizePercent: number hasOpenOrder: boolean // invert: boolean grouping: number minOrderSize: number tickSize: number }) => { const element = useRef(null) const [animationSettings] = useLocalStorageState( ANIMATION_SETTINGS_KEY, INITIAL_ANIMATION_SETTINGS ) const flashClassName = side === 'sell' ? 'red-flash' : 'green-flash' useEffect(() => { animationSettings['orderbook-flash'].active && !element.current?.classList.contains(`${flashClassName}`) && element.current?.classList.add(`${flashClassName}`) const id = setTimeout( () => element.current?.classList.contains(`${flashClassName}`) && element.current?.classList.remove(`${flashClassName}`), 500 ) return () => clearTimeout(id) }, [price, size]) const formattedSize = useMemo(() => { return minOrderSize && !isNaN(size) ? floorToDecimal(size, getDecimalCount(minOrderSize)) : new Decimal(size ?? -1) }, [size, minOrderSize]) const formattedPrice = useMemo(() => { return tickSize && !isNaN(price) ? floorToDecimal(price, getDecimalCount(tickSize)) : new Decimal(price) }, [price, tickSize]) const handlePriceClick = useCallback(() => { const set = mangoStore.getState().set set((state) => { state.tradeForm.price = formattedPrice.toFixed() if (state.tradeForm.baseSize && state.tradeForm.tradeType === 'Limit') { const quoteSize = floorToDecimal( formattedPrice.mul(new Decimal(state.tradeForm.baseSize)), getDecimalCount(tickSize) ) state.tradeForm.quoteSize = quoteSize.toFixed() } }) }, [formattedPrice, tickSize]) const handleSizeClick = useCallback(() => { const set = mangoStore.getState().set set((state) => { state.tradeForm.baseSize = formattedSize.toString() if (formattedSize && state.tradeForm.price) { const quoteSize = floorToDecimal( formattedSize.mul(new Decimal(state.tradeForm.price)), getDecimalCount(tickSize) ) state.tradeForm.quoteSize = quoteSize.toString() } }) }, [formattedSize, tickSize]) const groupingDecimalCount = useMemo( () => getDecimalCount(grouping), [grouping] ) const minOrderSizeDecimals = useMemo( () => getDecimalCount(minOrderSize), [minOrderSize] ) if (!minOrderSize) return null return (
<>
{size >= 1000000 ? sizeCompacter.format(size) : formattedSize.toFixed(minOrderSizeDecimals)}
{price < SHOW_EXPONENTIAL_THRESHOLD ? formattedPrice.toExponential() : formattedPrice.toFixed(groupingDecimalCount)}
) } const MemoizedOrderbookRow = React.memo(OrderbookRow) const Line = (props: { className: string invert?: boolean 'data-width': string }) => { return (
) } export default Orderbook