import React, { useRef, useEffect, useState, useMemo } from 'react'
import Big from 'big.js'
import isEqualLodash from 'lodash/isEqual'
import useInterval from '../hooks/useInterval'
import usePrevious from '../hooks/usePrevious'
import {
isEqual,
getDecimalCount,
getPrecisionDigits,
usdFormatter,
} from '../utils/'
import {
ArrowUpIcon,
ArrowDownIcon,
SwitchHorizontalIcon,
} from '@heroicons/react/solid'
import { CumulativeSizeIcon, StepSizeIcon } from './icons'
import useMarkPrice from '../hooks/useMarkPrice'
import { ElementTitle } from './styles'
import useMangoStore from '../stores/useMangoStore'
import Tooltip from './Tooltip'
import GroupSize from './GroupSize'
import { useViewport } from '../hooks/useViewport'
import { breakpoints } from './TradePageGrid'
import {
FlipCard,
FlipCardBack,
FlipCardFront,
FlipCardInner,
} from './FlipCard'
import { useTranslation } from 'next-i18next'
import FloatingElement from './FloatingElement'
import useLocalStorageState from '../hooks/useLocalStorageState'
import { ORDERBOOK_FLASH_KEY } from './SettingsModal'
import {
mangoGroupConfigSelector,
marketConfigSelector,
marketSelector,
orderbookSelector,
setStoreSelector,
} from '../stores/selectors'
import { Market } from '@project-serum/serum'
import { PerpMarket } from '@blockworks-foundation/mango-client'
const Line = (props) => {
return (
)
}
const groupBy = (ordersArray, market, grouping: number, isBids: boolean) => {
if (!ordersArray || !market || !grouping || grouping == market?.tickSize) {
return ordersArray || []
}
const groupFloors = {}
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)
: bigOrder.div(bigGrouping).round(0, Big.roundUp).times(bigGrouping)
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(function (a: number[], b: number[]) {
if (!a || !b) {
return -1
}
return isBids ? b[0] - a[0] : a[0] - b[0]
})
return sortedGroups
}
const getCumulativeOrderbookSide = (
orders,
totalSize,
maxSize,
depth,
backwards = false
) => {
let 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),
maxSizePercent: Math.round((size / (maxSize || 1)) * 100),
})
return cumulative
}, [])
if (backwards) {
cumulative = cumulative.reverse()
}
return cumulative
}
const hasOpenOrderForPriceGroup = (openOrderPrices, price, grouping) => {
return !!openOrderPrices.find((ooPrice) => {
return ooPrice >= parseFloat(price) && ooPrice < price + grouping
})
}
export default function Orderbook({ depth = 8 }) {
const { t } = useTranslation('common')
const groupConfig = useMangoStore(mangoGroupConfigSelector)
const marketConfig = useMangoStore(marketConfigSelector)
const orderbook = useMangoStore(orderbookSelector)
const market = useMangoStore(marketSelector)
const markPrice = useMarkPrice()
const { width } = useViewport()
const isMobile = width ? width < breakpoints.sm : false
const currentOrderbookData = useRef(null)
const nextOrderbookData = useRef(null)
const previousDepth = usePrevious(depth)
const [openOrderPrices, setOpenOrderPrices] = useState([])
const [orderbookData, setOrderbookData] = useState(null)
const [defaultLayout, setDefaultLayout] = useState(true)
const [displayCumulativeSize, setDisplayCumulativeSize] = useState(false)
const [grouping, setGrouping] = useState(0.01)
const [tickSize, setTickSize] = useState(0)
const previousGrouping = usePrevious(grouping)
useEffect(() => {
if (market && market.tickSize !== tickSize) {
setTickSize(market.tickSize)
setGrouping(market.tickSize)
}
}, [market])
useInterval(() => {
if (
nextOrderbookData?.current?.bids &&
(!currentOrderbookData.current ||
!isEqualLodash(
currentOrderbookData.current.bids,
nextOrderbookData.current.bids
) ||
!isEqualLodash(
currentOrderbookData.current.asks,
nextOrderbookData.current.asks
) ||
previousDepth !== depth ||
previousGrouping !== grouping)
) {
// check if user has open orders so we can highlight them on orderbook
const openOrders =
useMangoStore.getState().selectedMangoAccount.openOrders
const newOpenOrderPrices = openOrders?.length
? openOrders
.filter(({ market }) =>
market.account.publicKey.equals(marketConfig.publicKey)
)
.map(({ order }) => order.price)
: []
if (!isEqualLodash(newOpenOrderPrices, openOrderPrices)) {
setOpenOrderPrices(newOpenOrderPrices)
}
// updated orderbook data
const bids = groupBy(orderbook?.bids, market, grouping, true) || []
const asks = groupBy(orderbook?.asks, market, grouping, false) || []
const sum = (total, [, size], index) =>
index < depth ? total + size : total
const totalSize = bids.reduce(sum, 0) + asks.reduce(sum, 0)
const maxSize =
Math.max(
...asks.map(function (a) {
return a[1]
})
) +
Math.max(
...bids.map(function (b) {
return b[1]
})
)
const bidsToDisplay = defaultLayout
? getCumulativeOrderbookSide(bids, totalSize, maxSize, depth, false)
: getCumulativeOrderbookSide(bids, totalSize, maxSize, depth / 2, false)
const asksToDisplay = defaultLayout
? getCumulativeOrderbookSide(asks, totalSize, maxSize, depth, false)
: getCumulativeOrderbookSide(
asks,
totalSize,
maxSize,
(depth + 1) / 2,
true
)
currentOrderbookData.current = {
bids: orderbook?.bids,
asks: orderbook?.asks,
}
if (bidsToDisplay[0] || asksToDisplay[0]) {
const bid = bidsToDisplay[0]?.price
const ask = defaultLayout
? asksToDisplay[0]?.price
: asksToDisplay[asksToDisplay.length - 1]?.price
let spread = 0,
spreadPercentage = 0
if (bid && ask) {
spread = ask - bid
spreadPercentage = (spread / ask) * 100
}
setOrderbookData({
bids: bidsToDisplay,
asks: isMobile ? asksToDisplay.reverse() : asksToDisplay,
spread,
spreadPercentage,
})
} else {
setOrderbookData(null)
}
}
}, 500)
useEffect(() => {
nextOrderbookData.current = {
bids: orderbook?.bids,
asks: orderbook?.asks,
}
}, [orderbook])
const handleLayoutChange = () => {
setDefaultLayout(!defaultLayout)
setOrderbookData((prevState) => ({
...orderbookData,
asks: prevState.asks.reverse(),
}))
}
const onGroupSizeChange = (groupSize) => {
setGrouping(groupSize)
}
return !isMobile ? (
{defaultLayout ? (
{t('orderbook')}
{displayCumulativeSize ? 'Cumulative ' : ''}
{t('size')} ({marketConfig.baseSymbol})
{`${t('price')} (${groupConfig.quoteSymbol})`}
{displayCumulativeSize ? 'Cumulative ' : ''}
{t('size')} ({marketConfig.baseSymbol})
{orderbookData?.bids.map(
({
price,
size,
cumulativeSize,
sizePercent,
maxSizePercent,
}) => (
)
)}
{orderbookData?.asks.map(
({
price,
size,
cumulativeSize,
sizePercent,
maxSizePercent,
}) => (
)
)}
) : (
{t('orderbook')}
{displayCumulativeSize ? 'Cumulative ' : ''}
{t('size')} ({marketConfig.baseSymbol})
{`${t('price')} (${groupConfig.quoteSymbol})`}
{orderbookData?.asks.map(
({
price,
size,
cumulativeSize,
sizePercent,
maxSizePercent,
}) => (
)
)}
{t('spread')}
{orderbookData?.spread.toFixed(2)}
{orderbookData?.spreadPercentage.toFixed(2)}%
{orderbookData?.bids.map(
({
price,
size,
cumulativeSize,
sizePercent,
maxSizePercent,
}) => (
)
)}
)}
) : (
{displayCumulativeSize ? 'Cumulative ' : ''}
{t('size')}
{t('price')}
{orderbookData?.asks.map(
({ price, size, cumulativeSize, sizePercent, maxSizePercent }) => (
)
)}
{orderbookData?.bids.map(
({ price, size, cumulativeSize, sizePercent, maxSizePercent }) => (
)
)}
)
}
const OrderbookSpread = ({ orderbookData }) => {
const { t } = useTranslation('common')
const selectedMarket = useMangoStore((s) => s.selectedMarket.current)
const decimals = useMemo(() => {
if (selectedMarket) {
return getPrecisionDigits(selectedMarket?.tickSize)
}
return 2
}, [selectedMarket])
return (
{t('spread')}
{orderbookData?.spread.toFixed(decimals)}
{orderbookData?.spreadPercentage.toFixed(2)}%
)
}
const OrderbookRow = React.memo(
({
side,
price,
size,
sizePercent,
invert,
hasOpenOrder,
market,
grouping,
}: {
side: 'buy' | 'sell'
price: number
size: number
sizePercent: number
hasOpenOrder: boolean
invert: boolean
grouping: number
market: Market | PerpMarket
}) => {
const element = useRef(null)
const setMangoStore = useMangoStore(setStoreSelector)
const [showOrderbookFlash] = useLocalStorageState(ORDERBOOK_FLASH_KEY, true)
const flashClassName = side === 'sell' ? 'red-flash' : 'green-flash'
useEffect(() => {
showOrderbookFlash &&
!element.current?.classList.contains(flashClassName) &&
element.current?.classList.add(flashClassName)
const id = setTimeout(
() =>
element.current?.classList.contains(flashClassName) &&
element.current?.classList.remove(flashClassName),
250
)
return () => clearTimeout(id)
}, [price, size])
const formattedSize =
market?.minOrderSize && !isNaN(size)
? Number(size).toFixed(getDecimalCount(market.minOrderSize))
: size
const formattedPrice =
market?.tickSize && !isNaN(price)
? Number(price).toFixed(getDecimalCount(market.tickSize))
: price
const handlePriceClick = () => {
setMangoStore((state) => {
state.tradeForm.price = Number(formattedPrice)
})
}
const handleSizeClick = () => {
setMangoStore((state) => {
state.tradeForm.baseSize = Number(formattedSize)
})
}
if (!market) return null
const groupingDecimalCount = getDecimalCount(grouping)
const minOrderSizeDecimals = getDecimalCount(market.minOrderSize)
return (
{invert ? (
<>
{usdFormatter(formattedPrice, groupingDecimalCount, false)}
{usdFormatter(formattedSize, minOrderSizeDecimals, false)}
>
) : (
<>
{usdFormatter(formattedSize, minOrderSizeDecimals, false)}
{usdFormatter(formattedPrice, groupingDecimalCount, false)}
>
)}
)
},
(prevProps, nextProps) =>
isEqual(prevProps, nextProps, [
'price',
'size',
'sizePercent',
'hasOpenOrder',
])
)
const MarkPriceComponent = React.memo<{ markPrice: number }>(
({ markPrice }) => {
const previousMarkPrice: number = usePrevious(markPrice)
return (
previousMarkPrice
? `text-th-green`
: markPrice < previousMarkPrice
? `text-th-red`
: `text-th-fgd-1`
}`}
>
{markPrice > previousMarkPrice && (
)}
{markPrice < previousMarkPrice && (
)}
{markPrice || '----'}
)
},
(prevProps, nextProps) => isEqual(prevProps, nextProps, ['markPrice'])
)