Implement resolving to multiple answers, weighted by probability

This commit is contained in:
James Grugett 2022-02-20 01:26:26 -06:00
parent 42f88766b3
commit fd846254a0
9 changed files with 281 additions and 82 deletions

View File

@ -32,7 +32,8 @@ export type Contract = {
isResolved: boolean
resolutionTime?: number // When the contract creator resolved the market
resolution?: string
resolutionProbability?: number
resolutionProbability?: number // Used for BINARY markets resolved to MKT
resolutions?: { [outcome: string]: number } // Used for outcomeType FREE_RESPONSE resolved to MKT
closeEmailsSent?: number
volume24Hours: number

View File

@ -118,3 +118,46 @@ export const getPayouts = (
return getStandardPayouts(outcome, contract, bets)
}
}
export const getPayoutsMultiOutcome = (
resolutions: { [outcome: string]: number },
contract: Contract,
bets: Bet[]
) => {
const poolTotal = _.sum(Object.values(contract.pool))
const winningBets = bets.filter((bet) => resolutions[bet.outcome])
const betsByOutcome = _.groupBy(winningBets, (bet) => bet.outcome)
const sharesByOutcome = _.mapValues(betsByOutcome, (bets) =>
_.sumBy(bets, (bet) => bet.shares)
)
const probTotal = _.sum(Object.values(resolutions))
const payouts = winningBets.map(({ userId, outcome, amount, shares }) => {
const prob = resolutions[outcome] / probTotal
const winnings = (shares / sharesByOutcome[outcome]) * prob * poolTotal
const profit = winnings - amount
const payout = amount + (1 - FEES) * Math.max(0, profit)
return { userId, profit, payout }
})
const profits = _.sumBy(payouts, (po) => po.profit)
const creatorPayout = CREATOR_FEE * profits
console.log(
'resolved',
resolutions,
'pool',
poolTotal,
'profits',
profits,
'creator fee',
creatorPayout
)
return payouts
.map(({ userId, payout }) => ({ userId, payout }))
.concat([{ userId: contract.creatorId, payout: creatorPayout }]) // add creator fee
}

View File

