Implement leaderboards for folds!
This commit is contained in:
parent
bc1decdbfc
commit
aa1022546d
|
@ -11,7 +11,6 @@ export type Contract = {
|
||||||
description: string // More info about what the contract is about
|
description: string // More info about what the contract is about
|
||||||
tags: string[]
|
tags: string[]
|
||||||
outcomeType: 'BINARY' // | 'MULTI' | 'interval' | 'date'
|
outcomeType: 'BINARY' // | 'MULTI' | 'interval' | 'date'
|
||||||
// outcomes: ['YES', 'NO']
|
|
||||||
visibility: 'public' | 'unlisted'
|
visibility: 'public' | 'unlisted'
|
||||||
|
|
||||||
mechanism: 'dpm-2'
|
mechanism: 'dpm-2'
|
||||||
|
@ -26,8 +25,10 @@ export type Contract = {
|
||||||
|
|
||||||
isResolved: boolean
|
isResolved: boolean
|
||||||
resolutionTime?: number // When the contract creator resolved the market
|
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
|
volume24Hours: number
|
||||||
volume7Days: number
|
volume7Days: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type outcome = 'YES' | 'NO' | 'CANCEL' | 'MKT'
|
||||||
|
|
|
@ -1,22 +1,23 @@
|
||||||
import { Bet } from './bet'
|
import { Bet } from './bet'
|
||||||
import { getProbability } from './calculate'
|
import { getProbability } from './calculate'
|
||||||
import { Contract } from './contract'
|
import { Contract, outcome } from './contract'
|
||||||
import { CREATOR_FEE, FEES } from './fees'
|
import { CREATOR_FEE, FEES } from './fees'
|
||||||
|
|
||||||
export const getCancelPayouts = (truePool: number, bets: Bet[]) => {
|
export const getCancelPayouts = (contract: Contract, bets: Bet[]) => {
|
||||||
console.log('resolved N/A, pool M$', truePool)
|
const { pool } = contract
|
||||||
|
const poolTotal = pool.YES + pool.NO
|
||||||
|
console.log('resolved N/A, pool M$', poolTotal)
|
||||||
|
|
||||||
const betSum = sumBy(bets, (b) => b.amount)
|
const betSum = sumBy(bets, (b) => b.amount)
|
||||||
|
|
||||||
return bets.map((bet) => ({
|
return bets.map((bet) => ({
|
||||||
userId: bet.userId,
|
userId: bet.userId,
|
||||||
payout: (bet.amount / betSum) * truePool,
|
payout: (bet.amount / betSum) * poolTotal,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getStandardPayouts = (
|
export const getStandardPayouts = (
|
||||||
outcome: string,
|
outcome: 'YES' | 'NO',
|
||||||
truePool: number,
|
|
||||||
contract: Contract,
|
contract: Contract,
|
||||||
bets: Bet[]
|
bets: Bet[]
|
||||||
) => {
|
) => {
|
||||||
|
@ -25,11 +26,13 @@ export const getStandardPayouts = (
|
||||||
|
|
||||||
const betSum = sumBy(winningBets, (b) => b.amount)
|
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 shareDifferenceSum = sumBy(winningBets, (b) => b.shares - b.amount)
|
||||||
|
|
||||||
const winningsPool = truePool - betSum
|
const winningsPool = poolTotal - betSum
|
||||||
|
|
||||||
const winnerPayouts = winningBets.map((bet) => ({
|
const winnerPayouts = winningBets.map((bet) => ({
|
||||||
userId: bet.userId,
|
userId: bet.userId,
|
||||||
|
@ -46,7 +49,7 @@ export const getStandardPayouts = (
|
||||||
'resolved',
|
'resolved',
|
||||||
outcome,
|
outcome,
|
||||||
'pool: M$',
|
'pool: M$',
|
||||||
truePool,
|
poolTotal,
|
||||||
'creator fee: M$',
|
'creator fee: M$',
|
||||||
creatorPayout
|
creatorPayout
|
||||||
)
|
)
|
||||||
|
@ -56,13 +59,10 @@ export const getStandardPayouts = (
|
||||||
]) // add creator fee
|
]) // add creator fee
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getMktPayouts = (
|
export const getMktPayouts = (contract: Contract, bets: Bet[]) => {
|
||||||
truePool: number,
|
|
||||||
contract: Contract,
|
|
||||||
bets: Bet[]
|
|
||||||
) => {
|
|
||||||
const p = getProbability(contract.totalShares)
|
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')
|
const [yesBets, noBets] = partition(bets, (bet) => bet.outcome === 'YES')
|
||||||
|
|
||||||
|
@ -70,17 +70,17 @@ export const getMktPayouts = (
|
||||||
p * sumBy(yesBets, (b) => b.amount) +
|
p * sumBy(yesBets, (b) => b.amount) +
|
||||||
(1 - p) * sumBy(noBets, (b) => b.amount)
|
(1 - p) * sumBy(noBets, (b) => b.amount)
|
||||||
|
|
||||||
if (weightedBetTotal >= truePool) {
|
if (weightedBetTotal >= poolTotal) {
|
||||||
return bets.map((bet) => ({
|
return bets.map((bet) => ({
|
||||||
userId: bet.userId,
|
userId: bet.userId,
|
||||||
payout:
|
payout:
|
||||||
(((bet.outcome === 'YES' ? p : 1 - p) * bet.amount) /
|
(((bet.outcome === 'YES' ? p : 1 - p) * bet.amount) /
|
||||||
weightedBetTotal) *
|
weightedBetTotal) *
|
||||||
truePool,
|
poolTotal,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
const winningsPool = truePool - weightedBetTotal
|
const winningsPool = poolTotal - weightedBetTotal
|
||||||
|
|
||||||
const weightedShareTotal =
|
const weightedShareTotal =
|
||||||
p * sumBy(yesBets, (b) => b.shares - b.amount) +
|
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 = <T>(array: T[], f: (t: T) => boolean) => {
|
const partition = <T>(array: T[], f: (t: T) => boolean) => {
|
||||||
const yes = []
|
const yes = []
|
||||||
const no = []
|
const no = []
|
||||||
|
|
|
@ -7,11 +7,7 @@ import { User } from '../../common/user'
|
||||||
import { Bet } from '../../common/bet'
|
import { Bet } from '../../common/bet'
|
||||||
import { getUser, payUser } from './utils'
|
import { getUser, payUser } from './utils'
|
||||||
import { sendMarketResolutionEmail } from './emails'
|
import { sendMarketResolutionEmail } from './emails'
|
||||||
import {
|
import { getPayouts } from '../../common/payouts'
|
||||||
getCancelPayouts,
|
|
||||||
getMktPayouts,
|
|
||||||
getStandardPayouts,
|
|
||||||
} from '../../common/payouts'
|
|
||||||
|
|
||||||
export const resolveMarket = functions
|
export const resolveMarket = functions
|
||||||
.runWith({ minInstances: 1 })
|
.runWith({ minInstances: 1 })
|
||||||
|
@ -61,14 +57,7 @@ export const resolveMarket = functions
|
||||||
const bets = betsSnap.docs.map((doc) => doc.data() as Bet)
|
const bets = betsSnap.docs.map((doc) => doc.data() as Bet)
|
||||||
const openBets = bets.filter((b) => !b.isSold && !b.sale)
|
const openBets = bets.filter((b) => !b.isSold && !b.sale)
|
||||||
|
|
||||||
const truePool = contract.pool.YES + contract.pool.NO
|
const payouts = getPayouts(outcome, contract, openBets)
|
||||||
|
|
||||||
const payouts =
|
|
||||||
outcome === 'CANCEL'
|
|
||||||
? getCancelPayouts(truePool, openBets)
|
|
||||||
: outcome === 'MKT'
|
|
||||||
? getMktPayouts(truePool, contract, openBets)
|
|
||||||
: getStandardPayouts(outcome, truePool, contract, openBets)
|
|
||||||
|
|
||||||
console.log('payouts:', payouts)
|
console.log('payouts:', payouts)
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import Image from 'next/image'
|
|
||||||
import { User } from '../../common/user'
|
import { User } from '../../common/user'
|
||||||
import { Row } from './layout/row'
|
import { Row } from './layout/row'
|
||||||
import { SiteLink } from './site-link'
|
import { SiteLink } from './site-link'
|
||||||
|
@ -34,7 +33,7 @@ export function Leaderboard(props: {
|
||||||
<td>
|
<td>
|
||||||
<SiteLink className="relative" href={`/${user.username}`}>
|
<SiteLink className="relative" href={`/${user.username}`}>
|
||||||
<Row className="items-center gap-4">
|
<Row className="items-center gap-4">
|
||||||
<Image
|
<img
|
||||||
className="rounded-full bg-gray-400 flex-shrink-0 ring-8 ring-gray-50"
|
className="rounded-full bg-gray-400 flex-shrink-0 ring-8 ring-gray-50"
|
||||||
src={user.avatarUrl || ''}
|
src={user.avatarUrl || ''}
|
||||||
alt=""
|
alt=""
|
||||||
|
|
58
web/lib/firebase/scoring.ts
Normal file
58
web/lib/firebase/scoring.ts
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
import _ from 'lodash'
|
||||||
|
import { Contract } from '../../../common/contract'
|
||||||
|
import { getPayouts } from '../../../common/payouts'
|
||||||
|
import { Bet } from './bets'
|
||||||
|
|
||||||
|
export function scoreCreators(contracts: Contract[], bets: Bet[][]) {
|
||||||
|
const creatorScore = _.mapValues(
|
||||||
|
_.groupBy(contracts, ({ creatorId }) => 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
|
||||||
|
}
|
|
@ -6,19 +6,36 @@ import { Leaderboard } from '../../../components/leaderboard'
|
||||||
import { Page } from '../../../components/page'
|
import { Page } from '../../../components/page'
|
||||||
import { SiteLink } from '../../../components/site-link'
|
import { SiteLink } from '../../../components/site-link'
|
||||||
import { formatMoney } from '../../../lib/util/format'
|
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 { Fold } from '../../../../common/fold'
|
||||||
import { Spacer } from '../../../components/layout/spacer'
|
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 } }) {
|
export async function getStaticProps(props: { params: { foldSlug: string } }) {
|
||||||
const { foldSlug } = props.params
|
const { foldSlug } = props.params
|
||||||
|
|
||||||
const fold = await getFoldBySlug(foldSlug)
|
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 {
|
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' }
|
return { paths: [], fallback: 'blocking' }
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Leaderboards(props: { fold: Fold }) {
|
async function toUserScores(userScores: { [userId: string]: number }) {
|
||||||
const { fold } = props
|
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 (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<SiteLink href={foldPath(fold)}>
|
<SiteLink href={foldPath(fold)}>
|
||||||
|
@ -37,24 +73,26 @@ export default function Leaderboards(props: { fold: Fold }) {
|
||||||
|
|
||||||
<Spacer h={4} />
|
<Spacer h={4} />
|
||||||
|
|
||||||
<Col className="items-center lg:flex-row gap-10">
|
<Col className="lg:flex-row gap-10">
|
||||||
<Leaderboard
|
<Leaderboard
|
||||||
title="🏅 Top traders"
|
title="🏅 Top traders"
|
||||||
users={[]}
|
users={topTraders}
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
header: 'Total profit',
|
header: 'Total profit',
|
||||||
renderCell: (user) => formatMoney(user.totalPnLCached),
|
renderCell: (user) =>
|
||||||
|
formatMoney(topTraderScores[topTraders.indexOf(user)]),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Leaderboard
|
<Leaderboard
|
||||||
title="🏅 Top creators"
|
title="🏅 Top creators"
|
||||||
users={[]}
|
users={topCreators}
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
header: 'Market volume',
|
header: 'Market pool',
|
||||||
renderCell: (user) => formatMoney(user.creatorVolumeCached),
|
renderCell: (user) =>
|
||||||
|
formatMoney(topCreatorScores[topCreators.indexOf(user)]),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
Loading…
Reference in New Issue
Block a user