584 lines
20 KiB
TypeScript
584 lines
20 KiB
TypeScript
import { useState, useEffect, useMemo } from 'react'
|
|
import { useTheme } from 'next-themes'
|
|
import cloneDeep from 'lodash/cloneDeep'
|
|
import dayjs from 'dayjs'
|
|
import {
|
|
AreaChart,
|
|
Area,
|
|
XAxis,
|
|
YAxis,
|
|
Tooltip as ChartTooltip,
|
|
} from 'recharts'
|
|
import { InformationCircleIcon, ScaleIcon } from '@heroicons/react/solid'
|
|
import useDimensions from 'react-cool-dimensions'
|
|
import { useTranslation } from 'next-i18next'
|
|
import { ZERO_BN } from '@blockworks-foundation/mango-client'
|
|
import ButtonGroup from '../ButtonGroup'
|
|
import { formatUsdValue } from '../../utils'
|
|
import { numberCompacter } from '../SwapTokenInfo'
|
|
import Checkbox from '../Checkbox'
|
|
import Tooltip from '../Tooltip'
|
|
import useMangoStore, { PerpPosition } from 'stores/useMangoStore'
|
|
import LongShortChart from './LongShortChart'
|
|
import HealthHeart from 'components/HealthHeart'
|
|
|
|
type AccountOverviewStats = {
|
|
hourlyPerformanceStats: any[]
|
|
performanceRange: '24hr' | '7d' | '30d' | '3m'
|
|
}
|
|
|
|
const defaultData = [
|
|
{ account_equity: 0, pnl: 0, time: '2022-01-01T00:00:00.000Z' },
|
|
{ account_equity: 0, pnl: 0, time: '2023-01-01T00:00:00.000Z' },
|
|
]
|
|
|
|
const performanceRangePresets = [
|
|
{ label: '24h', value: 1 },
|
|
{ label: '7d', value: 7 },
|
|
{ label: '30d', value: 30 },
|
|
{ label: '3m', value: 90 },
|
|
]
|
|
const performanceRangePresetLabels = performanceRangePresets.map((x) => x.label)
|
|
|
|
const AccountOverviewStats = ({ hourlyPerformanceStats, accountValue }) => {
|
|
const { theme } = useTheme()
|
|
const { t } = useTranslation('common')
|
|
const { observe, width, height } = useDimensions()
|
|
const [chartToShow, setChartToShow] = useState<string>('Value')
|
|
const [chartData, setChartData] = useState<any[]>([])
|
|
const [mouseData, setMouseData] = useState<string | null>(null)
|
|
const [performanceRange, setPerformanceRange] = useState('30d')
|
|
const [showSpotPnl, setShowSpotPnl] = useState(true)
|
|
const [showPerpPnl, setShowPerpPnl] = useState(true)
|
|
const mangoAccount = useMangoStore((s) => s.selectedMangoAccount.current)
|
|
const mangoGroup = useMangoStore((s) => s.selectedMangoGroup.current)
|
|
const mangoCache = useMangoStore((s) => s.selectedMangoGroup.cache)
|
|
const spotBalances = useMangoStore((s) => s.selectedMangoAccount.spotBalances)
|
|
const perpPositions = useMangoStore(
|
|
(s) => s.selectedMangoAccount.perpPositions
|
|
)
|
|
|
|
const maintHealthRatio = useMemo(() => {
|
|
return mangoAccount && mangoGroup && mangoCache
|
|
? mangoAccount.getHealthRatio(mangoGroup, mangoCache, 'Maint')
|
|
: 100
|
|
}, [mangoAccount, mangoGroup, mangoCache])
|
|
|
|
const { longData, shortData, longExposure, shortExposure } = useMemo(() => {
|
|
const longData: any = []
|
|
const shortData: any = []
|
|
|
|
if (!spotBalances || !perpPositions) {
|
|
longData.push({ symbol: 'spacer', value: 1 })
|
|
shortData.push({ symbol: 'spacer', value: 1 })
|
|
// return {}
|
|
}
|
|
|
|
const DUST_THRESHOLD = 0.05
|
|
const netUnsettledPositionsValue = perpPositions.reduce(
|
|
(a, c) => a + (c?.unsettledPnl ?? 0),
|
|
0
|
|
)
|
|
|
|
for (const { net, symbol, value } of spotBalances) {
|
|
let amount = Number(net)
|
|
let totValue = Number(value)
|
|
if (symbol === 'USDC') {
|
|
amount += netUnsettledPositionsValue
|
|
totValue += netUnsettledPositionsValue
|
|
}
|
|
if (totValue > DUST_THRESHOLD) {
|
|
longData.push({
|
|
asset: symbol,
|
|
amount: amount,
|
|
symbol: symbol,
|
|
value: totValue,
|
|
})
|
|
}
|
|
if (-totValue > DUST_THRESHOLD) {
|
|
shortData.push({
|
|
asset: symbol,
|
|
amount: Math.abs(amount),
|
|
symbol: symbol,
|
|
value: Math.abs(totValue),
|
|
})
|
|
}
|
|
}
|
|
for (const {
|
|
marketConfig,
|
|
basePosition,
|
|
notionalSize,
|
|
perpAccount,
|
|
} of perpPositions.filter((p) => !!p) as PerpPosition[]) {
|
|
if (notionalSize < DUST_THRESHOLD) continue
|
|
|
|
if (perpAccount.basePosition.gt(ZERO_BN)) {
|
|
longData.push({
|
|
asset: marketConfig.name,
|
|
amount: basePosition,
|
|
symbol: marketConfig.baseSymbol,
|
|
value: notionalSize,
|
|
})
|
|
} else {
|
|
shortData.push({
|
|
asset: marketConfig.name,
|
|
amount: Math.abs(basePosition),
|
|
symbol: marketConfig.baseSymbol,
|
|
value: notionalSize,
|
|
})
|
|
}
|
|
}
|
|
const longExposure = longData.reduce((a, c) => a + c.value, 0)
|
|
const shortExposure = shortData.reduce((a, c) => a + c.value, 0)
|
|
if (shortExposure === 0) {
|
|
shortData.push({ symbol: 'spacer', value: 1 })
|
|
}
|
|
if (longExposure === 0) {
|
|
longData.push({ symbol: 'spacer', value: 1 })
|
|
}
|
|
const dif = longExposure - shortExposure
|
|
if (dif > 0) {
|
|
shortData.push({ symbol: 'spacer', value: dif })
|
|
}
|
|
|
|
return { longData, shortData, longExposure, shortExposure }
|
|
}, [spotBalances, perpPositions])
|
|
|
|
useEffect(() => {
|
|
if (hourlyPerformanceStats.length > 0) {
|
|
if (performanceRange === '3m') {
|
|
setChartData(hourlyPerformanceStats.slice().reverse())
|
|
}
|
|
if (!chartData?.length) return
|
|
if (performanceRange === '30d') {
|
|
const start = new Date(
|
|
// @ts-ignore
|
|
dayjs().utc().hour(0).minute(0).subtract(29, 'day')
|
|
).getTime()
|
|
const chartData = cloneDeep(hourlyPerformanceStats).filter(
|
|
(d) => new Date(d.time).getTime() > start
|
|
)
|
|
|
|
const pnlStart = chartData[chartData.length - 1]?.pnl
|
|
const perpPnlStart = chartData[chartData.length - 1].perp_pnl
|
|
for (let i = 0; i < chartData.length; i++) {
|
|
if (i === chartData.length - 1) {
|
|
chartData[i].pnl = 0
|
|
chartData[i].perp_pnl = 0
|
|
} else {
|
|
chartData[i].pnl = chartData[i].pnl - pnlStart
|
|
chartData[i].perp_pnl = chartData[i].perp_pnl - perpPnlStart
|
|
}
|
|
}
|
|
setChartData(chartData.reverse())
|
|
}
|
|
if (performanceRange === '7d') {
|
|
const start = new Date(
|
|
// @ts-ignore
|
|
dayjs().utc().hour(0).minute(0).subtract(7, 'day')
|
|
).getTime()
|
|
const chartData = cloneDeep(hourlyPerformanceStats).filter(
|
|
(d) => new Date(d.time).getTime() > start
|
|
)
|
|
const pnlStart = chartData[chartData.length - 1].pnl
|
|
const perpPnlStart = chartData[chartData.length - 1].perp_pnl
|
|
for (let i = 0; i < chartData.length; i++) {
|
|
if (i === chartData.length - 1) {
|
|
chartData[i].pnl = 0
|
|
chartData[i].perp_pnl = 0
|
|
} else {
|
|
chartData[i].pnl = chartData[i].pnl - pnlStart
|
|
chartData[i].perp_pnl = chartData[i].perp_pnl - perpPnlStart
|
|
}
|
|
}
|
|
setChartData(chartData.reverse())
|
|
}
|
|
if (performanceRange === '24h') {
|
|
const start = new Date(
|
|
// @ts-ignore
|
|
dayjs().utc().hour(0).minute(0).subtract(1, 'day')
|
|
).getTime()
|
|
const chartData = cloneDeep(hourlyPerformanceStats).filter(
|
|
(d) => new Date(d.time).getTime() > start
|
|
)
|
|
const pnlStart = chartData[chartData.length - 1].pnl
|
|
const perpPnlStart = chartData[chartData.length - 1].perp_pnl
|
|
for (let i = 0; i < chartData.length; i++) {
|
|
if (i === chartData.length - 1) {
|
|
chartData[i].pnl = 0
|
|
chartData[i].perp_pnl = 0
|
|
} else {
|
|
chartData[i].pnl = chartData[i].pnl - pnlStart
|
|
chartData[i].perp_pnl = chartData[i].perp_pnl - perpPnlStart
|
|
}
|
|
}
|
|
setChartData(chartData.reverse())
|
|
}
|
|
} else {
|
|
setChartData([])
|
|
}
|
|
}, [hourlyPerformanceStats, performanceRange])
|
|
|
|
useEffect(() => {
|
|
if (chartData.length > 0) {
|
|
for (const stat of chartData) {
|
|
stat.spot_pnl = stat.pnl - stat.perp_pnl
|
|
}
|
|
}
|
|
}, [chartData])
|
|
|
|
const handleMouseMove = (coords) => {
|
|
if (coords.activePayload) {
|
|
setMouseData(coords.activePayload[0].payload)
|
|
}
|
|
}
|
|
|
|
const handleMouseLeave = () => {
|
|
setMouseData(null)
|
|
}
|
|
|
|
const renderPnlChartTitle = () => {
|
|
if (showPerpPnl && showSpotPnl) {
|
|
return t('pnl')
|
|
}
|
|
if (!showSpotPnl) {
|
|
return `${t('perp')} ${t('pnl')}`
|
|
}
|
|
if (!showPerpPnl) {
|
|
return `${t('spot')} ${t('pnl')}`
|
|
}
|
|
}
|
|
|
|
const formatDateAxis = (date) => {
|
|
if (['3m', '30d'].includes(performanceRange)) {
|
|
return dayjs(date).format('D MMM')
|
|
} else if (performanceRange === '7d') {
|
|
return dayjs(date).format('ddd, h:mma')
|
|
} else {
|
|
return dayjs(date).format('h:mma')
|
|
}
|
|
}
|
|
|
|
const pnlChartDataKey = () => {
|
|
if (!showPerpPnl && showSpotPnl) {
|
|
return 'spot_pnl'
|
|
} else if (!showSpotPnl && showPerpPnl) {
|
|
return 'perp_pnl'
|
|
} else {
|
|
return 'pnl'
|
|
}
|
|
}
|
|
|
|
const pnlChartColor =
|
|
chartToShow === 'PnL' &&
|
|
chartData.length > 0 &&
|
|
chartData[chartData.length - 1][pnlChartDataKey()] > 0
|
|
? theme === 'Mango'
|
|
? '#AFD803'
|
|
: '#5EBF4D'
|
|
: theme === 'Mango'
|
|
? '#F84638'
|
|
: '#CC2929'
|
|
|
|
return (
|
|
<div className="grid grid-cols-12 lg:gap-6">
|
|
<div className="order-2 col-span-12 lg:order-1 lg:col-span-4">
|
|
<div className="px-3 pb-4 xl:pb-6">
|
|
<div className="flex items-center pb-1.5">
|
|
<div className="text-sm text-th-fgd-3">
|
|
{chartToShow === 'Value'
|
|
? t('account-value')
|
|
: renderPnlChartTitle()}{' '}
|
|
</div>
|
|
</div>
|
|
{mouseData ? (
|
|
<>
|
|
<div className="pb-1 text-2xl font-bold text-th-fgd-1 sm:text-3xl">
|
|
{formatUsdValue(
|
|
mouseData[
|
|
chartToShow === 'PnL' ? pnlChartDataKey() : 'account_equity'
|
|
]
|
|
)}
|
|
</div>
|
|
<div className="text-xs font-normal text-th-fgd-4">
|
|
{dayjs(mouseData['time']).format('ddd MMM D YYYY, h:mma')}
|
|
</div>
|
|
</>
|
|
) : chartData.length === 0 ? (
|
|
<>
|
|
<div className="pb-1 text-2xl font-bold text-th-fgd-1 sm:text-3xl">
|
|
{chartToShow === 'PnL' ? '--' : formatUsdValue(accountValue)}
|
|
</div>
|
|
<div className="text-xs font-normal text-th-fgd-4">
|
|
{dayjs().format('ddd MMM D YYYY, h:mma')}
|
|
</div>
|
|
</>
|
|
) : chartData.length > 0 ? (
|
|
<>
|
|
<div className="pb-1 text-2xl font-bold text-th-fgd-1 sm:text-3xl">
|
|
{chartToShow === 'PnL'
|
|
? formatUsdValue(
|
|
chartData[chartData.length - 1][pnlChartDataKey()]
|
|
)
|
|
: formatUsdValue(accountValue)}
|
|
</div>
|
|
<div className="text-xs font-normal text-th-fgd-4">
|
|
{chartToShow === 'PnL'
|
|
? dayjs(chartData[chartData.length - 1]['time']).format(
|
|
'ddd MMM D YYYY, h:mma'
|
|
)
|
|
: dayjs().format('ddd MMM D YYYY, h:mma')}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className="mt-1 h-8 w-48 animate-pulse rounded bg-th-bkg-3" />
|
|
<div className="mt-1 h-4 w-24 animate-pulse rounded bg-th-bkg-3" />
|
|
</>
|
|
)}
|
|
</div>
|
|
<div className="flex flex-col divide-y divide-th-bkg-3 border-y border-th-bkg-3 md:flex-row md:divide-y-0 md:p-3 lg:flex-col lg:divide-y lg:p-0 xl:flex-row xl:divide-y-0 xl:p-5">
|
|
<div className="flex w-full items-center space-x-3 p-3 md:w-1/2 md:p-0 lg:w-full lg:p-3 xl:w-1/2 xl:p-0">
|
|
<HealthHeart size={40} health={Number(maintHealthRatio)} />
|
|
<div>
|
|
<Tooltip
|
|
content={
|
|
<div>
|
|
{t('tooltip-account-liquidated')}{' '}
|
|
<a
|
|
href="https://docs.mango.markets/mango-markets/health-overview"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
>
|
|
{t('learn-more')}
|
|
</a>
|
|
</div>
|
|
}
|
|
>
|
|
<div className="flex items-center space-x-1.5 pb-0.5">
|
|
<div className="text-th-fgd-3">{t('health')}</div>
|
|
<InformationCircleIcon className="h-5 w-5 flex-shrink-0 cursor-help text-th-fgd-4" />
|
|
</div>
|
|
</Tooltip>
|
|
<div className={`text-lg font-bold text-th-fgd-1`}>
|
|
{maintHealthRatio < 100 ? maintHealthRatio.toFixed(2) : '>100'}%
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex w-full items-center space-x-3 p-3 md:w-1/2 md:p-0 md:pl-4 lg:w-full lg:p-3 xl:w-1/2 xl:p-0">
|
|
<ScaleIcon className="h-10 w-10 text-th-fgd-4" />
|
|
<div>
|
|
<div className="pb-0.5 text-th-fgd-3">{t('leverage')}</div>
|
|
{mangoGroup && mangoCache ? (
|
|
<div className={`text-lg font-bold text-th-fgd-1`}>
|
|
{mangoAccount?.getLeverage(mangoGroup, mangoCache).toFixed(2)}
|
|
x
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col divide-y divide-th-bkg-3 border-b border-th-bkg-3 md:flex-row md:divide-y-0 md:p-3 lg:flex-col lg:divide-y lg:p-0 xl:flex-row xl:divide-y-0 xl:p-5">
|
|
<div className="flex w-full items-center space-x-3 p-3 md:w-1/2 md:p-0 lg:w-full lg:p-3 xl:w-1/2 xl:p-0">
|
|
<LongShortChart chartData={longData} />
|
|
<div>
|
|
<Tooltip content={t('total-long-tooltip')}>
|
|
<div className="flex items-center space-x-1.5 pb-0.5">
|
|
<div className="text-th-fgd-3">{t('assets')}</div>
|
|
<InformationCircleIcon className="h-5 w-5 flex-shrink-0 cursor-help text-th-fgd-4" />
|
|
</div>
|
|
</Tooltip>
|
|
{mangoGroup && mangoCache ? (
|
|
<div className="text-lg font-bold text-th-fgd-1">
|
|
{formatUsdValue(+longExposure)}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
<div className="flex w-full items-center space-x-3 p-3 md:w-1/2 md:p-0 md:pl-4 lg:w-full lg:p-3 xl:w-1/2 xl:p-0">
|
|
<LongShortChart chartData={shortData} />
|
|
<div>
|
|
<Tooltip content={t('total-short-tooltip')}>
|
|
<div className="flex items-center space-x-1.5 pb-0.5">
|
|
<div className="whitespace-nowrap text-th-fgd-3">
|
|
{t('liabilities')}
|
|
</div>
|
|
<InformationCircleIcon className="h-5 w-5 flex-shrink-0 cursor-help text-th-fgd-4" />
|
|
</div>
|
|
</Tooltip>
|
|
{mangoGroup && mangoCache ? (
|
|
<div className="text-lg font-bold text-th-fgd-1">
|
|
{formatUsdValue(+shortExposure)}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="order-1 col-span-12 px-4 pb-6 lg:order-2 lg:col-span-8 lg:pb-0 xl:px-6">
|
|
<div className="mb-4 flex justify-between space-x-2 sm:mb-6">
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:space-x-3">
|
|
<div className="mb-3 w-28 sm:mb-0">
|
|
<ButtonGroup
|
|
activeValue={chartToShow}
|
|
className="h-8"
|
|
onChange={(c) => setChartToShow(c)}
|
|
values={['Value', 'PnL']}
|
|
/>
|
|
</div>
|
|
{chartToShow === 'PnL' && chartData.length ? (
|
|
<div className="flex space-x-3">
|
|
<Checkbox
|
|
checked={showSpotPnl}
|
|
disabled={!showPerpPnl}
|
|
onChange={(e) => setShowSpotPnl(e.target.checked)}
|
|
>
|
|
{t('include-spot')}
|
|
</Checkbox>
|
|
<Checkbox
|
|
checked={showPerpPnl}
|
|
disabled={!showSpotPnl}
|
|
onChange={(e) => setShowPerpPnl(e.target.checked)}
|
|
>
|
|
{t('include-perp')}
|
|
</Checkbox>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
<div className="w-40">
|
|
<ButtonGroup
|
|
activeValue={performanceRange}
|
|
className="h-8"
|
|
onChange={(p) => setPerformanceRange(p)}
|
|
values={performanceRangePresetLabels}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{chartData.length > 0 ? (
|
|
<div className="h-48 md:h-64 lg:h-[340px] xl:h-[225px]" ref={observe}>
|
|
<AreaChart
|
|
width={width}
|
|
height={height + 12}
|
|
data={chartData?.length ? chartData : defaultData}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseLeave={handleMouseLeave}
|
|
>
|
|
<ChartTooltip
|
|
cursor={{
|
|
strokeOpacity: 0,
|
|
}}
|
|
content={<></>}
|
|
/>
|
|
|
|
<defs>
|
|
<linearGradient
|
|
id="defaultGradientArea"
|
|
x1="0"
|
|
y1="0"
|
|
x2="0"
|
|
y2="1"
|
|
>
|
|
<stop offset="0%" stopColor="#ffba24" stopOpacity={0.9} />
|
|
<stop offset="80%" stopColor="#ffba24" stopOpacity={0} />
|
|
</linearGradient>
|
|
<linearGradient
|
|
id="greenGradientArea"
|
|
x1="0"
|
|
y1="0"
|
|
x2="0"
|
|
y2="1"
|
|
>
|
|
<stop
|
|
offset="0%"
|
|
stopColor={theme === 'Mango' ? '#AFD803' : '#5EBF4D'}
|
|
stopOpacity={0.9}
|
|
/>
|
|
<stop
|
|
offset="80%"
|
|
stopColor={theme === 'Mango' ? '#AFD803' : '#5EBF4D'}
|
|
stopOpacity={0}
|
|
/>
|
|
</linearGradient>
|
|
<linearGradient
|
|
id="redGradientArea"
|
|
x1="0"
|
|
y1="1"
|
|
x2="0"
|
|
y2="0"
|
|
>
|
|
<stop
|
|
offset="0%"
|
|
stopColor={theme === 'Mango' ? '#F84638' : '#CC2929'}
|
|
stopOpacity={0.9}
|
|
/>
|
|
<stop
|
|
offset="80%"
|
|
stopColor={theme === 'Mango' ? '#F84638' : '#CC2929'}
|
|
stopOpacity={0}
|
|
/>
|
|
</linearGradient>
|
|
</defs>
|
|
<Area
|
|
isAnimationActive={true}
|
|
type="monotone"
|
|
dataKey={
|
|
chartToShow === 'PnL' ? pnlChartDataKey() : 'account_equity'
|
|
}
|
|
stroke={chartToShow === 'PnL' ? pnlChartColor : '#ffba24'}
|
|
fill={
|
|
chartToShow === 'PnL'
|
|
? chartData[chartData.length - 1][pnlChartDataKey()] > 0
|
|
? 'url(#greenGradientArea)'
|
|
: 'url(#redGradientArea)'
|
|
: 'url(#defaultGradientArea)'
|
|
}
|
|
fillOpacity={0.3}
|
|
/>
|
|
|
|
<YAxis
|
|
dataKey={
|
|
chartToShow === 'PnL' ? pnlChartDataKey() : 'account_equity'
|
|
}
|
|
type="number"
|
|
domain={['dataMin', 'dataMax']}
|
|
axisLine={false}
|
|
dx={-10}
|
|
tick={{
|
|
fill:
|
|
theme === 'Light'
|
|
? 'rgba(0,0,0,0.4)'
|
|
: 'rgba(255,255,255,0.35)',
|
|
fontSize: 10,
|
|
}}
|
|
tickLine={false}
|
|
tickFormatter={(v) => numberCompacter.format(v)}
|
|
/>
|
|
<XAxis
|
|
dataKey="time"
|
|
axisLine={false}
|
|
dy={10}
|
|
minTickGap={20}
|
|
tick={{
|
|
fill:
|
|
theme === 'Light'
|
|
? 'rgba(0,0,0,0.4)'
|
|
: 'rgba(255,255,255,0.35)',
|
|
fontSize: 10,
|
|
}}
|
|
tickLine={false}
|
|
tickFormatter={(v) => formatDateAxis(v)}
|
|
/>
|
|
</AreaChart>
|
|
</div>
|
|
) : (
|
|
<div className="flex h-48 w-full items-center justify-center rounded-md bg-th-bkg-3 md:h-64 lg:h-[410px] xl:h-[270px]">
|
|
<p className="mb-0">{t('no-chart')}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default AccountOverviewStats
|