From aa1022546ddf71264525fc752c10f78c18ed9d8a Mon Sep 17 00:00:00 2001 From: jahooma Date: Sat, 22 Jan 2022 17:59:50 -0600 Subject: [PATCH] Implement leaderboards for folds! --- common/contract.ts | 5 +- common/payouts.ts | 52 ++++++++++++------- functions/src/resolve-market.ts | 15 +----- web/components/leaderboard.tsx | 3 +- web/lib/firebase/scoring.ts | 58 +++++++++++++++++++++ web/pages/fold/[foldSlug]/leaderboards.tsx | 60 ++++++++++++++++++---- 6 files changed, 147 insertions(+), 46 deletions(-) create mode 100644 web/lib/firebase/scoring.ts diff --git a/common/contract.ts b/common/contract.ts index f3405c4e..f60cbb1c 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -11,7 +11,6 @@ export type Contract = { description: string // More info about what the contract is about tags: string[] outcomeType: 'BINARY' // | 'MULTI' | 'interval' | 'date' - // outcomes: ['YES', 'NO'] visibility: 'public' | 'unlisted' mechanism: 'dpm-2' @@ -26,8 +25,10 @@ export type Contract = { isResolved: boolean resolutionTime?: number // When the contract creator resolved the market - resolution?: 'YES' | 'NO' | 'CANCEL' // Chosen by creator; must be one of outcomes + resolution?: outcome // Chosen by creator; must be one of outcomes volume24Hours: number volume7Days: number } + +export type outcome = 'YES' | 'NO' | 'CANCEL' | 'MKT' diff --git a/common/payouts.ts b/common/payouts.ts index 98f1d23e..851861df 100644 --- a/common/payouts.ts +++ b/common/payouts.ts @@ -1,22 +1,23 @@ import { Bet } from './bet' import { getProbability } from './calculate' -import { Contract } from './contract' +import { Contract, outcome } from './contract' import { CREATOR_FEE, FEES } from './fees' -export const getCancelPayouts = (truePool: number, bets: Bet[]) => { - console.log('resolved N/A, pool M$', truePool) +export const getCancelPayouts = (contract: Contract, bets: Bet[]) => { + const { pool } = contract + const poolTotal = pool.YES + pool.NO + console.log('resolved N/A, pool M$', poolTotal) const betSum = sumBy(bets, (b) => b.amount) return bets.map((bet) => ({ userId: bet.userId, - payout: (bet.amount / betSum) * truePool, + payout: (bet.amount / betSum) * poolTotal, })) } export const getStandardPayouts = ( - outcome: string, - truePool: number, + outcome: 'YES' | 'NO', contract: Contract, bets: Bet[] ) => { @@ -25,11 +26,13 @@ export const getStandardPayouts = ( const betSum = sumBy(winningBets, (b) => b.amount) - if (betSum >= truePool) return getCancelPayouts(truePool, winningBets) + const poolTotal = contract.pool.YES + contract.pool.NO + + if (betSum >= poolTotal) return getCancelPayouts(contract, winningBets) const shareDifferenceSum = sumBy(winningBets, (b) => b.shares - b.amount) - const winningsPool = truePool - betSum + const winningsPool = poolTotal - betSum const winnerPayouts = winningBets.map((bet) => ({ userId: bet.userId, @@ -46,7 +49,7 @@ export const getStandardPayouts = ( 'resolved', outcome, 'pool: M$', - truePool, + poolTotal, 'creator fee: M$', creatorPayout ) @@ -56,13 +59,10 @@ export const getStandardPayouts = ( ]) // add creator fee } -export const getMktPayouts = ( - truePool: number, - contract: Contract, - bets: Bet[] -) => { +export const getMktPayouts = (contract: Contract, bets: Bet[]) => { const p = getProbability(contract.totalShares) - console.log('Resolved MKT at p=', p, 'pool: $M', truePool) + const poolTotal = contract.pool.YES + contract.pool.NO + console.log('Resolved MKT at p=', p, 'pool: $M', poolTotal) const [yesBets, noBets] = partition(bets, (bet) => bet.outcome === 'YES') @@ -70,17 +70,17 @@ export const getMktPayouts = ( p * sumBy(yesBets, (b) => b.amount) + (1 - p) * sumBy(noBets, (b) => b.amount) - if (weightedBetTotal >= truePool) { + if (weightedBetTotal >= poolTotal) { return bets.map((bet) => ({ userId: bet.userId, payout: (((bet.outcome === 'YES' ? p : 1 - p) * bet.amount) / weightedBetTotal) * - truePool, + poolTotal, })) } - const winningsPool = truePool - weightedBetTotal + const winningsPool = poolTotal - weightedBetTotal const weightedShareTotal = p * sumBy(yesBets, (b) => b.shares - b.amount) + @@ -113,6 +113,22 @@ export const getMktPayouts = ( ] } +export const getPayouts = ( + outcome: outcome, + contract: Contract, + bets: Bet[] +) => { + switch (outcome) { + case 'YES': + case 'NO': + return getStandardPayouts(outcome, contract, bets) + case 'MKT': + return getMktPayouts(contract, bets) + case 'CANCEL': + return getCancelPayouts(contract, bets) + } +} + const partition = (array: T[], f: (t: T) => boolean) => { const yes = [] const no = [] diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index b9d2931e..df10feed 100644 --- a/functions/src/resolve-market.ts +++ b/functions/src/resolve-market.ts @@ -7,11 +7,7 @@ import { User } from '../../common/user' import { Bet } from '../../common/bet' import { getUser, payUser } from './utils' import { sendMarketResolutionEmail } from './emails' -import { - getCancelPayouts, - getMktPayouts, - getStandardPayouts, -} from '../../common/payouts' +import { getPayouts } from '../../common/payouts' export const resolveMarket = functions .runWith({ minInstances: 1 }) @@ -61,14 +57,7 @@ export const resolveMarket = functions const bets = betsSnap.docs.map((doc) => doc.data() as Bet) const openBets = bets.filter((b) => !b.isSold && !b.sale) - const truePool = contract.pool.YES + contract.pool.NO - - const payouts = - outcome === 'CANCEL' - ? getCancelPayouts(truePool, openBets) - : outcome === 'MKT' - ? getMktPayouts(truePool, contract, openBets) - : getStandardPayouts(outcome, truePool, contract, openBets) + const payouts = getPayouts(outcome, contract, openBets) console.log('payouts:', payouts) diff --git a/web/components/leaderboard.tsx b/web/components/leaderboard.tsx index 429ac1d7..27cf1c27 100644 --- a/web/components/leaderboard.tsx +++ b/web/components/leaderboard.tsx @@ -1,4 +1,3 @@ -import Image from 'next/image' import { User } from '../../common/user' import { Row } from './layout/row' import { SiteLink } from './site-link' @@ -34,7 +33,7 @@ export function Leaderboard(props: { - creatorId), + (contracts) => _.sumBy(contracts, ({ pool }) => pool.YES + pool.NO) + ) + + return creatorScore +} + +export function scoreTraders(contracts: Contract[], bets: Bet[][]) { + const userScoresByContract = contracts.map((contract, index) => + scoreUsersByContract(contract, bets[index]) + ) + const userScores: { [userId: string]: number } = {} + for (const scores of userScoresByContract) { + for (const [userId, score] of Object.entries(scores)) { + if (userScores[userId] === undefined) userScores[userId] = 0 + userScores[userId] += score + } + } + return userScores +} + +function scoreUsersByContract(contract: Contract, bets: Bet[]) { + const { resolution } = contract + + const [closedBets, openBets] = _.partition( + bets, + (bet) => bet.isSold || bet.sale + ) + const resolvePayouts = getPayouts(resolution ?? 'MKT', contract, openBets) + + const salePayouts = closedBets.map((bet) => { + const { userId, sale } = bet + return { userId, payout: sale ? sale.amount : 0 } + }) + + const investments = bets + .filter((bet) => !bet.sale) + .map((bet) => { + const { userId, amount } = bet + return { userId, payout: -amount } + }) + + const netPayouts = [...resolvePayouts, ...salePayouts, ...investments] + + const userScore = _.mapValues( + _.groupBy(netPayouts, (payout) => payout.userId), + (payouts) => _.sumBy(payouts, ({ payout }) => payout) + ) + + return userScore +} diff --git a/web/pages/fold/[foldSlug]/leaderboards.tsx b/web/pages/fold/[foldSlug]/leaderboards.tsx index 66070790..c65ff980 100644 --- a/web/pages/fold/[foldSlug]/leaderboards.tsx +++ b/web/pages/fold/[foldSlug]/leaderboards.tsx @@ -6,19 +6,36 @@ import { Leaderboard } from '../../../components/leaderboard' import { Page } from '../../../components/page' import { SiteLink } from '../../../components/site-link' import { formatMoney } from '../../../lib/util/format' -import { foldPath, getFoldBySlug } from '../../../lib/firebase/folds' +import { + foldPath, + getFoldBySlug, + getFoldContracts, +} from '../../../lib/firebase/folds' import { Fold } from '../../../../common/fold' import { Spacer } from '../../../components/layout/spacer' +import { scoreCreators, scoreTraders } from '../../../lib/firebase/scoring' +import { getUser, User } from '../../../lib/firebase/users' +import { listAllBets } from '../../../lib/firebase/bets' export async function getStaticProps(props: { params: { foldSlug: string } }) { const { foldSlug } = props.params const fold = await getFoldBySlug(foldSlug) + const contracts = fold ? await getFoldContracts(fold) : [] + const bets = await Promise.all( + contracts.map((contract) => listAllBets(contract.id)) + ) + + const creatorScores = scoreCreators(contracts, bets) + const [topCreators, topCreatorScores] = await toUserScores(creatorScores) + + const traderScores = scoreTraders(contracts, bets) + const [topTraders, topTraderScores] = await toUserScores(traderScores) return { - props: { fold }, + props: { fold, topTraders, topTraderScores, topCreators, topCreatorScores }, - revalidate: 60, // regenerate after a minute + revalidate: 15 * 60, // regenerate after 15 minutes } } @@ -26,8 +43,27 @@ export async function getStaticPaths() { return { paths: [], fallback: 'blocking' } } -export default function Leaderboards(props: { fold: Fold }) { - const { fold } = props +async function toUserScores(userScores: { [userId: string]: number }) { + const topUserPairs = _.take( + _.sortBy(Object.entries(userScores), ([_, score]) => -1 * score), + 10 + ) + const topUsers = await Promise.all( + topUserPairs.map(([userId]) => getUser(userId)) + ) + const topUserScores = topUserPairs.map(([_, score]) => score) + return [topUsers, topUserScores] as const +} + +export default function Leaderboards(props: { + fold: Fold + topTraders: User[] + topTraderScores: number[] + topCreators: User[] + topCreatorScores: number[] +}) { + const { fold, topTraders, topTraderScores, topCreators, topCreatorScores } = + props return ( @@ -37,24 +73,26 @@ export default function Leaderboards(props: { fold: Fold }) { - + formatMoney(user.totalPnLCached), + renderCell: (user) => + formatMoney(topTraderScores[topTraders.indexOf(user)]), }, ]} /> formatMoney(user.creatorVolumeCached), + header: 'Market pool', + renderCell: (user) => + formatMoney(topCreatorScores[topCreators.indexOf(user)]), }, ]} />