diff --git a/common/contract.ts b/common/contract.ts index 57a8d0b7..11609329 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -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 diff --git a/common/payouts.ts b/common/payouts.ts index 0b917943..5c29d6a9 100644 --- a/common/payouts.ts +++ b/common/payouts.ts @@ -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 +} diff --git a/functions/src/emails.ts b/functions/src/emails.ts index d1c61e3f..83404d4a 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -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}` diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index d0b6d1da..85e67785 100644 --- a/functions/src/resolve-market.ts +++ b/functions/src/resolve-market.ts @@ -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 ) ) ) diff --git a/web/components/answers-panel.tsx b/web/components/answers-panel.tsx index 4309d8a2..58a3f15c 100644 --- a/web/components/answers-panel.tsx +++ b/web/components/answers-panel.tsx @@ -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() + 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 ( @@ -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[] }) { )} - {tradingAllowed(contract) && } + {tradingAllowed(contract) && !resolveOption && ( + + )} {user?.id === creatorId && !resolution && ( )} @@ -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' )} > @@ -158,30 +216,71 @@ function AnswerItem(props: { closePanel={() => setIsBetting(false)} /> ) : ( - - {!wasResolvedTo && ( -
- {probPercent} -
- )} + + {!wasResolvedTo && + (showChoice === 'checkbox' ? ( + { + const { value } = e.target + const numberValue = value + ? parseInt(value.replace(/[^\d]/, '')) + : 0 + if (!isNaN(numberValue)) onChoose(answer.id, numberValue) + }} + /> + ) : ( +
+ {probPercent} +
+ ))} {showChoice ? (
-
) : ( <> @@ -195,7 +294,17 @@ function AnswerItem(props: { )} {wasResolvedTo && ( -
Chosen
+
+ Chosen{' '} + {resolutions + ? `${Math.round(resolutions[answer.id])}%` + : ''} +
{probPercent}
)} @@ -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(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 ( diff --git a/web/components/contract-card.tsx b/web/components/contract-card.tsx index 105c80f4..501987c8 100644 --- a/web/components/contract-card.tsx +++ b/web/components/contract-card.tsx @@ -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}` diff --git a/web/components/resolution-panel.tsx b/web/components/resolution-panel.tsx index 8bb714b8..f6cc58e1 100644 --- a/web/components/resolution-panel.tsx +++ b/web/components/resolution-panel.tsx @@ -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) } diff --git a/web/components/yes-no-selector.tsx b/web/components/yes-no-selector.tsx index ebc0ab7b..cb7ecee4 100644 --- a/web/components/yes-no-selector.tsx +++ b/web/components/yes-no-selector.tsx @@ -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 + +