Merge pull request #193 from blockworks-foundation/depth-chart

orderbook depth chart
This commit is contained in:
saml33 2023-07-21 20:01:41 +10:00 committed by GitHub
commit 226d610bbf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 1163 additions and 514 deletions

View File

@ -43,7 +43,7 @@ const Layout = ({ children }: { children: ReactNode }) => {
const { asPath } = useRouter()
useEffect(() => {
if (width < breakpoints.xl) {
if (width < breakpoints['2xl']) {
setIsCollapsed(true)
}
}, [width])
@ -84,7 +84,7 @@ const Layout = ({ children }: { children: ReactNode }) => {
<div className="fixed z-20 hidden h-screen md:block">
<button
className="absolute right-0 top-1/2 z-20 hidden h-8 w-3 -translate-y-1/2 rounded-none rounded-l bg-th-bkg-3 hover:bg-th-bkg-4 focus:outline-none focus-visible:bg-th-bkg-4 xl:block"
className="absolute right-0 top-1/2 z-20 hidden h-8 w-3 -translate-y-1/2 rounded-none rounded-l bg-th-bkg-3 hover:bg-th-bkg-4 focus:outline-none focus-visible:bg-th-bkg-4 2xl:block"
onClick={handleToggleSidebar}
>
<ChevronRightIcon

View File

@ -45,7 +45,7 @@ const SideNav = ({ collapsed }: { collapsed: boolean }) => {
<div
className={`transition-all duration-${sideBarAnimationDuration} ${
collapsed ? 'w-[64px]' : 'w-44 lg:w-48 xl:w-52'
} border-r border-th-bkg-3 bg-th-bkg-1`}
} box-border border-r border-th-bkg-3 bg-th-bkg-1`}
>
<div className="flex min-h-screen flex-col justify-between">
<div className="my-2">

View File

@ -167,7 +167,7 @@ const AccountHeroStats = ({
return (
<>
<div className="grid grid-cols-6 border-b border-th-bkg-3">
<div className="col-span-6 border-t border-th-bkg-3 py-3 pl-6 pr-4 md:col-span-3 lg:col-span-2 lg:border-t-0 xl:col-span-1">
<div className="col-span-6 border-t border-th-bkg-3 py-3 pl-6 pr-4 md:col-span-3 lg:col-span-2 lg:border-t-0 2xl:col-span-1">
<div id="account-step-four">
<div className="flex justify-between">
<Tooltip
@ -214,9 +214,7 @@ const AccountHeroStats = ({
</div>
}
>
<p className="tooltip-underline text-sm font-normal text-th-fgd-3 xl:text-base">
{t('health')}
</p>
<p className="tooltip-underline">{t('health')}</p>
</Tooltip>
{mangoAccountAddress ? (
<Tooltip
@ -255,7 +253,7 @@ const AccountHeroStats = ({
</span>
</div>
</div>
<div className="col-span-6 flex border-t border-th-bkg-3 py-3 pl-6 md:col-span-3 md:border-l lg:col-span-2 lg:border-t-0 xl:col-span-1">
<div className="col-span-6 flex border-t border-th-bkg-3 py-3 pl-6 md:col-span-3 md:border-l lg:col-span-2 lg:border-t-0 2xl:col-span-1">
<div id="account-step-five">
<Tooltip
content={t('account:tooltip-free-collateral')}
@ -263,9 +261,7 @@ const AccountHeroStats = ({
placement="top-start"
delay={100}
>
<p className="tooltip-underline text-sm text-th-fgd-3 xl:text-base">
{t('free-collateral')}
</p>
<p className="tooltip-underline">{t('free-collateral')}</p>
</Tooltip>
<p className="mt-1 mb-0.5 text-2xl font-bold text-th-fgd-1 lg:text-xl xl:text-2xl">
<FormatNumericValue
@ -305,7 +301,7 @@ const AccountHeroStats = ({
</span>
</div>
</div>
<div className="col-span-6 flex border-t border-th-bkg-3 py-3 pl-6 pr-4 md:col-span-3 lg:col-span-2 lg:border-l lg:border-t-0 xl:col-span-1">
<div className="col-span-6 flex border-t border-th-bkg-3 py-3 pl-6 pr-4 md:col-span-3 lg:col-span-2 lg:border-l lg:border-t-0 2xl:col-span-1">
<div
id="account-step-seven"
className="flex w-full flex-col items-start"
@ -316,9 +312,7 @@ const AccountHeroStats = ({
placement="top-start"
delay={100}
>
<p className="tooltip-underline inline text-sm text-th-fgd-3 xl:text-base">
{t('pnl')}
</p>
<p className="tooltip-underline">{t('pnl')}</p>
</Tooltip>
{mangoAccountAddress ? (
<div className="flex items-center space-x-3">
@ -364,12 +358,10 @@ const AccountHeroStats = ({
</div>
</div>
</div>
<div className="col-span-6 border-t border-th-bkg-3 py-3 pl-6 pr-4 md:col-span-3 md:border-l lg:col-span-2 lg:border-l-0 xl:col-span-1 xl:border-l xl:border-t-0">
<div className="col-span-6 border-t border-th-bkg-3 py-3 pl-6 pr-4 md:col-span-3 md:border-l lg:col-span-2 lg:border-l-0 2xl:col-span-1 2xl:border-l 2xl:border-t-0">
<div id="account-step-six">
<div className="flex w-full items-center justify-between">
<p className="text-sm text-th-fgd-3 xl:text-base">
{t('account:lifetime-volume')}
</p>
<p>{t('account:lifetime-volume')}</p>
{mangoAccountAddress ? (
<Tooltip
className="hidden md:block"
@ -413,7 +405,7 @@ const AccountHeroStats = ({
</span>
</div>
</div>
<div className="col-span-6 border-t border-th-bkg-3 py-3 pl-6 pr-4 text-left md:col-span-3 lg:col-span-2 lg:border-l xl:col-span-1 xl:border-t-0">
<div className="col-span-6 border-t border-th-bkg-3 py-3 pl-6 pr-4 text-left md:col-span-3 lg:col-span-2 lg:border-l 2xl:col-span-1 2xl:border-t-0">
<div id="account-step-eight">
<div className="flex w-full items-center justify-between">
<Tooltip
@ -422,7 +414,7 @@ const AccountHeroStats = ({
placement="top-start"
delay={100}
>
<p className="tooltip-underline text-sm text-th-fgd-3 xl:text-base">
<p className="tooltip-underline">
{t('total-interest-earned')}
</p>
</Tooltip>
@ -457,7 +449,7 @@ const AccountHeroStats = ({
</div>
</div>
</div>
<div className="col-span-6 border-t border-th-bkg-3 py-3 pl-6 pr-4 text-left md:col-span-3 md:border-l lg:col-span-2 xl:col-span-1 xl:border-t-0">
<div className="col-span-6 border-t border-th-bkg-3 py-3 pl-6 pr-4 text-left md:col-span-3 md:border-l lg:col-span-2 2xl:col-span-1 2xl:border-t-0">
<div className="flex w-full items-center justify-between">
<Tooltip
content={t('account:tooltip-total-funding')}
@ -465,7 +457,7 @@ const AccountHeroStats = ({
placement="top-start"
delay={100}
>
<p className="tooltip-underline text-sm text-th-fgd-3 xl:text-base">
<p className="tooltip-underline">
{t('account:total-funding-earned')}
</p>
</Tooltip>

View File

@ -0,0 +1,70 @@
import { ChangeEvent, useEffect, useRef, useState } from 'react'
const Slider = ({
amount,
max,
min,
onChange,
step,
}: {
amount: number
max?: string
min?: string
onChange: (x: string) => void
step: number
}) => {
const [value, setValue] = useState(0)
const inputEl = useRef<HTMLInputElement>(null)
useEffect(() => {
if (inputEl.current) {
const target = inputEl.current
const min = parseFloat(target.min)
const max = parseFloat(target.max)
target.style.backgroundSize =
max - min === 0
? '0% 100%'
: ((value - min) * 100) / (max - min) + '% 100%'
}
}, [value])
useEffect(() => {
if (amount) {
setValue(amount)
} else {
setValue(0)
}
}, [amount])
const handleSliderChange = (e: ChangeEvent<HTMLInputElement>) => {
const target = e.target
const min = parseFloat(target.min)
const max = parseFloat(target.max)
const val = parseFloat(target.value)
target.style.backgroundSize = ((val - min) * 100) / (max - min) + '% 100%'
onChange(e.target.value)
setValue(parseFloat(e.target.value))
}
return (
<>
<label htmlFor="default-range" className="block text-sm"></label>
<input
ref={inputEl}
id="default-range"
type="range"
min={min || '0'}
max={max || '100'}
step={step}
className="w-full focus:outline-none"
onChange={handleSliderChange}
value={value}
></input>
</>
)
}
export default Slider

View File

@ -6,6 +6,7 @@ interface SwitchProps {
onChange: (x: boolean) => void
children?: ReactNode
disabled?: boolean
small?: boolean
}
const Switch: FunctionComponent<SwitchProps> = ({
@ -14,6 +15,7 @@ const Switch: FunctionComponent<SwitchProps> = ({
children,
onChange,
disabled,
small,
}) => {
const handleClick = () => {
onChange(!checked)
@ -26,7 +28,9 @@ const Switch: FunctionComponent<SwitchProps> = ({
type="button"
className={`${
checked ? 'bg-th-success' : 'bg-th-bkg-4'
} relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer rounded-full
} relative inline-flex ${
small ? 'h-4 w-8' : 'h-5 w-10'
} flex-shrink-0 cursor-pointer rounded-full
border-2 border-transparent transition-colors duration-200 ease-in-out focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-th-fgd-4 ${
disabled ? 'opacity-60' : ''
}`}
@ -39,8 +43,14 @@ const Switch: FunctionComponent<SwitchProps> = ({
<span
aria-hidden="true"
className={`${
checked ? 'translate-x-5' : 'translate-x-0'
} pointer-events-none inline-block h-4 w-4 rounded-full
checked
? small
? 'translate-x-4'
: 'translate-x-5'
: 'translate-x-0'
} pointer-events-none inline-block ${
small ? 'h-3 w-3' : 'h-4 w-4'
} rounded-full
bg-white shadow ring-0 transition duration-200 ease-in-out`}
></span>
</button>

View File

@ -562,6 +562,7 @@ const SwapForm = () => {
className="text-th-fgd-3"
checked={useMargin}
onChange={handleSetMargin}
small
/>
</div>
<div className="flex items-center justify-between">

View File

@ -0,0 +1,516 @@
import Slider from '@components/forms/Slider'
import useMarkPrice from 'hooks/useMarkPrice'
import useSelectedMarket from 'hooks/useSelectedMarket'
import { useViewport } from 'hooks/useViewport'
import { useTheme } from 'next-themes'
import { useCallback, useMemo, useState } from 'react'
import {
XAxis,
YAxis,
ResponsiveContainer,
AreaChart,
Area,
ReferenceLine,
Label,
LabelProps,
} from 'recharts'
import { CategoricalChartFunc } from 'recharts/types/chart/generateCategoricalChart'
import { COLORS } from 'styles/colors'
import { floorToDecimal, getDecimalCount } from 'utils/numbers'
import { CartesianViewBox } from 'recharts/types/util/types'
import { cumOrderbookSide } from 'types'
import mangoStore from '@store/mangoStore'
import { breakpoints } from 'utils/theme'
type LabelPosition =
| 'left'
| 'right'
| 'center'
| 'bottom'
| 'insideLeft'
| 'insideRight'
| 'insideTop'
| 'insideBottom'
| 'insideTopLeft'
| 'insideTopRight'
| 'insideBottomLeft'
| 'insideBottomRight'
| 'top'
const Y_TICK_COUNT = 10
interface CustomLabel extends LabelProps {
viewBox?: CartesianViewBox
}
const MarkPriceLabel = ({ value, viewBox }: CustomLabel) => {
if (typeof value === 'string' && viewBox?.x && viewBox?.y) {
const { x, y } = viewBox
const valueLength = value.length
const valueWidth = valueLength * 6
return (
<g>
<foreignObject x={x - valueWidth} y={y - 10} width="100%" height={20}>
<div className="w-max rounded bg-th-bkg-3 p-1 font-mono text-[9px] leading-none">
{value}
</div>
</foreignObject>
</g>
)
} else return null
}
type RawOrderbook = number[][]
type DepthOrderbookSide = {
price: number
size: number
cumulativeSize: number
}
const DepthChart = () => {
const { theme } = useTheme()
const { serumOrPerpMarket } = useSelectedMarket()
const [mouseData, setMouseData] = useState<cumOrderbookSide | null>(null)
const markPrice = useMarkPrice()
const orderbook = mangoStore((s) => s.selectedMarket.orderbook)
const [priceRangePercent, setPriceRangePercentPercent] = useState('10')
const { width } = useViewport()
const increaseHeight = width ? width > breakpoints['3xl'] : false
const formatOrderbookData = (orderbook: RawOrderbook, markPrice: number) => {
const maxPrice = markPrice * 4
const minPrice = markPrice / 4
const formattedBook = []
let cumulativeSize = 0
for (let i = 0; i < orderbook.length; i++) {
const [price, size] = orderbook[i]
cumulativeSize += size
const object = {
price: price,
size: size,
cumulativeSize: cumulativeSize,
}
if (price >= minPrice && price <= maxPrice) {
formattedBook.push(object)
}
}
return formattedBook
}
// format chart data for the bids and asks series
const mergeCumulativeData = (
bids: DepthOrderbookSide[],
asks: DepthOrderbookSide[]
) => {
const bidsWithSide = bids.map((b) => ({ ...b, bids: b.cumulativeSize }))
const asksWithSide = asks.map((a) => ({ ...a, asks: a.cumulativeSize }))
return [...bidsWithSide, ...asksWithSide].sort((a, b) => a.price - b.price)
}
const chartData = useMemo(() => {
if (!orderbook || !serumOrPerpMarket || !markPrice) return []
const formattedBids = formatOrderbookData(orderbook.bids, markPrice)
const formattedAsks = formatOrderbookData(orderbook.asks, markPrice)
return mergeCumulativeData(formattedBids, formattedAsks)
}, [markPrice, orderbook, serumOrPerpMarket])
// find the max value for the x-axis
const findXDomainMax = (
data: DepthOrderbookSide[],
yMin: number,
yMax: number
) => {
let closestItemForYMin = 0
let minDifferenceForYMin = Infinity
let closestItemForYMax = 0
let minDifferenceForYMax = Infinity
for (const item of data) {
const differenceForYMin = Math.abs(item.price - yMin)
const differenceForYMax = Math.abs(item.price - yMax)
if (differenceForYMin < minDifferenceForYMin) {
minDifferenceForYMin = differenceForYMin
closestItemForYMin = item.cumulativeSize
}
if (differenceForYMax < minDifferenceForYMax) {
minDifferenceForYMax = differenceForYMax
closestItemForYMax = item.cumulativeSize
}
}
return Math.max(closestItemForYMin, closestItemForYMax)
}
// calc axis domains
const [xMax, yMin, yMax] = useMemo(() => {
let xMax = 100
let yMin = 0
let yMax = 100
if (markPrice) {
yMin = markPrice / (1 + parseFloat(priceRangePercent) / 100)
yMax = markPrice * (1 + parseFloat(priceRangePercent) / 100)
}
if (chartData.length) {
xMax = findXDomainMax(chartData, yMin, yMax)
}
return [xMax, yMin, yMax]
}, [chartData, markPrice, priceRangePercent])
// get nearest data on the opposing side to the mouse
const opposingMouseReference = useMemo(() => {
if (!markPrice || !mouseData) return null
const mousePrice = mouseData.price
const difference = Math.abs(mousePrice - markPrice) / markPrice
if (mousePrice >= markPrice) {
const price = markPrice / (1 + difference)
let closestItemBelow = null
let minDifference = Infinity
for (const item of chartData) {
const difference = Math.abs(item.price - price)
if (difference < minDifference) {
minDifference = difference
closestItemBelow = item
}
}
return closestItemBelow
} else {
const price = markPrice * (1 + difference)
let closestItemAbove = null
let minDifference = Infinity
for (const item of chartData) {
const difference = Math.abs(item.price - price)
if (difference < minDifference) {
minDifference = difference
closestItemAbove = item
}
}
return closestItemAbove
}
}, [markPrice, mouseData])
const priceFormatter = useCallback(
(price: number) => {
if (!serumOrPerpMarket) return price.toFixed()
const tickDecimals = getDecimalCount(serumOrPerpMarket.tickSize)
if (tickDecimals >= 7) {
return price.toExponential(3)
} else return price.toFixed(tickDecimals)
},
[serumOrPerpMarket]
)
const xTickFormatter = useCallback(
(size: number) => {
if (!serumOrPerpMarket) return size.toFixed()
const minOrderDecimals = getDecimalCount(serumOrPerpMarket.minOrderSize)
return size.toFixed(minOrderDecimals)
},
[serumOrPerpMarket]
)
const isWithinRangeOfTick = useCallback(
(value: number, baseValue: number) => {
const difference = Math.abs(value - baseValue)
const range = (yMax - yMin) / Y_TICK_COUNT
return difference <= range
},
[yMin, yMax]
)
const yTickFormatter = useCallback(
(tick: number) => {
if ((markPrice && isWithinRangeOfTick(markPrice, tick)) || mouseData) {
return ''
}
return priceFormatter(tick)
},
[markPrice, mouseData]
)
const getChartReferenceColor = (price: number | undefined) => {
if (!price || !markPrice) return 'var(--fgd-2)'
return price > markPrice ? 'var(--down)' : 'var(--up)'
}
const getPercentFromMarkPrice = (price: number | undefined) => {
if (!price || !markPrice) return
const percentDif = ((price - markPrice) / markPrice) * 100
return `${percentDif.toFixed(2)}%`
}
const getSizeFromMouseData = useCallback(
(size: number | undefined) => {
if (!size || !serumOrPerpMarket) return
return floorToDecimal(
size,
getDecimalCount(serumOrPerpMarket.tickSize)
).toString()
},
[serumOrPerpMarket]
)
const getSizeLabelPosition = useCallback(
(size: number | undefined, price: number | undefined) => {
if (!xMax || !size || !price || !markPrice) return `insideRight`
const yPosition = price > markPrice ? 'Top' : 'Bottom'
const midPoint = xMax / 2
const xPosition = size > midPoint ? 'Left' : 'Right'
return `inside${yPosition}${xPosition}` as LabelPosition
},
[xMax, markPrice]
)
const getPercentLabelPosition = useCallback(
(price: number | undefined) => {
if (!markPrice || !price || !yMax || !yMin) return 'bottom'
const upperMidPoint = (markPrice + yMax) / 2
const lowerMidPoint = (markPrice + yMin) / 2
return price > markPrice
? price > upperMidPoint
? 'bottom'
: 'top'
: price > lowerMidPoint
? 'bottom'
: 'top'
},
[markPrice, yMax, yMin]
)
const handleMouseMove: CategoricalChartFunc = (coords) => {
if (coords?.activePayload) {
setMouseData(coords.activePayload[0].payload)
}
}
const handleMouseLeave = () => {
setMouseData(null)
}
return chartData.length ? (
<>
<div className="flex h-10 items-center border-b border-th-bkg-3 py-1 px-2">
<div className="flex w-full items-center">
<span className="w-16 font-mono text-xs text-th-fgd-3">
{priceRangePercent}%
</span>
<Slider
amount={parseFloat(priceRangePercent)}
max="100"
min="0.5"
onChange={(p) => setPriceRangePercentPercent(p)}
step={0.5}
/>
</div>
</div>
<div className={increaseHeight ? 'h-[570px]' : 'h-[482px]'}>
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={chartData}
layout="vertical"
margin={{
top: 8,
left: -8,
}}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
>
<XAxis
axisLine={false}
reversed={true}
domain={[() => 0, () => xMax]}
type="number"
tick={false}
tickLine={false}
tickFormatter={(tick) => xTickFormatter(tick)}
/>
<YAxis
dataKey="price"
reversed={true}
domain={[() => yMin, () => yMax]}
axisLine={false}
tick={{
fill: 'var(--fgd-4)',
fontSize: 8,
}}
tickCount={Y_TICK_COUNT}
tickLine={false}
tickFormatter={(tick) => yTickFormatter(tick)}
/>
<Area
type="stepBefore"
dataKey="bids"
stroke={COLORS.UP[theme]}
fill="url(#bidsGradient)"
isAnimationActive={false}
strokeWidth={1}
/>
<Area
type="stepBefore"
dataKey="asks"
stroke={COLORS.DOWN[theme]}
fill="url(#asksGradient)"
isAnimationActive={false}
strokeWidth={1}
/>
<ReferenceLine
y={mouseData?.price}
stroke={getChartReferenceColor(mouseData?.price)}
strokeDasharray="3, 3"
>
<Label
value={mouseData ? priceFormatter(mouseData.price) : ''}
fontSize={9}
fill={getChartReferenceColor(mouseData?.price)}
position="left"
offset={5}
/>
<Label
value={getPercentFromMarkPrice(mouseData?.price)}
fontSize={9}
fill={getChartReferenceColor(opposingMouseReference?.price)}
position={getPercentLabelPosition(mouseData?.price)}
offset={6}
/>
</ReferenceLine>
<ReferenceLine
y={opposingMouseReference?.price}
stroke={getChartReferenceColor(opposingMouseReference?.price)}
strokeDasharray="3, 3"
>
<Label
value={
opposingMouseReference
? priceFormatter(opposingMouseReference.price)
: ''
}
fontSize={9}
fill={getChartReferenceColor(opposingMouseReference?.price)}
position="left"
offset={5}
/>
<Label
value={getPercentFromMarkPrice(opposingMouseReference?.price)}
fontSize={9}
fill={getChartReferenceColor(mouseData?.price)}
position={getPercentLabelPosition(
opposingMouseReference?.price
)}
offset={6}
/>
</ReferenceLine>
<ReferenceLine
stroke={getChartReferenceColor(mouseData?.price)}
strokeDasharray="3, 3"
segment={
mouseData && mouseData?.price >= markPrice
? [
{ x: mouseData?.cumulativeSize, y: markPrice },
{ x: mouseData?.cumulativeSize, y: yMax },
]
: [
{ x: mouseData?.cumulativeSize, y: yMin },
{ x: mouseData?.cumulativeSize, y: markPrice },
]
}
>
<Label
value={getSizeFromMouseData(mouseData?.cumulativeSize)}
fontSize={9}
fill={getChartReferenceColor(mouseData?.price)}
position={getSizeLabelPosition(
mouseData?.cumulativeSize,
mouseData?.price
)}
offset={6}
/>
</ReferenceLine>
<ReferenceLine
stroke={getChartReferenceColor(opposingMouseReference?.price)}
strokeDasharray="3, 3"
segment={
opposingMouseReference &&
opposingMouseReference?.price >= markPrice
? [
{
x: opposingMouseReference?.cumulativeSize,
y: markPrice,
},
{ x: opposingMouseReference?.cumulativeSize, y: yMax },
]
: [
{ x: opposingMouseReference?.cumulativeSize, y: yMin },
{
x: opposingMouseReference?.cumulativeSize,
y: markPrice,
},
]
}
>
<Label
value={getSizeFromMouseData(
opposingMouseReference?.cumulativeSize
)}
fontSize={9}
fill={getChartReferenceColor(opposingMouseReference?.price)}
position={getSizeLabelPosition(
opposingMouseReference?.cumulativeSize,
opposingMouseReference?.price
)}
offset={6}
/>
</ReferenceLine>
{markPrice ? (
<ReferenceLine y={markPrice} stroke="var(--bkg-4)">
<Label
value={priceFormatter(markPrice)}
content={<MarkPriceLabel />}
/>
</ReferenceLine>
) : null}
<defs>
<linearGradient id="bidsGradient" x1="0" y1="0" x2="1" y2="0">
<stop
offset="0%"
stopColor={COLORS.UP[theme]}
stopOpacity={0.15}
/>
<stop
offset="99%"
stopColor={COLORS.UP[theme]}
stopOpacity={0}
/>
</linearGradient>
<linearGradient id="asksGradient" x1="0" y1="0" x2="1" y2="0">
<stop
offset="0%"
stopColor={COLORS.DOWN[theme]}
stopOpacity={0.15}
/>
<stop
offset="99%"
stopColor={COLORS.DOWN[theme]}
stopOpacity={0}
/>
</linearGradient>
</defs>
</AreaChart>
</ResponsiveContainer>
</div>
</>
) : null
}
export default DepthChart

View File

@ -22,7 +22,7 @@ import { SOUND_SETTINGS_KEY } from 'utils/constants'
import { INITIAL_SOUND_SETTINGS } from '@components/settings/SoundSettings'
import { Howl } from 'howler'
import { isMangoError } from 'types'
import { decodeBook, decodeBookL2 } from './Orderbook'
import { decodeBook, decodeBookL2 } from 'utils/orderbook'
import InlineNotification from '@components/shared/InlineNotification'
interface MarketCloseModalProps {

View File

@ -8,6 +8,7 @@ import { TABS } from './OrderbookAndTrades'
import RecentTrades from './RecentTrades'
import TradingChartContainer from './TradingChartContainer'
import FavoriteMarketsBar from './FavoriteMarketsBar'
import DepthChart from './DepthChart'
const MobileTradeAdvancedPage = () => {
const [activeTab, setActiveTab] = useState('trade:book')
@ -32,17 +33,25 @@ const MobileTradeAdvancedPage = () => {
<AdvancedTradeForm />
</div>
<div className="col-span-1 border-l border-th-bkg-3">
<div className="border-b border-th-bkg-3">
<div className="hide-scroll overflow-x-auto border-b border-th-bkg-3">
<TabButtons
activeValue={activeTab}
onChange={(tab: string) => setActiveTab(tab)}
values={TABS}
fillWidth
showBorders
/>
</div>
<div className={activeTab === 'trade:book' ? 'visible' : 'hidden'}>
<Orderbook />
</div>
<div
className={`h-full ${
activeTab === 'trade:depth' ? 'visible' : 'hidden'
}`}
>
<DepthChart />
</div>
<div className={activeTab === 'trade:trades' ? 'visible' : 'hidden'}>
<RecentTrades />
</div>

View File

@ -1,36 +1,39 @@
import { AccountInfo, PublicKey } from '@solana/web3.js'
import Big from 'big.js'
import { PublicKey } from '@solana/web3.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 { Market } from '@project-serum/serum'
import useLocalStorageState from 'hooks/useLocalStorageState'
import {
floorToDecimal,
formatNumericValue,
getDecimalCount,
} from 'utils/numbers'
import { ANIMATION_SETTINGS_KEY } from 'utils/constants'
import {
ANIMATION_SETTINGS_KEY,
DEPTH_CHART_KEY,
// USE_ORDERBOOK_FEED_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,
Serum3Market,
} from '@blockworks-foundation/mango-v4'
import { BookSide, Serum3Market } 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'
import Switch from '@components/forms/Switch'
import { breakpoints } from 'utils/theme'
import {
decodeBook,
decodeBookL2,
getCumulativeOrderbookSide,
getMarket,
groupBy,
updatePerpMarketOnGroup,
} from 'utils/orderbook'
import { OrderbookData, OrderbookL2 } from 'types'
import { isEqual } from 'lodash'
const sizeCompacter = Intl.NumberFormat('en', {
maximumFractionDigits: 6,
@ -39,202 +42,31 @@ const sizeCompacter = Intl.NumberFormat('en', {
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<Buffer>,
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<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 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 { serumOrPerpMarket: market } = useSelectedMarket()
const connection = mangoStore((s) => s.connection)
const [isScrolled, setIsScrolled] = useState(false)
const [orderbookData, setOrderbookData] = useState<OrderbookData | null>(null)
const [grouping, setGrouping] = useState(0.01)
const [tickSize, setTickSize] = useState(0)
const [showBids, setShowBids] = useState(true)
const [showAsks, setShowAsks] = useState(true)
const [grouping, setGrouping] = useState(0.01)
const [useOrderbookFeed, setUseOrderbookFeed] = useState(false)
// const [useOrderbookFeed, setUseOrderbookFeed] = useState(
// localStorage.getItem(USE_ORDERBOOK_FEED_KEY) !== null
// ? localStorage.getItem(USE_ORDERBOOK_FEED_KEY) === 'true'
// : true
// )
const currentOrderbookData = useRef<OrderbookL2>()
const orderbookElRef = useRef<HTMLDivElement>(null)
const [showDepthChart, setShowDepthChart] = useLocalStorageState<boolean>(
DEPTH_CHART_KEY,
false
)
const { width } = useViewport()
const isMobile = width ? width < breakpoints.md : false
const isMobile = width ? width < breakpoints.lg : false
const [orderbookData, setOrderbookData] = useState<OrderbookData | null>(null)
const currentOrderbookData = useRef<OrderbookL2>()
const depth = useMemo(() => {
return isMobile ? 9 : 40
}, [isMobile])
return width > breakpoints['3xl'] ? 12 : 10
}, [width])
const depthArray: number[] = useMemo(() => {
return Array(depth).fill(0)
@ -249,19 +81,19 @@ const Orderbook = () => {
}
}, [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
}
}
}, [])
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])
useEffect(
() =>
@ -357,32 +189,15 @@ const Orderbook = () => {
spread,
spreadPercentage,
})
if (!isScrolled) {
verticallyCenterOrderbook()
}
} else {
setOrderbookData(null)
}
}
}
),
[grouping, market, isScrolled, verticallyCenterOrderbook]
[depth, grouping, market]
)
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(() => {
const set = mangoStore.getState().set
@ -609,82 +424,25 @@ const Orderbook = () => {
})
}, [bidAccountAddress])
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 (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b border-th-bkg-3 px-4 py-2">
<div
id="trade-step-three"
className="hidden items-center space-x-1.5 md:flex"
>
<Tooltip
className={`${!showAsks ? 'hidden' : ''}`}
content={t('trade:show-bids')}
placement="bottom"
<div>
<div className="flex h-10 items-center justify-between border-b border-th-bkg-3 px-4">
{!isMobile ? (
<Switch
checked={showDepthChart}
onChange={() => setShowDepthChart(!showDepthChart)}
small
>
<button
className={`rounded ${
showAsks ? 'bg-th-bkg-3' : 'bg-th-bkg-2'
} flex h-6 w-6 items-center justify-center hover:border-th-fgd-4 focus:outline-none focus-visible:bg-th-bkg-4 disabled:cursor-not-allowed`}
onClick={() => toggleSides('bids')}
>
<OrderbookIcon className="h-4 w-4" side="buy" />
</button>
</Tooltip>
<Tooltip
className={`${!showBids ? 'hidden' : ''}`}
content={t('trade:show-asks')}
placement="bottom"
>
<button
className={`rounded ${
showBids ? 'bg-th-bkg-3' : 'bg-th-bkg-2'
} flex h-6 w-6 items-center justify-center hover:border-th-fgd-4 focus:outline-none focus-visible:bg-th-bkg-4 disabled:cursor-not-allowed`}
onClick={() => toggleSides('asks')}
>
<OrderbookIcon className="h-4 w-4" side="sell" />
</button>
</Tooltip>
<Tooltip content={'Reset and center orderbook'} placement="bottom">
<button
className="flex h-6 w-6 items-center justify-center rounded bg-th-bkg-3 hover:border-th-fgd-4 focus:outline-none focus-visible:bg-th-bkg-4 disabled:cursor-not-allowed"
onClick={resetOrderbook}
>
<ArrowPathIcon className="h-4 w-4" />
</button>
</Tooltip>
</div>
<span className="text-xxs">{t('trade:depth')}</span>
</Switch>
) : null}
{market ? (
<>
<p className="text-xs md:hidden">{t('trade:grouping')}:</p>
<p className="text-xs lg:hidden">{t('trade:grouping')}:</p>
<div id="trade-step-four">
<Tooltip
className="hidden md:block"
@ -702,95 +460,80 @@ const Orderbook = () => {
</>
) : null}
</div>
<div className="grid grid-cols-2 px-4 pt-2 pb-1 text-xxs text-th-fgd-4">
<div className="col-span-1 text-right">
{t('trade:size')} ({baseSymbol})
</div>
<div className="col-span-1 text-right">
{t('price')} ({quoteSymbol})
</div>
<div className="grid grid-cols-2 px-2 py-0.5 text-xxs text-th-fgd-4">
<div className="col-span-1">{t('price')}</div>
<div className="col-span-1 text-right">{t('trade:size')}</div>
</div>
<div
className="hide-scroll relative h-full overflow-y-scroll"
ref={orderbookElRef}
onScroll={handleScroll}
>
{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 (
<div className="h-[24px]" key={idx}>
{!!orderbookData?.asks[index] && market ? (
<MemoizedOrderbookRow
minOrderSize={market.minOrderSize}
tickSize={market.tickSize}
hasOpenOrder={orderbookData?.asks[index].isUsersOrder}
key={orderbookData?.asks[index].price}
price={orderbookData?.asks[index].price}
size={orderbookData?.asks[index].size}
side="sell"
sizePercent={orderbookData?.asks[index].sizePercent}
cumulativeSizePercent={
orderbookData?.asks[index].cumulativeSizePercent
}
grouping={grouping}
/>
) : null}
</div>
)
})
: null}
{showBids && showAsks ? (
<div
className="my-2 grid grid-cols-2 border-y border-th-bkg-3 py-2 px-4 text-xs text-th-fgd-4"
id="trade-step-nine"
>
<div className="col-span-1 flex justify-between">
<div className="text-xxs">{t('trade:spread')}</div>
<div className="font-mono">
{orderbookData?.spreadPercentage.toFixed(2)}%
</div>
<div className="relative h-full">
{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 (
<div className="h-[20px]" key={idx}>
{!!orderbookData?.asks[index] && market ? (
<MemoizedOrderbookRow
minOrderSize={market.minOrderSize}
tickSize={market.tickSize}
hasOpenOrder={orderbookData?.asks[index].isUsersOrder}
key={orderbookData?.asks[index].price}
price={orderbookData?.asks[index].price}
size={orderbookData?.asks[index].size}
side="sell"
sizePercent={orderbookData?.asks[index].sizePercent}
cumulativeSizePercent={
orderbookData?.asks[index].cumulativeSizePercent
}
grouping={grouping}
/>
) : null}
</div>
<div className="col-span-1 text-right font-mono">
{orderbookData?.spread
? orderbookData.spread < SHOW_EXPONENTIAL_THRESHOLD
? orderbookData.spread.toExponential()
: formatNumericValue(
orderbookData.spread,
market ? getDecimalCount(market.tickSize) : undefined
)
: null}
)
})}
<div
className="my-1 grid grid-cols-2 border-y border-th-bkg-3 py-1 px-4 text-xs text-th-fgd-4"
id="trade-step-nine"
>
<div className="col-span-1 flex justify-between">
<div className="text-xxs">{t('trade:spread')}</div>
<div className="font-mono">
{orderbookData?.spreadPercentage.toFixed(2)}%
</div>
</div>
) : null}
{showBids
? depthArray.map((_x, index) => (
<div className="h-[24px]" key={index}>
{!!orderbookData?.bids[index] && market ? (
<MemoizedOrderbookRow
minOrderSize={market.minOrderSize}
tickSize={market.tickSize}
hasOpenOrder={orderbookData?.bids[index].isUsersOrder}
price={orderbookData?.bids[index].price}
size={orderbookData?.bids[index].size}
side="buy"
sizePercent={orderbookData?.bids[index].sizePercent}
cumulativeSizePercent={
orderbookData?.bids[index].cumulativeSizePercent
}
grouping={grouping}
/>
) : null}
</div>
))
: null}
<div className="col-span-1 text-right font-mono">
{orderbookData?.spread
? orderbookData.spread < SHOW_EXPONENTIAL_THRESHOLD
? orderbookData.spread.toExponential()
: formatNumericValue(
orderbookData.spread,
market ? getDecimalCount(market.tickSize) : undefined
)
: null}
</div>
</div>
{depthArray.map((_x, index) => (
<div className="h-[20px]" key={index}>
{!!orderbookData?.bids[index] && market ? (
<MemoizedOrderbookRow
minOrderSize={market.minOrderSize}
tickSize={market.tickSize}
hasOpenOrder={orderbookData?.bids[index].isUsersOrder}
price={orderbookData?.bids[index].price}
size={orderbookData?.bids[index].size}
side="buy"
sizePercent={orderbookData?.bids[index].sizePercent}
cumulativeSizePercent={
orderbookData?.bids[index].cumulativeSizePercent
}
grouping={grouping}
/>
) : null}
</div>
))}
</div>
</div>
)
@ -893,13 +636,23 @@ const OrderbookRow = ({
return (
<div
className={`relative flex h-[24px] cursor-pointer justify-between border-b border-b-th-bkg-1 text-sm`}
className={`relative flex h-[20px] cursor-pointer justify-between border-b border-b-th-bkg-1 text-sm`}
ref={element}
>
<>
<div className="flex h-full w-full items-center justify-between text-th-fgd-3 hover:bg-th-bkg-2">
<div
className="flex h-full w-full items-center justify-start pl-2 hover:underline"
className={`z-10 flex h-full w-full items-center pl-2 hover:underline`}
onClick={handlePriceClick}
>
<span className="w-full font-mono text-xs">
{price < SHOW_EXPONENTIAL_THRESHOLD
? formattedPrice.toExponential()
: formattedPrice.toFixed(groupingDecimalCount)}
</span>
</div>
<div
className="flex h-full w-full items-center justify-start pr-2 hover:underline"
onClick={handleSizeClick}
>
<div
@ -913,26 +666,16 @@ const OrderbookRow = ({
: formattedSize.toFixed(minOrderSizeDecimals)}
</div>
</div>
<div
className={`z-10 flex h-full w-full items-center pr-4 hover:underline`}
onClick={handlePriceClick}
>
<div className="w-full text-right font-mono text-xs">
{price < SHOW_EXPONENTIAL_THRESHOLD
? formattedPrice.toExponential()
: formattedPrice.toFixed(groupingDecimalCount)}
</div>
</div>
</div>
<Line
className={`absolute left-0 opacity-40 brightness-125 ${
className={`absolute left-0 opacity-30 brightness-125 ${
side === 'buy' ? `bg-th-up-muted` : `bg-th-down-muted`
}`}
data-width={Math.max(sizePercent, 0.5) + '%'}
/>
<Line
className={`absolute left-0 opacity-70 ${
className={`absolute left-0 opacity-40 ${
side === 'buy' ? `bg-th-up` : `bg-th-down`
}`}
data-width={

View File

@ -1,31 +1,67 @@
import TabButtons from '@components/shared/TabButtons'
import { useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import Orderbook from './Orderbook'
import RecentTrades from './RecentTrades'
import DepthChart from './DepthChart'
import useLocalStorageState from 'hooks/useLocalStorageState'
import { DEPTH_CHART_KEY } from 'utils/constants'
import { useViewport } from 'hooks/useViewport'
import { breakpoints } from 'utils/theme'
export const TABS: [string, number][] = [
['trade:book', 0],
['trade:depth', 0],
['trade:trades', 0],
]
const OrderbookAndTrades = () => {
const [activeTab, setActiveTab] = useState('trade:book')
const [showDepthChart] = useLocalStorageState<boolean>(DEPTH_CHART_KEY, false)
const { width } = useViewport()
const hideDepthTab = width ? width > breakpoints.lg : false
const tabsToShow = useMemo(() => {
if (hideDepthTab) {
return TABS.filter((t) => !t[0].includes('depth'))
}
return TABS
}, [hideDepthTab])
useEffect(() => {
if (hideDepthTab && activeTab === 'trade:depth') {
setActiveTab('trade:book')
}
}, [activeTab, hideDepthTab])
return (
<div className="hide-scroll h-full">
<div className="border-b border-th-bkg-3">
<div className="hide-scroll overflow-x-auto border-b border-th-bkg-3">
<TabButtons
activeValue={activeTab}
onChange={(tab: string) => setActiveTab(tab)}
values={TABS}
fillWidth
values={tabsToShow}
fillWidth={!showDepthChart || !hideDepthTab}
showBorders
/>
</div>
<div
className={`flex ${activeTab === 'trade:book' ? 'visible' : 'hidden'}`}
>
{showDepthChart ? (
<div className="hidden w-1/2 border-r border-th-bkg-3 lg:block">
<DepthChart />
</div>
) : null}
<div className={showDepthChart ? 'w-full lg:w-1/2' : 'w-full'}>
<Orderbook />
</div>
</div>
<div
className={`h-full ${
activeTab === 'trade:book' ? 'visible' : 'hidden'
activeTab === 'trade:depth' ? 'visible' : 'hidden'
}`}
>
<Orderbook />
<DepthChart />
</div>
<div
className={`h-full ${

View File

@ -1,6 +1,11 @@
import { useMemo } from 'react'
import { useCallback, useLayoutEffect, useMemo, useState } from 'react'
import dynamic from 'next/dynamic'
import ReactGridLayout, { Responsive, WidthProvider } from 'react-grid-layout'
import ReactGridLayout, {
Layout,
Layouts,
Responsive,
WidthProvider,
} from 'react-grid-layout'
import mangoStore from '@store/mangoStore'
// import { IS_ONBOARDED_KEY } from 'utils/constants'
// import useLocalStorageState from 'hooks/useLocalStorageState'
@ -15,7 +20,11 @@ import OrderbookAndTrades from './OrderbookAndTrades'
// import TradeOnboardingTour from '@components/tours/TradeOnboardingTour'
import FavoriteMarketsBar from './FavoriteMarketsBar'
import useLocalStorageState from 'hooks/useLocalStorageState'
import { TRADE_LAYOUT_KEY } from 'utils/constants'
import {
DEPTH_CHART_KEY,
SIDEBAR_COLLAPSE_KEY,
TRADE_LAYOUT_KEY,
} from 'utils/constants'
import TradeHotKeys from './TradeHotKeys'
export type TradeLayout =
@ -28,18 +37,6 @@ const TradingChartContainer = dynamic(() => import('./TradingChartContainer'), {
ssr: false,
})
const ResponsiveGridLayout = WidthProvider(Responsive)
const sidebarWidth = 63
const totalCols = 24
const gridBreakpoints = {
md: breakpoints.md - sidebarWidth,
lg: breakpoints.lg - sidebarWidth,
xl: breakpoints.xl - sidebarWidth,
xxl: breakpoints['2xl'] - sidebarWidth,
xxxl: breakpoints['3xl'] - sidebarWidth,
}
const getHeight = (
pageHeight: number,
minHeight: number,
@ -48,6 +45,8 @@ const getHeight = (
return Math.max(minHeight, pageHeight - remainingRowHeight)
}
const ResponsiveGridLayout = WidthProvider(Responsive)
const TradeAdvancedPage = () => {
const { height, width } = useViewport()
const { uiLocked } = mangoStore((s) => s.settings)
@ -59,150 +58,222 @@ const TradeAdvancedPage = () => {
TRADE_LAYOUT_KEY,
'chartLeft'
)
const [isCollapsed] = useLocalStorageState(SIDEBAR_COLLAPSE_KEY, false)
const [showDepthChart] = useLocalStorageState<boolean>(DEPTH_CHART_KEY, false)
const totalCols = 24
const gridBreakpoints = useMemo(() => {
const sidebarWidth = isCollapsed ? 64 : 207
return {
md: breakpoints.md - sidebarWidth,
lg: breakpoints.lg - sidebarWidth,
xl: breakpoints.xl - sidebarWidth,
xxl: breakpoints['2xl'] - sidebarWidth,
xxxl: breakpoints['3xl'] - sidebarWidth,
}
}, [isCollapsed])
const defaultLayouts: ReactGridLayout.Layouts = useMemo(() => {
const topnavbarHeight = 67
const topnavbarHeight = 64
const innerHeight = Math.max(height - topnavbarHeight, 1000)
const marketHeaderHeight = 48
const balancesXPos = {
chartLeft: { xxxl: 0, xxl: 0, xl: 0, lg: 0 },
chartMiddleOBRight: { xxxl: 4, xxl: 5, xl: 5, lg: 6 },
chartMiddleOBLeft: { xxxl: 4, xxl: 4, xl: 4, lg: 5 },
chartRight: { xxxl: 9, xxl: 9, xl: 9, lg: 11 },
chartMiddleOBLeft: { xxxl: 0, xxl: 0, xl: 0, lg: 0 },
chartRight: { xxxl: 4, xxl: 5, xl: 5, lg: 5 },
}
const chartXPos = {
chartLeft: { xxxl: 0, xxl: 0, xl: 0, lg: 0 },
chartMiddleOBRight: { xxxl: 4, xxl: 5, xl: 5, lg: 6 },
chartMiddleOBLeft: { xxxl: 4, xxl: 4, xl: 4, lg: 5 },
chartRight: { xxxl: 9, xxl: 9, xl: 9, lg: 11 },
chartMiddleOBRight: { xxxl: 4, xxl: 5, xl: 5, lg: 5 },
chartMiddleOBLeft: {
xxxl: showDepthChart ? 7 : 4,
xxl: showDepthChart ? 7 : 4,
xl: showDepthChart ? 7 : 4,
lg: showDepthChart ? 8 : 5,
},
chartRight: {
xxxl: showDepthChart ? 12 : 9,
xxl: showDepthChart ? 12 : 9,
xl: showDepthChart ? 12 : 9,
lg: showDepthChart ? 14 : 11,
},
}
const bookXPos = {
chartLeft: { xxxl: 16, xxl: 15, xl: 15, lg: 13 },
chartMiddleOBRight: { xxxl: 20, xxl: 20, xl: 20, lg: 19 },
chartLeft: {
xxxl: showDepthChart ? 13 : 16,
xxl: showDepthChart ? 12 : 15,
xl: showDepthChart ? 12 : 15,
lg: showDepthChart ? 11 : 14,
},
chartMiddleOBRight: {
xxxl: showDepthChart ? 17 : 20,
xxl: showDepthChart ? 17 : 20,
xl: showDepthChart ? 17 : 20,
lg: showDepthChart ? 16 : 19,
},
chartMiddleOBLeft: { xxxl: 0, xxl: 0, xl: 0, lg: 0 },
chartRight: { xxxl: 4, xxl: 5, xl: 5, lg: 6 },
chartRight: { xxxl: 4, xxl: 5, xl: 5, lg: 5 },
}
const formXPos = {
chartLeft: { xxxl: 20, xxl: 19, xl: 19, lg: 18 },
chartLeft: { xxxl: 20, xxl: 19, xl: 19, lg: 19 },
chartMiddleOBRight: { xxxl: 0, xxl: 0, xl: 0, lg: 0 },
chartMiddleOBLeft: { xxxl: 20, xxl: 19, xl: 19, lg: 18 },
chartMiddleOBLeft: { xxxl: 20, xxl: 19, xl: 19, lg: 19 },
chartRight: { xxxl: 0, xxl: 0, xl: 0, lg: 0 },
}
return {
xxxl: [
{ i: 'market-header', x: 0, y: 0, w: 24, h: marketHeaderHeight },
{ i: 'tv-chart', x: chartXPos[tradeLayout].xxxl, y: 1, w: 16, h: 640 },
{
i: 'balances',
x: balancesXPos[tradeLayout].xxxl,
y: 2,
w: 16,
h: getHeight(innerHeight, 0, 640),
i: 'tv-chart',
x: chartXPos[tradeLayout].xxxl,
y: 1,
w: showDepthChart ? 13 : 16,
h: 640,
},
{
i: 'orderbook',
x: bookXPos[tradeLayout].xxxl,
y: 0,
w: 4,
h: getHeight(innerHeight, 0, 0),
y: 1,
w: showDepthChart ? 7 : 4,
h: 640,
},
{
i: 'trade-form',
x: formXPos[tradeLayout].xxxl,
y: 0,
y: 1,
w: 4,
h: getHeight(innerHeight, 0, 0),
},
{
i: 'balances',
x: balancesXPos[tradeLayout].xxxl,
y: 2,
w: 20,
h: getHeight(innerHeight, 0, 640),
},
],
xxl: [
{ i: 'market-header', x: 0, y: 0, w: 24, h: marketHeaderHeight },
{ i: 'tv-chart', x: chartXPos[tradeLayout].xxl, y: 1, w: 15, h: 488 },
{
i: 'balances',
x: balancesXPos[tradeLayout].xxl,
y: 2,
w: 15,
h: getHeight(innerHeight, 0, 488),
i: 'tv-chart',
x: chartXPos[tradeLayout].xxl,
y: 1,
w: showDepthChart ? 12 : 15,
h: 552,
},
{
i: 'orderbook',
x: bookXPos[tradeLayout].xxl,
y: 0,
w: 4,
h: getHeight(innerHeight, 0, 0),
y: 1,
w: showDepthChart ? 7 : 4,
h: 552,
},
{
i: 'trade-form',
x: formXPos[tradeLayout].xxl,
y: 0,
y: 1,
w: 5,
h: getHeight(innerHeight, 0, 0),
},
{
i: 'balances',
x: balancesXPos[tradeLayout].xxl,
y: 2,
w: 19,
h: getHeight(innerHeight, 0, 552),
},
],
xl: [
{ i: 'market-header', x: 0, y: 0, w: 24, h: marketHeaderHeight },
{ i: 'tv-chart', x: chartXPos[tradeLayout].xl, y: 1, w: 15, h: 488 },
{
i: 'balances',
x: balancesXPos[tradeLayout].xl,
y: 2,
w: 15,
h: getHeight(innerHeight, 0, 488),
i: 'tv-chart',
x: chartXPos[tradeLayout].xl,
y: 1,
w: showDepthChart ? 12 : 15,
h: 552,
},
{
i: 'orderbook',
x: bookXPos[tradeLayout].xl,
y: 0,
w: 4,
h: getHeight(innerHeight, 0, 0),
y: 1,
w: showDepthChart ? 7 : 4,
h: 552,
},
{
i: 'trade-form',
x: formXPos[tradeLayout].xl,
y: 0,
y: 1,
w: 5,
h: getHeight(innerHeight, 0, 0),
},
{
i: 'balances',
x: balancesXPos[tradeLayout].xl,
y: 2,
w: 19,
h: getHeight(innerHeight, 0, 552),
},
],
lg: [
{ i: 'market-header', x: 0, y: 0, w: 24, h: marketHeaderHeight },
{ i: 'tv-chart', x: chartXPos[tradeLayout].lg, y: 1, w: 13, h: 456 },
{
i: 'balances',
x: balancesXPos[tradeLayout].lg,
y: 2,
w: 13,
h: getHeight(innerHeight, 0, 456),
i: 'tv-chart',
x: chartXPos[tradeLayout].lg,
y: 1,
w: showDepthChart ? 11 : 14,
h: 552,
},
{
i: 'orderbook',
x: bookXPos[tradeLayout].lg,
y: 0,
w: 5,
h: getHeight(innerHeight, 0, 0),
y: 1,
w: showDepthChart ? 8 : 5,
h: 552,
},
{
i: 'trade-form',
x: formXPos[tradeLayout].lg,
y: 0,
w: 6,
y: 1,
w: 5,
h: getHeight(innerHeight, 0, 0),
},
{
i: 'balances',
x: balancesXPos[tradeLayout].lg,
y: 2,
w: 19,
h: getHeight(innerHeight, 0, 552),
},
],
md: [
{ i: 'market-header', x: 0, y: 0, w: 24, h: marketHeaderHeight },
{ i: 'tv-chart', x: 0, y: 1, w: 17, h: 464 },
{ i: 'orderbook', x: 18, y: 2, w: 7, h: 552 },
{ i: 'trade-form', x: 18, y: 1, w: 7, h: 572 },
{ i: 'balances', x: 0, y: 2, w: 17, h: 428 + marketHeaderHeight },
{ i: 'orderbook', x: 18, y: 2, w: 7, h: 428 + marketHeaderHeight },
{ i: 'trade-form', x: 18, y: 1, w: 7, h: 492 + marketHeaderHeight },
],
}
}, [height])
}, [height, showDepthChart, tradeLayout])
const [layouts, setLayouts] = useState<Layouts>(defaultLayouts)
const [breakpoint, setBreakpoint] = useState('')
const handleLayoutChange = useCallback(
(layout: Layout[] | undefined, layouts: Layouts) => {
setLayouts(layouts)
},
[setLayouts]
)
useLayoutEffect(() => {
handleLayoutChange(undefined, defaultLayouts)
}, [breakpoint, showDepthChart, tradeLayout])
return showMobileView ? (
<MobileTradeAdvancedPage />
@ -210,10 +281,9 @@ const TradeAdvancedPage = () => {
<TradeHotKeys>
<FavoriteMarketsBar />
<ResponsiveGridLayout
onBreakpointChange={(bp) => console.log('bp: ', bp)}
// layouts={savedLayouts ? savedLayouts : defaultLayouts}
layouts={defaultLayouts}
layouts={layouts}
breakpoints={gridBreakpoints}
onBreakpointChange={(bp) => setBreakpoint(bp)}
cols={{
xxxl: totalCols,
xxl: totalCols,
@ -228,6 +298,8 @@ const TradeAdvancedPage = () => {
containerPadding={[0, 0]}
margin={[0, 0]}
useCSSTransforms
onLayoutChange={handleLayoutChange}
measureBeforeMount
>
<div key="market-header" className="z-10">
<AdvancedMarketHeader />
@ -240,22 +312,35 @@ const TradeAdvancedPage = () => {
<TradingChartContainer />
</div>
</div>
<div key="balances">
<div
className={`${
tradeLayout === 'chartLeft' ? 'lg:border-r lg:border-th-bkg-3' : ''
}`}
key="balances"
>
<TradeInfoTabs />
</div>
<div
className={`border-y border-l border-th-bkg-3 lg:border-b-0 ${
tradeLayout === 'chartMiddleOBRight' ? 'lg:border-r' : ''
} ${tradeLayout !== 'chartMiddleOBLeft' ? 'lg:border-l-0' : ''}`}
tradeLayout === 'chartMiddleOBRight'
? 'lg:border-r lg:border-l-0'
: ''
} ${
tradeLayout === 'chartRight' ? 'lg:border-r lg:border-l-0' : ''
} ${tradeLayout === 'chartLeft' ? 'lg:border-l-0' : ''}`}
key="trade-form"
>
<AdvancedTradeForm />
</div>
<div
key="orderbook"
className={`overflow-hidden border-l border-th-bkg-3 lg:border-t lg:border-r ${
tradeLayout === 'chartMiddleOBRight' ? 'lg:border-r-0' : ''
} ${tradeLayout === 'chartMiddleOBLeft' ? 'lg:border-l-0' : ''}`}
className={`overflow-hidden border-l border-th-bkg-3 lg:border-y ${
tradeLayout === 'chartRight' ? 'lg:border-l-0 lg:border-r' : ''
} ${
tradeLayout === 'chartMiddleOBLeft'
? 'lg:border-l-0 lg:border-r'
: ''
} ${tradeLayout === 'chartLeft' ? 'lg:border-r' : ''}`}
>
<OrderbookAndTrades />
</div>

View File

@ -6,6 +6,8 @@
"daily-volume": "24h Volume",
"export": "Export {{dataType}}",
"funding-chart": "Funding Chart",
"init-health": "Init Health",
"maint-health": "Maint Health",
"health-contributions": "Health Contributions",
"init-health-contribution": "Init Health Contribution",
"init-health-contributions": "Init Health Contributions",

View File

@ -15,6 +15,7 @@
"connect-unsettled": "Connect to view your unsettled funds",
"copy-and-share": "Copy Image to Clipboard",
"current-price": "Current Price",
"depth": "Depth",
"edit-order": "Edit Order",
"est-liq-price": "Est. Liq. Price",
"avg-entry-price": "Avg. Entry Price",

View File

@ -15,6 +15,7 @@
"connect-unsettled": "Connect to view your unsettled funds",
"copy-and-share": "Copy Image to Clipboard",
"current-price": "Current Price",
"depth": "Depth",
"edit-order": "Edit Order",
"est-liq-price": "Est. Liq. Price",
"avg-entry-price": "Avg. Entry Price",

View File

@ -15,6 +15,7 @@
"connect-unsettled": "Connect to view your unsettled funds",
"copy-and-share": "Copy Image to Clipboard",
"current-price": "Current Price",
"depth": "Depth",
"edit-order": "Edit Order",
"est-liq-price": "Est. Liq. Price",
"avg-entry-price": "Avg. Entry Price",

View File

@ -15,6 +15,7 @@
"connect-unsettled": "Connect to view your unsettled funds",
"copy-and-share": "Copy Image to Clipboard",
"current-price": "Current Price",
"depth": "Depth",
"edit-order": "Edit Order",
"est-liq-price": "Est. Liq. Price",
"avg-entry-price": "Avg. Entry Price",

View File

@ -16,6 +16,7 @@
"connect-unsettled": "連接以查看未結清餘額",
"copy-and-share": "將圖片複製到剪貼版",
"current-price": "目前價格",
"depth": "Depth",
"edit-order": "編輯訂單",
"est-liq-price": "預計清算價格",
"est-slippage": "預計下滑",

View File

@ -443,9 +443,6 @@ module.exports = {
},
},
},
screens: {
xl: '1600px',
},
},
},
// variants: {

View File

@ -414,6 +414,23 @@ export type TickerData = {
ticker_id: string
}
export type cumOrderbookSide = {
price: number
size: number
cumulativeSize: number
sizePercent: number
maxSizePercent: number
cumulativeSizePercent: number
isUsersOrder: boolean
}
export type OrderbookData = {
bids: cumOrderbookSide[]
asks: cumOrderbookSide[]
spread: number
spreadPercentage: number
}
export interface HealthContribution {
asset: string
contribution: number

View File

@ -57,6 +57,8 @@ export const ACCEPT_TERMS_KEY = 'termsOfUseAccepted-0.1'
export const TRADE_LAYOUT_KEY = 'tradeLayoutKey-0.1'
export const DEPTH_CHART_KEY = 'showDepthChart-0.1'
export const STATS_TAB_KEY = 'activeStatsTab-0.1'
export const USE_ORDERBOOK_FEED_KEY = 'useOrderbookFeed-0.1'

164
utils/orderbook.ts Normal file
View File

@ -0,0 +1,164 @@
import {
BookSide,
BookSideType,
MangoClient,
PerpMarket,
} from '@blockworks-foundation/mango-v4'
import { Market, Orderbook as SpotOrderBook } from '@project-serum/serum'
import { AccountInfo } from '@solana/web3.js'
import mangoStore from '@store/mangoStore'
import Big from 'big.js'
import { cumOrderbookSide } from 'types'
import { getDecimalCount } from './numbers'
export 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<Buffer>,
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 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()
}
}
export 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
})
}
export 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
),
}
})
}
export const groupBy = (
ordersArray: number[][],
market: PerpMarket | 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
}

View File

@ -15,5 +15,5 @@ export const breakpoints = {
// => @media (min-width: 1536px) { ... }
'3xl': 1792,
// => @media (min-width: 1536px) { ... }
// => @media (min-width: 1792px) { ... }
}