Daily/Weekly/Monthly Leaderboards by Fede (#557)
* [Leaderboards] Added period toggle for leaderboards * [Leaderboards] TopBettors now calculates by period correctly * [Leaderboard] Use a subcollection for the portfolio caching * [Leaderboard] Switches to a tab view, temporarily hides the missing topBettors periods * [Leaderboard] Reverts random yarn.lock changes * Fix type error from merge * Increase timeout on update metrics * Update firebase rules to allow reading user portfolioHistory Co-authored-by: Pico2x <pico2x@gmail.com>
This commit is contained in:
parent
c58e75f49a
commit
8b1d132e17
|
@ -15,8 +15,20 @@ export type User = {
|
|||
|
||||
balance: number
|
||||
totalDeposits: number
|
||||
totalPnLCached: number
|
||||
creatorVolumeCached: number
|
||||
|
||||
profitCached: {
|
||||
daily: number
|
||||
weekly: number
|
||||
monthly: number
|
||||
allTime: number
|
||||
}
|
||||
|
||||
creatorVolumeCached: {
|
||||
daily: number
|
||||
weekly: number
|
||||
monthly: number
|
||||
allTime: number
|
||||
}
|
||||
|
||||
followerCountCached: number
|
||||
|
||||
|
@ -42,3 +54,11 @@ export type PrivateUser = {
|
|||
}
|
||||
|
||||
export type notification_subscribe_types = 'all' | 'less' | 'none'
|
||||
|
||||
export type PortfolioMetrics = {
|
||||
investmentValue: number
|
||||
balance: number
|
||||
totalDeposits: number
|
||||
timestamp: number
|
||||
userId: string
|
||||
}
|
||||
|
|
|
@ -19,6 +19,10 @@ service cloud.firestore {
|
|||
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories']);
|
||||
}
|
||||
|
||||
match /{somePath=**}/portfolioHistory/{portfolioHistoryId} {
|
||||
allow read;
|
||||
}
|
||||
|
||||
match /users/{userId}/follows/{followUserId} {
|
||||
allow read;
|
||||
allow write: if request.auth.uid == userId;
|
||||
|
|
|
@ -70,8 +70,8 @@ export const createUser = functions
|
|||
balance,
|
||||
totalDeposits: balance,
|
||||
createdTime: Date.now(),
|
||||
totalPnLCached: 0,
|
||||
creatorVolumeCached: 0,
|
||||
profitCached: { daily: 0, weekly: 0, monthly: 0, allTime: 0 },
|
||||
creatorVolumeCached: { daily: 0, weekly: 0, monthly: 0, allTime: 0 },
|
||||
followerCountCached: 0,
|
||||
followedCategories: DEFAULT_CATEGORIES,
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
import { initAdmin } from './script-init'
|
||||
import { cleanDisplayName } from '../../../common/util/clean-username'
|
||||
import { log, writeUpdatesAsync, UpdateSpec } from '../utils'
|
||||
import { log, writeAsync, UpdateSpec } from '../utils'
|
||||
initAdmin()
|
||||
const firestore = admin.firestore()
|
||||
|
||||
|
@ -20,7 +20,7 @@ if (require.main === module) {
|
|||
return acc
|
||||
}, [] as UpdateSpec[])
|
||||
log(`Found ${updates.length} users to update:`, updates)
|
||||
await writeUpdatesAsync(firestore, updates)
|
||||
await writeAsync(firestore, updates)
|
||||
log(`Updated all users.`)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { groupBy, sum, sumBy } from 'lodash'
|
||||
|
||||
import { getValues, log, logMemory, writeUpdatesAsync } from './utils'
|
||||
import { groupBy, isEmpty, sum, sumBy } from 'lodash'
|
||||
import { getValues, log, logMemory, writeAsync } from './utils'
|
||||
import { Bet } from '../../common/bet'
|
||||
import { Contract } from '../../common/contract'
|
||||
import { User } from '../../common/user'
|
||||
import { PortfolioMetrics, User } from '../../common/user'
|
||||
import { calculatePayout } from '../../common/calculate'
|
||||
import { DAY_MS } from '../../common/util/time'
|
||||
import { last } from 'lodash'
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
|
@ -26,15 +27,25 @@ const computeInvestmentValue = (
|
|||
})
|
||||
}
|
||||
|
||||
const computeTotalPool = (contracts: Contract[]) => {
|
||||
return sum(contracts.map((contract) => sum(Object.values(contract.pool))))
|
||||
const computeTotalPool = (userContracts: Contract[], startTime = 0) => {
|
||||
const periodFilteredContracts = userContracts.filter(
|
||||
(contract) => contract.createdTime >= startTime
|
||||
)
|
||||
return sum(
|
||||
periodFilteredContracts.map((contract) => sum(Object.values(contract.pool)))
|
||||
)
|
||||
}
|
||||
|
||||
export const updateMetricsCore = async () => {
|
||||
const [users, contracts, bets] = await Promise.all([
|
||||
const [users, contracts, bets, allPortfolioHistories] = await Promise.all([
|
||||
getValues<User>(firestore.collection('users')),
|
||||
getValues<Contract>(firestore.collection('contracts')),
|
||||
getValues<Bet>(firestore.collectionGroup('bets')),
|
||||
getValues<PortfolioMetrics>(
|
||||
firestore
|
||||
.collectionGroup('portfolioHistory')
|
||||
.where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago
|
||||
),
|
||||
])
|
||||
log(
|
||||
`Loaded ${users.length} users, ${contracts.length} contracts, and ${bets.length} bets.`
|
||||
|
@ -53,7 +64,7 @@ export const updateMetricsCore = async () => {
|
|||
},
|
||||
}
|
||||
})
|
||||
await writeUpdatesAsync(firestore, contractUpdates)
|
||||
await writeAsync(firestore, contractUpdates)
|
||||
log(`Updated metrics for ${contracts.length} contracts.`)
|
||||
|
||||
const contractsById = Object.fromEntries(
|
||||
|
@ -61,24 +72,66 @@ export const updateMetricsCore = async () => {
|
|||
)
|
||||
const contractsByUser = groupBy(contracts, (contract) => contract.creatorId)
|
||||
const betsByUser = groupBy(bets, (bet) => bet.userId)
|
||||
const portfolioHistoryByUser = groupBy(allPortfolioHistories, (p) => p.userId)
|
||||
const userUpdates = users.map((user) => {
|
||||
const investmentValue = computeInvestmentValue(
|
||||
betsByUser[user.id] ?? [],
|
||||
contractsById
|
||||
const currentBets = betsByUser[user.id] ?? []
|
||||
const portfolioHistory = portfolioHistoryByUser[user.id] ?? []
|
||||
const userContracts = contractsByUser[user.id] ?? []
|
||||
const newCreatorVolume = calculateCreatorVolume(userContracts)
|
||||
const newPortfolio = calculateNewPortfolioMetrics(
|
||||
user,
|
||||
contractsById,
|
||||
currentBets
|
||||
)
|
||||
const creatorContracts = contractsByUser[user.id] ?? []
|
||||
const creatorVolume = computeTotalPool(creatorContracts)
|
||||
const totalValue = user.balance + investmentValue
|
||||
const totalPnL = totalValue - user.totalDeposits
|
||||
const lastPortfolio = last(portfolioHistory)
|
||||
const didProfitChange =
|
||||
lastPortfolio === undefined ||
|
||||
lastPortfolio.balance !== newPortfolio.balance ||
|
||||
lastPortfolio.totalDeposits !== newPortfolio.totalDeposits ||
|
||||
lastPortfolio.investmentValue !== newPortfolio.investmentValue
|
||||
|
||||
const newProfit = calculateNewProfit(
|
||||
portfolioHistory,
|
||||
newPortfolio,
|
||||
didProfitChange
|
||||
)
|
||||
|
||||
return {
|
||||
doc: firestore.collection('users').doc(user.id),
|
||||
fields: {
|
||||
totalPnLCached: totalPnL,
|
||||
creatorVolumeCached: creatorVolume,
|
||||
fieldUpdates: {
|
||||
doc: firestore.collection('users').doc(user.id),
|
||||
fields: {
|
||||
creatorVolumeCached: newCreatorVolume,
|
||||
...(didProfitChange && {
|
||||
profitCached: newProfit,
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
||||
subcollectionUpdates: {
|
||||
doc: firestore
|
||||
.collection('users')
|
||||
.doc(user.id)
|
||||
.collection('portfolioHistory')
|
||||
.doc(),
|
||||
fields: {
|
||||
...(didProfitChange && {
|
||||
...newPortfolio,
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
await writeUpdatesAsync(firestore, userUpdates)
|
||||
await writeAsync(
|
||||
firestore,
|
||||
userUpdates.map((u) => u.fieldUpdates)
|
||||
)
|
||||
await writeAsync(
|
||||
firestore,
|
||||
userUpdates
|
||||
.filter((u) => !isEmpty(u.subcollectionUpdates.fields))
|
||||
.map((u) => u.subcollectionUpdates),
|
||||
'set'
|
||||
)
|
||||
log(`Updated metrics for ${users.length} users.`)
|
||||
}
|
||||
|
||||
|
@ -88,7 +141,101 @@ const computeVolume = (contractBets: Bet[], since: number) => {
|
|||
)
|
||||
}
|
||||
|
||||
const calculateProfitForPeriod = (
|
||||
startTime: number,
|
||||
portfolioHistory: PortfolioMetrics[],
|
||||
currentProfit: number
|
||||
) => {
|
||||
const startingPortfolio = [...portfolioHistory]
|
||||
.reverse() // so we search in descending order (most recent first), for efficiency
|
||||
.find((p) => p.timestamp < startTime)
|
||||
|
||||
if (startingPortfolio === undefined) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const startingProfit = calculateTotalProfit(startingPortfolio)
|
||||
|
||||
return currentProfit - startingProfit
|
||||
}
|
||||
|
||||
const calculateTotalProfit = (portfolio: PortfolioMetrics) => {
|
||||
return portfolio.investmentValue + portfolio.balance - portfolio.totalDeposits
|
||||
}
|
||||
|
||||
const calculateCreatorVolume = (userContracts: Contract[]) => {
|
||||
const allTimeCreatorVolume = computeTotalPool(userContracts, 0)
|
||||
const monthlyCreatorVolume = computeTotalPool(
|
||||
userContracts,
|
||||
Date.now() - 30 * DAY_MS
|
||||
)
|
||||
const weeklyCreatorVolume = computeTotalPool(
|
||||
userContracts,
|
||||
Date.now() - 7 * DAY_MS
|
||||
)
|
||||
|
||||
const dailyCreatorVolume = computeTotalPool(
|
||||
userContracts,
|
||||
Date.now() - 1 * DAY_MS
|
||||
)
|
||||
|
||||
return {
|
||||
daily: dailyCreatorVolume,
|
||||
weekly: weeklyCreatorVolume,
|
||||
monthly: monthlyCreatorVolume,
|
||||
allTime: allTimeCreatorVolume,
|
||||
}
|
||||
}
|
||||
|
||||
const calculateNewPortfolioMetrics = (
|
||||
user: User,
|
||||
contractsById: { [k: string]: Contract },
|
||||
currentBets: Bet[]
|
||||
) => {
|
||||
const investmentValue = computeInvestmentValue(currentBets, contractsById)
|
||||
const newPortfolio = {
|
||||
investmentValue: investmentValue,
|
||||
balance: user.balance,
|
||||
totalDeposits: user.totalDeposits,
|
||||
timestamp: Date.now(),
|
||||
userId: user.id,
|
||||
}
|
||||
return newPortfolio
|
||||
}
|
||||
|
||||
const calculateNewProfit = (
|
||||
portfolioHistory: PortfolioMetrics[],
|
||||
newPortfolio: PortfolioMetrics,
|
||||
didProfitChange: boolean
|
||||
) => {
|
||||
if (!didProfitChange) {
|
||||
return {} // early return for performance
|
||||
}
|
||||
|
||||
const allTimeProfit = calculateTotalProfit(newPortfolio)
|
||||
const newProfit = {
|
||||
daily: calculateProfitForPeriod(
|
||||
Date.now() - 1 * DAY_MS,
|
||||
portfolioHistory,
|
||||
allTimeProfit
|
||||
),
|
||||
weekly: calculateProfitForPeriod(
|
||||
Date.now() - 7 * DAY_MS,
|
||||
portfolioHistory,
|
||||
allTimeProfit
|
||||
),
|
||||
monthly: calculateProfitForPeriod(
|
||||
Date.now() - 30 * DAY_MS,
|
||||
portfolioHistory,
|
||||
allTimeProfit
|
||||
),
|
||||
allTime: allTimeProfit,
|
||||
}
|
||||
|
||||
return newProfit
|
||||
}
|
||||
|
||||
export const updateMetrics = functions
|
||||
.runWith({ memory: '1GB' })
|
||||
.runWith({ memory: '1GB', timeoutSeconds: 540 })
|
||||
.pubsub.schedule('every 15 minutes')
|
||||
.onRun(updateMetricsCore)
|
||||
|
|
|
@ -20,9 +20,10 @@ export type UpdateSpec = {
|
|||
fields: { [k: string]: unknown }
|
||||
}
|
||||
|
||||
export const writeUpdatesAsync = async (
|
||||
export const writeAsync = async (
|
||||
db: admin.firestore.Firestore,
|
||||
updates: UpdateSpec[],
|
||||
operationType: 'update' | 'set' = 'update',
|
||||
batchSize = 500 // 500 = Firestore batch limit
|
||||
) => {
|
||||
const chunks = chunk(updates, batchSize)
|
||||
|
@ -30,7 +31,11 @@ export const writeUpdatesAsync = async (
|
|||
log(`${i * batchSize}/${updates.length} updates written...`)
|
||||
const batch = db.batch()
|
||||
for (const { doc, fields } of chunks[i]) {
|
||||
batch.update(doc, fields)
|
||||
if (operationType === 'update') {
|
||||
batch.update(doc, fields)
|
||||
} else {
|
||||
batch.set(doc, fields)
|
||||
}
|
||||
}
|
||||
await batch.commit()
|
||||
}
|
||||
|
|
|
@ -35,6 +35,8 @@ import { filterDefined } from 'common/util/array'
|
|||
|
||||
export type { User }
|
||||
|
||||
export type LeaderboardPeriod = 'daily' | 'weekly' | 'monthly' | 'allTime'
|
||||
|
||||
const db = getFirestore(app)
|
||||
export const auth = getAuth(app)
|
||||
|
||||
|
@ -178,22 +180,24 @@ export function listenForPrivateUsers(
|
|||
listenForValues(q, setUsers)
|
||||
}
|
||||
|
||||
const topTradersQuery = query(
|
||||
collection(db, 'users'),
|
||||
orderBy('totalPnLCached', 'desc'),
|
||||
limit(21)
|
||||
)
|
||||
export function getTopTraders(period: LeaderboardPeriod) {
|
||||
const topTraders = query(
|
||||
collection(db, 'users'),
|
||||
orderBy('profitCached.' + period, 'desc'),
|
||||
limit(20)
|
||||
)
|
||||
|
||||
export async function getTopTraders() {
|
||||
const users = await getValues<User>(topTradersQuery)
|
||||
return users.slice(0, 20)
|
||||
return getValues(topTraders)
|
||||
}
|
||||
|
||||
const topCreatorsQuery = query(
|
||||
collection(db, 'users'),
|
||||
orderBy('creatorVolumeCached', 'desc'),
|
||||
limit(20)
|
||||
)
|
||||
export function getTopCreators(period: LeaderboardPeriod) {
|
||||
const topCreators = query(
|
||||
collection(db, 'users'),
|
||||
orderBy('creatorVolumeCached.' + period, 'desc'),
|
||||
limit(20)
|
||||
)
|
||||
return getValues(topCreators)
|
||||
}
|
||||
|
||||
export async function getTopFollowed() {
|
||||
const users = await getValues<User>(topFollowedQuery)
|
||||
|
@ -206,10 +210,6 @@ const topFollowedQuery = query(
|
|||
limit(20)
|
||||
)
|
||||
|
||||
export function getTopCreators() {
|
||||
return getValues<User>(topCreatorsQuery)
|
||||
}
|
||||
|
||||
export function getUsers() {
|
||||
return getValues<User>(collection(db, 'users'))
|
||||
}
|
||||
|
|
|
@ -4,28 +4,34 @@ import { Page } from 'web/components/page'
|
|||
import {
|
||||
getTopCreators,
|
||||
getTopTraders,
|
||||
LeaderboardPeriod,
|
||||
getTopFollowed,
|
||||
User,
|
||||
} from 'web/lib/firebase/users'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { fromPropz, usePropz } from 'web/hooks/use-propz'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { LoadingIndicator } from 'web/components/loading-indicator'
|
||||
import { Title } from 'web/components/title'
|
||||
import { Tabs } from 'web/components/layout/tabs'
|
||||
import { useTracking } from 'web/hooks/use-tracking'
|
||||
|
||||
export const getStaticProps = fromPropz(getStaticPropz)
|
||||
export async function getStaticPropz() {
|
||||
return queryLeaderboardUsers('allTime')
|
||||
}
|
||||
const queryLeaderboardUsers = async (period: LeaderboardPeriod) => {
|
||||
const [topTraders, topCreators, topFollowed] = await Promise.all([
|
||||
getTopTraders().catch(() => {}),
|
||||
getTopCreators().catch(() => {}),
|
||||
getTopTraders(period).catch(() => {}),
|
||||
getTopCreators(period).catch(() => {}),
|
||||
getTopFollowed().catch(() => {}),
|
||||
])
|
||||
|
||||
return {
|
||||
props: {
|
||||
topTraders,
|
||||
topCreators,
|
||||
topFollowed,
|
||||
},
|
||||
|
||||
revalidate: 60, // regenerate after a minute
|
||||
}
|
||||
}
|
||||
|
@ -40,46 +46,108 @@ export default function Leaderboards(props: {
|
|||
topCreators: [],
|
||||
topFollowed: [],
|
||||
}
|
||||
const { topTraders, topCreators, topFollowed } = props
|
||||
const { topFollowed } = props
|
||||
const [topTradersState, setTopTraders] = useState(props.topTraders)
|
||||
const [topCreatorsState, setTopCreators] = useState(props.topCreators)
|
||||
const [isLoading, setLoading] = useState(false)
|
||||
const [period, setPeriod] = useState<LeaderboardPeriod>('allTime')
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
queryLeaderboardUsers(period).then((res) => {
|
||||
setTopTraders(res.props.topTraders as User[])
|
||||
setTopCreators(res.props.topCreators as User[])
|
||||
setLoading(false)
|
||||
})
|
||||
}, [period])
|
||||
|
||||
const LeaderboardWithPeriod = (period: LeaderboardPeriod) => {
|
||||
return (
|
||||
<>
|
||||
<Col className="mx-4 items-center gap-10 lg:flex-row">
|
||||
{!isLoading ? (
|
||||
<>
|
||||
{period === 'allTime' ? ( //TODO: show other periods once they're available
|
||||
<Leaderboard
|
||||
title="🏅 Top bettors"
|
||||
users={topTradersState}
|
||||
columns={[
|
||||
{
|
||||
header: 'Total profit',
|
||||
renderCell: (user) =>
|
||||
formatMoney(user.profitCached[period]),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
<Leaderboard
|
||||
title="🏅 Top creators"
|
||||
users={topCreatorsState}
|
||||
columns={[
|
||||
{
|
||||
header: 'Total bet',
|
||||
renderCell: (user) =>
|
||||
formatMoney(user.creatorVolumeCached[period]),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<LoadingIndicator spinnerClassName={'border-gray-500'} />
|
||||
)}
|
||||
</Col>
|
||||
{period === 'allTime' ? (
|
||||
<Col className="mx-4 my-10 w-1/2 items-center gap-10 lg:mx-0 lg:flex-row">
|
||||
<Leaderboard
|
||||
title="👀 Most followed"
|
||||
users={topFollowed}
|
||||
columns={[
|
||||
{
|
||||
header: 'Number of followers',
|
||||
renderCell: (user) => user.followerCountCached,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
useTracking('view leaderboards')
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<Col className="mx-4 items-center gap-10 lg:mx-0 lg:flex-row">
|
||||
<Leaderboard
|
||||
title="🏅 Top bettors"
|
||||
users={topTraders}
|
||||
columns={[
|
||||
{
|
||||
header: 'Total profit',
|
||||
renderCell: (user) => formatMoney(user.totalPnLCached),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Leaderboard
|
||||
title="🏅 Top creators"
|
||||
users={topCreators}
|
||||
columns={[
|
||||
{
|
||||
header: 'Total bet',
|
||||
renderCell: (user) => formatMoney(user.creatorVolumeCached),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
<Col className="mx-4 my-10 w-1/2 items-center gap-10 lg:mx-0 lg:flex-row">
|
||||
<Leaderboard
|
||||
title="👀 Most followed"
|
||||
users={topFollowed}
|
||||
columns={[
|
||||
{
|
||||
header: 'Number of followers',
|
||||
renderCell: (user) => user.followerCountCached,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
<Title text={'Leaderboards'} className={'hidden md:block'} />
|
||||
<Tabs
|
||||
defaultIndex={0}
|
||||
onClick={(title, index) => {
|
||||
const period = ['allTime', 'monthly', 'weekly', 'daily'][index]
|
||||
setPeriod(period as LeaderboardPeriod)
|
||||
}}
|
||||
tabs={[
|
||||
{
|
||||
title: 'All Time',
|
||||
content: LeaderboardWithPeriod('allTime'),
|
||||
},
|
||||
{
|
||||
title: 'Monthly',
|
||||
content: LeaderboardWithPeriod('monthly'),
|
||||
},
|
||||
{
|
||||
title: 'Weekly',
|
||||
content: LeaderboardWithPeriod('weekly'),
|
||||
},
|
||||
{
|
||||
title: 'Daily',
|
||||
content: LeaderboardWithPeriod('daily'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue
Block a user