mango-v4-ui/components/trade/Orderbook.tsx

613 lines
19 KiB
TypeScript
Raw Normal View History

2022-09-13 23:24:26 -07:00
import { AccountInfo } from '@solana/web3.js'
import Big from 'big.js'
import mangoStore from '@store/mangoStore'
2022-09-19 16:26:30 -07:00
import React, { useEffect, useMemo, useRef, useState } from 'react'
2022-09-13 23:24:26 -07:00
import { Market, Orderbook as SpotOrderBook } from '@project-serum/serum'
import useInterval from '@components/shared/useInterval'
import isEqualLodash from 'lodash/isEqual'
import usePrevious from '@components/shared/usePrevious'
import { PerpMarket } from '@blockworks-foundation/mango-v4/dist/types/src/accounts/perp'
import useLocalStorageState from 'hooks/useLocalStorageState'
import { floorToDecimal, formatDecimal, getDecimalCount } from 'utils/numbers'
import { ORDERBOOK_FLASH_KEY } from 'utils/constants'
import { useTranslation } from 'next-i18next'
import Decimal from 'decimal.js'
2022-09-18 05:53:28 -07:00
import OrderbookIcon from '@components/icons/OrderbookIcon'
import Tooltip from '@components/shared/Tooltip'
import GroupSize from './GroupSize'
2022-09-13 23:24:26 -07:00
function decodeBookL2(
market: Market,
accInfo: AccountInfo<Buffer>
): number[][] {
if (market && accInfo?.data) {
const depth = 40
if (market instanceof Market) {
const book = SpotOrderBook.decode(market, accInfo.data)
return book.getL2(depth).map(([price, size]) => [price, size])
}
// else if (market instanceof PerpMarket) {
// // FIXME: Review the null being passed here
// const book = new BookSide(
// // @ts-ignore
// null,
// market,
// BookSideLayout.decode(accInfo.data),
// undefined,
// 100000
// )
// return book.getL2Ui(depth)
// }
}
return []
}
export function decodeBook(
market: Market,
accInfo: AccountInfo<Buffer>
): 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
}
const getCumulativeOrderbookSide = (
orders: any[],
totalSize: number,
maxSize: number,
depth: number,
backwards = false
): cumOrderbookSide[] => {
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 groupBy = (
ordersArray: number[][],
market: Market,
grouping: number,
isBids: boolean
) => {
if (!ordersArray || !market || !grouping || grouping == market?.tickSize) {
return ordersArray || []
}
const groupFloors: Record<number, number> = {}
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 Orderbook = ({ depth = 12 }) => {
const { t } = useTranslation('common')
const selectedMarket = mangoStore((s) => s.selectedMarket.current)
const [openOrderPrices, setOpenOrderPrices] = useState<any[]>([])
const [orderbookData, setOrderbookData] = useState<any | null>(null)
const [defaultLayout, setDefaultLayout] = useState(true)
const [displayCumulativeSize, setDisplayCumulativeSize] = useState(false)
const [grouping, setGrouping] = useState(0.01)
2022-09-18 05:53:28 -07:00
const [showBuys, setShowBuys] = useState(true)
const [showSells, setShowSells] = useState(true)
2022-09-13 23:24:26 -07:00
const currentOrderbookData = useRef<any>(null)
const nextOrderbookData = useRef<any>(null)
const previousDepth = usePrevious(depth)
const previousGrouping = usePrevious(grouping)
const serum3MarketExternal = useMemo(() => {
const group = mangoStore.getState().group
if (!group || !selectedMarket) return
return group.serum3MarketExternalsMap.get(
selectedMarket.serumMarketExternal.toBase58()
)
}, [selectedMarket])
useEffect(() => {
if (serum3MarketExternal) {
setGrouping(serum3MarketExternal.tickSize)
}
}, [serum3MarketExternal])
useEffect(
() =>
mangoStore.subscribe(
(state) => [state.selectedMarket.orderbook],
(orderbook) => (nextOrderbookData.current = orderbook)
),
[]
)
useInterval(() => {
const orderbook = mangoStore.getState().selectedMarket.orderbook
const group = mangoStore.getState().group
if (!selectedMarket || !group) return
const serum3MarketExternal = group.serum3MarketExternalsMap.get(
selectedMarket.serumMarketExternal.toBase58()
)
if (
nextOrderbookData?.current &&
(!isEqualLodash(
currentOrderbookData.current,
nextOrderbookData.current
) ||
previousDepth !== depth ||
previousGrouping !== grouping)
) {
// check if user has open orders so we can highlight them on orderbook
// const openOrders = mangoStore.getState().mangoAccount.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, serum3MarketExternal!, grouping, true) || []
const asks =
groupBy(orderbook?.asks, serum3MarketExternal!, 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(
...asks.map((a: number[]) => {
return a[1]
})
) +
Math.max(
...bids.map((b: number[]) => {
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: asksToDisplay.reverse(),
spread,
spreadPercentage,
})
} else {
setOrderbookData(null)
}
}
}, 400)
useEffect(() => {
const connection = mangoStore.getState().connection
const group = mangoStore.getState().group
const set = mangoStore.getState().set
let previousBidInfo: AccountInfo<Buffer> | null = null
let previousAskInfo: AccountInfo<Buffer> | null = null
if (!serum3MarketExternal || !group) return
console.log('in orderbook WS useEffect')
const bidsPk = serum3MarketExternal['_decoded'].bids
const bidSubscriptionId = connection.onAccountChange(
bidsPk,
(info, context) => {
if (
!previousBidInfo ||
!previousBidInfo.data.equals(info.data) ||
previousBidInfo.lamports !== info.lamports
) {
previousBidInfo = info
// info['parsed'] = decodeBook(serum3MarketExternal, info)
set((state) => {
// state.accountInfos[bidsPk.toString()] = info
state.selectedMarket.orderbook.bids = decodeBookL2(
serum3MarketExternal,
info
)
})
}
}
)
const asksPk = serum3MarketExternal['_decoded'].asks
const askSubscriptionId = connection.onAccountChange(
asksPk,
(info, context) => {
if (
!previousAskInfo ||
!previousAskInfo.data.equals(info.data) ||
previousAskInfo.lamports !== info.lamports
) {
previousAskInfo = info
// info['parsed'] = decodeBook(serum3MarketExternal, info)
set((state) => {
// state.accountInfos[asksPk.toString()] = info
state.selectedMarket.orderbook.asks = decodeBookL2(
serum3MarketExternal,
info
)
})
}
}
)
return () => {
connection.removeAccountChangeListener(bidSubscriptionId)
connection.removeAccountChangeListener(askSubscriptionId)
}
}, [serum3MarketExternal])
2022-09-18 05:53:28 -07:00
const onGroupSizeChange = (groupSize: number) => {
setGrouping(groupSize)
}
2022-09-13 23:24:26 -07:00
if (!serum3MarketExternal) return null
return (
2022-09-17 06:03:46 -07:00
<div className="hide-scroll h-full overflow-y-scroll">
2022-09-19 16:29:17 -07:00
<div className="sticky top-0 z-20 grid h-[49px] select-none grid-cols-2 items-center justify-between border-b border-th-bkg-3 bg-th-bkg-1 text-base">
<div
className={`flex h-12 items-center justify-center px-4 text-sm font-bold hover:cursor-pointer ${
true
? 'bg-th-bkg-2 text-th-primary'
: 'text-th-fgd-4 hover:text-th-fgd-2'
}`}
>
Orderbook
</div>
<div
className={`flex h-12 items-center justify-center px-4 text-sm font-bold hover:cursor-pointer ${
false
? 'bg-th-bkg-2 text-th-primary'
: 'text-th-fgd-4 hover:text-th-fgd-2'
}`}
>
Trades
</div>
2022-09-17 06:03:46 -07:00
</div>
2022-09-18 05:53:28 -07:00
<div className="flex items-center justify-between px-4 py-3">
<div className="flex items-center space-x-2">
2022-09-18 19:25:31 -07:00
<Tooltip
content={showBuys ? 'Hide Buys' : 'Show Buys'}
placement="top"
>
2022-09-18 05:53:28 -07:00
<button
className={`rounded ${
showBuys ? 'bg-th-bkg-3' : 'bg-th-bkg-2'
} default-transition flex h-6 w-6 items-center justify-center hover:border-th-fgd-4 focus:outline-none disabled:cursor-not-allowed`}
onClick={() => setShowBuys(!showBuys)}
disabled={!showSells}
>
<OrderbookIcon className="h-4 w-4" side="buy" />
</button>
</Tooltip>
2022-09-18 19:25:31 -07:00
<Tooltip
content={showSells ? 'Hide Sells' : 'Show Sells'}
placement="top"
>
2022-09-18 05:53:28 -07:00
<button
className={`rounded ${
showSells ? 'bg-th-bkg-3' : 'bg-th-bkg-2'
} default-transition flex h-6 w-6 items-center justify-center hover:border-th-fgd-4 focus:outline-none disabled:cursor-not-allowed`}
onClick={() => setShowSells(!showSells)}
disabled={!showBuys}
>
<OrderbookIcon className="h-4 w-4" side="sell" />
</button>
</Tooltip>
</div>
<Tooltip content="Grouping" placement="top">
<GroupSize
tickSize={serum3MarketExternal.tickSize}
onChange={onGroupSizeChange}
value={grouping}
/>
</Tooltip>
</div>
2022-09-18 22:36:09 -07:00
<div className="grid grid-cols-2 px-4 pb-2 text-xs text-th-fgd-4">
<div className="col-span-1 text-right">Size</div>
<div className="col-span-1 text-right">Price</div>
2022-09-17 06:03:46 -07:00
</div>
<div className="">
2022-09-18 05:53:28 -07:00
{showSells
? orderbookData?.asks.map(
({
price,
size,
cumulativeSize,
sizePercent,
maxSizePercent,
}: cumOrderbookSide) => (
2022-09-19 16:26:30 -07:00
<MemoizedOrderbookRow
minOrderSize={serum3MarketExternal.minOrderSize}
tickSize={serum3MarketExternal.tickSize}
2022-09-18 05:53:28 -07:00
// hasOpenOrder={hasOpenOrderForPriceGroup(
// openOrderPrices,
// price,
// grouping
// )}
key={price + ''}
price={price}
size={displayCumulativeSize ? cumulativeSize : size}
side="sell"
sizePercent={
displayCumulativeSize ? maxSizePercent : sizePercent
}
grouping={grouping}
/>
)
)
: null}
{showBuys && showSells ? (
2022-09-18 22:36:09 -07:00
<div className="my-2 grid grid-cols-2 border-y border-th-bkg-3 py-2 px-4 text-xs">
<div className="col-span-1 flex justify-between">
<div className="text-th-fgd-3">{t('spread')}</div>
<div className="text-th-fgd-1">
{orderbookData?.spread.toFixed(2)}
</div>
2022-09-18 05:53:28 -07:00
</div>
2022-09-18 22:36:09 -07:00
<div className="col-span-1 text-right text-th-fgd-1">
2022-09-18 05:53:28 -07:00
{orderbookData?.spreadPercentage.toFixed(2)}%
</div>
2022-09-17 06:03:46 -07:00
</div>
2022-09-18 05:53:28 -07:00
) : null}
{showBuys
? orderbookData?.bids.map(
({
price,
size,
cumulativeSize,
sizePercent,
maxSizePercent,
}: cumOrderbookSide) => (
2022-09-19 16:26:30 -07:00
<MemoizedOrderbookRow
minOrderSize={serum3MarketExternal.minOrderSize}
tickSize={serum3MarketExternal.tickSize}
2022-09-18 05:53:28 -07:00
// hasOpenOrder={hasOpenOrderForPriceGroup(
// openOrderPrices,
// price,
// grouping
// )}
2022-09-19 16:26:30 -07:00
key={price}
2022-09-18 05:53:28 -07:00
price={price}
size={displayCumulativeSize ? cumulativeSize : size}
side="buy"
sizePercent={
displayCumulativeSize ? maxSizePercent : sizePercent
}
grouping={grouping}
/>
)
)
: null}
2022-09-13 23:24:26 -07:00
</div>
</div>
)
}
const OrderbookRow = ({
side,
price,
size,
sizePercent,
// invert,
// hasOpenOrder,
2022-09-19 16:26:30 -07:00
minOrderSize,
tickSize,
2022-09-13 23:24:26 -07:00
grouping,
}: {
side: 'buy' | 'sell'
price: number
size: number
sizePercent: number
// hasOpenOrder: boolean
// invert: boolean
grouping: number
2022-09-19 16:26:30 -07:00
minOrderSize: number
tickSize: number
2022-09-13 23:24:26 -07:00
}) => {
const element = useRef<HTMLDivElement>(null)
// const set = mangoStore.getState().set
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 =
2022-09-19 16:26:30 -07:00
minOrderSize && !isNaN(size)
? floorToDecimal(size, getDecimalCount(minOrderSize))
2022-09-13 23:24:26 -07:00
: new Decimal(size)
const formattedPrice =
2022-09-19 16:26:30 -07:00
tickSize && !isNaN(price)
? floorToDecimal(price, getDecimalCount(tickSize))
2022-09-13 23:24:26 -07:00
: new Decimal(price)
// const handlePriceClick = () => {
// set((state) => {
// state.tradeForm.price = Number(formattedPrice)
// })
// }
// const handleSizeClick = () => {
// set((state) => {
// state.tradeForm.baseSize = Number(formattedSize)
// })
// }
const groupingDecimalCount = useMemo(
() => getDecimalCount(grouping),
[grouping]
)
const minOrderSizeDecimals = useMemo(
2022-09-19 16:26:30 -07:00
() => getDecimalCount(minOrderSize),
[minOrderSize]
2022-09-13 23:24:26 -07:00
)
2022-09-19 16:26:30 -07:00
if (!minOrderSize) return null
2022-09-13 23:24:26 -07:00
return (
<div
className={`relative flex cursor-pointer justify-between border-b border-b-th-bkg-1 text-sm`}
ref={element}
>
<>
<div className="flex w-full items-center justify-between hover:bg-th-bkg-2">
2022-09-18 16:01:57 -07:00
<div className="flex w-full justify-start pl-2">
<div
style={{ fontFeatureSettings: 'zero 1' }}
2022-09-18 22:36:09 -07:00
className={`z-10 w-full text-right font-mono text-xs leading-5 md:leading-6 ${
2022-09-18 16:01:57 -07:00
/*hasOpenOrder*/ false ? 'text-th-primary' : ''
}`}
// onClick={handleSizeClick}
>
{formattedSize.toFixed(minOrderSizeDecimals)}
</div>
2022-09-13 23:24:26 -07:00
</div>
<div
2022-09-18 16:01:57 -07:00
className={`z-10 w-full pr-4 text-right font-mono text-xs leading-5 md:leading-6`}
2022-09-13 23:24:26 -07:00
// onClick={handlePriceClick}
>
{formattedPrice.toFixed(groupingDecimalCount)}
</div>
</div>
<Line
2022-09-18 22:36:09 -07:00
className={`absolute left-0 opacity-90 ${
2022-09-13 23:24:26 -07:00
side === 'buy' ? `bg-th-green-muted` : `bg-th-red-muted`
}`}
data-width={sizePercent + '%'}
/>
</>
</div>
)
}
2022-09-19 16:26:30 -07:00
const MemoizedOrderbookRow = React.memo(OrderbookRow)
2022-09-13 23:24:26 -07:00
const Line = (props: any) => {
return (
<div
className={`${props.className}`}
style={{
textAlign: props.invert ? 'left' : 'right',
height: '100%',
width: `${props['data-width'] ? props['data-width'] : ''}`,
}}
/>
)
}
export default Orderbook