diff --git a/common/user.ts b/common/user.ts index 0553b95c..298fee56 100644 --- a/common/user.ts +++ b/common/user.ts @@ -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 +} diff --git a/firestore.rules b/firestore.rules index e5d930bd..2be7f21a 100644 --- a/firestore.rules +++ b/firestore.rules @@ -18,6 +18,10 @@ service cloud.firestore { && request.resource.data.diff(resource.data).affectedKeys() .hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories']); } + + match /{somePath=**}/portfolioHistory/{portfolioHistoryId} { + allow read; + } match /users/{userId}/follows/{followUserId} { allow read; diff --git a/functions/src/create-user.ts b/functions/src/create-user.ts index 2471b92f..189976ed 100644 --- a/functions/src/create-user.ts +++ b/functions/src/create-user.ts @@ -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, } diff --git a/functions/src/scripts/clean-display-names.ts b/functions/src/scripts/clean-display-names.ts index 8dd2a3d3..e07d823d 100644 --- a/functions/src/scripts/clean-display-names.ts +++ b/functions/src/scripts/clean-display-names.ts @@ -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.`) }) } diff --git a/functions/src/update-metrics.ts b/functions/src/update-metrics.ts index 0a51e13c..76570f54 100644 --- a/functions/src/update-metrics.ts +++ b/functions/src/update-metrics.ts @@ -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(firestore.collection('users')), getValues(firestore.collection('contracts')), getValues(firestore.collectionGroup('bets')), + getValues( + 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) diff --git a/functions/src/utils.ts b/functions/src/utils.ts index a95ac858..29f0db00 100644 --- a/functions/src/utils.ts +++ b/functions/src/utils.ts @@ -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() } diff --git a/web/lib/firebase/users.ts b/web/lib/firebase/users.ts index 860e1a40..bc743cf2 100644 --- a/web/lib/firebase/users.ts +++ b/web/lib/firebase/users.ts @@ -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(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(topFollowedQuery) @@ -206,10 +210,6 @@ const topFollowedQuery = query( limit(20) ) -export function getTopCreators() { - return getValues(topCreatorsQuery) -} - export function getUsers() { return getValues(collection(db, 'users')) } diff --git a/web/pages/leaderboards.tsx b/web/pages/leaderboards.tsx index 762640de..dcf9b892 100644 --- a/web/pages/leaderboards.tsx +++ b/web/pages/leaderboards.tsx @@ -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('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 ( + <> + + {!isLoading ? ( + <> + {period === 'allTime' ? ( //TODO: show other periods once they're available + + formatMoney(user.profitCached[period]), + }, + ]} + /> + ) : ( + <> + )} + + + formatMoney(user.creatorVolumeCached[period]), + }, + ]} + /> + + ) : ( + + )} + + {period === 'allTime' ? ( + + user.followerCountCached, + }, + ]} + /> + + ) : ( + <> + )} + + ) + } useTracking('view leaderboards') return ( - - formatMoney(user.totalPnLCached), - }, - ]} - /> - formatMoney(user.creatorVolumeCached), - }, - ]} - /> - - - user.followerCountCached, - }, - ]} - /> - + + <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> ) }