import { AccountInfo } 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 useInterval from '@components/shared/useInterval' import isEqual from 'lodash/isEqual' import usePrevious from '@components/shared/usePrevious' import useLocalStorageState from 'hooks/useLocalStorageState' import { floorToDecimal, getDecimalCount } from 'utils/numbers' import { ANIMATION_SETTINGS_KEY } from 'utils/constants' import { useTranslation } from 'next-i18next' import Decimal from 'decimal.js' 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' export const decodeBookL2 = (book: SpotOrderBook | BookSide): number[][] => { const depth = 40 if (book instanceof SpotOrderBook) { return book.getL2(depth).map(([price, size]) => [price, size]) } else if (book instanceof BookSide) { return book.getL2Ui(depth) } return [] } 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 } } // export function decodeBook( // market: Market, // accInfo: AccountInfo // ): SpotOrderBook | undefined { // if (market && accInfo?.data) { // if (market instanceof Market) { // return SpotOrderBook.decode(market, accInfo.data) // } // else if (market instanceof PerpMarket) { // // FIXME: Review the null being passed here // return new BookSide( // // @ts-ignore // null, // market, // BookSideLayout.decode(accInfo.data), // undefined, // 100000 // ) // } // } // } type cumOrderbookSide = { price: number size: number cumulativeSize: number sizePercent: number maxSizePercent: number cumulativeSizePercent: number } const getCumulativeOrderbookSide = ( orders: any[], totalSize: number, maxSize: number, depth: number ): cumOrderbookSide[] => { const cumulative = orders .slice(0, depth) .reduce((cumulative, [price, size], i) => { const cumulativeSize = (cumulative[i - 1]?.cumulativeSize || 0) + size cumulative.push({ price, size, cumulativeSize, sizePercent: Math.round((cumulativeSize / (totalSize || 1)) * 100), cumulativeSizePercent: Math.round((size / (cumulativeSize || 1)) * 100), maxSizePercent: Math.round((size / (maxSize || 1)) * 100), }) return cumulative }, []) return cumulative } 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: string, grouping: number ) => { return !!openOrderPrices.find((ooPrice) => { return ( ooPrice >= parseFloat(price) && ooPrice < parseFloat(price) + grouping ) }) } const depth = 40 const Orderbook = () => { const { t } = useTranslation(['common', 'trade']) const { selectedMarket, serumOrPerpMarket: market } = useSelectedMarket() const [isScrolled, setIsScrolled] = useState(false) const [orderbookData, setOrderbookData] = useState(null) const [grouping, setGrouping] = useState(0.01) const [showBuys, setShowBuys] = useState(true) const [showSells, setShowSells] = useState(true) const [userOpenOrderPrices, setUserOpenOrderPrices] = useState([]) const currentOrderbookData = useRef(null) const nextOrderbookData = useRef(null) const orderbookElRef = useRef(null) const previousGrouping = usePrevious(grouping) const { width } = useViewport() const isMobile = width ? width < breakpoints.md : false const depthArray = useMemo(() => { const bookDepth = !isMobile ? depth : 7 return Array(bookDepth).fill(0) }, [isMobile]) useEffect(() => { if (!market) return setGrouping(market.tickSize) }, [market]) useEffect( () => mangoStore.subscribe( (state) => [state.selectedMarket.orderbook], (orderbook) => (nextOrderbookData.current = orderbook) ), [] ) 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 } } }, []) useInterval(() => { const orderbook = mangoStore.getState().selectedMarket.orderbook const group = mangoStore.getState().group if (!market || !group) return if ( nextOrderbookData?.current && (!isEqual(currentOrderbookData.current, nextOrderbookData.current) || previousGrouping !== grouping) ) { // check if user has open orders so we can highlight them on orderbook const openOrders = mangoStore.getState().mangoAccount.openOrders const marketPk = selectedMarket && selectedMarket instanceof PerpMarket ? selectedMarket.publicKey : selectedMarket?.serumMarketExternal const newUserOpenOrderPrices = marketPk && openOrders[marketPk.toString()]?.length ? openOrders[marketPk.toString()]?.map((order) => order.price) : [] if (!isEqual(newUserOpenOrderPrices, userOpenOrderPrices)) { setUserOpenOrderPrices(newUserOpenOrderPrices) } // updated orderbook data const bids = groupBy(orderbook?.bids, market, grouping, true) || [] const asks = groupBy(orderbook?.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 bidsToDisplay = getCumulativeOrderbookSide( bids, totalSize, maxSize, depth ) const asksToDisplay = getCumulativeOrderbookSide( asks, totalSize, maxSize, depth ) currentOrderbookData.current = { bids: orderbook?.bids, asks: orderbook?.asks, } if (bidsToDisplay[0] || asksToDisplay[0]) { const bid = bidsToDisplay[0]?.price const ask = asksToDisplay[0]?.price let spread = 0, spreadPercentage = 0 if (bid && ask) { spread = ask - bid spreadPercentage = (spread / ask) * 100 } setOrderbookData({ bids: bidsToDisplay, asks: asksToDisplay.reverse(), spread, spreadPercentage, }) if (!isScrolled) { verticallyCenterOrderbook() } } else { setOrderbookData(null) } } }, 400) useEffect(() => { const connection = mangoStore.getState().connection const group = mangoStore.getState().group const set = mangoStore.getState().set const client = mangoStore.getState().client if (!market || !group) return let previousBidInfo: AccountInfo | undefined = undefined let previousAskInfo: AccountInfo | undefined = undefined let bidSubscriptionId: number | undefined = undefined let askSubscriptionId: number | undefined = undefined const bidsPk = market instanceof Market ? market['_decoded'].bids : market.bids console.log('bidsPk', bidsPk?.toString()) if (bidsPk) { connection.getAccountInfo(bidsPk).then((info) => { if (!info) return const decodedBook = decodeBook(client, market, info, 'bids') set((state) => { state.selectedMarket.bidsAccount = decodedBook state.selectedMarket.orderbook.bids = decodeBookL2(decodedBook) }) }) bidSubscriptionId = connection.onAccountChange( bidsPk, (info, _context) => { if ( !previousBidInfo || !previousBidInfo.data.equals(info.data) || previousBidInfo.lamports !== info.lamports ) { previousBidInfo = info const decodedBook = decodeBook(client, market, info, 'bids') set((state) => { state.selectedMarket.bidsAccount = decodedBook state.selectedMarket.orderbook.bids = decodeBookL2(decodedBook) }) } } ) } const asksPk = market instanceof Market ? market['_decoded'].asks : market.asks console.log('asksPk', asksPk?.toString()) if (asksPk) { connection.getAccountInfo(asksPk).then((info) => { if (!info) return const decodedBook = decodeBook(client, market, info, 'asks') set((state) => { state.selectedMarket.asksAccount = decodedBook state.selectedMarket.orderbook.asks = decodeBookL2(decodedBook) }) }) askSubscriptionId = connection.onAccountChange( asksPk, (info, _context) => { if ( !previousAskInfo || !previousAskInfo.data.equals(info.data) || previousAskInfo.lamports !== info.lamports ) { previousAskInfo = info const decodedBook = decodeBook(client, market, info, 'asks') set((state) => { state.selectedMarket.asksAccount = decodedBook state.selectedMarket.orderbook.asks = decodeBookL2(decodedBook) }) } } ) } return () => { if (typeof bidSubscriptionId !== 'undefined') { connection.removeAccountChangeListener(bidSubscriptionId) } if (typeof askSubscriptionId !== 'undefined') { connection.removeAccountChangeListener(askSubscriptionId) } } }, [market]) useEffect(() => { window.addEventListener('resize', verticallyCenterOrderbook) // const id = setTimeout(verticallyCenterOrderbook, 400) // return () => clearTimeout(id) }, [verticallyCenterOrderbook]) const resetOrderbook = useCallback(async () => { setShowBuys(true) setShowSells(true) await sleep(300) verticallyCenterOrderbook() }, []) const onGroupSizeChange = useCallback((groupSize: number) => { setGrouping(groupSize) }, []) const handleScroll = useCallback(() => { setIsScrolled(true) }, []) return (
{market ? (
) : null}
{t('trade:size')}
{t('price')}
{showSells ? depthArray.map((_x, idx) => { let index = idx if (orderbookData?.asks) { 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} {showBuys && showSells ? (
{t('trade:spread')}
{orderbookData?.spreadPercentage.toFixed(2)}%
{orderbookData?.spread.toFixed(2)}
) : null} {showBuys ? 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 tradeForm = mangoStore((s) => s.tradeForm) 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) }, [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 (tradeForm.baseSize && tradeForm.tradeType === 'Limit') { const quoteSize = floorToDecimal( formattedPrice.mul(new Decimal(tradeForm.baseSize)), getDecimalCount(tickSize) ) state.tradeForm.quoteSize = quoteSize.toFixed() } }) }, [formattedPrice, tradeForm]) const handleSizeClick = useCallback(() => { const set = mangoStore.getState().set set((state) => { state.tradeForm.baseSize = formattedSize.toString() if (formattedSize && tradeForm.price) { const quoteSize = floorToDecimal( formattedSize.mul(new Decimal(tradeForm.price)), getDecimalCount(tickSize) ) state.tradeForm.quoteSize = quoteSize.toString() } }) }, [formattedSize, tradeForm]) const groupingDecimalCount = useMemo( () => getDecimalCount(grouping), [grouping] ) const minOrderSizeDecimals = useMemo( () => getDecimalCount(minOrderSize), [minOrderSize] ) if (!minOrderSize) return null return (
<>
{formattedSize.toFixed(minOrderSizeDecimals)}
{formattedPrice.toFixed(groupingDecimalCount)}
) } const MemoizedOrderbookRow = React.memo(OrderbookRow) const Line = (props: { className: string invert?: boolean 'data-width': string }) => { return (
) } export default Orderbook