Multiple choice markets (#698)

* multipe choice answers

* create multiple choice cloud function

* multi choice market page

* show outcome '0'

* stats: multi choice type

* update place bet

* answer doc id = outcome

* update resolve market

* prettier

* fix

* fix resolution
This commit is contained in:
mantikoros 2022-07-27 21:40:33 -05:00 committed by GitHub
parent b1c4f018f9
commit 1aaae93113
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 284 additions and 39 deletions

View File

@ -5,12 +5,14 @@ import {
CPMMBinaryContract, CPMMBinaryContract,
DPMBinaryContract, DPMBinaryContract,
FreeResponseContract, FreeResponseContract,
MultipleChoiceContract,
NumericContract, NumericContract,
} from './contract' } from './contract'
import { User } from './user' import { User } from './user'
import { LiquidityProvision } from './liquidity-provision' import { LiquidityProvision } from './liquidity-provision'
import { noFees } from './fees' import { noFees } from './fees'
import { ENV_CONFIG } from './envs/constants' import { ENV_CONFIG } from './envs/constants'
import { Answer } from './answer'
export const FIXED_ANTE = ENV_CONFIG.fixedAnte ?? 100 export const FIXED_ANTE = ENV_CONFIG.fixedAnte ?? 100
@ -111,6 +113,50 @@ export function getFreeAnswerAnte(
return anteBet 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( export function getNumericAnte(
anteBettorId: string, anteBettorId: string,
contract: NumericContract, contract: NumericContract,

View File

@ -23,6 +23,7 @@ import {
BinaryContract, BinaryContract,
FreeResponseContract, FreeResponseContract,
PseudoNumericContract, PseudoNumericContract,
MultipleChoiceContract,
} from './contract' } from './contract'
import { floatingEqual } from './util/math' 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 { answers } = contract
const top = maxBy( const top = maxBy(
answers?.map((answer) => ({ answers?.map((answer) => ({

View File

@ -4,13 +4,19 @@ import { JSONContent } from '@tiptap/core'
import { GroupLink } from 'common/group' import { GroupLink } from 'common/group'
export type AnyMechanism = DPM | CPMM export type AnyMechanism = DPM | CPMM
export type AnyOutcomeType = Binary | PseudoNumeric | FreeResponse | Numeric export type AnyOutcomeType =
| Binary
| MultipleChoice
| PseudoNumeric
| FreeResponse
| Numeric
export type AnyContractType = export type AnyContractType =
| (CPMM & Binary) | (CPMM & Binary)
| (CPMM & PseudoNumeric) | (CPMM & PseudoNumeric)
| (DPM & Binary) | (DPM & Binary)
| (DPM & FreeResponse) | (DPM & FreeResponse)
| (DPM & Numeric) | (DPM & Numeric)
| (DPM & MultipleChoice)
export type Contract<T extends AnyContractType = AnyContractType> = { export type Contract<T extends AnyContractType = AnyContractType> = {
id: string id: string
@ -57,6 +63,7 @@ export type BinaryContract = Contract & Binary
export type PseudoNumericContract = Contract & PseudoNumeric export type PseudoNumericContract = Contract & PseudoNumeric
export type NumericContract = Contract & Numeric export type NumericContract = Contract & Numeric
export type FreeResponseContract = Contract & FreeResponse export type FreeResponseContract = Contract & FreeResponse
export type MultipleChoiceContract = Contract & MultipleChoice
export type DPMContract = Contract & DPM export type DPMContract = Contract & DPM
export type CPMMContract = Contract & CPMM export type CPMMContract = Contract & CPMM
export type DPMBinaryContract = BinaryContract & DPM export type DPMBinaryContract = BinaryContract & DPM
@ -104,6 +111,13 @@ export type FreeResponse = {
resolutions?: { [outcome: string]: number } // Used for MKT resolution. 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 = { export type Numeric = {
outcomeType: 'NUMERIC' outcomeType: 'NUMERIC'
bucketCount: number bucketCount: number
@ -118,6 +132,7 @@ export type resolution = 'YES' | 'NO' | 'MKT' | 'CANCEL'
export const RESOLUTIONS = ['YES', 'NO', 'MKT', 'CANCEL'] as const export const RESOLUTIONS = ['YES', 'NO', 'MKT', 'CANCEL'] as const
export const OUTCOME_TYPES = [ export const OUTCOME_TYPES = [
'BINARY', 'BINARY',
'MULTIPLE_CHOICE',
'FREE_RESPONSE', 'FREE_RESPONSE',
'PSEUDO_NUMERIC', 'PSEUDO_NUMERIC',
'NUMERIC', 'NUMERIC',

View File

@ -18,6 +18,7 @@ import {
CPMMBinaryContract, CPMMBinaryContract,
DPMBinaryContract, DPMBinaryContract,
FreeResponseContract, FreeResponseContract,
MultipleChoiceContract,
NumericContract, NumericContract,
PseudoNumericContract, PseudoNumericContract,
} from './contract' } from './contract'
@ -322,7 +323,7 @@ export const getNewBinaryDpmBetInfo = (
export const getNewMultiBetInfo = ( export const getNewMultiBetInfo = (
outcome: string, outcome: string,
amount: number, amount: number,
contract: FreeResponseContract, contract: FreeResponseContract | MultipleChoiceContract,
loanAmount: number loanAmount: number
) => { ) => {
const { pool, totalShares, totalBets } = contract const { pool, totalShares, totalBets } = contract

View File

@ -5,6 +5,7 @@ import {
CPMM, CPMM,
DPM, DPM,
FreeResponse, FreeResponse,
MultipleChoice,
Numeric, Numeric,
outcomeType, outcomeType,
PseudoNumeric, PseudoNumeric,
@ -30,7 +31,10 @@ export function getNewContract(
bucketCount: number, bucketCount: number,
min: number, min: number,
max: number, max: number,
isLogScale: boolean isLogScale: boolean,
// for multiple choice
answers: string[]
) { ) {
const tags = parseTags( const tags = parseTags(
[ [
@ -48,6 +52,8 @@ export function getNewContract(
? getPseudoNumericCpmmProps(initialProb, ante, min, max, isLogScale) ? getPseudoNumericCpmmProps(initialProb, ante, min, max, isLogScale)
: outcomeType === 'NUMERIC' : outcomeType === 'NUMERIC'
? getNumericProps(ante, bucketCount, min, max) ? getNumericProps(ante, bucketCount, min, max)
: outcomeType === 'MULTIPLE_CHOICE'
? getMultipleChoiceProps(ante, answers)
: getFreeAnswerProps(ante) : getFreeAnswerProps(ante)
const contract: Contract = removeUndefinedProps({ const contract: Contract = removeUndefinedProps({
@ -151,6 +157,26 @@ const getFreeAnswerProps = (ante: number) => {
return system 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 = ( const getNumericProps = (
ante: number, ante: number,
bucketCount: number, bucketCount: number,

View File

@ -2,7 +2,11 @@ import { sum, groupBy, sumBy, mapValues } from 'lodash'
import { Bet, NumericBet } from './bet' import { Bet, NumericBet } from './bet'
import { deductDpmFees, getDpmProbability } from './calculate-dpm' 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 { DPM_CREATOR_FEE, DPM_FEES, DPM_PLATFORM_FEE } from './fees'
import { addObjects } from './util/object' import { addObjects } from './util/object'
@ -180,7 +184,7 @@ export const getDpmMktPayouts = (
export const getPayoutsMultiOutcome = ( export const getPayoutsMultiOutcome = (
resolutions: { [outcome: string]: number }, resolutions: { [outcome: string]: number },
contract: FreeResponseContract, contract: FreeResponseContract | MultipleChoiceContract,
bets: Bet[] bets: Bet[]
) => { ) => {
const poolTotal = sum(Object.values(contract.pool)) const poolTotal = sum(Object.values(contract.pool))

View File

@ -117,6 +117,7 @@ export const getDpmPayouts = (
resolutionProbability?: number resolutionProbability?: number
): PayoutInfo => { ): PayoutInfo => {
const openBets = bets.filter((b) => !b.isSold && !b.sale) const openBets = bets.filter((b) => !b.isSold && !b.sale)
const { outcomeType } = contract
switch (outcome) { switch (outcome) {
case 'YES': case 'YES':
@ -124,7 +125,8 @@ export const getDpmPayouts = (
return getDpmStandardPayouts(outcome, contract, openBets) return getDpmStandardPayouts(outcome, contract, openBets)
case 'MKT': 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) ? getPayoutsMultiOutcome(resolutions!, contract, openBets)
: getDpmMktPayouts(contract, openBets, resolutionProbability) : getDpmMktPayouts(contract, openBets, resolutionProbability)
case 'CANCEL': case 'CANCEL':
@ -132,7 +134,7 @@ export const getDpmPayouts = (
return getDpmCancelPayouts(contract, openBets) return getDpmCancelPayouts(contract, openBets)
default: default:
if (contract.outcomeType === 'NUMERIC') if (outcomeType === 'NUMERIC')
return getNumericDpmPayouts(outcome, contract, openBets as NumericBet[]) return getNumericDpmPayouts(outcome, contract, openBets as NumericBet[])
// Outcome is a free response answer id. // Outcome is a free response answer id.

View File

@ -7,6 +7,7 @@ import {
FreeResponseContract, FreeResponseContract,
MAX_QUESTION_LENGTH, MAX_QUESTION_LENGTH,
MAX_TAG_LENGTH, MAX_TAG_LENGTH,
MultipleChoiceContract,
NumericContract, NumericContract,
OUTCOME_TYPES, OUTCOME_TYPES,
} from '../../common/contract' } from '../../common/contract'
@ -20,15 +21,18 @@ import {
FIXED_ANTE, FIXED_ANTE,
getCpmmInitialLiquidity, getCpmmInitialLiquidity,
getFreeAnswerAnte, getFreeAnswerAnte,
getMultipleChoiceAntes,
getNumericAnte, getNumericAnte,
} from '../../common/antes' } from '../../common/antes'
import { getNoneAnswer } from '../../common/answer' import { Answer, getNoneAnswer } from '../../common/answer'
import { getNewContract } from '../../common/new-contract' import { getNewContract } from '../../common/new-contract'
import { NUMERIC_BUCKET_COUNT } from '../../common/numeric-constants' import { NUMERIC_BUCKET_COUNT } from '../../common/numeric-constants'
import { User } from '../../common/user' import { User } from '../../common/user'
import { Group, MAX_ID_LENGTH } from '../../common/group' import { Group, MAX_ID_LENGTH } from '../../common/group'
import { getPseudoProbability } from '../../common/pseudo-numeric' import { getPseudoProbability } from '../../common/pseudo-numeric'
import { JSONContent } from '@tiptap/core' import { JSONContent } from '@tiptap/core'
import { zip } from 'lodash'
import { Bet } from 'common/bet'
const descScehma: z.ZodType<JSONContent> = z.lazy(() => const descScehma: z.ZodType<JSONContent> = z.lazy(() =>
z.intersection( z.intersection(
@ -79,11 +83,15 @@ const numericSchema = z.object({
isLogScale: z.boolean().optional(), isLogScale: z.boolean().optional(),
}) })
const multipleChoiceSchema = z.object({
answers: z.string().trim().min(1).array().min(2),
})
export const createmarket = newEndpoint({}, async (req, auth) => { export const createmarket = newEndpoint({}, async (req, auth) => {
const { question, description, tags, closeTime, outcomeType, groupId } = const { question, description, tags, closeTime, outcomeType, groupId } =
validate(bodySchema, req.body) validate(bodySchema, req.body)
let min, max, initialProb, isLogScale let min, max, initialProb, isLogScale, answers
if (outcomeType === 'PSEUDO_NUMERIC' || outcomeType === 'NUMERIC') { if (outcomeType === 'PSEUDO_NUMERIC' || outcomeType === 'NUMERIC') {
let initialValue let initialValue
@ -104,10 +112,15 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
) )
else throw new APIError(400, 'Invalid initial probability.') else throw new APIError(400, 'Invalid initial probability.')
} }
if (outcomeType === 'BINARY') { if (outcomeType === 'BINARY') {
;({ initialProb } = validate(binarySchema, req.body)) ;({ initialProb } = validate(binarySchema, req.body))
} }
if (outcomeType === 'MULTIPLE_CHOICE') {
;({ answers } = validate(multipleChoiceSchema, req.body))
}
const userDoc = await firestore.collection('users').doc(auth.uid).get() const userDoc = await firestore.collection('users').doc(auth.uid).get()
if (!userDoc.exists) { if (!userDoc.exists) {
throw new APIError(400, 'No user exists with the authenticated user ID.') 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, NUMERIC_BUCKET_COUNT,
min ?? 0, min ?? 0,
max ?? 0, max ?? 0,
isLogScale ?? false isLogScale ?? false,
answers ?? []
) )
if (ante) await chargeUser(user.id, ante, true) if (ante) await chargeUser(user.id, ante, true)
@ -189,6 +203,31 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
) )
await liquidityDoc.set(lp) 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') { } else if (outcomeType === 'FREE_RESPONSE') {
const noneAnswerDoc = firestore const noneAnswerDoc = firestore
.collection(`contracts/${contract.id}/answers`) .collection(`contracts/${contract.id}/answers`)

View File

@ -96,7 +96,10 @@ export const placebet = newEndpoint({}, async (req, auth) => {
limitProb, limitProb,
unfilledBets 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 { outcome } = validate(freeResponseSchema, req.body)
const answerDoc = contractDoc.collection('answers').doc(outcome) const answerDoc = contractDoc.collection('answers').doc(outcome)
const answerSnap = await trans.get(answerDoc) const answerSnap = await trans.get(answerDoc)

View File

@ -5,6 +5,7 @@ import { difference, uniq, mapValues, groupBy, sumBy } from 'lodash'
import { import {
Contract, Contract,
FreeResponseContract, FreeResponseContract,
MultipleChoiceContract,
RESOLUTIONS, RESOLUTIONS,
} from '../../common/contract' } from '../../common/contract'
import { User } from '../../common/user' import { User } from '../../common/user'
@ -245,7 +246,10 @@ function getResolutionParams(contract: Contract, body: string) {
...validate(pseudoNumericSchema, body), ...validate(pseudoNumericSchema, body),
resolutions: undefined, resolutions: undefined,
} }
} else if (outcomeType === 'FREE_RESPONSE') { } else if (
outcomeType === 'FREE_RESPONSE' ||
outcomeType === 'MULTIPLE_CHOICE'
) {
const freeResponseParams = validate(freeResponseSchema, body) const freeResponseParams = validate(freeResponseSchema, body)
const { outcome } = freeResponseParams const { outcome } = freeResponseParams
switch (outcome) { switch (outcome) {
@ -292,7 +296,10 @@ function getResolutionParams(contract: Contract, body: string) {
throw new APIError(500, `Invalid outcome type: ${outcomeType}`) 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) const validIds = contract.answers.map((a) => a.id)
if (!validIds.includes(answer.toString())) { if (!validIds.includes(answer.toString())) {
throw new APIError(400, `${answer} is not a valid answer ID`) throw new APIError(400, `${answer} is not a valid answer ID`)

View File

@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'react'
import { XIcon } from '@heroicons/react/solid' import { XIcon } from '@heroicons/react/solid'
import { Answer } from 'common/answer' import { Answer } from 'common/answer'
import { FreeResponseContract } from 'common/contract' import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
import { BuyAmountInput } from '../amount-input' import { BuyAmountInput } from '../amount-input'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { APIError, placeBet } from 'web/lib/firebase/api' import { APIError, placeBet } from 'web/lib/firebase/api'
@ -29,7 +29,7 @@ import { isIOS } from 'web/lib/util/device'
export function AnswerBetPanel(props: { export function AnswerBetPanel(props: {
answer: Answer answer: Answer
contract: FreeResponseContract contract: FreeResponseContract | MultipleChoiceContract
closePanel: () => void closePanel: () => void
className?: string className?: string
isModal?: boolean isModal?: boolean

View File

@ -1,7 +1,7 @@
import clsx from 'clsx' import clsx from 'clsx'
import { Answer } from 'common/answer' import { Answer } from 'common/answer'
import { FreeResponseContract } from 'common/contract' import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { Avatar } from '../avatar' import { Avatar } from '../avatar'
@ -13,7 +13,7 @@ import { Linkify } from '../linkify'
export function AnswerItem(props: { export function AnswerItem(props: {
answer: Answer answer: Answer
contract: FreeResponseContract contract: FreeResponseContract | MultipleChoiceContract
showChoice: 'radio' | 'checkbox' | undefined showChoice: 'radio' | 'checkbox' | undefined
chosenProb: number | undefined chosenProb: number | undefined
totalChosenProb?: number totalChosenProb?: number

View File

@ -2,7 +2,7 @@ import clsx from 'clsx'
import { sum } from 'lodash' import { sum } from 'lodash'
import { useState } from 'react' import { useState } from 'react'
import { Contract, FreeResponse } from 'common/contract' import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { APIError, resolveMarket } from 'web/lib/firebase/api' import { APIError, resolveMarket } from 'web/lib/firebase/api'
import { Row } from '../layout/row' import { Row } from '../layout/row'
@ -11,7 +11,7 @@ import { ResolveConfirmationButton } from '../confirmation-button'
import { removeUndefinedProps } from 'common/util/object' import { removeUndefinedProps } from 'common/util/object'
export function AnswerResolvePanel(props: { export function AnswerResolvePanel(props: {
contract: Contract & FreeResponse contract: FreeResponseContract | MultipleChoiceContract
resolveOption: 'CHOOSE' | 'CHOOSE_MULTIPLE' | 'CANCEL' | undefined resolveOption: 'CHOOSE' | 'CHOOSE_MULTIPLE' | 'CANCEL' | undefined
setResolveOption: ( setResolveOption: (
option: 'CHOOSE' | 'CHOOSE_MULTIPLE' | 'CANCEL' | undefined option: 'CHOOSE' | 'CHOOSE_MULTIPLE' | 'CANCEL' | undefined

View File

@ -5,14 +5,14 @@ import { groupBy, sortBy, sumBy } from 'lodash'
import { memo } from 'react' import { memo } from 'react'
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import { FreeResponseContract } from 'common/contract' import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
import { getOutcomeProbability } from 'common/calculate' import { getOutcomeProbability } from 'common/calculate'
import { useWindowSize } from 'web/hooks/use-window-size' import { useWindowSize } from 'web/hooks/use-window-size'
const NUM_LINES = 6 const NUM_LINES = 6
export const AnswersGraph = memo(function AnswersGraph(props: { export const AnswersGraph = memo(function AnswersGraph(props: {
contract: FreeResponseContract contract: FreeResponseContract | MultipleChoiceContract
bets: Bet[] bets: Bet[]
height?: number height?: number
}) { }) {
@ -178,15 +178,22 @@ function formatTime(
return d.format(format) return d.format(format)
} }
const computeProbsByOutcome = (bets: Bet[], contract: FreeResponseContract) => { const computeProbsByOutcome = (
const { totalBets } = contract bets: Bet[],
contract: FreeResponseContract | MultipleChoiceContract
) => {
const { totalBets, outcomeType } = contract
const betsByOutcome = groupBy(bets, (bet) => bet.outcome) const betsByOutcome = groupBy(bets, (bet) => bet.outcome)
const outcomes = Object.keys(betsByOutcome).filter((outcome) => { const outcomes = Object.keys(betsByOutcome).filter((outcome) => {
const maxProb = Math.max( const maxProb = Math.max(
...betsByOutcome[outcome].map((bet) => bet.probAfter) ...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( const trackedOutcomes = sortBy(

View File

@ -1,7 +1,7 @@
import { sortBy, partition, sum, uniq } from 'lodash' import { sortBy, partition, sum, uniq } from 'lodash'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { FreeResponseContract } from 'common/contract' import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { getDpmOutcomeProbability } from 'common/calculate-dpm' import { getDpmOutcomeProbability } from 'common/calculate-dpm'
@ -25,14 +25,19 @@ import { UserLink } from 'web/components/user-page'
import { Linkify } from 'web/components/linkify' import { Linkify } from 'web/components/linkify'
import { BuyButton } from 'web/components/yes-no-selector' 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 { contract } = props
const { creatorId, resolution, resolutions, totalBets } = contract const { creatorId, resolution, resolutions, totalBets, outcomeType } =
contract
const answers = useAnswers(contract.id) ?? contract.answers const answers = useAnswers(contract.id) ?? contract.answers
const [winningAnswers, losingAnswers] = partition( const [winningAnswers, losingAnswers] = partition(
answers.filter( 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) =>
answer.id === resolution || (resolutions && resolutions[answer.id]) answer.id === resolution || (resolutions && resolutions[answer.id])
@ -131,7 +136,8 @@ export function AnswersPanel(props: { contract: FreeResponseContract }) {
<div className="pb-4 text-gray-500">No answers yet...</div> <div className="pb-4 text-gray-500">No answers yet...</div>
)} )}
{tradingAllowed(contract) && {outcomeType === 'FREE_RESPONSE' &&
tradingAllowed(contract) &&
(!resolveOption || resolveOption === 'CANCEL') && ( (!resolveOption || resolveOption === 'CANCEL') && (
<CreateAnswerPanel contract={contract} /> <CreateAnswerPanel contract={contract} />
)} )}
@ -152,7 +158,7 @@ export function AnswersPanel(props: { contract: FreeResponseContract }) {
} }
function getAnswerItems( function getAnswerItems(
contract: FreeResponseContract, contract: FreeResponseContract | MultipleChoiceContract,
answers: Answer[], answers: Answer[],
user: User | undefined | null user: User | undefined | null
) { ) {
@ -178,7 +184,7 @@ function getAnswerItems(
} }
function OpenAnswer(props: { function OpenAnswer(props: {
contract: FreeResponseContract contract: FreeResponseContract | MultipleChoiceContract
answer: Answer answer: Answer
items: ActivityItem[] items: ActivityItem[]
type: string type: string

View File

@ -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 (
<Col>
{answers.map((answer, i) => (
<Row className="mb-2 items-center align-middle">
{i + 1}.{' '}
<Textarea
value={answer}
onChange={(e) => setAnswer(i, e.target.value)}
className="textarea textarea-bordered ml-2 w-full resize-none"
placeholder="Type your answer..."
rows={1}
maxLength={MAX_ANSWER_LENGTH}
/>
{answers.length > 2 && (
<button
className="btn btn-xs btn-outline ml-2"
onClick={() => removeAnswer(i)}
>
<XIcon className="h-4 w-4 flex-shrink-0" />
</button>
)}
</Row>
))}
<Row className="justify-end">
<button className="btn btn-outline btn-xs" onClick={addAnswer}>
Add answer
</button>
</Row>
</Col>
)
}
const setElement = <T,>(array: T[], i: number, elem: T) => {
const newArray = array.concat()
newArray[i] = elem
return newArray
}

View File

@ -8,6 +8,7 @@ import {
BinaryContract, BinaryContract,
Contract, Contract,
FreeResponseContract, FreeResponseContract,
MultipleChoiceContract,
NumericContract, NumericContract,
PseudoNumericContract, PseudoNumericContract,
} from 'common/contract' } from 'common/contract'
@ -227,7 +228,7 @@ function FreeResponseTopAnswer(props: {
} }
export function FreeResponseResolutionOrChance(props: { export function FreeResponseResolutionOrChance(props: {
contract: FreeResponseContract contract: FreeResponseContract | MultipleChoiceContract
truncate: 'short' | 'long' | 'none' truncate: 'short' | 'long' | 'none'
className?: string className?: string
}) { }) {

View File

@ -41,6 +41,8 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
? 'YES / NO' ? 'YES / NO'
: outcomeType === 'FREE_RESPONSE' : outcomeType === 'FREE_RESPONSE'
? 'Free response' ? 'Free response'
: outcomeType === 'MULTIPLE_CHOICE'
? 'Multiple choice'
: 'Numeric' : 'Numeric'
return ( return (

View File

@ -85,7 +85,8 @@ export const ContractOverview = (props: {
{tradingAllowed(contract) && <BetRow contract={contract} />} {tradingAllowed(contract) && <BetRow contract={contract} />}
</Row> </Row>
) : ( ) : (
outcomeType === 'FREE_RESPONSE' && (outcomeType === 'FREE_RESPONSE' ||
outcomeType === 'MULTIPLE_CHOICE') &&
resolution && ( resolution && (
<FreeResponseResolutionOrChance <FreeResponseResolutionOrChance
contract={contract} contract={contract}
@ -110,7 +111,8 @@ export const ContractOverview = (props: {
{(isBinary || isPseudoNumeric) && ( {(isBinary || isPseudoNumeric) && (
<ContractProbGraph contract={contract} bets={bets} /> <ContractProbGraph contract={contract} bets={bets} />
)}{' '} )}{' '}
{outcomeType === 'FREE_RESPONSE' && ( {(outcomeType === 'FREE_RESPONSE' ||
outcomeType === 'MULTIPLE_CHOICE') && (
<AnswersGraph contract={contract} bets={bets} /> <AnswersGraph contract={contract} bets={bets} />
)} )}
{outcomeType === 'NUMERIC' && <NumericGraph contract={contract} />} {outcomeType === 'NUMERIC' && <NumericGraph contract={contract} />}

View File

@ -7,6 +7,7 @@ import {
BinaryContract, BinaryContract,
Contract, Contract,
FreeResponseContract, FreeResponseContract,
MultipleChoiceContract,
resolution, resolution,
} from 'common/contract' } from 'common/contract'
import { formatLargeNumber, formatPercent } from 'common/util/format' import { formatLargeNumber, formatPercent } from 'common/util/format'
@ -77,7 +78,7 @@ export function BinaryContractOutcomeLabel(props: {
} }
export function FreeResponseOutcomeLabel(props: { export function FreeResponseOutcomeLabel(props: {
contract: FreeResponseContract contract: FreeResponseContract | MultipleChoiceContract
resolution: string | 'CANCEL' | 'MKT' resolution: string | 'CANCEL' | 'MKT'
truncate: 'short' | 'long' | 'none' truncate: 'short' | 'long' | 'none'
answerClassName?: string answerClassName?: string

View File

@ -217,7 +217,8 @@ export function ContractPageContent(
/> />
)} )}
{outcomeType === 'FREE_RESPONSE' && ( {(outcomeType === 'FREE_RESPONSE' ||
outcomeType === 'MULTIPLE_CHOICE') && (
<> <>
<Spacer h={4} /> <Spacer h={4} />
<AnswersPanel contract={contract} /> <AnswersPanel contract={contract} />

View File

@ -31,6 +31,7 @@ import { Checkbox } from 'web/components/checkbox'
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
import { Title } from 'web/components/title' import { Title } from 'web/components/title'
import { SEO } from 'web/components/SEO' import { SEO } from 'web/components/SEO'
import { MultipleChoiceAnswers } from 'web/components/answers/multiple-choice-answers'
export const getServerSideProps = redirectIfLoggedOut('/') export const getServerSideProps = redirectIfLoggedOut('/')
@ -116,6 +117,8 @@ export function NewContract(props: {
const [isLogScale, setIsLogScale] = useState<boolean>(!!params?.isLogScale) const [isLogScale, setIsLogScale] = useState<boolean>(!!params?.isLogScale)
const [initialValueString, setInitialValueString] = useState(initValue) const [initialValueString, setInitialValueString] = useState(initValue)
const [answers, setAnswers] = useState<string[]>([]) // for multiple choice
useEffect(() => { useEffect(() => {
if (groupId && creator) if (groupId && creator)
getGroup(groupId).then((group) => { getGroup(groupId).then((group) => {
@ -160,6 +163,10 @@ export function NewContract(props: {
// get days from today until the end of this year: // get days from today until the end of this year:
const daysLeftInTheYear = dayjs().endOf('year').diff(dayjs(), 'day') const daysLeftInTheYear = dayjs().endOf('year').diff(dayjs(), 'day')
const isValidMultipleChoice = answers.every(
(answer) => answer.trim().length > 0
)
const isValid = const isValid =
(outcomeType === 'BINARY' ? initialProb >= 5 && initialProb <= 95 : true) && (outcomeType === 'BINARY' ? initialProb >= 5 && initialProb <= 95 : true) &&
question.length > 0 && question.length > 0 &&
@ -178,7 +185,8 @@ export function NewContract(props: {
min < max && min < max &&
max - min > 0.01 && max - min > 0.01 &&
min < initialValue && min < initialValue &&
initialValue < max)) initialValue < max)) &&
(outcomeType !== 'MULTIPLE_CHOICE' || isValidMultipleChoice)
const [errorText, setErrorText] = useState<string>('') const [errorText, setErrorText] = useState<string>('')
useEffect(() => { useEffect(() => {
@ -221,6 +229,7 @@ export function NewContract(props: {
max, max,
initialValue, initialValue,
isLogScale, isLogScale,
answers,
groupId: selectedGroup?.id, groupId: selectedGroup?.id,
}) })
) )
@ -259,10 +268,11 @@ export function NewContract(props: {
'Users can submit their own answers to this market.' 'Users can submit their own answers to this market.'
) )
else setMarketInfoText('') else setMarketInfoText('')
setOutcomeType(choice as 'BINARY' | 'FREE_RESPONSE') setOutcomeType(choice as outcomeType)
}} }}
choicesMap={{ choicesMap={{
'Yes / No': 'BINARY', 'Yes / No': 'BINARY',
'Multiple choice': 'MULTIPLE_CHOICE',
'Free response': 'FREE_RESPONSE', 'Free response': 'FREE_RESPONSE',
Numeric: 'PSEUDO_NUMERIC', Numeric: 'PSEUDO_NUMERIC',
}} }}
@ -277,6 +287,10 @@ export function NewContract(props: {
<Spacer h={6} /> <Spacer h={6} />
{outcomeType === 'MULTIPLE_CHOICE' && (
<MultipleChoiceAnswers setAnswers={setAnswers} />
)}
{outcomeType === 'PSEUDO_NUMERIC' && ( {outcomeType === 'PSEUDO_NUMERIC' && (
<> <>
<div className="form-control mb-2 items-start"> <div className="form-control mb-2 items-start">