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:
James Grugett 2022-06-22 15:29:40 -05:00 committed by GitHub
parent c58e75f49a
commit 8b1d132e17
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 329 additions and 85 deletions

View File

@ -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
}

View File

@ -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;

View File

@ -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,
}

View File

@ -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.`)
})
}

View File

@ -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 {
fieldUpdates: {
doc: firestore.collection('users').doc(user.id),
fields: {
totalPnLCached: totalPnL,
creatorVolumeCached: creatorVolume,
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)

View File

@ -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]) {
if (operationType === 'update') {
batch.update(doc, fields)
} else {
batch.set(doc, fields)
}
}
await batch.commit()
}

View File

@ -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(
export function getTopTraders(period: LeaderboardPeriod) {
const topTraders = query(
collection(db, 'users'),
orderBy('totalPnLCached', 'desc'),
limit(21)
)
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(
export function getTopCreators(period: LeaderboardPeriod) {
const topCreators = query(
collection(db, 'users'),
orderBy('creatorVolumeCached', 'desc'),
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'))
}

View File

@ -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,34 +46,60 @@ 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')
useTracking('view leaderboards')
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 (
<Page>
<Col className="mx-4 items-center gap-10 lg:mx-0 lg:flex-row">
<>
<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={topTraders}
users={topTradersState}
columns={[
{
header: 'Total profit',
renderCell: (user) => formatMoney(user.totalPnLCached),
renderCell: (user) =>
formatMoney(user.profitCached[period]),
},
]}
/>
) : (
<></>
)}
<Leaderboard
title="🏅 Top creators"
users={topCreators}
users={topCreatorsState}
columns={[
{
header: 'Total bet',
renderCell: (user) => formatMoney(user.creatorVolumeCached),
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"
@ -80,6 +112,42 @@ export default function Leaderboards(props: {
]}
/>
</Col>
) : (
<></>
)}
</>
)
}
useTracking('view leaderboards')
return (
<Page>
<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>
)
}