diff --git a/common/antes.ts b/common/antes.ts index b3dd990b..b9914451 100644 --- a/common/antes.ts +++ b/common/antes.ts @@ -5,12 +5,14 @@ import { CPMMBinaryContract, DPMBinaryContract, FreeResponseContract, + MultipleChoiceContract, NumericContract, } from './contract' import { User } from './user' import { LiquidityProvision } from './liquidity-provision' import { noFees } from './fees' import { ENV_CONFIG } from './envs/constants' +import { Answer } from './answer' export const FIXED_ANTE = ENV_CONFIG.fixedAnte ?? 100 @@ -111,6 +113,50 @@ export function getFreeAnswerAnte( return anteBet } +export function getMultipleChoiceAntes( + creator: User, + contract: MultipleChoiceContract, + answers: string[], + betDocIds: string[] +) { + const { totalBets, totalShares } = contract + const amount = totalBets['0'] + const shares = totalShares['0'] + const p = 1 / answers.length + + const { createdTime } = contract + + const bets: Bet[] = answers.map((answer, i) => ({ + id: betDocIds[i], + userId: creator.id, + contractId: contract.id, + amount, + shares, + outcome: i.toString(), + probBefore: p, + probAfter: p, + createdTime, + isAnte: true, + fees: noFees, + })) + + const { username, name, avatarUrl } = creator + + const answerObjects: Answer[] = answers.map((answer, i) => ({ + id: i.toString(), + number: i, + contractId: contract.id, + createdTime, + userId: creator.id, + username, + name, + avatarUrl, + text: answer, + })) + + return { bets, answerObjects } +} + export function getNumericAnte( anteBettorId: string, contract: NumericContract, diff --git a/common/calculate.ts b/common/calculate.ts index e1f3e239..d25fd313 100644 --- a/common/calculate.ts +++ b/common/calculate.ts @@ -23,6 +23,7 @@ import { BinaryContract, FreeResponseContract, PseudoNumericContract, + MultipleChoiceContract, } from './contract' import { floatingEqual } from './util/math' @@ -200,7 +201,9 @@ export function getContractBetNullMetrics() { } } -export function getTopAnswer(contract: FreeResponseContract) { +export function getTopAnswer( + contract: FreeResponseContract | MultipleChoiceContract +) { const { answers } = contract const top = maxBy( answers?.map((answer) => ({ diff --git a/common/contract.ts b/common/contract.ts index 177af862..8bdab6fe 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -4,13 +4,19 @@ import { JSONContent } from '@tiptap/core' import { GroupLink } from 'common/group' export type AnyMechanism = DPM | CPMM -export type AnyOutcomeType = Binary | PseudoNumeric | FreeResponse | Numeric +export type AnyOutcomeType = + | Binary + | MultipleChoice + | PseudoNumeric + | FreeResponse + | Numeric export type AnyContractType = | (CPMM & Binary) | (CPMM & PseudoNumeric) | (DPM & Binary) | (DPM & FreeResponse) | (DPM & Numeric) + | (DPM & MultipleChoice) export type Contract = { id: string @@ -57,6 +63,7 @@ export type BinaryContract = Contract & Binary export type PseudoNumericContract = Contract & PseudoNumeric export type NumericContract = Contract & Numeric export type FreeResponseContract = Contract & FreeResponse +export type MultipleChoiceContract = Contract & MultipleChoice export type DPMContract = Contract & DPM export type CPMMContract = Contract & CPMM export type DPMBinaryContract = BinaryContract & DPM @@ -104,6 +111,13 @@ export type FreeResponse = { resolutions?: { [outcome: string]: number } // Used for MKT resolution. } +export type MultipleChoice = { + outcomeType: 'MULTIPLE_CHOICE' + answers: Answer[] + resolution?: string | 'MKT' | 'CANCEL' + resolutions?: { [outcome: string]: number } // Used for MKT resolution. +} + export type Numeric = { outcomeType: 'NUMERIC' bucketCount: number @@ -118,6 +132,7 @@ export type resolution = 'YES' | 'NO' | 'MKT' | 'CANCEL' export const RESOLUTIONS = ['YES', 'NO', 'MKT', 'CANCEL'] as const export const OUTCOME_TYPES = [ 'BINARY', + 'MULTIPLE_CHOICE', 'FREE_RESPONSE', 'PSEUDO_NUMERIC', 'NUMERIC', diff --git a/common/new-bet.ts b/common/new-bet.ts index 1f5c0340..576f35f8 100644 --- a/common/new-bet.ts +++ b/common/new-bet.ts @@ -18,6 +18,7 @@ import { CPMMBinaryContract, DPMBinaryContract, FreeResponseContract, + MultipleChoiceContract, NumericContract, PseudoNumericContract, } from './contract' @@ -322,7 +323,7 @@ export const getNewBinaryDpmBetInfo = ( export const getNewMultiBetInfo = ( outcome: string, amount: number, - contract: FreeResponseContract, + contract: FreeResponseContract | MultipleChoiceContract, loanAmount: number ) => { const { pool, totalShares, totalBets } = contract diff --git a/common/new-contract.ts b/common/new-contract.ts index abfafaf8..ad7dc5a2 100644 --- a/common/new-contract.ts +++ b/common/new-contract.ts @@ -5,6 +5,7 @@ import { CPMM, DPM, FreeResponse, + MultipleChoice, Numeric, outcomeType, PseudoNumeric, @@ -30,7 +31,10 @@ export function getNewContract( bucketCount: number, min: number, max: number, - isLogScale: boolean + isLogScale: boolean, + + // for multiple choice + answers: string[] ) { const tags = parseTags( [ @@ -48,6 +52,8 @@ export function getNewContract( ? getPseudoNumericCpmmProps(initialProb, ante, min, max, isLogScale) : outcomeType === 'NUMERIC' ? getNumericProps(ante, bucketCount, min, max) + : outcomeType === 'MULTIPLE_CHOICE' + ? getMultipleChoiceProps(ante, answers) : getFreeAnswerProps(ante) const contract: Contract = removeUndefinedProps({ @@ -151,6 +157,26 @@ const getFreeAnswerProps = (ante: number) => { return system } +const getMultipleChoiceProps = (ante: number, answers: string[]) => { + const numAnswers = answers.length + const betAnte = ante / numAnswers + const betShares = Math.sqrt(ante ** 2 / numAnswers) + + const defaultValues = (x: any) => + Object.fromEntries(range(0, numAnswers).map((k) => [k, x])) + + const system: DPM & MultipleChoice = { + mechanism: 'dpm-2', + outcomeType: 'MULTIPLE_CHOICE', + pool: defaultValues(betAnte), + totalShares: defaultValues(betShares), + totalBets: defaultValues(betAnte), + answers: [], + } + + return system +} + const getNumericProps = ( ante: number, bucketCount: number, diff --git a/common/payouts-dpm.ts b/common/payouts-dpm.ts index 6cecddff..7d4a0185 100644 --- a/common/payouts-dpm.ts +++ b/common/payouts-dpm.ts @@ -2,7 +2,11 @@ import { sum, groupBy, sumBy, mapValues } from 'lodash' import { Bet, NumericBet } from './bet' import { deductDpmFees, getDpmProbability } from './calculate-dpm' -import { DPMContract, FreeResponseContract } from './contract' +import { + DPMContract, + FreeResponseContract, + MultipleChoiceContract, +} from './contract' import { DPM_CREATOR_FEE, DPM_FEES, DPM_PLATFORM_FEE } from './fees' import { addObjects } from './util/object' @@ -180,7 +184,7 @@ export const getDpmMktPayouts = ( export const getPayoutsMultiOutcome = ( resolutions: { [outcome: string]: number }, - contract: FreeResponseContract, + contract: FreeResponseContract | MultipleChoiceContract, bets: Bet[] ) => { const poolTotal = sum(Object.values(contract.pool)) diff --git a/common/payouts.ts b/common/payouts.ts index 1469cf4e..cc6c338d 100644 --- a/common/payouts.ts +++ b/common/payouts.ts @@ -117,6 +117,7 @@ export const getDpmPayouts = ( resolutionProbability?: number ): PayoutInfo => { const openBets = bets.filter((b) => !b.isSold && !b.sale) + const { outcomeType } = contract switch (outcome) { case 'YES': @@ -124,7 +125,8 @@ export const getDpmPayouts = ( return getDpmStandardPayouts(outcome, contract, openBets) case 'MKT': - return contract.outcomeType === 'FREE_RESPONSE' // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return outcomeType === 'FREE_RESPONSE' || + outcomeType === 'MULTIPLE_CHOICE' // eslint-disable-next-line @typescript-eslint/no-non-null-assertion ? getPayoutsMultiOutcome(resolutions!, contract, openBets) : getDpmMktPayouts(contract, openBets, resolutionProbability) case 'CANCEL': @@ -132,7 +134,7 @@ export const getDpmPayouts = ( return getDpmCancelPayouts(contract, openBets) default: - if (contract.outcomeType === 'NUMERIC') + if (outcomeType === 'NUMERIC') return getNumericDpmPayouts(outcome, contract, openBets as NumericBet[]) // Outcome is a free response answer id. diff --git a/functions/src/create-contract.ts b/functions/src/create-contract.ts index a30d508d..786ee8ae 100644 --- a/functions/src/create-contract.ts +++ b/functions/src/create-contract.ts @@ -7,6 +7,7 @@ import { FreeResponseContract, MAX_QUESTION_LENGTH, MAX_TAG_LENGTH, + MultipleChoiceContract, NumericContract, OUTCOME_TYPES, } from '../../common/contract' @@ -20,15 +21,18 @@ import { FIXED_ANTE, getCpmmInitialLiquidity, getFreeAnswerAnte, + getMultipleChoiceAntes, getNumericAnte, } from '../../common/antes' -import { getNoneAnswer } from '../../common/answer' +import { Answer, getNoneAnswer } from '../../common/answer' import { getNewContract } from '../../common/new-contract' import { NUMERIC_BUCKET_COUNT } from '../../common/numeric-constants' import { User } from '../../common/user' import { Group, MAX_ID_LENGTH } from '../../common/group' import { getPseudoProbability } from '../../common/pseudo-numeric' import { JSONContent } from '@tiptap/core' +import { zip } from 'lodash' +import { Bet } from 'common/bet' const descScehma: z.ZodType = z.lazy(() => z.intersection( @@ -79,11 +83,15 @@ const numericSchema = z.object({ isLogScale: z.boolean().optional(), }) +const multipleChoiceSchema = z.object({ + answers: z.string().trim().min(1).array().min(2), +}) + export const createmarket = newEndpoint({}, async (req, auth) => { const { question, description, tags, closeTime, outcomeType, groupId } = validate(bodySchema, req.body) - let min, max, initialProb, isLogScale + let min, max, initialProb, isLogScale, answers if (outcomeType === 'PSEUDO_NUMERIC' || outcomeType === 'NUMERIC') { let initialValue @@ -104,10 +112,15 @@ export const createmarket = newEndpoint({}, async (req, auth) => { ) else throw new APIError(400, 'Invalid initial probability.') } + if (outcomeType === 'BINARY') { ;({ initialProb } = validate(binarySchema, req.body)) } + if (outcomeType === 'MULTIPLE_CHOICE') { + ;({ answers } = validate(multipleChoiceSchema, req.body)) + } + const userDoc = await firestore.collection('users').doc(auth.uid).get() if (!userDoc.exists) { throw new APIError(400, 'No user exists with the authenticated user ID.') @@ -167,7 +180,8 @@ export const createmarket = newEndpoint({}, async (req, auth) => { NUMERIC_BUCKET_COUNT, min ?? 0, max ?? 0, - isLogScale ?? false + isLogScale ?? false, + answers ?? [] ) if (ante) await chargeUser(user.id, ante, true) @@ -189,6 +203,31 @@ export const createmarket = newEndpoint({}, async (req, auth) => { ) await liquidityDoc.set(lp) + } else if (outcomeType === 'MULTIPLE_CHOICE') { + const betCol = firestore.collection(`contracts/${contract.id}/bets`) + const betDocs = (answers ?? []).map(() => betCol.doc()) + + const answerCol = firestore.collection(`contracts/${contract.id}/answers`) + const answerDocs = (answers ?? []).map((_, i) => + answerCol.doc(i.toString()) + ) + + const { bets, answerObjects } = getMultipleChoiceAntes( + user, + contract as MultipleChoiceContract, + answers ?? [], + betDocs.map((bd) => bd.id) + ) + + await Promise.all( + zip(bets, betDocs).map(([bet, doc]) => doc?.create(bet as Bet)) + ) + await Promise.all( + zip(answerObjects, answerDocs).map(([answer, doc]) => + doc?.create(answer as Answer) + ) + ) + await contractRef.update({ answers: answerObjects }) } else if (outcomeType === 'FREE_RESPONSE') { const noneAnswerDoc = firestore .collection(`contracts/${contract.id}/answers`) diff --git a/functions/src/place-bet.ts b/functions/src/place-bet.ts index 97ff9780..7501309a 100644 --- a/functions/src/place-bet.ts +++ b/functions/src/place-bet.ts @@ -96,7 +96,10 @@ export const placebet = newEndpoint({}, async (req, auth) => { limitProb, unfilledBets ) - } else if (outcomeType == 'FREE_RESPONSE' && mechanism == 'dpm-2') { + } else if ( + (outcomeType == 'FREE_RESPONSE' || outcomeType === 'MULTIPLE_CHOICE') && + mechanism == 'dpm-2' + ) { const { outcome } = validate(freeResponseSchema, req.body) const answerDoc = contractDoc.collection('answers').doc(outcome) const answerSnap = await trans.get(answerDoc) diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index f8976cb3..08778a41 100644 --- a/functions/src/resolve-market.ts +++ b/functions/src/resolve-market.ts @@ -5,6 +5,7 @@ import { difference, uniq, mapValues, groupBy, sumBy } from 'lodash' import { Contract, FreeResponseContract, + MultipleChoiceContract, RESOLUTIONS, } from '../../common/contract' import { User } from '../../common/user' @@ -245,7 +246,10 @@ function getResolutionParams(contract: Contract, body: string) { ...validate(pseudoNumericSchema, body), resolutions: undefined, } - } else if (outcomeType === 'FREE_RESPONSE') { + } else if ( + outcomeType === 'FREE_RESPONSE' || + outcomeType === 'MULTIPLE_CHOICE' + ) { const freeResponseParams = validate(freeResponseSchema, body) const { outcome } = freeResponseParams switch (outcome) { @@ -292,7 +296,10 @@ function getResolutionParams(contract: Contract, body: string) { throw new APIError(500, `Invalid outcome type: ${outcomeType}`) } -function validateAnswer(contract: FreeResponseContract, answer: number) { +function validateAnswer( + contract: FreeResponseContract | MultipleChoiceContract, + answer: number +) { const validIds = contract.answers.map((a) => a.id) if (!validIds.includes(answer.toString())) { throw new APIError(400, `${answer} is not a valid answer ID`) diff --git a/web/components/answers/answer-bet-panel.tsx b/web/components/answers/answer-bet-panel.tsx index 8c1d0430..6dcba79b 100644 --- a/web/components/answers/answer-bet-panel.tsx +++ b/web/components/answers/answer-bet-panel.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'react' import { XIcon } from '@heroicons/react/solid' import { Answer } from 'common/answer' -import { FreeResponseContract } from 'common/contract' +import { FreeResponseContract, MultipleChoiceContract } from 'common/contract' import { BuyAmountInput } from '../amount-input' import { Col } from '../layout/col' import { APIError, placeBet } from 'web/lib/firebase/api' @@ -29,7 +29,7 @@ import { isIOS } from 'web/lib/util/device' export function AnswerBetPanel(props: { answer: Answer - contract: FreeResponseContract + contract: FreeResponseContract | MultipleChoiceContract closePanel: () => void className?: string isModal?: boolean diff --git a/web/components/answers/answer-item.tsx b/web/components/answers/answer-item.tsx index 87756a07..f1ab2f88 100644 --- a/web/components/answers/answer-item.tsx +++ b/web/components/answers/answer-item.tsx @@ -1,7 +1,7 @@ import clsx from 'clsx' import { Answer } from 'common/answer' -import { FreeResponseContract } from 'common/contract' +import { FreeResponseContract, MultipleChoiceContract } from 'common/contract' import { Col } from '../layout/col' import { Row } from '../layout/row' import { Avatar } from '../avatar' @@ -13,7 +13,7 @@ import { Linkify } from '../linkify' export function AnswerItem(props: { answer: Answer - contract: FreeResponseContract + contract: FreeResponseContract | MultipleChoiceContract showChoice: 'radio' | 'checkbox' | undefined chosenProb: number | undefined totalChosenProb?: number diff --git a/web/components/answers/answer-resolve-panel.tsx b/web/components/answers/answer-resolve-panel.tsx index 5b59f050..0a4ac1e1 100644 --- a/web/components/answers/answer-resolve-panel.tsx +++ b/web/components/answers/answer-resolve-panel.tsx @@ -2,7 +2,7 @@ import clsx from 'clsx' import { sum } from 'lodash' import { useState } from 'react' -import { Contract, FreeResponse } from 'common/contract' +import { FreeResponseContract, MultipleChoiceContract } from 'common/contract' import { Col } from '../layout/col' import { APIError, resolveMarket } from 'web/lib/firebase/api' import { Row } from '../layout/row' @@ -11,7 +11,7 @@ import { ResolveConfirmationButton } from '../confirmation-button' import { removeUndefinedProps } from 'common/util/object' export function AnswerResolvePanel(props: { - contract: Contract & FreeResponse + contract: FreeResponseContract | MultipleChoiceContract resolveOption: 'CHOOSE' | 'CHOOSE_MULTIPLE' | 'CANCEL' | undefined setResolveOption: ( option: 'CHOOSE' | 'CHOOSE_MULTIPLE' | 'CANCEL' | undefined diff --git a/web/components/answers/answers-graph.tsx b/web/components/answers/answers-graph.tsx index 3e16a4c2..27152db9 100644 --- a/web/components/answers/answers-graph.tsx +++ b/web/components/answers/answers-graph.tsx @@ -5,14 +5,14 @@ import { groupBy, sortBy, sumBy } from 'lodash' import { memo } from 'react' import { Bet } from 'common/bet' -import { FreeResponseContract } from 'common/contract' +import { FreeResponseContract, MultipleChoiceContract } from 'common/contract' import { getOutcomeProbability } from 'common/calculate' import { useWindowSize } from 'web/hooks/use-window-size' const NUM_LINES = 6 export const AnswersGraph = memo(function AnswersGraph(props: { - contract: FreeResponseContract + contract: FreeResponseContract | MultipleChoiceContract bets: Bet[] height?: number }) { @@ -178,15 +178,22 @@ function formatTime( return d.format(format) } -const computeProbsByOutcome = (bets: Bet[], contract: FreeResponseContract) => { - const { totalBets } = contract +const computeProbsByOutcome = ( + bets: Bet[], + contract: FreeResponseContract | MultipleChoiceContract +) => { + const { totalBets, outcomeType } = contract const betsByOutcome = groupBy(bets, (bet) => bet.outcome) const outcomes = Object.keys(betsByOutcome).filter((outcome) => { const maxProb = Math.max( ...betsByOutcome[outcome].map((bet) => bet.probAfter) ) - return outcome !== '0' && maxProb > 0.02 && totalBets[outcome] > 0.000000001 + return ( + (outcome !== '0' || outcomeType === 'MULTIPLE_CHOICE') && + maxProb > 0.02 && + totalBets[outcome] > 0.000000001 + ) }) const trackedOutcomes = sortBy( diff --git a/web/components/answers/answers-panel.tsx b/web/components/answers/answers-panel.tsx index e7bf4da8..6e0bfef6 100644 --- a/web/components/answers/answers-panel.tsx +++ b/web/components/answers/answers-panel.tsx @@ -1,7 +1,7 @@ import { sortBy, partition, sum, uniq } from 'lodash' import { useEffect, useState } from 'react' -import { FreeResponseContract } from 'common/contract' +import { FreeResponseContract, MultipleChoiceContract } from 'common/contract' import { Col } from '../layout/col' import { useUser } from 'web/hooks/use-user' import { getDpmOutcomeProbability } from 'common/calculate-dpm' @@ -25,14 +25,19 @@ import { UserLink } from 'web/components/user-page' import { Linkify } from 'web/components/linkify' import { BuyButton } from 'web/components/yes-no-selector' -export function AnswersPanel(props: { contract: FreeResponseContract }) { +export function AnswersPanel(props: { + contract: FreeResponseContract | MultipleChoiceContract +}) { const { contract } = props - const { creatorId, resolution, resolutions, totalBets } = contract + const { creatorId, resolution, resolutions, totalBets, outcomeType } = + contract const answers = useAnswers(contract.id) ?? contract.answers const [winningAnswers, losingAnswers] = partition( answers.filter( - (answer) => answer.id !== '0' && totalBets[answer.id] > 0.000000001 + (answer) => + (answer.id !== '0' || outcomeType === 'MULTIPLE_CHOICE') && + totalBets[answer.id] > 0.000000001 ), (answer) => answer.id === resolution || (resolutions && resolutions[answer.id]) @@ -131,7 +136,8 @@ export function AnswersPanel(props: { contract: FreeResponseContract }) {
No answers yet...
)} - {tradingAllowed(contract) && + {outcomeType === 'FREE_RESPONSE' && + tradingAllowed(contract) && (!resolveOption || resolveOption === 'CANCEL') && ( )} @@ -152,7 +158,7 @@ export function AnswersPanel(props: { contract: FreeResponseContract }) { } function getAnswerItems( - contract: FreeResponseContract, + contract: FreeResponseContract | MultipleChoiceContract, answers: Answer[], user: User | undefined | null ) { @@ -178,7 +184,7 @@ function getAnswerItems( } function OpenAnswer(props: { - contract: FreeResponseContract + contract: FreeResponseContract | MultipleChoiceContract answer: Answer items: ActivityItem[] type: string diff --git a/web/components/answers/multiple-choice-answers.tsx b/web/components/answers/multiple-choice-answers.tsx new file mode 100644 index 00000000..450c221a --- /dev/null +++ b/web/components/answers/multiple-choice-answers.tsx @@ -0,0 +1,65 @@ +import { MAX_ANSWER_LENGTH } from 'common/answer' +import { useState } from 'react' +import Textarea from 'react-expanding-textarea' +import { XIcon } from '@heroicons/react/solid' + +import { Col } from '../layout/col' +import { Row } from '../layout/row' + +export function MultipleChoiceAnswers(props: { + setAnswers: (answers: string[]) => void +}) { + const [answers, setInternalAnswers] = useState(['', '', '']) + + const setAnswer = (i: number, answer: string) => { + const newAnswers = setElement(answers, i, answer) + setInternalAnswers(newAnswers) + props.setAnswers(newAnswers) + } + + const removeAnswer = (i: number) => { + const newAnswers = answers.slice(0, i).concat(answers.slice(i + 1)) + setInternalAnswers(newAnswers) + props.setAnswers(newAnswers) + } + + const addAnswer = () => setAnswer(answers.length, '') + + return ( + + {answers.map((answer, i) => ( + + {i + 1}.{' '} +