@ -17,7 +17,13 @@ type market_resolved_template = {
url: string
}
const toDisplayResolution = (outcome: string, prob: number) => {
const toDisplayResolution = (
outcome: string,
prob: number,
resolutions?: { [outcome: string]: number }
) => {
if (outcome === 'MKT' && resolutions) return 'MULTI'
const display = {
YES: 'YES',
NO: 'NO',
@ -33,8 +39,9 @@ export const sendMarketResolutionEmail = async (
payout: number,
creator: User,
contract: Contract,
resolution: 'YES' | 'NO' | 'CANCEL' | 'MKT' | string,
resolutionProbability?: number
resolution: string,
resolutionProbability?: number,
resolutions?: { [outcome: string]: number }
) => {
const privateUser = await getPrivateUser(userId)
if (
@ -49,7 +56,7 @@ export const sendMarketResolutionEmail = async (
const prob = resolutionProbability ?? getProbability(contract.totalShares)
const outcome = toDisplayResolution(resolution, prob)
const outcome = toDisplayResolution(resolution, prob, resolutions)
const subject = `Resolved ${outcome}: ${contract.question}`

View File

@ -7,23 +7,24 @@ import { User } from '../../common/user'
import { Bet } from '../../common/bet'
import { getUser, payUser } from './utils'
import { sendMarketResolutionEmail } from './emails'
import { getPayouts } from '../../common/payouts'
import { getPayouts, getPayoutsMultiOutcome } from '../../common/payouts'
export const resolveMarket = functions
.runWith({ minInstances: 1 })
.https.onCall(
async (
data: {
outcome: 'YES' | 'NO' | 'CANCEL' | 'MKT'
outcome: string
contractId: string
probabilityInt?: number
resolutions?: { [outcome: string]: number }
},
context
) => {
const userId = context?.auth?.uid
if (!userId) return { status: 'error', message: 'Not authorized' }
const { outcome, contractId, probabilityInt } = data
const { outcome, contractId, probabilityInt, resolutions } = data
const contractDoc = firestore.doc(`contracts/${contractId}`)
const contractSnap = await contractDoc.get()
@ -36,7 +37,11 @@ export const resolveMarket = functions
if (!['YES', 'NO', 'MKT', 'CANCEL'].includes(outcome))
return { status: 'error', message: 'Invalid outcome' }
} else if (outcomeType === 'FREE_RESPONSE') {
if (outcome !== 'CANCEL' && isNaN(+outcome))
if (
isNaN(+outcome) &&
!(outcome === 'MKT' && resolutions) &&
outcome !== 'CANCEL'
)
return { status: 'error', message: 'Invalid outcome' }
} else {
return { status: 'error', message: 'Invalid contract outcomeType' }
@ -70,6 +75,7 @@ export const resolveMarket = functions
...(resolutionProbability === undefined
? {}
: { resolutionProbability }),
...(resolutions === undefined ? {} : { resolutions }),
})
console.log('contract ', contractId, 'resolved to:', outcome)
@ -81,12 +87,10 @@ export const resolveMarket = functions
const bets = betsSnap.docs.map((doc) => doc.data() as Bet)
const openBets = bets.filter((b) => !b.isSold && !b.sale)
const payouts = getPayouts(
outcome,
contract,
openBets,
resolutionProbability
)
const payouts =
outcomeType === 'FREE_RESPONSE' && resolutions
? getPayoutsMultiOutcome(resolutions, contract, openBets)
: getPayouts(outcome, contract, openBets, resolutionProbability)
console.log('payouts:', payouts)
@ -109,7 +113,8 @@ export const resolveMarket = functions
creator,
contract,
outcome,
resolutionProbability
resolutionProbability,
resolutions
)
return result
@ -121,8 +126,9 @@ const sendResolutionEmails = async (
userPayouts: { [userId: string]: number },
creator: User,
contract: Contract,
outcome: 'YES' | 'NO' | 'CANCEL' | 'MKT' | string,
resolutionProbability?: number
outcome: string,
resolutionProbability?: number,
resolutions?: { [outcome: string]: number }
) => {
const nonWinners = _.difference(
_.uniq(openBets.map(({ userId }) => userId)),
@ -140,7 +146,8 @@ const sendResolutionEmails = async (
creator,
contract,
outcome,
resolutionProbability
resolutionProbability,
resolutions
)
)
)

View File

@ -1,6 +1,6 @@
import clsx from 'clsx'
import _ from 'lodash'
import { useEffect, useRef, useState } from 'react'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import Textarea from 'react-expanding-textarea'
import { XIcon } from '@heroicons/react/solid'
@ -34,18 +34,22 @@ import { Bet } from '../../common/bet'
import { useAnswers } from '../hooks/use-answers'
import { ResolveConfirmationButton } from './confirmation-button'
import { tradingAllowed } from '../lib/firebase/contracts'
import { removeUndefinedProps } from '../../common/util/object'
export function AnswersPanel(props: { contract: Contract; answers: Answer[] }) {
const { contract } = props
const { creatorId, resolution } = contract
const { creatorId, resolution, resolutions } = contract
const answers = useAnswers(contract.id) ?? props.answers
const [chosenAnswer, otherAnswers] = _.partition(
const [winningAnswers, otherAnswers] = _.partition(
answers.filter((answer) => answer.id !== '0'),
(answer) => answer.id === resolution
(answer) =>
answer.id === resolution || (resolutions && resolutions[answer.id])
)
const sortedAnswers = [
...chosenAnswer,
..._.sortBy(winningAnswers, (answer) =>
resolutions ? -1 * resolutions[answer.id] : 0
),
..._.sortBy(
otherAnswers,
(answer) => -1 * getOutcomeProbability(contract.totalShares, answer.id)
@ -55,13 +59,46 @@ export function AnswersPanel(props: { contract: Contract; answers: Answer[] }) {
const user = useUser()
const [resolveOption, setResolveOption] = useState<
'CHOOSE' | 'CANCEL' | undefined
'CHOOSE' | 'CHOOSE_MULTIPLE' | 'CANCEL' | undefined
>()
const [answerChoice, setAnswerChoice] = useState<string | undefined>()
const [chosenAnswers, setChosenAnswers] = useState<{
[answerId: string]: number
}>({})
useEffect(() => {
if (resolveOption !== 'CHOOSE' && answerChoice) setAnswerChoice(undefined)
}, [answerChoice, resolveOption])
const chosenTotal = _.sum(Object.values(chosenAnswers))
const onChoose = (answerId: string, prob: number) => {
if (resolveOption === 'CHOOSE') {
setChosenAnswers({ [answerId]: prob })
} else {
setChosenAnswers((chosenAnswers) => {
return {
...chosenAnswers,
[answerId]: prob,
}
})
}
}
const onDeselect = (answerId: string) => {
setChosenAnswers((chosenAnswers) => {
const newChosenAnswers = { ...chosenAnswers }
delete newChosenAnswers[answerId]
return newChosenAnswers
})
}
useLayoutEffect(() => {
setChosenAnswers({})
}, [resolveOption])
const showChoice = resolution
? undefined
: resolveOption === 'CHOOSE'
? 'radio'
: resolveOption === 'CHOOSE_MULTIPLE'
? 'checkbox'
: undefined
return (
<Col className="gap-3">
@ -70,9 +107,11 @@ export function AnswersPanel(props: { contract: Contract; answers: Answer[] }) {
key={answer.id}
answer={answer}
contract={contract}
showChoice={!resolution && resolveOption === 'CHOOSE'}
isChosen={answer.id === answerChoice}
onChoose={() => setAnswerChoice(answer.id)}
showChoice={showChoice}
chosenProb={chosenAnswers[answer.id]}
totalChosenProb={chosenTotal}
onChoose={onChoose}
onDeselect={onDeselect}
/>
))}
@ -85,14 +124,16 @@ export function AnswersPanel(props: { contract: Contract; answers: Answer[] }) {
</div>
)}
{tradingAllowed(contract) && <CreateAnswerInput contract={contract} />}
{tradingAllowed(contract) && !resolveOption && (
<CreateAnswerInput contract={contract} />
)}
{user?.id === creatorId && !resolution && (
<AnswerResolvePanel
contract={contract}
resolveOption={resolveOption}
setResolveOption={setResolveOption}
answer={answerChoice}
chosenAnswers={chosenAnswers}
/>
)}
</Col>
@ -102,18 +143,31 @@ export function AnswersPanel(props: { contract: Contract; answers: Answer[] }) {
function AnswerItem(props: {
answer: Answer
contract: Contract
showChoice: boolean
isChosen: boolean
onChoose: () => void
showChoice: 'radio' | 'checkbox' | undefined
chosenProb: number | undefined
totalChosenProb?: number
onChoose: (answerId: string, prob: number) => void
onDeselect: (answerId: string) => void
}) {
const { answer, contract, showChoice, isChosen, onChoose } = props
const { resolution, totalShares } = contract
const {
answer,
contract,
showChoice,
chosenProb,
totalChosenProb,
onChoose,
onDeselect,
} = props
const { resolution, resolutions, totalShares } = contract
const { username, avatarUrl, name, createdTime, number, text } = answer
const isChosen = chosenProb !== undefined
const createdDate = dayjs(createdTime).format('MMM D')
const prob = getOutcomeProbability(totalShares, answer.id)
const roundedProb = Math.round(prob * 100)
const probPercent = formatPercent(prob)
const wasResolvedTo = resolution === answer.id
const wasResolvedTo =
resolution === answer.id || (resolutions && resolutions[answer.id])
const [isBetting, setIsBetting] = useState(false)
@ -122,10 +176,14 @@ function AnswerItem(props: {
className={clsx(
'p-4 sm:flex-row rounded gap-4',
wasResolvedTo
? 'bg-green-50 mb-8'
: isChosen
? resolution === 'MKT'
? 'bg-blue-50 mb-2'
: 'bg-green-50 mb-8'
: chosenProb === undefined
? 'bg-gray-50'
: showChoice === 'radio'
? 'bg-green-50'
: 'bg-gray-50'
: 'bg-blue-50'
)}
>
<Col className="gap-3 flex-1">
@ -158,8 +216,24 @@ function AnswerItem(props: {
closePanel={() => setIsBetting(false)}
/>
) : (
<Row className="self-end sm:self-start items-center gap-4">
{!wasResolvedTo && (
<Row className="self-end sm:self-start items-center gap-4 justify-end">
{!wasResolvedTo &&
(showChoice === 'checkbox' ? (
<input
className="input input-bordered text-2xl justify-self-end w-24"
type="number"
placeholder={`${roundedProb}`}
maxLength={9}
value={chosenProb ? Math.round(chosenProb) : ''}
onChange={(e) => {
const { value } = e.target
const numberValue = value
? parseInt(value.replace(/[^\d]/, ''))
: 0
if (!isNaN(numberValue)) onChoose(answer.id, numberValue)
}}
/>
) : (
<div
className={clsx(
'text-2xl',
@ -168,20 +242,45 @@ function AnswerItem(props: {
>
{probPercent}
</div>
)}
))}
{showChoice ? (
<div className="form-control py-1">
<label className="cursor-pointer label gap-2">
<span className="label-text">Choose this answer</span>
<label className="cursor-pointer label gap-3">
<span className="">Choose this answer</span>
{showChoice === 'radio' && (
<input
className={clsx('radio', isChosen && '!bg-green-500')}
className={clsx('radio', chosenProb && '!bg-green-500')}
type="radio"
name="opt"
checked={isChosen}
onChange={onChoose}
onChange={() => onChoose(answer.id, 1)}
value={answer.id}
/>
)}
{showChoice === 'checkbox' && (
<input
className={clsx('checkbox', chosenProb && '!bg-blue-500')}
type="checkbox"
name="opt"
checked={isChosen}
onChange={() => {
if (isChosen) onDeselect(answer.id)
else {
onChoose(answer.id, 100 * prob)
}
}}
value={answer.id}
/>
)}
</label>
{showChoice === 'checkbox' && (
<div className="ml-1">
{chosenProb && totalChosenProb
? Math.round((100 * chosenProb) / totalChosenProb)
: 0}
% share
</div>
)}
</div>
) : (
<>
@ -195,7 +294,17 @@ function AnswerItem(props: {
)}
{wasResolvedTo && (
<Col className="items-end">
<div className="text-green-700 text-xl">Chosen</div>
<div
className={clsx(
'text-xl',
resolution === 'MKT' ? 'text-blue-700' : 'text-green-700'
)}
>
Chosen{' '}
{resolutions
? `${Math.round(resolutions[answer.id])}%`
: ''}
</div>
<div className="text-2xl text-gray-500">{probPercent}</div>
</Col>
)}
@ -482,29 +591,48 @@ function CreateAnswerInput(props: { contract: Contract }) {
function AnswerResolvePanel(props: {
contract: Contract
resolveOption: 'CHOOSE' | 'CANCEL' | undefined
setResolveOption: (option: 'CHOOSE' | 'CANCEL' | undefined) => void
answer: string | undefined
resolveOption: 'CHOOSE' | 'CHOOSE_MULTIPLE' | 'CANCEL' | undefined
setResolveOption: (
option: 'CHOOSE' | 'CHOOSE_MULTIPLE' | 'CANCEL' | undefined
) => void
chosenAnswers: { [answerId: string]: number }
}) {
const { contract, resolveOption, setResolveOption, answer } = props
const { contract, resolveOption, setResolveOption, chosenAnswers } = props
const answers = Object.keys(chosenAnswers)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | undefined>(undefined)
const onResolve = async () => {
if (resolveOption === 'CHOOSE' && answer === undefined) return
if (resolveOption === 'CHOOSE' && answers.length !== 1) return
if (resolveOption === 'CHOOSE_MULTIPLE' && answers.length < 2) return
setIsSubmitting(true)
const result = await resolveMarket({
outcome: resolveOption === 'CHOOSE' ? (answer as string) : 'CANCEL',
contractId: contract.id,
}).then((r) => r.data as any)
const totalProb = _.sum(Object.values(chosenAnswers))
const normalizedProbs = _.mapValues(
chosenAnswers,
(prob) => (100 * prob) / totalProb
)
console.log('resolved', `#${answer}`, 'result:', result)
const resolutionProps = removeUndefinedProps({
outcome:
resolveOption === 'CHOOSE'
? answers[0]
: resolveOption === 'CHOOSE_MULTIPLE'
? 'MKT'
: 'CANCEL',
resolutions:
resolveOption === 'CHOOSE_MULTIPLE' ? normalizedProbs : undefined,
contractId: contract.id,
})
const result = await resolveMarket(resolutionProps).then((r) => r.data)
console.log('resolved', resolutionProps, 'result:', result)
if (result?.status !== 'success') {
setError(result?.error || 'Error resolving market')
setError(result?.message || 'Error resolving market')
}
setResolveOption(undefined)
setIsSubmitting(false)
@ -513,8 +641,12 @@ function AnswerResolvePanel(props: {
const resolutionButtonClass =
resolveOption === 'CANCEL'
? 'bg-yellow-400 hover:bg-yellow-500'
: resolveOption === 'CHOOSE' && answer
: resolveOption === 'CHOOSE' && answers.length
? 'btn-primary'
: resolveOption === 'CHOOSE_MULTIPLE' &&
answers.length > 1 &&
answers.every((answer) => chosenAnswers[answer] > 0)
? 'bg-blue-400 hover:bg-blue-500'
: 'btn-disabled'
return (

View File

@ -85,7 +85,7 @@ export function ResolutionOrChance(props: {
{
YES: 'YES',
NO: 'NO',
MKT: getBinaryProbPercent(contract),
MKT: isBinary ? getBinaryProbPercent(contract) : 'MULTI',
CANCEL: 'N/A',
'': '',
}[resolution || ''] ?? `#${resolution}`

View File

@ -43,12 +43,12 @@ export function ResolutionPanel(props: {
outcome,
contractId: contract.id,
probabilityInt: prob,
}).then((r) => r.data as any)
}).then((r) => r.data)
console.log('resolved', outcome, 'result:', result)
if (result?.status !== 'success') {
setError(result?.error || 'Error resolving market')
setError(result?.message || 'Error resolving market')
}
setIsSubmitting(false)
}

View File

@ -91,8 +91,8 @@ export function YesNoCancelSelector(props: {
}
export function ChooseCancelSelector(props: {
selected: 'CHOOSE' | 'CANCEL' | undefined
onSelect: (selected: 'CHOOSE' | 'CANCEL') => void
selected: 'CHOOSE' | 'CHOOSE_MULTIPLE' | 'CANCEL' | undefined
onSelect: (selected: 'CHOOSE' | 'CHOOSE_MULTIPLE' | 'CANCEL') => void
className?: string
btnClassName?: string
}) {
@ -110,6 +110,14 @@ export function ChooseCancelSelector(props: {
Choose an answer
</Button>
<Button
color={selected === 'CHOOSE_MULTIPLE' ? 'blue' : 'gray'}
onClick={() => onSelect('CHOOSE_MULTIPLE')}
className={clsx('whitespace-nowrap', btnClassName)}
>
Choose multiple
</Button>
<Button
color={selected === 'CANCEL' ? 'yellow' : 'gray'}
onClick={() => onSelect('CANCEL')}

View File

@ -33,6 +33,7 @@ export const resolveMarket = cloudFunction<
outcome: string
contractId: string
probabilityInt?: number
resolutions?: { [outcome: string]: number }
},
{ status: 'error' | 'success'; message?: string }
>('resolveMarket')