Pnl leaderboard (#43)
* leaderboard layout
* hook up pnl endpoint
* api limits, offset and start_date
* pnl history charts
* support account names
* ui feedback updates
* play with leaderboard 🙈
* fix chart bug
Co-authored-by: Maximilian Schneider <mail@maximilianschneider.net>
This commit is contained in:
parent
cae7d92a16
commit
522425d90b
|
@ -1,4 +1,12 @@
|
|||
import 'dayjs'
|
||||
|
||||
declare module '*.svg' {
|
||||
const content: any
|
||||
export default content
|
||||
}
|
||||
|
||||
declare module 'dayjs' {
|
||||
interface Dayjs {
|
||||
utc()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,242 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import styled from '@emotion/styled'
|
||||
import dayjs from 'dayjs'
|
||||
import { Table, Thead, Tbody, Tr, Th, Td } from 'react-super-responsive-table'
|
||||
import { AreaChart, Area, ReferenceLine, XAxis, YAxis, Tooltip } from 'recharts'
|
||||
import { ExternalLinkIcon } from '@heroicons/react/outline'
|
||||
import { usdFormatter } from '../utils'
|
||||
import { AwardIcon, TrophyIcon } from './icons'
|
||||
import useMangoStore from '../stores/useMangoStore'
|
||||
|
||||
const utc = require('dayjs/plugin/utc')
|
||||
dayjs.extend(utc)
|
||||
|
||||
const StyledTooltipWrapper = styled.div`
|
||||
min-width: 180px;
|
||||
`
|
||||
|
||||
const LeaderboardTable = () => {
|
||||
const [pnlHistory, setPnlHistory] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const pnlLeaderboard = useMangoStore((s) => s.pnlLeaderboard)
|
||||
|
||||
/* API Returns:
|
||||
* [ { cumulative_pnl: -3.687498
|
||||
date: "2021-06-10"
|
||||
margin_account: "J8XtwLVyZjeH1PG1Nnk9cWbLn3zEemS1rCbn4x6AjtXM"
|
||||
name: ""
|
||||
owner: "APLKzSqJQw79q4U4ipBWnLdqkVzijSPNpDCNKwL8mW3B"
|
||||
}, ... ]
|
||||
*/
|
||||
useEffect(() => {
|
||||
const getPnlHistory = async () => {
|
||||
setLoading(true)
|
||||
const start = dayjs().utc().subtract(31, 'day').format('YYYY-MM-DD')
|
||||
console.log(start)
|
||||
const results = await Promise.all(
|
||||
pnlLeaderboard.slice(pnlHistory.length).map(async (acc) => {
|
||||
const response = await fetch(
|
||||
`https://mango-transaction-log.herokuapp.com/stats/pnl_history/${acc.margin_account}?start_date=${start}`
|
||||
)
|
||||
const parsedResponse = await response.json()
|
||||
return parsedResponse ? parsedResponse.reverse() : []
|
||||
})
|
||||
)
|
||||
setPnlHistory(pnlHistory.concat(results))
|
||||
setLoading(false)
|
||||
}
|
||||
getPnlHistory()
|
||||
}, [pnlLeaderboard])
|
||||
|
||||
const formatPnlHistoryData = (data) => {
|
||||
const start = new Date(
|
||||
dayjs().utc().hour(0).minute(0).subtract(31, 'day')
|
||||
).getTime()
|
||||
|
||||
return data.filter((d) => new Date(d.date).getTime() > start)
|
||||
}
|
||||
|
||||
const tooltipContent = (tooltipProps) => {
|
||||
if (tooltipProps.payload.length > 0) {
|
||||
return (
|
||||
<StyledTooltipWrapper className="bg-th-bkg-1 flex p-2 rounded">
|
||||
<div>
|
||||
<div className="text-th-fgd-3 text-xs">Date</div>
|
||||
<div className="font-bold text-th-fgd-1 text-xs">
|
||||
{tooltipProps.payload[0].payload.date}
|
||||
</div>
|
||||
</div>
|
||||
<div className="pl-3">
|
||||
<div className="text-th-fgd-3 text-xs">PNL</div>
|
||||
<div className="font-bold text-th-fgd-1 text-xs">
|
||||
{usdFormatter.format(
|
||||
tooltipProps.payload[0].payload.cumulative_pnl
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</StyledTooltipWrapper>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col py-4`}>
|
||||
<div className={`-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8`}>
|
||||
<div className={`align-middle inline-block min-w-full sm:px-6 lg:px-8`}>
|
||||
{pnlLeaderboard.length > 0 ? (
|
||||
<div className={`shadow overflow-hidden border-b border-th-bkg-2`}>
|
||||
<Table className={`min-w-full divide-y divide-th-bkg-2`}>
|
||||
<Thead>
|
||||
<Tr className="text-th-fgd-3 text-xs">
|
||||
<Th
|
||||
scope="col"
|
||||
className={`px-6 py-3 text-left font-normal`}
|
||||
>
|
||||
Rank
|
||||
</Th>
|
||||
<Th
|
||||
scope="col"
|
||||
className={`px-6 py-3 text-left font-normal`}
|
||||
>
|
||||
Account
|
||||
</Th>
|
||||
<Th
|
||||
scope="col"
|
||||
className={`px-6 py-3 text-right font-normal`}
|
||||
>
|
||||
PNL
|
||||
</Th>
|
||||
<Th
|
||||
scope="col"
|
||||
className={`px-6 py-3 text-right font-normal`}
|
||||
>
|
||||
PNL / Time
|
||||
</Th>
|
||||
<Th
|
||||
scope="col"
|
||||
className={`px-6 py-3 text-right font-normal`}
|
||||
>
|
||||
<div className="flex items-center justify-start md:justify-end">
|
||||
<span>View on Step</span>
|
||||
<img
|
||||
alt=""
|
||||
width="20"
|
||||
height="20"
|
||||
src="/assets/icons/step.png"
|
||||
className={`ml-1`}
|
||||
/>
|
||||
</div>
|
||||
</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{pnlLeaderboard.map((acc, index) => (
|
||||
<Tr
|
||||
key={acc.margin_account}
|
||||
className={`border-b border-th-bkg-3
|
||||
${index % 2 === 0 ? `bg-th-bkg-3` : `bg-th-bkg-2`}
|
||||
`}
|
||||
>
|
||||
<Td
|
||||
className={`px-6 py-3 whitespace-nowrap text-sm text-th-fgd-1 w-8`}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{acc.rank}
|
||||
{acc.rank === 1 ? (
|
||||
<TrophyIcon className="h-5 w-5 ml-1.5 text-th-primary" />
|
||||
) : null}
|
||||
{acc.rank === 2 || acc.rank === 3 ? (
|
||||
<AwardIcon className="h-5 w-5 ml-1.5 text-th-primary-dark" />
|
||||
) : null}
|
||||
</div>
|
||||
</Td>
|
||||
<Td
|
||||
className={`px-6 py-3 whitespace-nowrap text-left text-sm text-th-fgd-1 md:w-1/3`}
|
||||
>
|
||||
{acc.name
|
||||
? acc.name
|
||||
: `${acc.margin_account.slice(
|
||||
0,
|
||||
5
|
||||
)}...${acc.margin_account.slice(-5)}`}
|
||||
</Td>
|
||||
<Td
|
||||
className={`px-6 py-3 whitespace-nowrap text-sm text-th-fgd-1`}
|
||||
>
|
||||
<div className="flex md:justify-end">
|
||||
{usdFormatter.format(acc.pnl)}
|
||||
</div>
|
||||
</Td>
|
||||
<Td
|
||||
className={`flex justify-end px-6 py-3 whitespace-nowrap`}
|
||||
>
|
||||
{loading && !pnlHistory[index] ? (
|
||||
<div className="animate-pulse bg-th-fgd-4 h-14 opacity-10 rounded-md w-44" />
|
||||
) : (
|
||||
<AreaChart
|
||||
width={176}
|
||||
height={56}
|
||||
data={
|
||||
pnlHistory[index]
|
||||
? formatPnlHistoryData(pnlHistory[index])
|
||||
: null
|
||||
}
|
||||
>
|
||||
<ReferenceLine
|
||||
y={0}
|
||||
stroke="#FF9C24"
|
||||
strokeDasharray="3 3"
|
||||
strokeOpacity={0.6}
|
||||
/>
|
||||
<Area
|
||||
isAnimationActive={false}
|
||||
type="monotone"
|
||||
dataKey="cumulative_pnl"
|
||||
stroke="#FF9C24"
|
||||
fill="#FF9C24"
|
||||
fillOpacity={0.1}
|
||||
/>
|
||||
<XAxis dataKey="date" hide />
|
||||
<YAxis dataKey="cumulative_pnl" hide />
|
||||
<Tooltip
|
||||
content={tooltipContent}
|
||||
position={{ x: 0, y: -50 }}
|
||||
/>
|
||||
</AreaChart>
|
||||
)}
|
||||
</Td>
|
||||
<Td
|
||||
className={`px-6 py-3 whitespace-nowrap text-sm text-th-fgd-1`}
|
||||
>
|
||||
<a
|
||||
className="default-transition flex items-center md:justify-end text-th-fgd-2"
|
||||
href={`https://app.step.finance/#/watch/${acc.owner}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span>View</span>
|
||||
<ExternalLinkIcon className={`h-4 w-4 ml-1.5`} />
|
||||
</a>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="pt-2">
|
||||
<div className="animate-pulse bg-th-bkg-3 h-10 mb-2 rounded-md w-full" />
|
||||
<div className="animate-pulse bg-th-bkg-3 h-10 mb-2 rounded-md w-full" />
|
||||
<div className="animate-pulse bg-th-bkg-3 h-10 mb-2 rounded-md w-full" />
|
||||
<div className="animate-pulse bg-th-bkg-3 h-10 mb-2 rounded-md w-full" />
|
||||
<div className="animate-pulse bg-th-bkg-3 h-10 mb-2 rounded-md w-full" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LeaderboardTable
|
|
@ -31,6 +31,7 @@ const TopBar = () => {
|
|||
<MenuItem href="/account">Account</MenuItem>
|
||||
<MenuItem href="/borrow">Borrow</MenuItem>
|
||||
<MenuItem href="/alerts">Alerts</MenuItem>
|
||||
<MenuItem href="/leaderboard">Leaderboard</MenuItem>
|
||||
<MenuItem href="/stats">Stats</MenuItem>
|
||||
<MenuItem href="https://docs.mango.markets/">Learn</MenuItem>
|
||||
</div>
|
||||
|
|
|
@ -147,7 +147,7 @@ export default function AccountAssets() {
|
|||
scope="col"
|
||||
className={`px-6 py-3 text-left font-normal`}
|
||||
>
|
||||
Available
|
||||
Deposits
|
||||
</Th>
|
||||
<Th
|
||||
scope="col"
|
||||
|
|
|
@ -141,3 +141,32 @@ export const ProfileIcon = ({ className }) => {
|
|||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export const AwardIcon = ({ className }) => {
|
||||
return (
|
||||
<svg
|
||||
className={`${className}`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<circle cx="12" cy="8" r="7"></circle>
|
||||
<polyline points="8.21 13.89 7 23 12 20 17 23 15.79 13.88"></polyline>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export const TrophyIcon = ({ className }) => {
|
||||
return (
|
||||
<svg
|
||||
className={`${className}`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 38 34"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M36.0082 3.8971H30.7152V1.9751C30.7152 1.1461 30.0432 0.475098 29.2152 0.475098H8.78519C7.95619 0.475098 7.28519 1.1461 7.28519 1.9751V3.8971L1.99219 3.8981C1.16319 3.8981 0.492188 4.5701 0.492188 5.3981C0.492188 9.4881 3.47119 12.8871 7.37219 13.5611C7.87919 17.8821 10.7462 21.4901 14.6442 23.0591V25.6651H14.1752C13.7802 25.6651 13.4012 25.8211 13.1202 26.1001C11.5172 27.6871 10.6362 29.7911 10.6362 32.0241C10.6362 32.8521 11.3082 33.5241 12.1362 33.5241H25.8622C26.6902 33.5241 27.3622 32.8521 27.3622 32.0241C27.3622 29.7921 26.4802 27.6881 24.8792 26.1001C24.5992 25.8221 24.2192 25.6651 23.8232 25.6651H23.3542V23.0601C27.2532 21.4901 30.1212 17.8831 30.6272 13.5621C34.5272 12.8881 37.5072 9.4891 37.5072 5.3981C37.5082 4.5681 36.8362 3.8971 36.0082 3.8971ZM7.28519 10.4751C5.57019 9.9671 4.21519 8.6131 3.70819 6.8981H7.28519V10.4751ZM24.1462 30.5241H13.8532C14.0512 29.8511 14.3852 29.2211 14.8382 28.6661H23.1622C23.6152 29.2221 23.9472 29.8521 24.1462 30.5241ZM17.6452 25.6661V23.8171C18.0902 23.8691 18.5412 23.9041 18.9992 23.9041C19.4592 23.9041 19.9092 23.8691 20.3542 23.8171V25.6661H17.6452ZM27.7152 12.1911C27.7152 16.9961 23.8052 20.9041 18.9992 20.9041C14.1932 20.9041 10.2852 16.9961 10.2852 12.1911V3.4751H27.7152V12.1911ZM30.7152 10.4751V6.8981L34.2912 6.8991C33.7842 8.6131 32.4302 9.9671 30.7152 10.4751Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,47 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import useMangoStore from '../stores/useMangoStore'
|
||||
import PageBodyContainer from '../components/PageBodyContainer'
|
||||
import TopBar from '../components/TopBar'
|
||||
import LeaderboardTable from '../components/LeaderboardTable'
|
||||
import { LinkButton } from '../components/Button'
|
||||
|
||||
export default function Leaderboard() {
|
||||
const [offsetResults, setOffsetResults] = useState(0)
|
||||
const actions = useMangoStore((s) => s.actions)
|
||||
const pnlLeaderboard = useMangoStore((s) => s.pnlLeaderboard)
|
||||
|
||||
useEffect(() => {
|
||||
actions.fetchPnlLeaderboard(offsetResults, 29)
|
||||
}, [])
|
||||
|
||||
const handleShowMore = async () => {
|
||||
const offset = offsetResults + 25
|
||||
await actions.fetchPnlLeaderboard(offset, 29)
|
||||
setOffsetResults(offset)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-th-bkg-1 text-th-fgd-1 transition-all`}>
|
||||
<TopBar />
|
||||
<PageBodyContainer>
|
||||
<div className="pt-8 pb-3 sm:pb-6 md:pt-10">
|
||||
<h1 className={`font-semibold text-th-fgd-1 text-2xl`}>
|
||||
Leaderboard
|
||||
</h1>
|
||||
</div>
|
||||
<div className="p-6 rounded-lg bg-th-bkg-2">
|
||||
<p className="mb-0">Top 100 accounts by PNL over the last 30 days</p>
|
||||
<LeaderboardTable />
|
||||
{pnlLeaderboard.length < 100 ? (
|
||||
<LinkButton
|
||||
className="flex h-10 items-center justify-center mt-1 text-th-primary w-full"
|
||||
onClick={() => handleShowMore()}
|
||||
>
|
||||
Show More
|
||||
</LinkButton>
|
||||
) : null}
|
||||
</div>
|
||||
</PageBodyContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
Binary file not shown.
After Width: | Height: | Size: 9.3 KiB |
|
@ -10,11 +10,15 @@ import {
|
|||
} from '@blockworks-foundation/mango-client'
|
||||
import { SRM_DECIMALS } from '@project-serum/serum/lib/token-instructions'
|
||||
import { AccountInfo, Connection, PublicKey } from '@solana/web3.js'
|
||||
import dayjs from 'dayjs'
|
||||
import { EndpointInfo, WalletAdapter } from '../@types/types'
|
||||
import { getWalletTokenInfo } from '../utils/tokens'
|
||||
import { isDefined } from '../utils/index'
|
||||
import { notify } from '../utils/notifications'
|
||||
|
||||
const utc = require('dayjs/plugin/utc')
|
||||
dayjs.extend(utc)
|
||||
|
||||
export const ENDPOINTS: EndpointInfo[] = [
|
||||
{
|
||||
name: 'mainnet-beta',
|
||||
|
@ -57,6 +61,14 @@ interface AccountInfoList {
|
|||
[key: string]: AccountInfo<Buffer>
|
||||
}
|
||||
|
||||
interface AccountPnl {
|
||||
margin_account: string
|
||||
name: string
|
||||
owner: string
|
||||
pnl: number
|
||||
rank: number
|
||||
}
|
||||
|
||||
interface MangoStore extends State {
|
||||
notifications: Array<{
|
||||
type: string
|
||||
|
@ -119,9 +131,12 @@ interface MangoStore extends State {
|
|||
liquidationHistory: any[]
|
||||
withdrawalHistory: any[]
|
||||
tradeHistory: any[]
|
||||
pnlHistory: any[]
|
||||
pnlLeaderboard: any[]
|
||||
accountPnl: AccountPnl
|
||||
set: (x: any) => void
|
||||
actions: {
|
||||
[key: string]: () => void
|
||||
[key: string]: (...args: any[]) => void
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -177,6 +192,9 @@ const useMangoStore = create<MangoStore>((set, get) => ({
|
|||
liquidationHistory: [],
|
||||
withdrawalHistory: [],
|
||||
tradeHistory: [],
|
||||
pnlHistory: [],
|
||||
pnlLeaderboard: [],
|
||||
accountPnl: null,
|
||||
set: (fn) => set(produce(fn)),
|
||||
actions: {
|
||||
async fetchWalletBalances() {
|
||||
|
@ -428,6 +446,82 @@ const useMangoStore = create<MangoStore>((set, get) => ({
|
|||
state.withdrawalHistory = results
|
||||
})
|
||||
},
|
||||
async fetchPnlHistory(marginAccount = null) {
|
||||
const selectedMarginAccount =
|
||||
marginAccount ||
|
||||
get().selectedMarginAccount.current.publicKey.toString()
|
||||
const set = get().set
|
||||
|
||||
if (!selectedMarginAccount) return
|
||||
|
||||
const response = await fetch(
|
||||
`https://mango-transaction-log.herokuapp.com/stats/pnl_history/${selectedMarginAccount}`
|
||||
)
|
||||
const parsedResponse = await response.json()
|
||||
const results = parsedResponse.length ? parsedResponse.reverse() : []
|
||||
|
||||
set((state) => {
|
||||
state.pnlHistory = results
|
||||
})
|
||||
},
|
||||
async fetchPnlLeaderboard(offset = 0, start?: number) {
|
||||
const baseUrl =
|
||||
'https://mango-transaction-log.herokuapp.com/stats/pnl_leaderboard'
|
||||
|
||||
const startAt = start
|
||||
? dayjs().utc().subtract(start, 'day').format('YYYY-MM-DD')
|
||||
: null
|
||||
|
||||
const url = startAt
|
||||
? `${baseUrl}?start_date=${startAt}&limit=25&offset=${offset}`
|
||||
: `${baseUrl}?limit=25&offset=${offset}`
|
||||
|
||||
const response = await fetch(url)
|
||||
const parsedResponse = await response.json()
|
||||
const results = parsedResponse ? parsedResponse : []
|
||||
|
||||
const currentLeaderboard = get().pnlLeaderboard
|
||||
|
||||
if (currentLeaderboard.length > 0 && offset > 0) {
|
||||
const updatedLeaderboard = currentLeaderboard.concat(results)
|
||||
set((state) => {
|
||||
state.pnlLeaderboard = updatedLeaderboard
|
||||
})
|
||||
} else {
|
||||
set((state) => {
|
||||
state.pnlLeaderboard = results
|
||||
})
|
||||
}
|
||||
},
|
||||
async fetchPnlByAccount(marginAccount = null, start?: number) {
|
||||
const selectedMarginAccount =
|
||||
marginAccount || get().selectedMarginAccount.current
|
||||
const set = get().set
|
||||
|
||||
if (!selectedMarginAccount) return
|
||||
|
||||
const startAt = start
|
||||
? new Date(Date.now() - start * 24 * 60 * 60 * 1000).toLocaleDateString(
|
||||
'en-ZA'
|
||||
)
|
||||
: null
|
||||
|
||||
const baseUrl =
|
||||
'https://mango-transaction-log.herokuapp.com/stats/pnl_leaderboard_rank'
|
||||
|
||||
const url = startAt
|
||||
? `${baseUrl}/${selectedMarginAccount.publicKey.toString()}?start_date=${startAt}`
|
||||
: `${baseUrl}/${selectedMarginAccount.publicKey.toString()}`
|
||||
|
||||
const response = await fetch(url)
|
||||
const parsedResponse =
|
||||
response.status === 200 ? await response.json() : null
|
||||
const results = parsedResponse ? parsedResponse : null
|
||||
|
||||
set((state) => {
|
||||
state.accountPnl = results
|
||||
})
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
|
|
|
@ -2515,9 +2515,9 @@ boolbase@^1.0.0, boolbase@~1.0.0:
|
|||
resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e"
|
||||
integrity sha1-aN/1++YMUes3cl6p4+0xDcwed24=
|
||||
|
||||
"borsh@git+https://github.com/defactojob/borsh-js.git#field-mapper":
|
||||
"borsh@https://github.com/defactojob/borsh-js#field-mapper":
|
||||
version "0.3.1"
|
||||
resolved "git+https://github.com/defactojob/borsh-js.git#33a0d24af281112c0a48efb3fa503f3212443de9"
|
||||
resolved "https://github.com/defactojob/borsh-js#33a0d24af281112c0a48efb3fa503f3212443de9"
|
||||
dependencies:
|
||||
"@types/bn.js" "^4.11.5"
|
||||
bn.js "^5.0.0"
|
||||
|
|
Loading…
Reference in New Issue